Files
coder/templates/docker/main.tf

424 lines
14 KiB
HCL

# Coder workspace template: Docker workspaces on the loca operator host.
#
# The provisioner is the `coder` server container itself (compose project
# /home/loca/dev/coder), which is the only thing on this host that holds
# /var/run/docker.sock. Workspaces created here are plain Docker containers on
# that same daemon.
#
# Every construct below is derived from sources cited in README.md, not from
# memory. Read that file before changing anything.
terraform {
required_version = ">= 1.0"
required_providers {
# coder/coder 2.x requires Coder Server >= v2.18.0 (provider "Version 2
# Upgrade Guide"). This deployment runs v2.35.3, so the 2.x line applies.
# 2.18.0 is the latest release published on registry.terraform.io.
coder = {
source = "coder/coder"
version = "~> 2.18"
}
# 4.5.0 is the latest release published on registry.terraform.io.
docker = {
source = "kreuzwerker/docker"
version = "~> 4.5"
}
}
}
provider "coder" {}
# No `host` argument: the provisioner runs inside the `coder` container, which
# already has /var/run/docker.sock bind-mounted, so the provider's default
# socket is the right one. Nothing needs to be exposed over TCP.
provider "docker" {}
data "coder_provisioner" "me" {}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
# ---------------------------------------------------------------------------
# Parameters
#
# The upstream Docker example ships no parameters; the upstream Kubernetes
# example does, and this follows that idiom. Defaults are deliberately modest:
# the host has 10 cores and ~22 GiB available RAM, but its 15 GiB of swap is
# already ~97% consumed, so workspaces must not be sized to lean on swap.
#
# There is intentionally NO disk-size parameter. The home volume uses Docker's
# `local` driver on an overlay2/extfs root (verified with `docker info`), which
# cannot enforce a size quota without XFS project quotas. A disk parameter here
# would be decorative and would mislead the operator; home disk usage is
# reported through agent metadata instead.
# ---------------------------------------------------------------------------
data "coder_parameter" "cpu" {
name = "cpu"
display_name = "CPU"
description = "CPU cores available to the workspace. This is a hard ceiling, not a reservation."
type = "number"
default = "2"
icon = "/icon/memory.svg"
mutable = true
order = 1
option {
name = "1 core"
value = "1"
}
option {
name = "2 cores (default)"
value = "2"
}
option {
name = "4 cores"
value = "4"
}
}
data "coder_parameter" "memory" {
name = "memory"
display_name = "Memory (GB)"
description = "Memory ceiling in GB. Container swap is pinned to this value, so a workspace can never push the host further into swap."
type = "number"
default = "2"
icon = "/icon/memory.svg"
mutable = true
order = 2
option {
name = "2 GB (default)"
value = "2"
}
option {
name = "4 GB"
value = "4"
}
option {
name = "8 GB"
value = "8"
}
}
locals {
username = data.coder_workspace_owner.me.name
# Matches the image used by the upstream Docker example at the tag this
# server runs (v2.35.3). Upstream `main` has since renamed the same image to
# codercom/example-base:ubuntu.
workspace_image = "codercom/enterprise-base:ubuntu"
memory_mb = tonumber(data.coder_parameter.memory.value) * 1024
# Hostname the workspace must reach to find the Coder server, read from the
# access URL (https://code.loca.zone on this deployment) instead of hardcoded.
# regexall, not regex: regex throws a build-time error on no match, and an
# unset access URL must degrade to "don't add a host entry", not fail.
access_url_match = regexall("^[a-zA-Z][a-zA-Z0-9+.-]*://([^/:]+)", data.coder_workspace.me.access_url)
access_host = length(local.access_url_match) > 0 ? local.access_url_match[0][0] : ""
# Pin that hostname to the Docker gateway so agent -> server traffic reaches
# the host's nginx directly rather than depending on hairpin NAT out to the
# public IP and back. Verified reachable: an HTTPS request to /healthz with
# code.loca.zone resolved to 172.17.0.1 returns 200 OK with a valid
# certificate. Skipped for a bare IPv4 access URL, which the entrypoint
# rewrite further down already handles.
pin_access_host = local.access_host != "" && length(regexall("^[0-9]+(\\.[0-9]+){3}$", local.access_host)) == 0
}
# ---------------------------------------------------------------------------
# Agent
# ---------------------------------------------------------------------------
resource "coder_agent" "main" {
arch = data.coder_provisioner.me.arch
os = "linux"
# "blocking" is the behaviour the provider docs recommend: the workspace is
# not advertised as ready until the script below exits.
startup_script_behavior = "blocking"
# Deliberately no `set -e`. Every step here is optional, idempotent, and must
# never fail the workspace build; failures are logged and stepped over. The
# script ends with an explicit `exit 0` for the same reason.
startup_script = <<-EOT
set -u
log() { printf '[startup] %s\n' "$*"; }
# 1. Seed the home directory on first boot only. The home volume is
# persistent, so this must not clobber an existing home.
if [ ! -f "$HOME/.init_done" ]; then
if [ -d /etc/skel ]; then
cp -rT /etc/skel "$HOME" 2>/dev/null || log "skel copy skipped"
fi
touch "$HOME/.init_done" 2>/dev/null || log "could not mark init_done"
fi
# 2. code-server, installed into the persistent home so a workspace restart
# does not re-download it. Skipped cleanly if curl or the network is
# unavailable; the code-server app then just reports unhealthy.
CS_DIR="$HOME/.local/share/code-server-dist"
CS_BIN="$CS_DIR/bin/code-server"
if [ ! -x "$CS_BIN" ]; then
if command -v curl >/dev/null 2>&1; then
log "installing code-server into $CS_DIR"
curl -fsSL --max-time 180 https://code-server.dev/install.sh \
| sh -s -- --method=standalone --prefix="$CS_DIR" \
|| log "code-server install failed; skipping"
else
log "curl not present; skipping code-server install"
fi
fi
# 3. Start code-server unless something is already serving that port.
if [ -x "$CS_BIN" ] && ! curl -fsS --max-time 2 http://localhost:13337/healthz >/dev/null 2>&1; then
"$CS_BIN" --auth none --bind-addr 127.0.0.1:13337 \
>"$HOME/.code-server.log" 2>&1 &
log "code-server started on 127.0.0.1:13337"
fi
exit 0
EOT
# Lets users commit immediately. These take precedence over ~/.gitconfig.
env = {
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
}
# Observability, mirroring the upstream Docker example. The host rows matter
# on this box because the daemon, both wikis, and every workspace share it.
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
}
metadata {
display_name = "CPU Usage (Host)"
key = "3_cpu_usage_host"
script = "coder stat cpu --host"
interval = 10
timeout = 1
}
metadata {
display_name = "Memory Usage (Host)"
key = "4_mem_usage_host"
script = "coder stat mem --host"
interval = 10
timeout = 1
}
metadata {
display_name = "Swap Usage (Host)"
key = "5_swap_host"
script = <<-EOT
free -b | awk '/^Swap/ { printf("%.1f/%.1f GiB", $3/1024/1024/1024, $2/1024/1024/1024) }'
EOT
interval = 30
timeout = 1
}
}
# ---------------------------------------------------------------------------
# Apps
#
# CRITICAL: every coder_app in this template MUST set `subdomain = false`.
#
# This deployment has no wildcard access URL. Nested names such as
# deep.nested.code.loca.zone do resolve, but the certbot lineage covers only
# code.loca.zone and wiki.code.loca.zone; a wildcard certificate would require
# a DNS-01 challenge with DNS-provider credentials that are not configured on
# this host. Subdomain apps would therefore be served with a name-mismatched
# certificate and fail in the browser. Path-based apps
# (/@user/workspace.main/apps/<slug>) are the only working mode here.
#
# The upstream example pulls code-server in from the
# registry.coder.com/coder/code-server/coder module. This template declares the
# app directly instead, for two reasons: a module-owned coder_app is not under
# our control and could ship subdomain = true, and modules add a provision-time
# dependency on registry.coder.com.
# ---------------------------------------------------------------------------
resource "coder_app" "code_server" {
agent_id = coder_agent.main.id
slug = "code-server"
display_name = "code-server"
icon = "/icon/code.svg"
url = "http://localhost:13337/?folder=/home/coder"
share = "owner"
order = 1
# See the block comment above. Do not change this to true.
subdomain = false
healthcheck {
url = "http://localhost:13337/healthz"
interval = 5
threshold = 12
}
}
# ---------------------------------------------------------------------------
# Infrastructure
# ---------------------------------------------------------------------------
resource "docker_image" "workspace" {
name = local.workspace_image
# Every workspace on this host shares this image. Deleting one workspace must
# not pull it out from under the others.
keep_locally = true
}
resource "docker_volume" "home" {
# Keyed to the immutable workspace id rather than its name, so stopping,
# starting, renaming, or rebuilding a workspace all reattach the same volume
# instead of silently discarding /home/coder.
name = "coder-${data.coder_workspace.me.id}-home"
lifecycle {
# Never let an attribute diff (labels, driver options) destroy user data.
ignore_changes = all
}
labels {
label = "coder.owner"
value = data.coder_workspace_owner.me.name
}
labels {
label = "coder.owner_id"
value = data.coder_workspace_owner.me.id
}
labels {
label = "coder.workspace_id"
value = data.coder_workspace.me.id
}
# Goes stale if the workspace is renamed, but useful when hunting dangling
# volumes by hand.
labels {
label = "coder.workspace_name_at_creation"
value = data.coder_workspace.me.name
}
}
resource "docker_container" "workspace" {
count = data.coder_workspace.me.start_count
image = docker_image.workspace.image_id
# lower() because Docker rejects uppercase in container names.
name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}"
# Makes the shell prompt friendly: coder@my-workspace:~$
hostname = data.coder_workspace.me.name
# Rewrites a loopback access URL to the Docker gateway. A no-op on this
# deployment, where CODER_ACCESS_URL is https://code.loca.zone.
entrypoint = ["sh", "-c", replace(coder_agent.main.init_script, "/localhost|127\\.0\\.0\\.1/", "host.docker.internal")]
env = ["CODER_AGENT_TOKEN=${coder_agent.main.token}"]
# Hard ceilings from the parameters above. memory_swap is set equal to memory,
# which disables container swap entirely: the host's 15 GiB swap is already
# ~97% used, so a workspace must not be allowed to add to it.
cpus = tostring(data.coder_parameter.cpu.value)
memory = local.memory_mb
memory_swap = local.memory_mb
# SECURITY BOUNDARY: /var/run/docker.sock is deliberately NOT mounted here.
# The socket stays with the `coder` server container, which needs it to
# provision workspaces. Handing it to a workspace would give anyone inside
# that workspace root-equivalent control of the whole host, including the
# nginx/certbot stack, the Postgres container behind Coder, and every other
# workspace. Docker-in-workspace, if it is ever wanted, must be done with a
# sysbox/rootless runtime, not by exposing this socket.
volumes {
container_path = "/home/coder"
volume_name = docker_volume.home.name
read_only = false
}
host {
host = "host.docker.internal"
ip = "host-gateway"
}
# See local.pin_access_host: keeps agent -> server traffic on the host
# gateway instead of routing out to the public IP and back.
dynamic "host" {
for_each = local.pin_access_host ? [local.access_host] : []
iterator = h
content {
host = h.value
ip = "host-gateway"
}
}
labels {
label = "coder.owner"
value = data.coder_workspace_owner.me.name
}
labels {
label = "coder.owner_id"
value = data.coder_workspace_owner.me.id
}
labels {
label = "coder.workspace_id"
value = data.coder_workspace.me.id
}
labels {
label = "coder.workspace_name"
value = data.coder_workspace.me.name
}
}
# Surfaces the limits actually applied, so the dashboard does not require an
# operator to shell in and run `docker inspect` to see them.
resource "coder_metadata" "workspace" {
count = data.coder_workspace.me.start_count
resource_id = docker_container.workspace[0].id
item {
key = "image"
value = local.workspace_image
}
item {
key = "cpu limit"
value = "${data.coder_parameter.cpu.value} core(s)"
}
item {
key = "memory limit"
value = "${data.coder_parameter.memory.value} GB (swap disabled)"
}
item {
key = "home volume"
value = docker_volume.home.name
}
item {
key = "app routing"
value = "path-based (`subdomain = false`); no wildcard access URL on this deployment"
}
}