# coder/desktop — Coder template: a KDE Plasma desktop in the browser (KasmVNC) # on the homelab Kubernetes cluster, set up from this dotfiles repo. # # Push it with: coder templates push desktop -d coder/desktop # # The workspace is a Deployment (replicas 0/1 follows start/stop) plus a PVC # for /home/coder, so the home directory — ~/.local/bin tools, the dotfiles # checkout, ~/.config/dotfiles/op.env — survives stops and rebuilds. terraform { required_providers { coder = { source = "coder/coder" } kubernetes = { source = "hashicorp/kubernetes" } } } provider "coder" {} variable "use_kubeconfig" { type = bool description = "Use ~/.kube/config instead of the in-cluster service account. Leave false when the provisioner runs inside the cluster (the default Helm install)." default = false } variable "namespace" { type = string description = "Namespace for workspaces. The coder service account's workspace Role is bound here." default = "coder" } variable "storage_class" { type = string description = "StorageClass for the home volume (the cluster has no default class)." default = "truenas-iscsi" } provider "kubernetes" { config_path = var.use_kubeconfig ? "~/.kube/config" : null } data "coder_provisioner" "me" {} data "coder_workspace" "me" {} data "coder_workspace_owner" "me" {} # --- parameters -------------------------------------------------------------- # Nodes have ~4 CPU / ~5 GiB allocatable and are already mostly requested, so # the pod REQUESTS little (it schedules anywhere) and these set the LIMITS. data "coder_parameter" "cpu" { name = "cpu" display_name = "CPU limit" description = "CPU cores the workspace may burst to." icon = "/icon/memory.svg" type = "number" default = "2" mutable = true order = 1 option { name = "2 cores" value = "2" } option { name = "3 cores" value = "3" } option { name = "4 cores" value = "4" } } data "coder_parameter" "memory" { name = "memory" display_name = "Memory limit" description = "Memory in GiB the workspace may use (includes the 1 GiB /dev/shm)." icon = "/icon/memory.svg" type = "number" default = "4" mutable = true order = 2 option { name = "3 GB" value = "3" } option { name = "4 GB" value = "4" } } data "coder_parameter" "home_disk_size" { name = "home_disk_size" display_name = "Home disk size" description = "Size of /home/coder in GiB. Can be grown later, not shrunk." icon = "/emojis/1f4be.png" type = "number" default = "30" mutable = false order = 3 validation { min = 10 max = 200 } } # The dotfiles secrets/ module reads the 1Password vault non-interactively # when OP_SERVICE_ACCOUNT_TOKEN is in the environment. It writes # ~/.config/dotfiles/op.env on the home volume, so the token is only needed # on the FIRST start: ephemeral means it is not carried into later builds. data "coder_parameter" "op_service_account_token" { name = "op_service_account_token" display_name = "1Password service-account token" description = "Optional, first start only. Lets the dotfiles secrets module pull your vault (LITELLM_KEY, GITEA_KEY, ...). Stored in ~/.config/dotfiles/op.env after that. Leave empty to skip." icon = "/icon/1password.svg" type = "string" default = "" mutable = true ephemeral = true order = 4 styling = jsonencode({ mask_input = true }) } data "coder_parameter" "op_vault" { name = "op_vault" display_name = "1Password vault" description = "Vault holding your secrets item. Empty = the only vault the token can see." icon = "/icon/1password.svg" type = "string" default = "t-mint" mutable = true order = 5 } data "coder_parameter" "op_item" { name = "op_item" display_name = "1Password item" description = "Item whose fields become env vars. Empty = the only item in the vault." icon = "/icon/1password.svg" type = "string" default = "noconnor" mutable = true order = 6 } locals { name = "coder-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}" labels = { "app.kubernetes.io/name" = "coder-workspace" "app.kubernetes.io/instance" = local.name "app.kubernetes.io/part-of" = "coder" "com.coder.resource" = "true" "com.coder.workspace.id" = data.coder_workspace.me.id "com.coder.workspace.name" = data.coder_workspace.me.name "com.coder.user.id" = data.coder_workspace_owner.me.id "com.coder.user.username" = data.coder_workspace_owner.me.name } # Only the non-empty ones: an empty OP_VAULT/OP_ITEM would override the # secrets module's own auto-detection. op_env = { for k, v in { OP_SERVICE_ACCOUNT_TOKEN = data.coder_parameter.op_service_account_token.value OP_VAULT = data.coder_parameter.op_vault.value OP_ITEM = data.coder_parameter.op_item.value } : k => v if v != "" } } # --- agent ------------------------------------------------------------------- resource "coder_agent" "main" { arch = data.coder_provisioner.me.arch os = "linux" # The home PVC hides whatever the image put in /home/coder, so seed it # with the skel dotfiles on first start. startup_script = <<-EOT set -e if [ ! -f ~/.init_done ]; then cp -rT /etc/skel ~ || true touch ~/.init_done fi EOT env = merge({ GIT_AUTHOR_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name) GIT_AUTHOR_EMAIL = data.coder_workspace_owner.me.email GIT_COMMITTER_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name) GIT_COMMITTER_EMAIL = data.coder_workspace_owner.me.email }, local.op_env) metadata { display_name = "CPU Usage" key = "0_cpu_usage" script = "coder stat cpu" interval = 10 timeout = 1 } metadata { display_name = "RAM Usage" key = "1_ram_usage" script = "coder stat mem" interval = 10 timeout = 1 } metadata { display_name = "Home Disk" key = "2_home_disk" script = "coder stat disk --path $HOME" interval = 60 timeout = 1 } } # --- modules ----------------------------------------------------------------- # Clones the repo into ~/.config/coderv2/dotfiles and runs its bootstrap.sh on # every start (each module is idempotent). The first run takes ~5 minutes; # progress is in ~/.dotfiles.log and the "Dotfiles" script log in the UI. module "dotfiles" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/dotfiles/coder" version = "1.4.2" agent_id = coder_agent.main.id default_dotfiles_uri = "http://git.nic-oconnor.com/public/dotfiles.git" manual_update = true } # KDE Plasma and KasmVNC are baked into the image (image/Dockerfile), so the # module skips its install and just configures and starts KasmVNC on :6800. # kasm_version: only used if the image lacks KasmVNC; keep it in step with # KASMVNC_VERSION in the Dockerfile. # subdomain = false: the deployment has no wildcard access URL, so the app # is served path-based under coder.nic-oconnor.com. module "kasmvnc" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/kasmvnc/coder" version = "1.3.0" agent_id = coder_agent.main.id desktop_environment = "kde" kasm_version = "1.5.0" subdomain = false } # --- kubernetes resources ---------------------------------------------------- resource "kubernetes_persistent_volume_claim_v1" "home" { metadata { name = "${local.name}-home" namespace = var.namespace labels = merge(local.labels, { "app.kubernetes.io/component" = "home" }) annotations = { "com.coder.user.email" = data.coder_workspace_owner.me.email } } wait_until_bound = false spec { access_modes = ["ReadWriteOnce"] storage_class_name = var.storage_class resources { requests = { storage = "${data.coder_parameter.home_disk_size.value}Gi" } } } } resource "kubernetes_deployment_v1" "main" { count = data.coder_workspace.me.start_count depends_on = [kubernetes_persistent_volume_claim_v1.home] wait_for_rollout = false metadata { name = local.name namespace = var.namespace labels = merge(local.labels, { "app.kubernetes.io/component" = "workspace" }) annotations = { "com.coder.user.email" = data.coder_workspace_owner.me.email } } spec { replicas = 1 selector { match_labels = local.labels } # RWO home volume: the old pod must let go of it before the new one starts. strategy { type = "Recreate" } template { metadata { labels = local.labels } spec { # Runs as the image's coder user (uid 1000), which has passwordless # sudo. The KasmVNC module needs it to write /etc/kasmvnc, and the # dotfiles modules (zsh, docker) for apt; the namespace enforces PSA # baseline, which allows it. security_context { run_as_user = 1000 fs_group = 1000 run_as_non_root = true } container { name = "dev" image = "git.nic-oconnor.com/public/coder-desktop-kde:noble" image_pull_policy = "Always" command = ["sh", "-c", coder_agent.main.init_script] env { name = "CODER_AGENT_TOKEN" value = coder_agent.main.token } resources { requests = { cpu = "500m" memory = "1Gi" } limits = { cpu = data.coder_parameter.cpu.value memory = "${data.coder_parameter.memory.value}Gi" } } volume_mount { mount_path = "/home/coder" name = "home" } # The default 64 MiB /dev/shm crashes browsers inside the desktop. volume_mount { mount_path = "/dev/shm" name = "dshm" } } volume { name = "home" persistent_volume_claim { claim_name = kubernetes_persistent_volume_claim_v1.home.metadata[0].name } } volume { name = "dshm" empty_dir { medium = "Memory" size_limit = "1Gi" } } affinity { pod_anti_affinity { preferred_during_scheduling_ignored_during_execution { weight = 1 pod_affinity_term { topology_key = "kubernetes.io/hostname" label_selector { match_expressions { key = "app.kubernetes.io/name" operator = "In" values = ["coder-workspace"] } } } } } } } } } }