diff --git a/README.md b/README.md index cb719c2..59e51fe 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,13 @@ Already have a clone? Just run it directly: are read from the **local checkout**, not re-downloaded from Gitea, so this repo does not have to be public for a shell to work; see its own README. +Not run by `bootstrap.sh`: + +- `coder/desktop/` — Coder template for coder.nic-oconnor.com: an XFCE + desktop (KasmVNC) pod on the homelab cluster that applies this repo via the + dotfiles module. Push with `coder templates push desktop -d coder/desktop`; + see its README. + ## Per-machine customizations and secrets `~/.zshrc` is managed — bootstrap overwrites it, so never hand-edit it. diff --git a/coder/desktop/.gitignore b/coder/desktop/.gitignore new file mode 100644 index 0000000..9b5533c --- /dev/null +++ b/coder/desktop/.gitignore @@ -0,0 +1,2 @@ +.terraform/ +*.tfstate* diff --git a/coder/desktop/.terraform.lock.hcl b/coder/desktop/.terraform.lock.hcl new file mode 100644 index 0000000..bce6e90 --- /dev/null +++ b/coder/desktop/.terraform.lock.hcl @@ -0,0 +1,43 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/coder/coder" { + version = "2.18.0" + constraints = ">= 2.5.0" + hashes = [ + "h1:O25nA2tkM1JFJtQpaX2TOTeWvsSyuxDoJo6/QTv78hM=", + "zh:107c4eb7a36335ac94637679845c040759e21d3fb108d07081e0689cb93121b1", + "zh:1f8d904d59e35948f8f795e58d7895b791bd9a12c8409079f5bb90221303c1b6", + "zh:3942d5c6b6fad45b3b92f82c7b3f40a2d7f2b7b72da07f1ac63e1022b46bdbc7", + "zh:464f3b53eccfefc3ca02b46da07fb277362c16f81f45cc4570150520e5b9bf75", + "zh:6ae37bef989559a212ce04a615444d40452425e01081d8499843a24db0a09e85", + "zh:6f1410911124b0078a0f1c077899b4d834cd6821596ed350e4b4ad8f5b0030e7", + "zh:9ca356eb0e23bc034091fc9672bbe307a69e242c341a669f10db81e9a75fab2d", + "zh:b36687f0793295337286ae71d8157dc4c78a13fb5a9b1276093db82cdb8edd76", + "zh:c60a39cbde9fb473bfd0c2ad1685a0ce8d4a56408f060c6d1b858bae2e535d98", + "zh:d26ffdb52e095662e16b19942b7579bea7dc60577bd81f5cd671e6632ce48a0b", + "zh:da5414d74b21ecdcf8f93adc2199925c530cde1f53cde014201713d315916366", + "zh:e7e801f91b8afba90d40aa390c8b822ba064a54932a4a25bbff1593942e9bf72", + "zh:f064fb800fc687b2d60d4c12e1e65875a2f4b21cf8bb0b5e018d3ecbf7d29de2", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + ] +} + +provider "registry.terraform.io/hashicorp/kubernetes" { + version = "3.2.1" + hashes = [ + "h1:XccFuTe/eJ94vkoZNYUL3wsdXJwlxHguIY36jildRQ8=", + "zh:067fe16a852d42e0f571712e36cb3e71855f917ea2041415e155f56ebc480d7f", + "zh:2815e174f8f0f032ea3a64f2196740ad000a39f88ae5646e7061bf15ed589f62", + "zh:2f94f6b689c59c43e596e724228f2861095d02c2a2ac2a257a4619667135ac75", + "zh:3e807310c84f11561b9ba06b978f03c46cfeca2e84ad0803d34d5d30a8a637cb", + "zh:5cba6f92202c60cac6898141356420709f5341b80ae4c360725cc647f86188ff", + "zh:72b841b6f0820d8f87c3d7c5a3611c35121ab9a4c1db4ea7a98b0319f209e474", + "zh:74770b892ee9b04829d92318d9e8ca96f8143b0c6c766e4141901908173fd01d", + "zh:7a723c8ebf9e218d0f7a0cfe6c0437f2b5eeb7ae015a14fad16e0f7fd9ef79ab", + "zh:a0f5073b2636a3894d4e9dd1b6853d5f324dd78728313bff79b842e5e9eca96f", + "zh:c13241cba993ef63a537beb6a1caf00e233bb045b50d197349530de0ee3276d5", + "zh:d52826f4b0227b7db99ea4a1d48f49a0bfb440563c92ebd2f8faec273c856c2d", + "zh:dc1cf5505a39a264a650b0830f74150ad02368787e5ead89e4007034f8f47831", + ] +} diff --git a/coder/desktop/README.md b/coder/desktop/README.md new file mode 100644 index 0000000..3930c74 --- /dev/null +++ b/coder/desktop/README.md @@ -0,0 +1,50 @@ +# coder/desktop — Coder desktop template + +A Coder template for : an XFCE desktop in the +browser (the [KasmVNC module](https://registry.coder.com/modules/coder/kasmvnc)) +running as a pod on the homelab Kubernetes cluster, set up from this repo by the +[dotfiles module](https://registry.coder.com/modules/coder/dotfiles). + +## Push / update + +```sh +coder login https://coder.nic-oconnor.com +coder templates push desktop -d coder/desktop -y +``` + +The in-cluster provisioner uses the `coder` service account, whose workspace +Role is bound in the `coder` namespace — which is where workspaces go. + +## What a workspace gets + +- `codercom/enterprise-desktop:ubuntu-noble`: XFCE, git, python3, and the + `coder` user (uid 1000) with passwordless sudo. +- A PVC (`truenas-iscsi`, default 30 GiB) at `/home/coder`. Everything the + dotfiles install into `$HOME` survives stops; system packages (zsh, docker + CLI, KasmVNC) are reinstalled on each start because the root filesystem + is not persisted. +- The KasmVNC app, path-based (`subdomain = false`) because the deployment has + no wildcard access URL. +- A "Dotfiles" startup script: clones this repo into + `~/.config/coderv2/dotfiles` and runs `bootstrap.sh` (log: + `~/.dotfiles.log`), plus a "Refresh Dotfiles" button. The first run takes + about 5 minutes. +- Pod requests 0.5 CPU / 1 GiB; the CPU and memory parameters set limits (the + nodes are ~4 CPU / ~5 GiB and mostly requested already). + +## 1Password secrets + +bootstrap's `secrets/` module needs `OP_SERVICE_ACCOUNT_TOKEN` when there is +no TTY. Paste it into the **1Password service-account token** parameter when +you create the workspace. It is ephemeral: used for that build only, then +`~/.config/dotfiles/op.env` on the home volume carries it. Leave it empty and +bootstrap still applies everything else; `secrets`, `mcp-config` and `hermes` +are reported as not completed. Fix that later from a workspace terminal with +`~/.config/coderv2/dotfiles/secrets/install.sh`, then refresh the dotfiles. + +## Why these pins + +- `ubuntu-noble`, not `ubuntu`: the `ubuntu` tag is now 26.04 (resolute), and + KasmVNC publishes no resolute package, so the module's install 404s. +- `kasm_version = "1.5.0"`: the newest KasmVNC release, which has a noble + build. Revisit both once KasmVNC ships a resolute `.deb`. diff --git a/coder/desktop/main.tf b/coder/desktop/main.tf new file mode 100644 index 0000000..af5d73b --- /dev/null +++ b/coder/desktop/main.tf @@ -0,0 +1,374 @@ +# coder/desktop — Coder template: an XFCE 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 on + # first start: the skel dotfiles, plus the image's .xsessionrc (XFCE session + # env that KasmVNC's X session reads). + startup_script = <<-EOT + set -e + if [ ! -f ~/.init_done ]; then + cp -rT /etc/skel ~ || true + touch ~/.init_done + fi + if [ ! -f ~/.xsessionrc ]; then + printf '%s\n' \ + 'export GNOME_SHELL_SESSION_MODE=ubuntu' \ + 'export XDG_CURRENT_DESKTOP=xfce' \ + 'export XDG_SESSION_TYPE=x11' > ~/.xsessionrc + 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 +} + +# XFCE is in the image; the module installs KasmVNC and serves it on :6800. +# kasm_version: KasmVNC ships a .deb per Ubuntu codename, and the module's +# default (1.4.0) is older than we need; 1.5.0 has noble. +# 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 = "xfce" + 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 and the dotfiles modules (zsh, docker) + # need it 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 = "codercom/enterprise-desktop:ubuntu-noble" + image_pull_policy = "IfNotPresent" + 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"] + } + } + } + } + } + } + } + } + } +}