Files
dotfiles/coder/desktop/main.tf
T
noconnorandClaude Opus 5 1524fa8203 coder: desktop template (KasmVNC + dotfiles) for the homelab cluster
Kubernetes template for coder.nic-oconnor.com: an XFCE desktop served by
the KasmVNC module, with this repo applied by the dotfiles module. Home is
a truenas-iscsi PVC so $HOME tools and op.env survive stops.

Pinned to enterprise-desktop:ubuntu-noble + KasmVNC 1.5.0 because the
plain `ubuntu` tag is now resolute, which KasmVNC has no package for.
Path-based app (no wildcard access URL). An ephemeral 1Password token
parameter lets bootstrap's secrets module run unattended on first start.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 13:08:35 -04:00

375 lines
12 KiB
Terraform

# 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"]
}
}
}
}
}
}
}
}
}
}