diff --git a/.icons/agent-sandbox.svg b/.icons/agent-sandbox.svg
new file mode 100644
index 000000000..66ec04f4b
--- /dev/null
+++ b/.icons/agent-sandbox.svg
@@ -0,0 +1 @@
+
diff --git a/registry/coder-labs/.images/kubernetes-agent-sandbox.gif b/registry/coder-labs/.images/kubernetes-agent-sandbox.gif
new file mode 100644
index 000000000..4cb8698a2
Binary files /dev/null and b/registry/coder-labs/.images/kubernetes-agent-sandbox.gif differ
diff --git a/registry/coder-labs/templates/kubernetes-agent-sandbox/README.md b/registry/coder-labs/templates/kubernetes-agent-sandbox/README.md
new file mode 100644
index 000000000..914bb93ca
--- /dev/null
+++ b/registry/coder-labs/templates/kubernetes-agent-sandbox/README.md
@@ -0,0 +1,182 @@
+---
+display_name: Kubernetes (agent-sandbox)
+description: Run each workspace in an agent-sandbox Sandbox with gVisor or Kata isolation and a NetworkPolicy firewall
+icon: ../../../../.icons/agent-sandbox.svg
+verified: false
+tags: [kubernetes, container, agent-sandbox, gvisor, kata, ai]
+---
+
+# Kubernetes (agent-sandbox)
+
+Run each workspace in a Sandbox from [agent-sandbox](https://github.com/kubernetes-sigs/agent-sandbox), a Kubernetes SIG project. You and your AI agent work in the same sandbox, with one Coder agent named `dev`. The sandbox runs under gVisor or a Kata microVM, so it does not share the node kernel. A Kubernetes NetworkPolicy works as the firewall, and ClusterNetworkPolicy or [Cilium](https://cilium.io/) can add more advanced rules (see [Firewall](#firewall)). By default the workspace can reach only the Coder server.
+
+> [!WARNING]
+> With the AI Gateway on, `ANTHROPIC_AUTH_TOKEN` holds a Coder token of the workspace owner, and code in the workspace can use it. Each start creates a new one with only the `user:read_personal` scope, so it works with the AI Gateway but cannot list workspaces, start builds, or create tokens. Coder cannot revoke it on stop, so it stays valid until the deployment's default token lifetime ends. Set `enable_ai_gateway = false` to keep it out of the workspace.
+>
+> A scratch home keeps its agent token in a Pod annotation, so anyone who can read Pods in the namespace can read it. Keep read access to the namespace tight.
+
+## Why agent-sandbox
+
+agent-sandbox adds Kubernetes primitives for running agentic workloads:
+
+- **Kernel isolation at the cluster level:** a SandboxTemplate sets the runtime (gVisor, Kata with QEMU, or Kata with Cloud Hypervisor), the image, the security settings, and the network rules. A scratch workspace can only claim a sandbox from these templates, and the Coder provisioner never creates Pods.
+- **Cluster-level warm pools:** a SandboxWarmPool keeps booted sandboxes ready for any client of the cluster, not only Coder, and it does not need Coder Premium.
+- **A growing ecosystem:** SDKs, a router, and a managed version on GKE. The API already has a suspended mode and expiry, and GKE adds Pod snapshots. This template does not use them yet.
+
+Plain Kubernetes and Coder already do some of this. To compare each feature, and to decide if you need agent-sandbox, see [agent-sandbox vs Pods](#agent-sandbox-vs-pods).
+
+## Startup speed
+
+With a warm pool or a prebuild, workspaces often start in 3 to 8 seconds, which matters for agents that start a new workspace for each task.
+
+
+
+To see where the time goes and how to tune it, see [Speed tuning](#speed-tuning).
+
+## agent-sandbox vs Pods
+
+Coder already manages most of the workspace lifecycle. It creates and deletes Pods, starts and stops workspaces on a schedule, and stops idle workspaces. It also keeps prebuilds ready and runs the agent and the apps. agent-sandbox adds a second lifecycle under Coder, with Sandboxes, claims, warm pools, and expiry. You must also run and upgrade its controller.
+
+| | Plain Pods | Pods + Coder | agent-sandbox + Coder (this template) |
+| ----------------- | ---------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------- |
+| Kernel isolation | Set `runtimeClassName` on each Pod | The template sets the runtime | The cluster administrator sets the runtime once in a SandboxTemplate |
+| Firewall | NetworkPolicies that you write | The template creates a NetworkPolicy | The template, or a shared policy for each SandboxTemplate, plus an optional guardrail |
+| Fast start | No, every Pod starts cold | Prebuilds (Premium) | Prebuilds, and warm pools that need no Premium |
+| Warm pools | No | No, prebuilds serve Coder only | SandboxWarmPools, for Coder and any other client in the cluster |
+| Who creates Pods | You | The Coder provisioner | For scratch homes, agent-sandbox. The provisioner only claims from admin-owned pools |
+| Lifecycle | Kubernetes only | Start, stop, and TTL in Coder | Coder, plus the Sandbox API: expiry, a suspended mode, and Pod snapshots on GKE |
+| IDE and AI agents | No | Coder agent, apps, AI Gateway | Coder agent, apps, AI Gateway |
+| Extra components | None | None | The agent-sandbox controller |
+
+Use the [Kubernetes template](https://registry.coder.com/templates/coder/kubernetes) with a `runtimeClassName` and a NetworkPolicy if only Coder uses the cluster, you have Premium for prebuilds, and template authors can set the runtime. Prebuilds start about as fast as warm pools.
+
+Use agent-sandbox if:
+
+- Cluster administrators must own the runtime, the image, and the network rules.
+- Other clients, such as SDKs or CI jobs, share the sandboxes and warm pools.
+- You want warm pools without Coder Premium.
+- You run on GKE, which manages agent-sandbox and adds Pod snapshots.
+
+The [roadmap](https://github.com/kubernetes-sigs/agent-sandbox/blob/main/roadmap.md) plans storage in the claim ([#225](https://github.com/kubernetes-sigs/agent-sandbox/issues/225)), network rules by host name ([#818](https://github.com/kubernetes-sigs/agent-sandbox/issues/818)), and automatic suspend and resume. These would let persistent homes use warm pools, and remove the IP-only firewall limit. agent-sandbox releases about every week, so pin the version.
+
+## Prerequisites
+
+Required:
+
+- **A Kubernetes cluster** with a network plugin that enforces NetworkPolicy, and a StorageClass for the home disk.
+- **agent-sandbox** v1.0.4 or later, with the extensions.
+- **A sandbox runtime** on the nodes, with a RuntimeClass: gVisor `release-20261019.0` or later (`gvisor`), or Kata (`kata-qemu`, `kata-clh`). Older gVisor releases block on an empty write to a terminal, so the web terminal takes no input.
+- **The cluster setup** in [`cluster/`](https://github.com/coder/registry/blob/main/registry/coder-labs/templates/kubernetes-agent-sandbox/cluster): a Terraform configuration that creates the namespace, RBAC, and warm pools, and prints the template variables. See [`cluster/README.md`](https://github.com/coder/registry/blob/main/registry/coder-labs/templates/kubernetes-agent-sandbox/cluster/README.md).
+- **The image**, built from [`build/Dockerfile`](https://github.com/coder/registry/blob/main/registry/coder-labs/templates/kubernetes-agent-sandbox/build/Dockerfile).
+- **Access to the cluster** for the Coder provisioner. The template uses the in-cluster ServiceAccount, or `~/.kube/config` with `use_kubeconfig = true`. With the AI Gateway on, the provisioner also calls the Coder API at the access URL to create the workspace's token.
+
+Optional:
+
+- **Kata nodes**, for the Kata runtimes: a node group with `/dev/kvm`, such as EC2 instances with nested virtualization. See [Install the runtimes](https://github.com/coder/registry/blob/main/registry/coder-labs/templates/kubernetes-agent-sandbox/cluster/README.md#2-install-the-runtimes).
+- **Coder Premium**, for prebuilds and the AI Gateway. Without it, set `prebuild_instances = 0` and `enable_ai_gateway = false`, and give Claude Code another way to sign in.
+- **Warm pools**, for scratch homes. `cluster/` creates them by default. Set `scratch_profiles` to empty in both to skip them.
+- **A DaemonSet that pulls the image** on every node.
+- **A provider cache or mirror** for the Coder provisioner.
+- **The guardrail**, on Amazon EKS with VPC CNI v1.21.1 or later.
+
+## Quick start
+
+1. Build the image and push it to your registry. See [Build the image](#build-the-image).
+2. Set up the cluster with the Terraform configuration in [`cluster/`](https://github.com/coder/registry/blob/main/registry/coder-labs/templates/kubernetes-agent-sandbox/cluster). See [`cluster/README.md`](https://github.com/coder/registry/blob/main/registry/coder-labs/templates/kubernetes-agent-sandbox/cluster/README.md).
+3. Push the template with the variables that `cluster/` prints. They keep the template and the warm pools in line:
+
+ ```sh
+ cd cluster
+ terraform output -raw coder_variables > ~/kubernetes-agent-sandbox.yaml
+ cd ..
+ coder templates push kubernetes-agent-sandbox -d . --variables-file ~/kubernetes-agent-sandbox.yaml
+ ```
+
+Users pick the network profile, the runtime, and a persistent or scratch home when they create a workspace. They cannot change them later. Each persistent profile is a preset with `prebuild_instances` prebuilds. Each scratch profile is a preset that claims from a warm pool.
+
+If the access URL uses a host name, also set `coder_host_ip`, because Fully isolated blocks DNS. A Fully isolated build fails with a clear message if it is missing.
+
+## Build the image
+
+```sh
+docker buildx build --platform linux/amd64 \
+ --build-arg CODER_VERSION=2.30.0 \
+ -t registry.example.com/coder/workspace:v2.30.0 --push build
+```
+
+- Match `CODER_VERSION` to your Coder server, and rebuild when you upgrade Coder. `CODER_BINARY_URL`, such as `https://coder.example.com/bin/coder-linux-amd64`, copies the binary from your server instead of GitHub.
+- `INSTALL_CODE_SERVER=false` and `INSTALL_CLAUDE_CODE=false` leave out the tools. With `INSTALL_CLAUDE_CODE=false`, set `enable_claude_code = false` in the template.
+- Use a fixed tag or a digest. The template rejects `:latest`, because `imagePullPolicy: IfNotPresent` would keep an old copy on the nodes.
+
+## Architecture
+
+- **Persistent home:** Terraform creates a disk, a NetworkPolicy for the profile, a Secret with the agent token, and a Sandbox. Stop deletes everything except the disk. Delete also deletes the disk.
+- **Scratch home:** Terraform creates only a SandboxClaim. agent-sandbox hands it a booted Pod from the warm pool, and the Pod reads the agent token from an annotation. Stop deletes the Pod and its home, so nothing survives a stop.
+- **Policy check:** before the agent starts, the Pod waits until the Kubernetes API server is unreachable, which shows that its NetworkPolicy is in force. It checks again every 5 s and stops the agent if the API server becomes reachable. VPC CNI can apply a new Pod's policy up to 30 s late, and can drop it while the Pod runs.
+- **Pods:** run as user 1000 with no capabilities and no ServiceAccount token, and request 500m CPU and 1 GiB (`shared/defaults.json`). They use `8.8.8.8` and `1.1.1.1` for DNS, or the servers in `dns_servers`.
+- **Apps:** code-server, and Claude Code, which continues the last conversation in `/home/coder`.
+
+## Speed tuning
+
+Workspace speed and Pod startup speed are not the same. Pod startup is the time until the sandbox runs, and agent-sandbox makes it very short: a scratch home claims a booted sandbox from a warm pool in 50 to 80 ms. Workspace speed is the time from create until you can connect, and it also includes the Coder build: the provisioner job, Terraform, and the agent connecting to the Coder server. Most of a fast start is the Coder build, so a warm pool and a prebuild are about as fast.
+
+In our Amazon EKS test cluster (`m7i.2xlarge` nodes, VPC CNI), from create to a connected agent:
+
+| Workspace | Ready in |
+| ------------------------------------------------------ | ------------ |
+| Scratch home, from a warm pool | 3.5 to 6.5 s |
+| Persistent home, from a prebuild | 4 to 7 s |
+| Persistent home, no prebuild, gVisor | about 17 s |
+| Persistent home, no prebuild, Kata QEMU | about 19 s |
+| Persistent home, no prebuild, with a coder/coder clone | about 58 s |
+
+These all add to the speed:
+
+1. **The image.** [`build/Dockerfile`](https://github.com/coder/registry/blob/main/registry/coder-labs/templates/kubernetes-agent-sandbox/build/Dockerfile) adds the Coder agent, code-server, and Claude Code to `codercom/enterprise-base:ubuntu`. The Pod runs the agent from the image instead of the init script that downloads it, and the code-server module runs with `offline = true`. So a start downloads nothing, and it also works in Fully isolated, which has no internet. The image is about 800 MB compressed, so pull it ahead of time with a DaemonSet, or the first Pod on a node waits for the pull.
+2. **Prebuilds** (Premium). Each persistent preset keeps `prebuild_instances` running workspaces, and the GitHub only and Public internet ones have already cloned `git_repo_url`. A claim reuses the Sandbox and its disk, so the Pod does not restart.
+3. **Warm pools.** Scratch homes claim a booted sandbox from agent-sandbox in 50 to 80 ms. GitHub only pools clone `git_repo_url` before a claim.
+4. **The runtime.** On a cold start, gVisor added about 0.2 s over a normal container, Kata Cloud Hypervisor about 1.5 s, and Kata QEMU about 2.6 s. Prebuilds and warm pools hide this.
+5. **Terraform.** Coder caches modules, but downloads providers on every build unless the provisioner has a provider cache or mirror.
+6. **The Coder server.** Builds use the server's CPU, so a low CPU limit slows every start. Give it enough CPU and enough provisioners.
+7. **The disk.** A StorageClass with `volumeBindingMode: WaitForFirstConsumer` creates the disk in the node's zone. Moving an EBS volume to another node adds 5 to 10 s.
+
+## Firewall
+
+Each workspace gets a Kubernetes NetworkPolicy, which matches IP ranges and ports. That is enough for restricted workloads that need only a few known hosts, which are often background jobs such as codebase research or reading logs. It falls apart for dynamic, human-driven AI work. There, the agent installs packages, reads docs, and calls APIs that you cannot list ahead of time, and most of them sit behind CDNs with shared IP addresses that change. You end up allowing whole CDN ranges, or Public internet.
+
+For more advanced rules:
+
+- **ClusterNetworkPolicy** sets rules for the whole cluster that no NetworkPolicy in a namespace can undo. `cluster/` uses it for the optional guardrail on Amazon EKS. See [Guardrail](https://github.com/coder/registry/blob/main/registry/coder-labs/templates/kubernetes-agent-sandbox/cluster/README.md#guardrail).
+- **[Cilium](https://cilium.io/)** can match host names ([`toFQDNs`](https://docs.cilium.io/en/stable/security/dns/)) and HTTP requests, so a profile can allow `registry.npmjs.org` or `pypi.org` without their IP ranges.
+- **An egress proxy** gives you host name rules and logs on any network plugin. Set `HTTPS_PROXY` in the image, and allow only the proxy in the NetworkPolicy.
+
+### Network ingress
+
+Every profile blocks all ingress. The Coder agent dials out to the Coder server, so the workspace needs no open port. Your options to reach it:
+
+| Option | How it works | Ingress to open |
+| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- |
+| Through the Coder server (default) | The web terminal, code-server, `coder ssh`, `coder port-forward`, port sharing, and desktop IDEs relay through the Coder server. | None |
+| Direct connections | Coder connects the client and the workspace peer to peer, which lowers latency. It works only if the client can reach the Pod IP, such as over a VPN into the VPC. | Allow UDP egress to your STUN servers and to the client networks. NetworkPolicy allows the replies. |
+| A Kubernetes Service, Ingress, or Gateway | Other Pods or users reach a port in the workspace directly, without Coder. | An ingress rule from those Pods or from the ingress controller. Turn off the guardrail, because it blocks ingress from every Pod. |
+
+Use the Coder server when you can. It checks who the user is and what they can access, and it works with every profile. A Service or Ingress skips those checks, so add your own sign-in in front of it. The template creates no Service or Ingress.
+
+### Network egress
+
+| Profile | Allowed egress |
+| --------------- | ---------------------------------------------------------------------------------------------------------- |
+| Fully isolated | The Coder server on `coder_egress_port`. No DNS. |
+| GitHub only | Coder, the GitHub IPv4 ranges on TCP 443 and 80, and DNS to `dns_servers`. |
+| Public internet | Coder, every public IPv4 address on TCP 443 and 80, and the same DNS. No private ranges or cloud metadata. |
+
+The GitHub ranges are in `shared/defaults.json`. If your `kubernetes` Service points to a public IP (`kubectl get endpoints kubernetes -n default`), Public internet can reach it and the policy check never passes, so set `network_policy_gate = false`. For an air-gapped cluster, mirror the images and providers, and build the image with `CODER_BINARY_URL`.
+
+## Limits
+
+- NetworkPolicy matches IP ranges, so there is no profile for npm or PyPI. Use Public internet, or see [Firewall](#firewall).
+- GitHub only and Public internet can still send data out, for example to an attacker's repository or through DNS.
+- GitHub only pools can clone only public repositories.
+- The AI Gateway token works because the AI Gateway accepts any valid token of an active user. If a later Coder release checks scopes there, the token needs a matching scope. Each start adds an `ai-gateway--` token to the owner's token list until it expires.
+- If VPC CNI drops a Pod's rules, the Pod can reach the network for up to 5 s before the check stops it.
+- No root, no Docker, IPv4 only. The workspace TTL in Coder is the only stop timer.
diff --git a/registry/coder-labs/templates/kubernetes-agent-sandbox/build/Dockerfile b/registry/coder-labs/templates/kubernetes-agent-sandbox/build/Dockerfile
new file mode 100644
index 000000000..2c2fb5813
--- /dev/null
+++ b/registry/coder-labs/templates/kubernetes-agent-sandbox/build/Dockerfile
@@ -0,0 +1,48 @@
+FROM codercom/enterprise-base:ubuntu
+
+ARG TARGETARCH=amd64
+ARG CODER_VERSION=
+ARG CODER_BINARY_URL=
+ARG INSTALL_CODE_SERVER=true
+ARG CODE_SERVER_VERSION=4.105.1
+ARG INSTALL_CLAUDE_CODE=true
+
+USER root
+
+# The Coder agent.
+RUN set -eu; \
+ if [ -n "${CODER_BINARY_URL}" ]; then \
+ curl -fsSL --retry 5 -o /usr/local/bin/coder "${CODER_BINARY_URL}"; \
+ elif [ -n "${CODER_VERSION}" ]; then \
+ v="${CODER_VERSION#v}"; \
+ curl -fsSL --retry 5 "https://github.com/coder/coder/releases/download/v${v}/coder_${v}_linux_${TARGETARCH}.tar.gz" \
+ | tar -xz -C /usr/local/bin ./coder; \
+ else \
+ echo "Set CODER_VERSION or CODER_BINARY_URL" >&2; exit 1; \
+ fi; \
+ chmod 755 /usr/local/bin/coder; \
+ /usr/local/bin/coder version
+
+# code-server, outside /home so a home volume does not hide it. The
+# code-server module uses it with offline = true.
+RUN set -eu; \
+ if [ "${INSTALL_CODE_SERVER}" = true ]; then \
+ curl -fsSL https://code-server.dev/install.sh \
+ | sh -s -- --method=standalone --prefix=/opt/code-server --version "${CODE_SERVER_VERSION}"; \
+ fi
+
+# Claude Code, outside /home for the same reason. The claude-code module
+# uses it with install_claude_code = false and claude_binary_path =
+# "/opt/claude/bin".
+RUN set -eu; \
+ if [ "${INSTALL_CLAUDE_CODE}" = true ]; then \
+ export HOME=/opt/claude; \
+ mkdir -p /opt/claude/bin; \
+ curl -fsSL https://claude.ai/install.sh | bash; \
+ ln -sf "$(readlink -f /opt/claude/.local/bin/claude)" /opt/claude/bin/claude; \
+ chmod -R a+rX /opt/claude; \
+ /opt/claude/bin/claude --version; \
+ fi
+
+USER coder
+WORKDIR /home/coder
diff --git a/registry/coder-labs/templates/kubernetes-agent-sandbox/cluster/README.md b/registry/coder-labs/templates/kubernetes-agent-sandbox/cluster/README.md
new file mode 100644
index 000000000..d703f350c
--- /dev/null
+++ b/registry/coder-labs/templates/kubernetes-agent-sandbox/cluster/README.md
@@ -0,0 +1,184 @@
+# Cluster setup for kubernetes-agent-sandbox
+
+A cluster administrator sets up the cluster once. The Terraform configuration in this directory creates everything the template needs in the cluster:
+
+- The workspace namespace, and a Role and RoleBinding for the Coder provisioner. The provisioner can manage workspaces in this namespace only. It can read the SandboxTemplates and warm pools, but cannot change them.
+- A ClusterRole that lets the provisioner read CRDs, which `kubernetes_manifest` needs at plan time.
+- One SandboxTemplate and one SandboxWarmPool for each runtime and scratch profile, for scratch homes.
+- Optionally, a cluster-wide guardrail on Amazon EKS.
+
+Persistent homes need only the namespace and the Role. The template creates their Sandbox, disk, and NetworkPolicy.
+
+## 1. Install agent-sandbox
+
+Install the controller and the extensions, v1.0.4 or later. The extensions add SandboxTemplate, SandboxClaim, and SandboxWarmPool:
+
+```sh
+kubectl apply -f https://github.com/kubernetes-sigs/agent-sandbox/releases/download/v1.0.4/sandbox-with-extensions.yaml
+```
+
+The default settings suit one warm pool. With more pools, add these flags to the controller Deployment, so that the pools refill at the same time:
+
+```sh
+kubectl -n agent-sandbox-system patch deployment agent-sandbox-controller --type json -p '[
+ {"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value": "--sandbox-warm-pool-concurrent-workers=4"},
+ {"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value": "--api-connections=4"},
+ {"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value": "--separate-watch-connection=true"}
+]'
+```
+
+Set `--sandbox-warm-pool-concurrent-workers` to the number of pools. These flags make refills faster. Claims were already fast, 50 to 80 ms. The template puts only annotations on a claimed Pod, so you do not need to change the `allowed-label-domains` setting. See the agent-sandbox `docs/performance-tuning.md` for all flags.
+
+## 2. Install the runtimes
+
+The template supports three RuntimeClasses: `gvisor`, `kata-qemu`, and `kata-clh`. Install the ones you want to offer, and list them in `runtimes` in step 3.
+
+### gVisor
+
+gVisor runs on any Linux node. Install `runsc` and its containerd shim on each node, add a `runsc` runtime to the containerd config, and create a RuntimeClass named `gvisor` with `handler: runsc`. See the [gVisor containerd guide](https://gvisor.dev/docs/user_guide/containerd/quick_start/). On GKE, GKE Sandbox provides the `gvisor` RuntimeClass.
+
+Use `release-20261019.0` or later. Older releases block when the Coder agent writes nothing to a terminal, which it does on every resize, so the web terminal takes no input. To check the version on a node, run `runsc --version`. Sandboxes that are already running keep their version until they restart.
+
+### Kata (QEMU and Cloud Hypervisor)
+
+Kata runs each Pod in a small VM, so the node needs `/dev/kvm`: a bare metal node, or a VM with nested virtualization.
+
+On EC2, C7i, M7i, R7i, C8i, M8i, and R8i instances support nested virtualization when they start with `CpuOptions.NestedVirtualization=enabled`. eksctl cannot set this, so create the node group from an EC2 launch template, and label its nodes `coder.com/runtime=kata`.
+
+Install Kata with the [kata-deploy Helm chart](https://github.com/kata-containers/kata-containers/blob/main/tools/packaging/kata-deploy/helm-chart/README.md). With these values, it installs only the QEMU and Cloud Hypervisor shims, and only on the Kata nodes:
+
+```yaml
+# kata-values.yaml
+nodeSelector:
+ coder.com/runtime: kata
+shims:
+ disableAll: true
+ qemu:
+ enabled: true
+ clh:
+ enabled: true
+runtimeClasses:
+ enabled: true
+ createDefault: false
+```
+
+```sh
+helm install kata-deploy oci://ghcr.io/kata-containers/kata-deploy-charts/kata-deploy \
+ --version 4.2.0 -n kube-system -f kata-values.yaml
+```
+
+The `kata-qemu` and `kata-clh` RuntimeClasses from the chart have a `scheduling.nodeSelector`, so Kubernetes places Kata Pods on the Kata nodes without a nodeSelector in the template.
+
+Cloud Hypervisor starts faster. QEMU is the more common choice and supports more devices. On a cold start, gVisor added about 0.2 s over a normal container, Cloud Hypervisor about 1.5 s, and QEMU about 2.6 s. A warm pool or a prebuild hides this time.
+
+To check a runtime, start a Pod with it and look at the kernel. gVisor reports a kernel such as `4.4.0` or `4.19.0-gvisor`, and Kata reports the VM kernel, not the node kernel:
+
+```sh
+kubectl run kata-check --rm -it --restart=Never --image=busybox \
+ --overrides='{"spec":{"runtimeClassName":"kata-clh"}}' -- uname -r
+```
+
+## 3. Set your values
+
+Copy [`example.tfvars`](https://github.com/coder/registry/blob/main/registry/coder-labs/templates/kubernetes-agent-sandbox/cluster/example.tfvars) to `install.auto.tfvars` in this directory and edit it. Terraform loads `*.auto.tfvars` files on its own, and `coder templates push` leaves them out of the template.
+
+| Variable | Default | Description |
+| ----------------------------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------- |
+| `namespace` | none | The namespace for workspaces. |
+| `workspace_image` | none | The image built from `build/Dockerfile`, with a fixed tag or a digest. |
+| `coder_access_url` | `""` | Optional. The pools take the agent URL from each workspace. Set it to derive `coder_egress_port`, or with `coder_host_ip`. |
+| `coder_egress_cidrs` | none | The IPv4 CIDR ranges of the Coder server, for example `["203.0.113.10/32"]`. |
+| `coder_egress_port` | from the URL | The TCP port to the Coder server. Empty uses the port of `coder_access_url`, or 443. |
+| `coder_host_ip` | `""` | The Coder server IP. Set it if `coder_access_url` uses a host name, because Fully isolated blocks DNS. |
+| `runtimes` | `["gvisor"]` | The runtimes to create pools for: `gvisor`, `kata-qemu`, `kata-clh`. |
+| `scratch_profiles` | `["isolated", "github"]` | The network profiles to create pools for. `[]` creates no pools. |
+| `warm_pool_replicas` | `1` | Idle sandboxes in each pool. See [Size the warm pools](#size-the-warm-pools). |
+| `git_repo_url` | `""` | A public repository that GitHub only pools clone while they wait. |
+| `network_policy_gate` | `true` | Start the agent only once the NetworkPolicy blocks the Kubernetes API server. |
+| `dns_servers` | `[]` | The DNS servers of the pools. Empty uses `8.8.8.8` and `1.1.1.1`. |
+| `enable_guardrail` | `false` | Create the cluster-wide guardrail. Amazon EKS only. See [Guardrail](#guardrail). |
+| `create_namespace` | `true` | Create the namespace. Set `false` if it exists. |
+| `provisioner_service_account` | `coder` in `coder` | The ServiceAccount of the Coder provisioner. |
+| `grant_crd_read` | `true` | Let the provisioner read CRDs. |
+| `kubeconfig_path` | `~/.kube/config` | The kubeconfig file. |
+| `kubeconfig_context` | `""` | The kubeconfig context. Empty uses the current context. |
+
+## 4. Apply
+
+Run Terraform from this directory with an account that can create namespaces, RBAC, and agent-sandbox resources:
+
+```sh
+terraform init
+terraform apply
+```
+
+Install agent-sandbox (step 1) first. Terraform reads the agent-sandbox CRDs when it plans, so the plan fails without them. The guardrail also needs the ClusterNetworkPolicy CRD, which VPC CNI v1.21.1 or later installs.
+
+Keep the Terraform state. Terraform needs it to change or remove the setup later. If more than one person runs it, use a [remote backend](https://developer.hashicorp.com/terraform/language/backend).
+
+Check that each pool has a ready sandbox:
+
+```sh
+kubectl get sandboxwarmpool -n
+```
+
+The GitHub only pools turn ready after their Pods clone the repository, about 40 s for coder/coder.
+
+## 5. Push the template with the same values
+
+The template and the pools must agree on the namespace, image, Coder address, runtimes, and scratch profiles. The `coder_variables` output has these as a variables file for `coder templates push`. Write it outside the template directory:
+
+```sh
+terraform output -raw coder_variables > ~/kubernetes-agent-sandbox.yaml
+cd ..
+coder templates push kubernetes-agent-sandbox -d . --variables-file ~/kubernetes-agent-sandbox.yaml
+```
+
+Add other template variables, such as `prebuild_instances`, to the file. The template takes `runtimes` and `scratch_profiles` comma-separated, for example `gvisor,kata-clh`, because Coder checks presets with every variable as a string. The output already has them in that form. Run both steps again when you change a value.
+
+## Changes and removal
+
+- A change to the image, the Coder address, the network rules, or `git_repo_url` changes the SandboxTemplates. The pools use `updateStrategy.type: Recreate`, so they replace their idle sandboxes. Claimed sandboxes keep running until their workspace stops.
+- When you upgrade the template and this configuration together, push the template first. New pools can wait for claim annotations that only the new template sets.
+- Removing a runtime or a profile from the lists deletes its SandboxTemplate and pool. Remove it from the template first, so that no new workspace claims from it.
+- Changing `create_namespace` from `true` to `false` deletes the namespace and every workspace in it. To stop managing an existing namespace, run `terraform state rm 'kubernetes_namespace_v1.workspaces[0]'` first.
+- `terraform destroy` removes everything this configuration created, including the namespace if it created it.
+
+## Size the warm pools
+
+Each pool keeps `warm_pool_replicas` booted, idle sandboxes. A scratch workspace claims one and is ready in about 3.5 to 6.5 s, and the pool boots a replacement. Set it to the number of scratch workspaces that start at about the same time. With `0`, a scratch workspace waits until the pool boots a sandbox, so remove the profile from `scratch_profiles` instead if you do not want scratch homes.
+
+Each idle sandbox reserves the Pod's requests (500m CPU and 1 GiB) plus the RuntimeClass overhead (Kata: 250m CPU and 130 to 320 MiB). An idle sandbox also uses a little CPU for the runtime and for the loop that waits for the token. Use `kubectl top pod` to see it on your nodes.
+
+## How a pooled sandbox starts
+
+1. The Pod boots and copies the image's dotfiles into `/home/coder`. A GitHub only Pod then clones `git_repo_url`. If the clone fails 5 times, the container exits and Kubernetes starts it again. The Pod turns ready after this.
+2. It waits for the file `/podinfo/token`.
+3. A workspace build creates a SandboxClaim. agent-sandbox hands it an idle Pod and adds the claim's annotations to the Pod.
+4. The downward API writes the `coder.com/agent-token` and `coder.com/agent-url` annotations to `/podinfo/token` and `/podinfo/url`, usually within a second. The URL is the workspace's access URL, so the pools always use the same Coder address as the template.
+5. The Pod runs [`shared/start-agent.sh`](https://github.com/coder/registry/blob/main/registry/coder-labs/templates/kubernetes-agent-sandbox/shared/start-agent.sh). It waits until a TCP connection to the Kubernetes API server fails, which shows that its NetworkPolicy is in force, and then starts the Coder agent as PID 1. Every 5 s it checks again. If the API server becomes reachable, it stops the agent and the container exits.
+
+Persistent homes run the same script, so the two cannot drift apart. The GitHub ranges, DNS servers, private ranges, and Pod resources come from [`shared/defaults.json`](https://github.com/coder/registry/blob/main/registry/coder-labs/templates/kubernetes-agent-sandbox/shared/defaults.json), which both use too.
+
+The agent token stays in plain text in the Pod annotation and the SandboxClaim, so only trusted users should read Pods and SandboxClaims in the namespace.
+
+Claimed Pods get only annotations, not the `com.coder.workspace.id` label, because claim labels need a domain from the controller's `allowed-label-domains` setting.
+
+## Network rules of the pools
+
+Each SandboxTemplate sets its own NetworkPolicy, which replaces the agent-sandbox default:
+
+- `coder-`: Fully isolated, the Coder server only.
+- `coder--github`: GitHub only, the Coder server, the GitHub ranges, and DNS to `8.8.8.8` and `1.1.1.1`.
+
+Pods of one pool share one NetworkPolicy. On Amazon VPC CNI, the Pods of one pool on one node also share their network rules, so each network profile has its own pool, and Public internet has no scratch home.
+
+## Guardrail
+
+The NetworkPolicies of the template and the SandboxTemplates are namespace-scoped. Anyone who can edit a SandboxTemplate or create a NetworkPolicy in the namespace can open the network.
+
+With `enable_guardrail = true`, Terraform adds a ClusterNetworkPolicy in the Admin tier, which Amazon EKS checks before every NetworkPolicy. For every Pod in the workspace namespace, it allows the Coder server, and it blocks private, shared, and link-local ranges (the VPC, other Pods, the Kubernetes API server, and cloud metadata) and ingress from other Pods. The rest goes on to the NetworkPolicies, so the profiles work as before. It needs VPC CNI v1.21.1 or later. On other clusters, the upstream [Network Policy API](https://network-policy-api.sigs.k8s.io/) `ClusterNetworkPolicy` can do the same, but it has another API group and schema.
+
+In our test, a new Pod got the guardrail about 2 s after it started, the same as a NetworkPolicy. So keep `network_policy_gate` on. The ingress rule becomes a list of Pod IP addresses, so a Pod that started a few seconds ago can still connect until the list catches up. The profiles' own NetworkPolicies block all ingress too.
+
+The guardrail and the profiles match IP ranges. To allow host names, such as `registry.npmjs.org`, you need a network plugin with DNS-based rules, such as [Cilium `toFQDNs`](https://docs.cilium.io/en/stable/security/dns/) or EKS Auto Mode `ApplicationNetworkPolicy`, or an egress proxy that checks the host name. A rule based on DNS still allows every host behind an allowed IP address. Only a proxy can tell hosts on a shared IP address apart.
diff --git a/registry/coder-labs/templates/kubernetes-agent-sandbox/cluster/example.tfvars b/registry/coder-labs/templates/kubernetes-agent-sandbox/cluster/example.tfvars
new file mode 100644
index 000000000..f698ca16f
--- /dev/null
+++ b/registry/coder-labs/templates/kubernetes-agent-sandbox/cluster/example.tfvars
@@ -0,0 +1,31 @@
+# Copy this file to install.auto.tfvars and set your values. Terraform
+# loads *.auto.tfvars on its own, and `coder templates push` leaves it out.
+
+# The namespace for workspaces. Terraform creates it.
+namespace = "coder-workspaces"
+
+# The image you built from build/Dockerfile, with a fixed tag or a digest.
+workspace_image = "registry.example.com/coder/workspace:v2.30.0"
+
+# The Coder access URL. Optional: each workspace passes its own access URL
+# to the pool. Set it if Coder is not on port 443, or with coder_host_ip.
+# coder_access_url = "https://coder.example.com"
+
+# The IP ranges of the Coder server, as workspaces see it. Fully isolated
+# workspaces can reach only these. Use the IP of the load balancer or
+# ingress behind coder_access_url, for example the output of
+# `dig +short coder.example.com`, with /32 for one address.
+coder_egress_cidrs = ["203.0.113.10/32"]
+
+# Fully isolated blocks DNS. If coder_access_url uses a host name, set the
+# Coder server IP so the pools can resolve it.
+# coder_host_ip = "203.0.113.10"
+
+# Offer only the runtimes that your nodes have. See "Install the runtimes".
+# runtimes = ["gvisor", "kata-qemu", "kata-clh"]
+
+# A public repository that GitHub only pools clone while they wait.
+# git_repo_url = "https://github.com/coder/coder"
+
+# Amazon EKS with VPC CNI v1.21.1 or later only.
+# enable_guardrail = true
diff --git a/registry/coder-labs/templates/kubernetes-agent-sandbox/cluster/main.tf b/registry/coder-labs/templates/kubernetes-agent-sandbox/cluster/main.tf
new file mode 100644
index 000000000..4ad6fe4ba
--- /dev/null
+++ b/registry/coder-labs/templates/kubernetes-agent-sandbox/cluster/main.tf
@@ -0,0 +1,546 @@
+# Cluster setup for the kubernetes-agent-sandbox template. A cluster
+# administrator applies this once. See README.md in this directory.
+
+terraform {
+ required_providers {
+ kubernetes = {
+ source = "hashicorp/kubernetes"
+ version = ">= 2.23"
+ }
+ }
+}
+
+variable "kubeconfig_path" {
+ description = "The kubeconfig file to use."
+ type = string
+ default = "~/.kube/config"
+}
+
+variable "kubeconfig_context" {
+ description = "The kubeconfig context to use. Empty uses the current context."
+ type = string
+ default = ""
+}
+
+variable "namespace" {
+ description = "The namespace for workspaces. Use the same value in the template."
+ type = string
+
+ validation {
+ condition = can(regex("^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", var.namespace)) && length(var.namespace) <= 63
+ error_message = "namespace must be a valid Kubernetes namespace name."
+ }
+}
+
+variable "create_namespace" {
+ description = "Create the namespace. Set false if it exists already. Changing this from true to false deletes the namespace."
+ type = bool
+ default = true
+}
+
+variable "provisioner_service_account" {
+ description = "The ServiceAccount of the Coder provisioner. The Coder Helm chart uses coder in the coder namespace."
+ type = object({
+ name = string
+ namespace = string
+ })
+ default = {
+ name = "coder"
+ namespace = "coder"
+ }
+}
+
+variable "grant_crd_read" {
+ description = "Let the provisioner read CustomResourceDefinitions, which kubernetes_manifest needs at plan time. CRDs are cluster-scoped, so this adds a ClusterRole."
+ type = bool
+ default = true
+}
+
+variable "workspace_image" {
+ description = "The image built from build/Dockerfile, with a fixed tag or a digest. Use the same value in the template."
+ type = string
+
+ validation {
+ condition = !endswith(var.workspace_image, ":latest") && can(regex("(:[^/]+|@sha256:[0-9a-f]{64})$", var.workspace_image))
+ error_message = "workspace_image must have a fixed tag other than :latest, or a digest."
+ }
+}
+
+variable "coder_access_url" {
+ description = "The Coder access URL, for example https://coder.example.com. Optional: the agent URL comes from each workspace. Set it to derive coder_egress_port, or with coder_host_ip for an access URL that uses a host name."
+ type = string
+ default = ""
+
+ validation {
+ condition = var.coder_access_url == "" || can(regex("^https?://[^/:]+(:[0-9]+)?/?$", var.coder_access_url))
+ error_message = "coder_access_url must be an http or https URL with no path, for example https://coder.example.com."
+ }
+}
+
+variable "coder_egress_cidrs" {
+ description = "The IPv4 CIDR ranges of the Coder server. Use the same value in the template."
+ type = list(string)
+
+ validation {
+ condition = length(var.coder_egress_cidrs) > 0 && alltrue([for c in var.coder_egress_cidrs : can(cidrhost(c, 0)) && !strcontains(c, ":")])
+ error_message = "coder_egress_cidrs must have at least one valid IPv4 CIDR range, such as 203.0.113.10/32."
+ }
+}
+
+variable "coder_egress_port" {
+ description = "The TCP port that workspaces use to reach the Coder server. Empty uses the port of coder_access_url, or 443 if that is empty too."
+ type = number
+ default = null
+
+ validation {
+ condition = var.coder_egress_port == null ? true : (var.coder_egress_port >= 1 && var.coder_egress_port <= 65535 && floor(var.coder_egress_port) == var.coder_egress_port)
+ error_message = "coder_egress_port must be empty or a whole number from 1 to 65535."
+ }
+}
+
+variable "coder_host_ip" {
+ description = "The IPv4 address of the Coder server. Fully isolated blocks DNS, so if coder_access_url uses a host name, the pools map it to this address. Use the same value in the template."
+ type = string
+ default = ""
+
+ validation {
+ condition = var.coder_host_ip == "" || (can(cidrhost("${var.coder_host_ip}/32", 0)) && !strcontains(var.coder_host_ip, ":"))
+ error_message = "coder_host_ip must be empty or an IPv4 address."
+ }
+}
+
+variable "runtimes" {
+ description = "The RuntimeClasses to create warm pools for: gvisor, kata-qemu, kata-clh. Use the same value in the template."
+ type = list(string)
+ default = ["gvisor"]
+
+ validation {
+ condition = length(var.runtimes) > 0 && length(var.runtimes) == length(distinct(var.runtimes)) && alltrue([for r in var.runtimes : contains(["gvisor", "kata-qemu", "kata-clh"], r)])
+ error_message = "runtimes must list one or more of gvisor, kata-qemu, and kata-clh, each once."
+ }
+}
+
+variable "scratch_profiles" {
+ description = "The network profiles to create warm pools for: isolated, github, or both. Use the same value in the template. Empty creates no pools."
+ type = list(string)
+ default = ["isolated", "github"]
+
+ validation {
+ condition = length(var.scratch_profiles) == length(distinct(var.scratch_profiles)) && alltrue([for p in var.scratch_profiles : contains(["isolated", "github"], p)])
+ error_message = "scratch_profiles can list only isolated and github, each once."
+ }
+}
+
+variable "warm_pool_replicas" {
+ description = "The number of idle sandboxes in each warm pool."
+ type = number
+ default = 1
+
+ validation {
+ condition = var.warm_pool_replicas >= 0 && floor(var.warm_pool_replicas) == var.warm_pool_replicas
+ error_message = "warm_pool_replicas must be a whole number, 0 or more."
+ }
+}
+
+variable "git_repo_url" {
+ description = "A public Git repository that GitHub only pools clone while they wait. Use the same value in the template. Empty clones nothing."
+ type = string
+ default = ""
+
+ validation {
+ condition = var.git_repo_url == "" || can(regex("^https://", var.git_repo_url))
+ error_message = "git_repo_url must be empty or an https URL."
+ }
+}
+
+variable "network_policy_gate" {
+ description = "Start the Coder agent only after the NetworkPolicy blocks the Kubernetes API server. Use the same value in the template."
+ type = bool
+ default = true
+}
+
+variable "dns_servers" {
+ description = "The DNS servers that workspaces use. Empty uses 8.8.8.8 and 1.1.1.1 from shared/defaults.json. Use the same value in the template."
+ type = list(string)
+ default = []
+
+ validation {
+ condition = alltrue([for d in var.dns_servers : can(cidrhost("${d}/32", 0)) && !strcontains(d, ":")])
+ error_message = "dns_servers must be IPv4 addresses."
+ }
+}
+
+variable "enable_guardrail" {
+ description = "Create the cluster-wide ClusterNetworkPolicy guardrail. Needs Amazon EKS with VPC CNI v1.21.1 or later."
+ type = bool
+ default = false
+}
+
+provider "kubernetes" {
+ config_path = var.kubeconfig_path
+ config_context = var.kubeconfig_context == "" ? null : var.kubeconfig_context
+}
+
+locals {
+ defaults = jsondecode(file("${path.module}/../shared/defaults.json"))
+ dns_servers = length(var.dns_servers) > 0 ? var.dns_servers : local.defaults.dns_servers
+
+ access_host = try(regex("^https?://([^/:]+)", var.coder_access_url)[0], "")
+ access_host_is_ip = can(regex("^[0-9]{1,3}(\\.[0-9]{1,3}){3}$", local.access_host))
+ access_port = var.coder_access_url == "" ? 443 : try(
+ tonumber(regex("^https?://[^/:]+:([0-9]+)", var.coder_access_url)[0]),
+ startswith(var.coder_access_url, "http://") ? 80 : 443,
+ )
+ coder_port = coalesce(var.coder_egress_port, local.access_port)
+ host_aliases = var.coder_host_ip != "" && local.access_host != "" && !local.access_host_is_ip ? [{ ip = var.coder_host_ip, hostnames = [local.access_host] }] : null
+ # Leave hostAliases out when empty. A null value makes every plan
+ # update the SandboxTemplate.
+ host_alias_field = { for k, v in { hostAliases = local.host_aliases } : k => v if v != null }
+
+ namespace = var.create_namespace ? kubernetes_namespace_v1.workspaces[0].metadata[0].name : var.namespace
+
+ # One pool for each runtime and scratch profile. Keep the names in line
+ # with local.pool_suffix in ../main.tf.
+ pool_suffix = { isolated = "", github = "-github" }
+ pools = {
+ for pair in setproduct(var.runtimes, var.scratch_profiles) :
+ "coder-${pair[0]}${local.pool_suffix[pair[1]]}" => {
+ runtime = pair[0]
+ profile = pair[1]
+ }
+ }
+
+ coder_egress = {
+ to = [for c in var.coder_egress_cidrs : { ipBlock = { cidr = c } }]
+ ports = [{ protocol = "TCP", port = local.coder_port }]
+ }
+ github_egress = [
+ {
+ to = [for c in local.defaults.github_cidrs : { ipBlock = { cidr = c } }]
+ ports = [{ protocol = "TCP", port = 443 }, { protocol = "TCP", port = 80 }]
+ },
+ {
+ to = [for d in local.dns_servers : { ipBlock = { cidr = "${d}/32" } }]
+ ports = [{ protocol = "UDP", port = 53 }, { protocol = "TCP", port = 53 }]
+ },
+ ]
+
+ # A pooled Pod seeds its home, clones the repository if it has one, and
+ # then waits for the claim's token and the workspace's access URL. The
+ # values come from env and files, not from the script text.
+ pool_command = join("\n", [
+ "[ -e /home/coder/.bashrc ] || cp -an /etc/skel/. /home/coder/ 2>/dev/null || true",
+ "if [ -n \"$GIT_REPO_URL\" ]; then",
+ " dir=\"/home/coder/$(basename \"$GIT_REPO_URL\" .git)\"",
+ " if [ ! -d \"$dir/.git\" ]; then",
+ " n=0",
+ " until git clone --quiet \"$GIT_REPO_URL\" \"$dir.clone\"; do",
+ " rm -rf \"$dir.clone\"",
+ " n=$((n + 1))",
+ " if [ \"$n\" -ge 5 ]; then echo \"git clone of $GIT_REPO_URL failed 5 times\" >&2; exit 1; fi",
+ " sleep 5",
+ " done",
+ " mv \"$dir.clone\" \"$dir\"",
+ " fi",
+ "fi",
+ "touch /tmp/.pool-ready",
+ "while [ ! -s /podinfo/token ] || [ ! -s /podinfo/url ]; do sleep 0.25; done",
+ "CODER_AGENT_TOKEN=\"$(cat /podinfo/token)\"",
+ "CODER_AGENT_URL=\"$(cat /podinfo/url)\"",
+ "export CODER_AGENT_TOKEN CODER_AGENT_URL",
+ "ulimit -n 65536 2>/dev/null || true",
+ var.network_policy_gate ? file("${path.module}/../shared/start-agent.sh") : "exec /usr/local/bin/coder agent",
+ ])
+}
+
+resource "kubernetes_namespace_v1" "workspaces" {
+ count = var.create_namespace ? 1 : 0
+ metadata {
+ name = var.namespace
+ }
+}
+
+# The provisioner manages workspaces in this namespace only. It reads the
+# SandboxTemplates and warm pools, but cannot change them.
+resource "kubernetes_role_v1" "provisioner" {
+ metadata {
+ name = "coder-agent-sandbox"
+ namespace = local.namespace
+ }
+ rule {
+ api_groups = [""]
+ resources = ["persistentvolumeclaims", "secrets"]
+ verbs = ["create", "delete", "get", "list", "patch", "update", "watch"]
+ }
+ rule {
+ api_groups = ["networking.k8s.io"]
+ resources = ["networkpolicies"]
+ verbs = ["create", "delete", "get", "list", "patch", "update", "watch"]
+ }
+ rule {
+ api_groups = ["agents.x-k8s.io"]
+ resources = ["sandboxes"]
+ verbs = ["create", "delete", "get", "list", "patch", "update", "watch"]
+ }
+ rule {
+ api_groups = ["extensions.agents.x-k8s.io"]
+ resources = ["sandboxclaims"]
+ verbs = ["create", "delete", "get", "list", "patch", "update", "watch"]
+ }
+ # Read-only, to debug workspaces with kubectl as the provisioner.
+ rule {
+ api_groups = [""]
+ resources = ["pods", "pods/log", "events"]
+ verbs = ["get", "list", "watch"]
+ }
+ rule {
+ api_groups = ["extensions.agents.x-k8s.io"]
+ resources = ["sandboxtemplates", "sandboxwarmpools"]
+ verbs = ["get", "list", "watch"]
+ }
+}
+
+resource "kubernetes_role_binding_v1" "provisioner" {
+ metadata {
+ name = "coder-agent-sandbox"
+ namespace = local.namespace
+ }
+ role_ref {
+ api_group = "rbac.authorization.k8s.io"
+ kind = "Role"
+ name = kubernetes_role_v1.provisioner.metadata[0].name
+ }
+ subject {
+ kind = "ServiceAccount"
+ name = var.provisioner_service_account.name
+ namespace = var.provisioner_service_account.namespace
+ }
+}
+
+resource "kubernetes_cluster_role_v1" "crd_read" {
+ count = var.grant_crd_read ? 1 : 0
+ metadata {
+ name = "coder-crd-read"
+ }
+ rule {
+ api_groups = ["apiextensions.k8s.io"]
+ resources = ["customresourcedefinitions"]
+ verbs = ["get", "list", "watch"]
+ }
+}
+
+resource "kubernetes_cluster_role_binding_v1" "crd_read" {
+ count = var.grant_crd_read ? 1 : 0
+ metadata {
+ name = "coder-crd-read"
+ }
+ role_ref {
+ api_group = "rbac.authorization.k8s.io"
+ kind = "ClusterRole"
+ name = kubernetes_cluster_role_v1.crd_read[0].metadata[0].name
+ }
+ subject {
+ kind = "ServiceAccount"
+ name = var.provisioner_service_account.name
+ namespace = var.provisioner_service_account.namespace
+ }
+}
+
+resource "kubernetes_manifest" "sandbox_template" {
+ for_each = local.pools
+
+ lifecycle {
+ precondition {
+ condition = var.coder_host_ip == "" || local.access_host != ""
+ error_message = "coder_host_ip needs coder_access_url, so the pools know which host name to map to it."
+ }
+ }
+
+ # This configuration owns these objects, so it takes over fields that an
+ # earlier kubectl apply set.
+ field_manager {
+ force_conflicts = true
+ }
+
+ manifest = {
+ apiVersion = "extensions.agents.x-k8s.io/v1beta1"
+ kind = "SandboxTemplate"
+ metadata = {
+ name = each.key
+ namespace = local.namespace
+ }
+ spec = {
+ networkPolicyManagement = "Managed"
+ networkPolicy = {
+ ingress = []
+ egress = concat([local.coder_egress], each.value.profile == "github" ? local.github_egress : [])
+ }
+ podTemplate = {
+ metadata = {
+ labels = {
+ "app.kubernetes.io/name" = "coder-workspace"
+ "app.kubernetes.io/part-of" = "coder"
+ }
+ }
+ spec = merge({
+ runtimeClassName = each.value.runtime
+ automountServiceAccountToken = false
+ enableServiceLinks = false
+ terminationGracePeriodSeconds = 2
+ dnsPolicy = "None"
+ dnsConfig = { nameservers = local.dns_servers }
+ securityContext = {
+ runAsNonRoot = true
+ runAsUser = 1000
+ runAsGroup = 1000
+ fsGroup = 1000
+ fsGroupChangePolicy = "OnRootMismatch"
+ seccompProfile = { type = "RuntimeDefault" }
+ }
+ containers = [{
+ name = "dev"
+ image = var.workspace_image
+ imagePullPolicy = "IfNotPresent"
+ workingDir = "/home/coder"
+ command = ["bash", "-c", local.pool_command]
+ env = concat(
+ [
+ { name = "CODER_AGENT_AUTH", value = "token" },
+ ],
+ each.value.profile == "github" && var.git_repo_url != "" ? [{ name = "GIT_REPO_URL", value = var.git_repo_url }] : [],
+ )
+ securityContext = {
+ allowPrivilegeEscalation = false
+ runAsNonRoot = true
+ capabilities = { drop = ["ALL"] }
+ }
+ resources = local.defaults.resources
+ # Ready once the home is seeded and the repository is cloned.
+ readinessProbe = {
+ exec = { command = ["test", "-e", "/tmp/.pool-ready"] }
+ periodSeconds = 2
+ }
+ volumeMounts = [
+ { name = "home", mountPath = "/home/coder" },
+ { name = "tmp", mountPath = "/tmp" },
+ { name = "podinfo", mountPath = "/podinfo", readOnly = true },
+ ]
+ }]
+ volumes = [
+ { name = "home", emptyDir = {} },
+ { name = "tmp", emptyDir = {} },
+ {
+ name = "podinfo"
+ downwardAPI = {
+ items = [
+ {
+ path = "token"
+ fieldRef = { fieldPath = "metadata.annotations['coder.com/agent-token']" }
+ },
+ {
+ path = "url"
+ fieldRef = { fieldPath = "metadata.annotations['coder.com/agent-url']" }
+ },
+ ]
+ }
+ },
+ ]
+ }, local.host_alias_field)
+ }
+ }
+ }
+}
+
+# Recreate replaces idle sandboxes when the SandboxTemplate changes. The
+# default, OnReplenish, keeps old idle sandboxes until someone claims them.
+resource "kubernetes_manifest" "warm_pool" {
+ for_each = local.pools
+ depends_on = [kubernetes_manifest.sandbox_template]
+
+ # This configuration owns these objects, so it takes over fields that an
+ # earlier kubectl apply set.
+ field_manager {
+ force_conflicts = true
+ }
+
+ manifest = {
+ apiVersion = "extensions.agents.x-k8s.io/v1beta1"
+ kind = "SandboxWarmPool"
+ metadata = {
+ name = each.key
+ namespace = local.namespace
+ }
+ spec = {
+ replicas = var.warm_pool_replicas
+ updateStrategy = { type = "Recreate" }
+ sandboxTemplateRef = { name = each.key }
+ }
+ }
+}
+
+# Admin tier: checked before every NetworkPolicy, so nothing in the
+# namespace can undo it. Allows Coder, blocks private ranges and ingress
+# from other Pods, and passes the rest to the NetworkPolicies.
+resource "kubernetes_manifest" "guardrail" {
+ count = var.enable_guardrail ? 1 : 0
+
+ # This configuration owns these objects, so it takes over fields that an
+ # earlier kubectl apply set.
+ field_manager {
+ force_conflicts = true
+ }
+
+ manifest = {
+ apiVersion = "networking.k8s.aws/v1alpha1"
+ kind = "ClusterNetworkPolicy"
+ metadata = {
+ name = "${local.namespace}-guardrail"
+ }
+ spec = {
+ tier = "Admin"
+ priority = 100
+ subject = {
+ namespaces = { matchLabels = { "kubernetes.io/metadata.name" = local.namespace } }
+ }
+ egress = [
+ {
+ name = "allow-coder"
+ action = "Accept"
+ to = [{ networks = var.coder_egress_cidrs }]
+ ports = [{ portNumber = { protocol = "TCP", port = local.coder_port } }]
+ },
+ {
+ name = "deny-private-ranges"
+ action = "Deny"
+ to = [{ networks = local.defaults.private_cidrs }]
+ },
+ ]
+ ingress = [
+ {
+ name = "deny-pods"
+ action = "Deny"
+ from = [{ namespaces = {} }]
+ },
+ ]
+ }
+ }
+}
+
+# A variables file for `coder templates push --variables-file`, so the
+# template uses the same values. Coder reads every value as a string, and
+# the template takes runtimes and scratch_profiles comma-separated.
+output "coder_variables" {
+ description = "The template variables for this setup, as YAML."
+ value = yamlencode({
+ namespace = local.namespace
+ workspace_image = var.workspace_image
+ coder_egress_cidrs = jsonencode(var.coder_egress_cidrs)
+ coder_egress_port = tostring(local.coder_port)
+ coder_host_ip = var.coder_host_ip
+ runtimes = join(",", var.runtimes)
+ scratch_profiles = join(",", var.scratch_profiles)
+ git_repo_url = var.git_repo_url
+ network_policy_gate = tostring(var.network_policy_gate)
+ dns_servers = join(",", local.dns_servers)
+ })
+}
diff --git a/registry/coder-labs/templates/kubernetes-agent-sandbox/cluster/tests/cluster.tftest.hcl b/registry/coder-labs/templates/kubernetes-agent-sandbox/cluster/tests/cluster.tftest.hcl
new file mode 100644
index 000000000..31919662a
--- /dev/null
+++ b/registry/coder-labs/templates/kubernetes-agent-sandbox/cluster/tests/cluster.tftest.hcl
@@ -0,0 +1,265 @@
+mock_provider "kubernetes" {}
+
+variables {
+ namespace = "coder-workspaces"
+ workspace_image = "registry.example.com/coder/workspace:v2.30.0"
+ coder_access_url = "https://coder.example.com"
+ coder_egress_cidrs = ["203.0.113.10/32"]
+}
+
+run "defaults" {
+ command = plan
+
+ assert {
+ condition = toset(keys(kubernetes_manifest.sandbox_template)) == toset(["coder-gvisor", "coder-gvisor-github"]) && toset(keys(kubernetes_manifest.warm_pool)) == toset(["coder-gvisor", "coder-gvisor-github"])
+ error_message = "The defaults must create a Fully isolated and a GitHub only gVisor pool."
+ }
+ assert {
+ condition = length(kubernetes_namespace_v1.workspaces) == 1 && length(kubernetes_cluster_role_v1.crd_read) == 1 && length(kubernetes_manifest.guardrail) == 0
+ error_message = "The defaults must create the namespace and the CRD read role, and no guardrail."
+ }
+ assert {
+ condition = local.coder_port == 443
+ error_message = "An https access URL without a port must use 443."
+ }
+ assert {
+ condition = kubernetes_manifest.warm_pool["coder-gvisor"].manifest.spec.replicas == 1 && kubernetes_manifest.warm_pool["coder-gvisor"].manifest.spec.sandboxTemplateRef.name == "coder-gvisor"
+ error_message = "A pool must keep warm_pool_replicas sandboxes from its SandboxTemplate."
+ }
+}
+
+run "network_rules" {
+ command = plan
+
+ variables {
+ git_repo_url = "https://github.com/coder/coder"
+ }
+
+ assert {
+ condition = length(kubernetes_manifest.sandbox_template["coder-gvisor"].manifest.spec.networkPolicy.egress) == 1 && kubernetes_manifest.sandbox_template["coder-gvisor"].manifest.spec.networkPolicy.egress[0].ports[0].port == 443
+ error_message = "A Fully isolated pool must allow only the Coder server."
+ }
+ assert {
+ condition = length(kubernetes_manifest.sandbox_template["coder-gvisor-github"].manifest.spec.networkPolicy.egress) == 3
+ error_message = "A GitHub only pool must allow Coder, the GitHub ranges, and DNS."
+ }
+ assert {
+ condition = contains([for e in kubernetes_manifest.sandbox_template["coder-gvisor-github"].manifest.spec.podTemplate.spec.containers[0].env : e.name], "GIT_REPO_URL") && !contains([for e in kubernetes_manifest.sandbox_template["coder-gvisor"].manifest.spec.podTemplate.spec.containers[0].env : e.name], "GIT_REPO_URL")
+ error_message = "Only the GitHub only pool must clone git_repo_url."
+ }
+ assert {
+ condition = !strcontains(local.pool_command, "github.com") && strcontains(local.pool_command, "kill -TERM 1") && strcontains(local.pool_command, "exec /usr/local/bin/coder agent")
+ error_message = "The pool script must read the repository from env and include the policy check."
+ }
+}
+
+run "runtime_matrix" {
+ command = plan
+
+ variables {
+ runtimes = ["gvisor", "kata-qemu", "kata-clh"]
+ }
+
+ assert {
+ condition = length(kubernetes_manifest.warm_pool) == 6 && kubernetes_manifest.sandbox_template["coder-kata-clh-github"].manifest.spec.podTemplate.spec.runtimeClassName == "kata-clh"
+ error_message = "There must be one pool for each runtime and scratch profile."
+ }
+}
+
+run "no_scratch" {
+ command = plan
+
+ variables {
+ scratch_profiles = []
+ }
+
+ assert {
+ condition = length(kubernetes_manifest.warm_pool) == 0 && length(kubernetes_role_v1.provisioner.rule) > 0
+ error_message = "No scratch profiles must create no pools, but still the Role."
+ }
+}
+
+run "http_port" {
+ command = plan
+
+ variables {
+ coder_access_url = "http://203.0.113.10"
+ }
+
+ assert {
+ condition = local.coder_port == 80 && !can(kubernetes_manifest.sandbox_template["coder-gvisor"].manifest.spec.podTemplate.spec.hostAliases)
+ error_message = "An http access URL must use 80, and an IP needs no host alias."
+ }
+}
+
+run "explicit_port" {
+ command = plan
+
+ variables {
+ coder_access_url = "https://coder.example.com:8443"
+ }
+
+ assert {
+ condition = local.coder_port == 8443
+ error_message = "The port of the access URL must win over the scheme."
+ }
+}
+
+run "port_override" {
+ command = plan
+
+ variables {
+ coder_egress_port = 3000
+ }
+
+ assert {
+ condition = local.coder_port == 3000
+ error_message = "coder_egress_port must override the access URL."
+ }
+}
+
+run "host_alias" {
+ command = plan
+
+ variables {
+ coder_host_ip = "203.0.113.10"
+ }
+
+ assert {
+ condition = kubernetes_manifest.sandbox_template["coder-gvisor"].manifest.spec.podTemplate.spec.hostAliases[0].hostnames == ["coder.example.com"]
+ error_message = "A host name access URL with coder_host_ip must map the host name."
+ }
+}
+
+run "no_access_url" {
+ command = plan
+
+ variables {
+ coder_access_url = ""
+ }
+
+ assert {
+ condition = local.coder_port == 443 && !contains([for e in kubernetes_manifest.sandbox_template["coder-gvisor"].manifest.spec.podTemplate.spec.containers[0].env : e.name], "CODER_AGENT_URL")
+ error_message = "Without coder_access_url, the pools must use port 443 and take the agent URL from the claim."
+ }
+ assert {
+ condition = strcontains(local.pool_command, "CODER_AGENT_URL=\"$(cat /podinfo/url)\"") && contains([for i in kubernetes_manifest.sandbox_template["coder-gvisor"].manifest.spec.podTemplate.spec.volumes[2].downwardAPI.items : i.path], "url")
+ error_message = "The pool must read the agent URL from the claim annotation."
+ }
+}
+
+run "host_ip_needs_access_url" {
+ command = plan
+
+ variables {
+ coder_access_url = ""
+ coder_host_ip = "203.0.113.10"
+ }
+
+ expect_failures = [kubernetes_manifest.sandbox_template]
+}
+
+run "existing_namespace" {
+ command = plan
+
+ variables {
+ create_namespace = false
+ grant_crd_read = false
+ }
+
+ assert {
+ condition = length(kubernetes_namespace_v1.workspaces) == 0 && kubernetes_role_v1.provisioner.metadata[0].namespace == "coder-workspaces" && length(kubernetes_cluster_role_v1.crd_read) == 0
+ error_message = "create_namespace = false must use the existing namespace."
+ }
+}
+
+run "guardrail" {
+ command = plan
+
+ variables {
+ enable_guardrail = true
+ }
+
+ assert {
+ condition = kubernetes_manifest.guardrail[0].manifest.spec.tier == "Admin" && kubernetes_manifest.guardrail[0].manifest.spec.egress[0].ports[0].portNumber.port == 443
+ error_message = "The guardrail must be an Admin tier policy that allows the Coder port."
+ }
+}
+
+run "no_policy_gate" {
+ command = plan
+
+ variables {
+ network_policy_gate = false
+ }
+
+ assert {
+ condition = endswith(local.pool_command, "exec /usr/local/bin/coder agent") && !strcontains(local.pool_command, "KUBERNETES_SERVICE_HOST")
+ error_message = "Without the gate, the pool must start the agent directly."
+ }
+}
+
+run "coder_variables" {
+ command = plan
+
+ variables {
+ runtimes = ["kata-clh", "gvisor"]
+ }
+
+ assert {
+ condition = yamldecode(output.coder_variables).runtimes == "kata-clh,gvisor" && yamldecode(output.coder_variables).coder_egress_port == "443" && yamldecode(output.coder_variables).namespace == "coder-workspaces"
+ error_message = "coder_variables must have the template variables as strings."
+ }
+}
+
+run "dns_servers_and_clone_limit" {
+ command = plan
+
+ variables {
+ dns_servers = ["10.0.0.2"]
+ git_repo_url = "https://github.com/coder/coder"
+ }
+
+ assert {
+ condition = kubernetes_manifest.sandbox_template["coder-gvisor"].manifest.spec.podTemplate.spec.dnsConfig.nameservers == tolist(["10.0.0.2"]) && kubernetes_manifest.sandbox_template["coder-gvisor-github"].manifest.spec.networkPolicy.egress[2].to[0].ipBlock.cidr == "10.0.0.2/32"
+ error_message = "The pools must use dns_servers for DNS and its NetworkPolicy rule."
+ }
+ assert {
+ condition = strcontains(local.pool_command, "failed 5 times") && kubernetes_manifest.sandbox_template["coder-gvisor"].manifest.spec.podTemplate.spec.containers[0].command[0] == "bash"
+ error_message = "The pool script must stop retrying the clone, and run with bash."
+ }
+ assert {
+ condition = yamldecode(output.coder_variables).dns_servers == "10.0.0.2"
+ error_message = "coder_variables must pass dns_servers to the template."
+ }
+}
+
+run "rejects_latest_image" {
+ command = plan
+
+ variables {
+ workspace_image = "registry.example.com/coder/workspace:latest"
+ }
+
+ expect_failures = [var.workspace_image]
+}
+
+run "rejects_url_with_path" {
+ command = plan
+
+ variables {
+ coder_access_url = "https://coder.example.com/app"
+ }
+
+ expect_failures = [var.coder_access_url]
+}
+
+run "rejects_unknown_runtime" {
+ command = plan
+
+ variables {
+ runtimes = ["runc"]
+ }
+
+ expect_failures = [var.runtimes]
+}
diff --git a/registry/coder-labs/templates/kubernetes-agent-sandbox/main.tf b/registry/coder-labs/templates/kubernetes-agent-sandbox/main.tf
new file mode 100644
index 000000000..ae3323011
--- /dev/null
+++ b/registry/coder-labs/templates/kubernetes-agent-sandbox/main.tf
@@ -0,0 +1,812 @@
+terraform {
+ required_providers {
+ coder = {
+ source = "coder/coder"
+ version = ">= 2.12"
+ }
+ kubernetes = {
+ source = "hashicorp/kubernetes"
+ version = ">= 2.23"
+ }
+ http = {
+ source = "hashicorp/http"
+ version = ">= 3.4"
+ }
+ }
+}
+
+provider "coder" {}
+
+variable "use_kubeconfig" {
+ description = <<-EOF
+ Use host kubeconfig? (true/false)
+
+ Set this to false if the Coder host is itself running as a Pod on the same
+ Kubernetes cluster as you are deploying workspaces to.
+
+ Set this to true if the Coder host is running outside the Kubernetes cluster
+ for workspaces. A valid "~/.kube/config" must be present on the Coder host.
+ EOF
+ type = bool
+ default = false
+}
+
+variable "namespace" {
+ description = "The Kubernetes namespace for workspaces. cluster/ creates it with the Role and the warm pools."
+ type = string
+
+ validation {
+ condition = length(trimspace(var.namespace)) > 0
+ error_message = "namespace must not be empty."
+ }
+}
+
+variable "workspace_image" {
+ description = "The workspace image. Build it from build/Dockerfile, with a Coder agent that matches your Coder server. Use a fixed tag or a digest, not :latest."
+ type = string
+
+ validation {
+ condition = (
+ length(trimspace(var.workspace_image)) > 0 &&
+ !endswith(var.workspace_image, ":latest") &&
+ can(regex("[:@]", element(split("/", var.workspace_image), length(split("/", var.workspace_image)) - 1)))
+ )
+ error_message = "workspace_image must have a tag other than :latest, or a digest, such as registry.example.com/coder/workspace:v2.30.0."
+ }
+}
+
+variable "coder_egress_cidrs" {
+ description = "The IPv4 CIDR ranges of the Coder server, for example [\"203.0.113.10/32\"]. Every workspace can reach these ranges, and nothing else in Fully isolated."
+ type = list(string)
+
+ validation {
+ condition = length(var.coder_egress_cidrs) > 0 && alltrue([for c in var.coder_egress_cidrs : can(cidrhost(c, 0)) && !strcontains(c, ":")])
+ error_message = "coder_egress_cidrs must have at least one valid IPv4 CIDR range, such as 203.0.113.10/32."
+ }
+}
+
+variable "coder_egress_port" {
+ description = "The TCP port that workspaces use to reach the Coder server. Empty uses the port of the access URL: its explicit port, or 443 for https and 80 for http."
+ type = number
+ default = null
+
+ validation {
+ condition = var.coder_egress_port == null ? true : (var.coder_egress_port >= 1 && var.coder_egress_port <= 65535 && floor(var.coder_egress_port) == var.coder_egress_port)
+ error_message = "coder_egress_port must be empty or a whole number from 1 to 65535."
+ }
+}
+
+# runtimes and scratch_profiles are comma-separated strings, not lists,
+# because Coder checks presets with every template variable as a string.
+variable "runtimes" {
+ description = "The runtimes that users can pick, comma-separated, in order. The first is the default. Offer only RuntimeClasses that the cluster has: gvisor, kata-qemu, kata-clh."
+ type = string
+ default = "gvisor"
+
+ validation {
+ condition = (
+ length(compact(split(",", replace(var.runtimes, " ", "")))) > 0 &&
+ length(compact(split(",", replace(var.runtimes, " ", "")))) == length(distinct(compact(split(",", replace(var.runtimes, " ", ""))))) &&
+ alltrue([for r in compact(split(",", replace(var.runtimes, " ", ""))) : contains(["gvisor", "kata-qemu", "kata-clh"], r)])
+ )
+ error_message = "runtimes must list one or more of gvisor, kata-qemu, and kata-clh, comma-separated, each once."
+ }
+}
+
+variable "scratch_profiles" {
+ description = "The network profiles that offer a scratch home, comma-separated: isolated, github, or both. Each needs the warm pools from cluster/ for every runtime. Empty turns off scratch homes."
+ type = string
+ default = "isolated,github"
+
+ validation {
+ condition = (
+ length(compact(split(",", replace(var.scratch_profiles, " ", "")))) == length(distinct(compact(split(",", replace(var.scratch_profiles, " ", ""))))) &&
+ alltrue([for p in compact(split(",", replace(var.scratch_profiles, " ", ""))) : contains(["isolated", "github"], p)])
+ )
+ error_message = "scratch_profiles can list only isolated and github, comma-separated, each once."
+ }
+}
+
+variable "coder_host_ip" {
+ description = "The IPv4 address of the Coder access URL host. Fully isolated blocks DNS, so if the access URL uses a host name, the template adds this address to /etc/hosts. Leave empty if the access URL uses an IP address."
+ type = string
+ default = ""
+
+ validation {
+ condition = var.coder_host_ip == "" || (can(cidrhost("${var.coder_host_ip}/32", 0)) && !strcontains(var.coder_host_ip, ":"))
+ error_message = "coder_host_ip must be empty or an IPv4 address."
+ }
+}
+
+variable "home_disk_size" {
+ description = "The size of the persistent home disk in GiB."
+ type = number
+ default = 10
+
+ validation {
+ condition = var.home_disk_size >= 1
+ error_message = "home_disk_size must be at least 1."
+ }
+}
+
+variable "storage_class_name" {
+ description = "The StorageClass for the persistent home disk. Leave empty to use the cluster default. Use a class with volumeBindingMode WaitForFirstConsumer."
+ type = string
+ default = ""
+}
+
+variable "prebuild_instances" {
+ description = "The number of prebuilt workspaces to keep for each persistent preset. Prebuilds need a Coder Premium license. Set 0 to turn them off."
+ type = number
+ default = 1
+
+ validation {
+ condition = var.prebuild_instances >= 0 && floor(var.prebuild_instances) == var.prebuild_instances
+ error_message = "prebuild_instances must be a whole number, 0 or more."
+ }
+}
+
+variable "git_repo_url" {
+ description = "A Git repository to clone into /home/coder, for example https://github.com/coder/coder. Only GitHub only and Public internet workspaces clone it, because Fully isolated cannot reach Git hosts. Empty clones nothing."
+ type = string
+ default = ""
+}
+
+variable "enable_ai_gateway" {
+ description = "Send Claude Code requests through the Coder AI Gateway (Premium). Claude Code then reaches Anthropic through the Coder server, so it works in Fully isolated."
+ type = bool
+ default = true
+}
+
+variable "network_policy_gate" {
+ description = "Start the Coder agent only after the NetworkPolicy blocks the Kubernetes API server. Turn this off only if coder_egress_cidrs includes the API server address."
+ type = bool
+ default = true
+}
+
+variable "dns_servers" {
+ description = "The DNS servers that workspaces use, comma-separated IPv4 addresses. Empty uses 8.8.8.8 and 1.1.1.1 from shared/defaults.json. Use servers that the GitHub only and Public internet profiles can reach."
+ type = string
+ default = ""
+
+ validation {
+ condition = alltrue([for d in compact(split(",", replace(var.dns_servers, " ", ""))) : can(cidrhost("${d}/32", 0)) && !strcontains(d, ":")])
+ error_message = "dns_servers must be empty or IPv4 addresses, comma-separated."
+ }
+}
+
+variable "enable_claude_code" {
+ description = "Add the Claude Code app and its settings. Turn this off if you build the image with INSTALL_CLAUDE_CODE=false."
+ type = bool
+ default = true
+}
+
+provider "kubernetes" {
+ config_path = var.use_kubeconfig ? "~/.kube/config" : null
+}
+
+data "coder_workspace" "me" {}
+data "coder_workspace_owner" "me" {}
+
+# Network profiles. A NetworkPolicy matches IP ranges, not host names.
+locals {
+ runtimes = compact(split(",", replace(var.runtimes, " ", "")))
+ scratch_profiles = compact(split(",", replace(var.scratch_profiles, " ", "")))
+
+ runtime_options = {
+ gvisor = {
+ name = "gVisor"
+ description = "A kernel that runs in user space. Runs on any node with gVisor installed."
+ icon = "/icon/container.svg"
+ }
+ kata-qemu = {
+ name = "Kata microVM (QEMU)"
+ description = "A small VM with its own kernel. Needs a node with /dev/kvm."
+ icon = "/icon/memory.svg"
+ }
+ kata-clh = {
+ name = "Kata microVM (Cloud Hypervisor)"
+ description = "A small VM with a lighter hypervisor. Needs a node with /dev/kvm."
+ icon = "/icon/memory.svg"
+ }
+ }
+
+ # shared/defaults.json is also read by cluster/main.tf. It has:
+ # github_cidrs: the large GitHub IPv4 ranges from
+ # https://api.github.com/meta (web, api, git). To refresh:
+ # curl -s https://api.github.com/meta | jq -r '.web+.api+.git|unique[]|select(test(":")|not)|select(endswith("/32")|not)'
+ # private_cidrs: the VPC, the cluster, other Pods, and cloud metadata.
+ # Public internet excludes them.
+ # dns_servers: the default DNS servers, unless var.dns_servers is set.
+ # Fully isolated cannot reach them, so lookups fail fast.
+ # resources: the Pod requests and limits.
+ defaults = jsondecode(file("${path.module}/shared/defaults.json"))
+ github_cidrs = local.defaults.github_cidrs
+ private_cidrs = local.defaults.private_cidrs
+ dns_servers = var.dns_servers != "" ? compact(split(",", replace(var.dns_servers, " ", ""))) : local.defaults.dns_servers
+
+ profiles = {
+ isolated = {
+ display_name = "Fully isolated"
+ description = "Coder only. No internet and no DNS."
+ icon = "/emojis/1f512.png"
+ cidrs = []
+ except = []
+ }
+ github = {
+ display_name = "GitHub only"
+ description = "Coder, the GitHub IP ranges on ports 443 and 80, and public DNS."
+ icon = "/icon/github.svg"
+ cidrs = local.github_cidrs
+ except = []
+ }
+ internet = {
+ display_name = "Public internet"
+ description = "Coder and every public IPv4 address on ports 443 and 80, and public DNS. No private ranges."
+ icon = "/emojis/1f310.png"
+ cidrs = ["0.0.0.0/0"]
+ except = local.private_cidrs
+ }
+ }
+}
+
+data "coder_parameter" "network_isolation" {
+ name = "network_isolation"
+ display_name = "Network isolation"
+ description = "What the workspace can reach in addition to Coder. A Kubernetes NetworkPolicy enforces it by IP range. You cannot change it after create."
+ type = "string"
+ form_type = "dropdown"
+ default = "isolated"
+ mutable = false
+ order = 1
+
+ dynamic "option" {
+ for_each = local.profiles
+ content {
+ name = option.value.display_name
+ description = option.value.description
+ value = option.key
+ icon = option.value.icon
+ }
+ }
+}
+
+data "coder_parameter" "runtime" {
+ name = "runtime"
+ display_name = "Kernel isolation"
+ description = "The RuntimeClass of the sandbox. The network rules are the same for every option."
+ type = "string"
+ form_type = "dropdown"
+ default = local.runtimes[0]
+ mutable = false
+ order = 2
+
+ dynamic "option" {
+ for_each = local.runtimes
+ content {
+ name = local.runtime_options[option.value].name
+ value = option.value
+ description = local.runtime_options[option.value].description
+ icon = local.runtime_options[option.value].icon
+ }
+ }
+}
+
+# A pooled Pod shares network rules with the other Pods of its pool on the
+# same node (Amazon VPC CNI), so each scratch profile has its own pool:
+# coder- for Fully isolated and coder--github for GitHub
+# only. Public internet has no scratch home. cluster/main.tf creates the
+# pools for scratch_profiles.
+data "coder_parameter" "home" {
+ name = "home"
+ display_name = "Home directory"
+ description = "Where /home/coder lives. You cannot change it after create."
+ type = "string"
+ form_type = "radio"
+ default = "persistent"
+ mutable = false
+ order = 3
+
+ option {
+ name = "Persistent"
+ value = "persistent"
+ description = "A disk at /home/coder that is kept when the workspace stops."
+ icon = "/emojis/1f4be.png"
+ }
+ dynamic "option" {
+ for_each = contains(local.scratch_profiles, data.coder_parameter.network_isolation.value) ? [1] : []
+ content {
+ name = "Scratch"
+ value = "scratch"
+ description = "A booted sandbox from the warm pool. /home/coder is deleted when the workspace stops. Fully isolated or GitHub only."
+ icon = "/emojis/26a1.png"
+ }
+ }
+}
+
+data "coder_workspace_preset" "persistent" {
+ for_each = local.profiles
+ name = each.value.display_name
+ description = "${each.value.description} Persistent home."
+ icon = each.value.icon
+ default = each.key == "isolated"
+ parameters = {
+ network_isolation = each.key
+ runtime = local.runtimes[0]
+ home = "persistent"
+ }
+
+ dynamic "prebuilds" {
+ for_each = var.prebuild_instances > 0 ? [1] : []
+ content {
+ instances = var.prebuild_instances
+ }
+ }
+}
+
+# No prebuilds: the warm pools already keep booted sandboxes.
+data "coder_workspace_preset" "scratch" {
+ for_each = toset(local.scratch_profiles)
+ name = "${local.profiles[each.key].display_name}, scratch home"
+ description = "A booted sandbox from the warm pool. Home is deleted on stop."
+ icon = "/emojis/26a1.png"
+ parameters = {
+ network_isolation = each.key
+ runtime = local.runtimes[0]
+ home = "scratch"
+ }
+}
+
+locals {
+ name = "coder-${data.coder_workspace.me.id}"
+ scratch = data.coder_parameter.home.value == "scratch"
+ # The pool name suffix of each scratch profile. Keep in line with
+ # cluster/main.tf.
+ pool_suffix = { isolated = "", github = "-github" }
+ profile = local.profiles[data.coder_parameter.network_isolation.value]
+
+ # Labels stay the same when a prebuilt workspace is claimed, so a claim
+ # does not change the Pod.
+ labels = {
+ "app.kubernetes.io/name" = "coder-workspace"
+ "app.kubernetes.io/part-of" = "coder"
+ "com.coder.resource" = "true"
+ "com.coder.workspace.id" = data.coder_workspace.me.id
+ }
+ owner_annotations = {
+ "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
+ }
+
+ access_host = try(regex("^[a-zA-Z][a-zA-Z0-9+.-]*://([^/:]+)", data.coder_workspace.me.access_url)[0], "")
+ access_host_is_ip = can(regex("^[0-9]{1,3}(\\.[0-9]{1,3}){3}$", local.access_host))
+ access_port = try(
+ tonumber(regex("^[a-zA-Z][a-zA-Z0-9+.-]*://[^/:]+:([0-9]+)", data.coder_workspace.me.access_url)[0]),
+ startswith(lower(data.coder_workspace.me.access_url), "http://") ? 80 : 443,
+ )
+ coder_port = coalesce(var.coder_egress_port, local.access_port)
+ host_aliases = var.coder_host_ip != "" && !local.access_host_is_ip ? [{
+ ip = var.coder_host_ip
+ hostnames = [local.access_host]
+ }] : null
+ # Leave hostAliases out when empty. A null value makes every plan
+ # update the Sandbox.
+ host_alias_field = { for k, v in { hostAliases = local.host_aliases } : k => v if v != null }
+
+ # Copy the image's dotfiles into a new, empty home.
+ seed_home = "[ -e /home/coder/.bashrc ] || cp -an /etc/skel/. /home/coder/ 2>/dev/null || true"
+
+ # The policy check in shared/start-agent.sh, also used by the warm pools.
+ policy_check = file("${path.module}/shared/start-agent.sh")
+
+ agent_command = join("\n", [
+ local.seed_home,
+ "ulimit -n 65536 2>/dev/null || true",
+ var.network_policy_gate ? local.policy_check : "exec /usr/local/bin/coder agent",
+ ])
+
+ resources = local.defaults.resources
+}
+
+resource "coder_agent" "dev" {
+ os = "linux"
+ arch = "amd64"
+
+ 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 = "Kernel"
+ key = "3_kernel"
+ script = "uname -r"
+ interval = 600
+ timeout = 1
+ }
+}
+
+# Scratch home: claim a booted sandbox from the warm pool. The pooled Pod
+# reads the agent token and the access URL from these annotations through
+# the downward API and then starts the agent. The warm pool's
+# SandboxTemplate owns its NetworkPolicy.
+resource "kubernetes_manifest" "claim" {
+ count = local.scratch ? data.coder_workspace.me.start_count : 0
+
+ lifecycle {
+ precondition {
+ condition = contains(local.scratch_profiles, data.coder_parameter.network_isolation.value)
+ error_message = "A scratch home supports only Fully isolated and GitHub only, because Pods of one warm pool share network rules on Amazon VPC CNI. Pick a persistent home for Public internet."
+ }
+ }
+
+ manifest = {
+ apiVersion = "extensions.agents.x-k8s.io/v1beta1"
+ kind = "SandboxClaim"
+ metadata = {
+ name = local.name
+ namespace = var.namespace
+ labels = local.labels
+ }
+ spec = {
+ warmPoolRef = { name = "coder-${data.coder_parameter.runtime.value}${lookup(local.pool_suffix, data.coder_parameter.network_isolation.value, "")}" }
+ lifecycle = { shutdownPolicy = "Delete" }
+ additionalPodMetadata = {
+ annotations = {
+ "coder.com/agent-token" = coder_agent.dev.token
+ "coder.com/agent-url" = data.coder_workspace.me.access_url
+ "coder.com/workspace-id" = data.coder_workspace.me.id
+ }
+ }
+ }
+ }
+ computed_fields = ["metadata.labels", "metadata.annotations", "metadata.finalizers", "spec.lifecycle"]
+}
+
+# Persistent home: Coder owns the disk, so it outlives the Sandbox.
+resource "kubernetes_persistent_volume_claim_v1" "home" {
+ count = local.scratch ? 0 : 1
+ metadata {
+ name = "${local.name}-home"
+ namespace = var.namespace
+ labels = local.labels
+ annotations = local.owner_annotations
+ }
+ wait_until_bound = false
+ spec {
+ access_modes = ["ReadWriteOnce"]
+ storage_class_name = var.storage_class_name != "" ? var.storage_class_name : null
+ resources {
+ requests = {
+ storage = "${var.home_disk_size}Gi"
+ }
+ }
+ }
+}
+
+# Deny all ingress. Allow egress to Coder, plus the profile's ranges and
+# DNS. The agent dials out, so no ingress is needed.
+resource "kubernetes_network_policy_v1" "workspace" {
+ count = local.scratch ? 0 : 1
+ metadata {
+ name = local.name
+ namespace = var.namespace
+ labels = local.labels
+ }
+ spec {
+ pod_selector {
+ match_labels = {
+ "com.coder.workspace.id" = data.coder_workspace.me.id
+ }
+ }
+ policy_types = ["Ingress", "Egress"]
+
+ egress {
+ dynamic "to" {
+ for_each = var.coder_egress_cidrs
+ content {
+ ip_block {
+ cidr = to.value
+ }
+ }
+ }
+ ports {
+ protocol = "TCP"
+ port = tostring(local.coder_port)
+ }
+ }
+
+ dynamic "egress" {
+ for_each = length(local.profile.cidrs) > 0 ? [1] : []
+ content {
+ dynamic "to" {
+ for_each = local.profile.cidrs
+ content {
+ ip_block {
+ cidr = to.value
+ except = local.profile.except
+ }
+ }
+ }
+ ports {
+ protocol = "TCP"
+ port = "443"
+ }
+ ports {
+ protocol = "TCP"
+ port = "80"
+ }
+ }
+ }
+
+ dynamic "egress" {
+ for_each = length(local.profile.cidrs) > 0 ? [1] : []
+ content {
+ dynamic "to" {
+ for_each = local.dns_servers
+ content {
+ ip_block {
+ cidr = "${to.value}/32"
+ }
+ }
+ }
+ ports {
+ protocol = "UDP"
+ port = "53"
+ }
+ ports {
+ protocol = "TCP"
+ port = "53"
+ }
+ }
+ }
+ }
+}
+
+resource "kubernetes_secret_v1" "agent_token" {
+ count = local.scratch ? 0 : data.coder_workspace.me.start_count
+ metadata {
+ name = "${local.name}-agent-token"
+ namespace = var.namespace
+ labels = local.labels
+ }
+ data = {
+ token = coder_agent.dev.token
+ }
+}
+
+# A new id on every start. VPC CNI groups Pods by their name without the
+# last "-" part and drops a group's rules when an old Pod of the group is
+# deleted. A fixed "-pod" after this id keeps every Pod in its own group.
+resource "terraform_data" "sandbox_name" {
+ count = local.scratch ? 0 : data.coder_workspace.me.start_count
+}
+
+# agent-sandbox creates the Pod. Stop deletes the Sandbox and its Pod, but
+# not the home disk.
+resource "kubernetes_manifest" "sandbox" {
+ count = local.scratch ? 0 : data.coder_workspace.me.start_count
+ depends_on = [
+ kubernetes_network_policy_v1.workspace,
+ ]
+
+ lifecycle {
+ precondition {
+ condition = data.coder_parameter.network_isolation.value != "isolated" || local.access_host_is_ip || var.coder_host_ip != ""
+ error_message = "Fully isolated blocks DNS, so the agent cannot resolve ${local.access_host}. Set the coder_host_ip template variable to the IP address of the Coder server."
+ }
+ }
+
+ manifest = {
+ apiVersion = "agents.x-k8s.io/v1beta1"
+ kind = "Sandbox"
+ metadata = {
+ name = "${local.name}-${substr(terraform_data.sandbox_name[0].id, 0, 8)}-pod"
+ namespace = var.namespace
+ labels = local.labels
+ }
+ spec = {
+ shutdownPolicy = "Delete"
+ podTemplate = {
+ metadata = {
+ labels = local.labels
+ }
+ spec = merge({
+ runtimeClassName = data.coder_parameter.runtime.value
+ automountServiceAccountToken = false
+ enableServiceLinks = false
+ terminationGracePeriodSeconds = 2
+ dnsPolicy = "None"
+ dnsConfig = {
+ nameservers = local.dns_servers
+ }
+ securityContext = {
+ runAsNonRoot = true
+ runAsUser = 1000
+ runAsGroup = 1000
+ fsGroup = 1000
+ fsGroupChangePolicy = "OnRootMismatch"
+ seccompProfile = {
+ type = "RuntimeDefault"
+ }
+ }
+ containers = [{
+ name = "dev"
+ image = var.workspace_image
+ imagePullPolicy = "IfNotPresent"
+ command = ["bash", "-c", local.agent_command]
+ workingDir = "/home/coder"
+ env = [
+ {
+ name = "CODER_AGENT_TOKEN"
+ valueFrom = {
+ secretKeyRef = {
+ name = kubernetes_secret_v1.agent_token[0].metadata[0].name
+ key = "token"
+ }
+ }
+ },
+ { name = "CODER_AGENT_URL", value = data.coder_workspace.me.access_url },
+ { name = "CODER_AGENT_AUTH", value = "token" },
+ ]
+ securityContext = {
+ allowPrivilegeEscalation = false
+ runAsNonRoot = true
+ capabilities = {
+ drop = ["ALL"]
+ }
+ }
+ resources = local.resources
+ volumeMounts = [
+ { name = "home", mountPath = "/home/coder" },
+ { name = "tmp", mountPath = "/tmp" },
+ ]
+ }]
+ volumes = [
+ {
+ name = "home"
+ persistentVolumeClaim = {
+ claimName = kubernetes_persistent_volume_claim_v1.home[0].metadata[0].name
+ }
+ },
+ { name = "tmp", emptyDir = {} },
+ ]
+ }, local.host_alias_field)
+ }
+ }
+ }
+ # The controller adds labels, annotations, and finalizers, and the API
+ # server fills container defaults.
+ computed_fields = [
+ "metadata.labels",
+ "metadata.annotations",
+ "metadata.finalizers",
+ "spec.podTemplate.spec.containers",
+ ]
+}
+
+# code-server and Claude Code are in the image, so the modules download
+# nothing and work in Fully isolated.
+module "code-server" {
+ count = data.coder_workspace.me.start_count
+ source = "registry.coder.com/coder/code-server/coder"
+ version = "1.6.0"
+ agent_id = coder_agent.dev.id
+ folder = "/home/coder"
+ offline = true
+ install_prefix = "/opt/code-server"
+ order = 1
+}
+
+# A prebuild or a GitHub only warm pool clones the repository before anyone
+# claims it, and the module skips the clone when the folder exists, so a
+# claim does not wait for it.
+module "git-clone" {
+ count = data.coder_workspace.me.start_count != 0 && var.git_repo_url != "" && data.coder_parameter.network_isolation.value != "isolated" ? 1 : 0
+ source = "registry.coder.com/coder/git-clone/coder"
+ version = "2.0.5"
+ agent_id = coder_agent.dev.id
+ url = var.git_repo_url
+ base_dir = "/home/coder"
+}
+
+# Claude Code is in the image, so the template only sets its environment.
+# The claude-code registry module would do the same, but it reads the
+# deprecated coder_task data source, which adds a warning to every build.
+resource "coder_env" "claude_disable_autoupdater" {
+ count = var.enable_claude_code ? data.coder_workspace.me.start_count : 0
+ agent_id = coder_agent.dev.id
+ name = "DISABLE_AUTOUPDATER"
+ value = "1"
+}
+
+# With the AI Gateway, Claude Code reaches Anthropic through the Coder
+# server. It does not get the owner's session token, which has full access
+# to the Coder API. The build uses that token once to create a token with
+# only the user:read_personal scope, which the AI Gateway accepts, and gives
+# the workspace that one. timestamp() defers the request to apply, so a plan
+# creates nothing, and a prebuild gets its token when it is claimed.
+#
+# Coder deletes the session token before a stop build, so Terraform cannot
+# revoke the scoped token on stop. It expires after the deployment's
+# default token lifetime, and each start creates a new one.
+locals {
+ mint_ai_gateway_token = (
+ var.enable_ai_gateway && var.enable_claude_code &&
+ data.coder_workspace.me.start_count > 0 &&
+ !data.coder_workspace.me.is_prebuild &&
+ data.coder_workspace_owner.me.session_token != ""
+ )
+}
+
+data "http" "ai_gateway_token" {
+ count = local.mint_ai_gateway_token ? 1 : 0
+ url = "${data.coder_workspace.me.access_url}/api/v2/users/me/keys/tokens"
+ method = "POST"
+ request_headers = {
+ "Coder-Session-Token" = data.coder_workspace_owner.me.session_token
+ "Content-Type" = "application/json"
+ }
+ request_body = jsonencode({
+ token_name = "ai-gateway-${substr(data.coder_workspace.me.id, 0, 8)}-${formatdate("YYYYMMDDhhmmss", timestamp())}"
+ scopes = ["user:read_personal"]
+ })
+
+ lifecycle {
+ postcondition {
+ condition = self.status_code == 201
+ error_message = "Could not create the AI Gateway token: HTTP ${self.status_code}."
+ }
+ }
+}
+
+resource "coder_env" "anthropic_base_url" {
+ count = var.enable_ai_gateway && var.enable_claude_code ? data.coder_workspace.me.start_count : 0
+ agent_id = coder_agent.dev.id
+ name = "ANTHROPIC_BASE_URL"
+ value = "${data.coder_workspace.me.access_url}/api/v2/aibridge/anthropic"
+}
+
+resource "coder_env" "anthropic_auth_token" {
+ count = local.mint_ai_gateway_token ? 1 : 0
+ agent_id = coder_agent.dev.id
+ name = "ANTHROPIC_AUTH_TOKEN"
+ value = sensitive(jsondecode(data.http.ai_gateway_token[0].response_body).key)
+}
+
+# Opens Claude Code in /home/coder. It continues the last conversation in
+# this folder, or starts a new one if there is none. In tmux, if the image
+# has it, the session outlives the browser tab.
+resource "coder_app" "claude" {
+ count = var.enable_claude_code ? data.coder_workspace.me.start_count : 0
+ agent_id = coder_agent.dev.id
+ slug = "claude-code"
+ display_name = "Claude Code"
+ icon = "/icon/claude.svg"
+ order = 2
+ command = <<-EOT
+ cd /home/coder
+ run="/opt/claude/bin/claude --continue || /opt/claude/bin/claude"
+ if command -v tmux >/dev/null 2>&1; then
+ exec tmux new-session -A -s claude "$run"
+ fi
+ eval "$run"
+ EOT
+}
diff --git a/registry/coder-labs/templates/kubernetes-agent-sandbox/shared/defaults.json b/registry/coder-labs/templates/kubernetes-agent-sandbox/shared/defaults.json
new file mode 100644
index 000000000..07de03d36
--- /dev/null
+++ b/registry/coder-labs/templates/kubernetes-agent-sandbox/shared/defaults.json
@@ -0,0 +1,20 @@
+{
+ "github_cidrs": [
+ "140.82.112.0/20",
+ "143.55.64.0/20",
+ "185.199.108.0/22",
+ "192.30.252.0/22"
+ ],
+ "private_cidrs": [
+ "10.0.0.0/8",
+ "100.64.0.0/10",
+ "169.254.0.0/16",
+ "172.16.0.0/12",
+ "192.168.0.0/16"
+ ],
+ "dns_servers": ["8.8.8.8", "1.1.1.1"],
+ "resources": {
+ "requests": { "cpu": "500m", "memory": "1Gi" },
+ "limits": { "cpu": "2", "memory": "4Gi" }
+ }
+}
diff --git a/registry/coder-labs/templates/kubernetes-agent-sandbox/shared/start-agent.sh b/registry/coder-labs/templates/kubernetes-agent-sandbox/shared/start-agent.sh
new file mode 100644
index 000000000..55b262fe3
--- /dev/null
+++ b/registry/coder-labs/templates/kubernetes-agent-sandbox/shared/start-agent.sh
@@ -0,0 +1,43 @@
+#!/usr/bin/env bash
+# Starts the Coder agent once the Pod's NetworkPolicy is in force. Used by
+# main.tf (persistent homes) and cluster/main.tf (warm pools), which run it
+# inline with bash -c.
+#
+# VPC CNI can apply a new Pod's NetworkPolicy up to 30 s late, and can drop
+# it when another Pod with the same name prefix is deleted. So the agent
+# starts only once a TCP connection to the Kubernetes API server, which no
+# profile allows, fails. If it still connects after about 60 s, the script
+# exits and Kubernetes restarts the container. After start, the same check
+# runs every 5 s, and the container stops if the API server becomes
+# reachable. A slow TLS reply still counts as reachable, and so does a
+# check that prints nothing.
+api="https://$KUBERNETES_SERVICE_HOST:$KUBERNETES_SERVICE_PORT/"
+api_open() {
+ r=$(curl -sk -o /dev/null --noproxy '*' --connect-timeout 0.3 -m 1 -w '%{http_code} %{time_connect}' "$api" 2> /dev/null)
+ [ -z "$r" ] && return 0
+ awk -v t="${r#* }" 'BEGIN { exit !(t + 0 > 0) }'
+}
+i=0
+while api_open; do
+ i=$((i + 1))
+ if [ "$i" -ge 240 ]; then
+ echo "NetworkPolicy is not enforced" >&2
+ exit 1
+ fi
+ sleep 0.25
+done
+echo "NetworkPolicy is in force after $i checks" >&2
+# The agent must be PID 1, or the web terminal gets no input under Kata.
+# So the re-check runs in the background.
+(
+ while sleep 5; do
+ if api_open; then
+ echo "NetworkPolicy is no longer enforced" >&2
+ kill -TERM 1
+ sleep 2
+ kill -KILL -1
+ exit 1
+ fi
+ done
+) &
+exec /usr/local/bin/coder agent
diff --git a/registry/coder-labs/templates/kubernetes-agent-sandbox/tests/template.tftest.hcl b/registry/coder-labs/templates/kubernetes-agent-sandbox/tests/template.tftest.hcl
new file mode 100644
index 000000000..0857a057b
--- /dev/null
+++ b/registry/coder-labs/templates/kubernetes-agent-sandbox/tests/template.tftest.hcl
@@ -0,0 +1,664 @@
+mock_provider "coder" {
+ mock_data "coder_workspace" {
+ defaults = {
+ id = "0b6b6a5e-0000-4000-8000-000000000001"
+ name = "test"
+ start_count = 1
+ transition = "start"
+ access_url = "https://203.0.113.10"
+ }
+ }
+ mock_data "coder_workspace_owner" {
+ defaults = {
+ id = "0b6b6a5e-0000-4000-8000-000000000002"
+ name = "owner"
+ email = "owner@example.org"
+ session_token = "owner-full-session-token"
+ }
+ }
+}
+
+mock_provider "kubernetes" {}
+
+mock_provider "http" {
+ mock_data "http" {
+ defaults = {
+ status_code = 201
+ response_body = "{\"key\":\"scoped-id-scoped-secret\"}"
+ }
+ }
+}
+
+override_module {
+ target = module.code-server
+ outputs = {}
+}
+
+variables {
+ namespace = "coder-workspaces"
+ workspace_image = "registry.example.com/coder/workspace:v2.30.0"
+ coder_egress_cidrs = ["203.0.113.10/32"]
+}
+
+override_data {
+ target = data.coder_parameter.network_isolation
+ values = { value = "isolated" }
+}
+
+override_data {
+ target = data.coder_parameter.runtime
+ values = { value = "gvisor" }
+}
+
+override_data {
+ target = data.coder_parameter.home
+ values = { value = "persistent" }
+}
+
+run "persistent_isolated" {
+ command = plan
+
+ assert {
+ condition = length(kubernetes_manifest.sandbox) == 1 && length(kubernetes_manifest.claim) == 0
+ error_message = "A persistent home must create a Sandbox and no SandboxClaim."
+ }
+ assert {
+ condition = length(kubernetes_persistent_volume_claim_v1.home) == 1 && kubernetes_persistent_volume_claim_v1.home[0].spec[0].resources[0].requests.storage == "10Gi"
+ error_message = "A persistent home must create a 10Gi PVC."
+ }
+ assert {
+ condition = kubernetes_manifest.sandbox[0].manifest.spec.podTemplate.spec.runtimeClassName == "gvisor"
+ error_message = "The runtime parameter must set the RuntimeClass."
+ }
+ assert {
+ condition = alltrue([
+ kubernetes_manifest.sandbox[0].manifest.spec.podTemplate.spec.automountServiceAccountToken == false,
+ kubernetes_manifest.sandbox[0].manifest.spec.podTemplate.spec.securityContext.runAsNonRoot == true,
+ kubernetes_manifest.sandbox[0].manifest.spec.podTemplate.spec.securityContext.runAsUser == 1000,
+ kubernetes_manifest.sandbox[0].manifest.spec.podTemplate.spec.securityContext.fsGroup == 1000,
+ kubernetes_manifest.sandbox[0].manifest.spec.podTemplate.spec.securityContext.fsGroupChangePolicy == "OnRootMismatch",
+ kubernetes_manifest.sandbox[0].manifest.spec.podTemplate.spec.securityContext.seccompProfile.type == "RuntimeDefault",
+ kubernetes_manifest.sandbox[0].manifest.spec.podTemplate.spec.containers[0].securityContext.allowPrivilegeEscalation == false,
+ kubernetes_manifest.sandbox[0].manifest.spec.podTemplate.spec.containers[0].securityContext.capabilities.drop[0] == "ALL",
+ kubernetes_manifest.sandbox[0].manifest.spec.podTemplate.spec.containers[0].imagePullPolicy == "IfNotPresent",
+ ])
+ error_message = "The workspace Pod must be hardened."
+ }
+ assert {
+ condition = kubernetes_manifest.sandbox[0].manifest.spec.podTemplate.spec.containers[0].env[0].valueFrom.secretKeyRef.name == kubernetes_secret_v1.agent_token[0].metadata[0].name
+ error_message = "The agent token must come from the Secret."
+ }
+ assert {
+ condition = kubernetes_manifest.sandbox[0].manifest.spec.podTemplate.spec.containers[0].env[1].value == "https://203.0.113.10"
+ error_message = "CODER_AGENT_URL must be the access URL."
+ }
+ assert {
+ condition = strcontains(kubernetes_manifest.sandbox[0].manifest.spec.podTemplate.spec.containers[0].command[2], "exec /usr/local/bin/coder agent\n") && strcontains(kubernetes_manifest.sandbox[0].manifest.spec.podTemplate.spec.containers[0].command[2], "kill -TERM 1") && strcontains(kubernetes_manifest.sandbox[0].manifest.spec.podTemplate.spec.containers[0].command[2], "KUBERNETES_SERVICE_HOST")
+ error_message = "The container must run the policy gate and then the agent from the image."
+ }
+ assert {
+ condition = strcontains(kubernetes_manifest.sandbox[0].manifest.spec.podTemplate.spec.containers[0].command[2], "%%{time_connect}") && strcontains(kubernetes_manifest.sandbox[0].manifest.spec.podTemplate.spec.containers[0].command[2], "NetworkPolicy is no longer enforced")
+ error_message = "The gate must check the TCP connection, and keep checking after the agent starts."
+ }
+ assert {
+ condition = startswith(kubernetes_manifest.sandbox[0].manifest.spec.podTemplate.spec.containers[0].command[2], "[ -e /home/coder/.bashrc ] || cp -an /etc/skel/.")
+ error_message = "A new home must get the image's dotfiles."
+ }
+ assert {
+ condition = kubernetes_manifest.sandbox[0].manifest.spec.podTemplate.metadata.labels["com.coder.workspace.id"] == kubernetes_network_policy_v1.workspace[0].spec[0].pod_selector[0].match_labels["com.coder.workspace.id"]
+ error_message = "The NetworkPolicy must select the workspace Pod."
+ }
+ assert {
+ condition = length(kubernetes_network_policy_v1.workspace[0].spec[0].egress) == 1 && kubernetes_network_policy_v1.workspace[0].spec[0].egress[0].to[0].ip_block[0].cidr == "203.0.113.10/32" && kubernetes_network_policy_v1.workspace[0].spec[0].egress[0].ports[0].port == "443"
+ error_message = "Fully isolated must allow only the Coder server."
+ }
+ assert {
+ condition = !can(kubernetes_manifest.sandbox[0].manifest.spec.podTemplate.spec.hostAliases)
+ error_message = "An IP access URL needs no host alias."
+ }
+}
+
+run "persistent_github" {
+ command = plan
+
+ override_data {
+ target = data.coder_parameter.network_isolation
+ values = { value = "github" }
+ }
+
+ assert {
+ condition = length(kubernetes_network_policy_v1.workspace[0].spec[0].egress) == 3
+ error_message = "GitHub only must allow Coder, the GitHub ranges, and DNS."
+ }
+ assert {
+ condition = [for t in kubernetes_network_policy_v1.workspace[0].spec[0].egress[1].to : t.ip_block[0].cidr] == local.github_cidrs
+ error_message = "GitHub only must allow the GitHub ranges."
+ }
+ assert {
+ condition = [for t in kubernetes_network_policy_v1.workspace[0].spec[0].egress[2].to : t.ip_block[0].cidr] == ["8.8.8.8/32", "1.1.1.1/32"]
+ error_message = "GitHub only must allow DNS to the public resolvers only."
+ }
+ assert {
+ condition = length(module.git-clone) == 0
+ error_message = "An empty git_repo_url must clone nothing."
+ }
+}
+
+run "git_clone" {
+ command = plan
+
+ variables {
+ git_repo_url = "https://github.com/coder/coder"
+ }
+
+ override_data {
+ target = data.coder_parameter.network_isolation
+ values = { value = "github" }
+ }
+
+ assert {
+ condition = length(module.git-clone) == 1
+ error_message = "GitHub only must clone git_repo_url."
+ }
+}
+
+run "git_clone_skips_fully_isolated" {
+ command = plan
+
+ variables {
+ git_repo_url = "https://github.com/coder/coder"
+ }
+
+ assert {
+ condition = length(module.git-clone) == 0
+ error_message = "Fully isolated cannot reach Git hosts, so it must not clone."
+ }
+}
+
+run "persistent_internet" {
+ command = plan
+
+ override_data {
+ target = data.coder_parameter.network_isolation
+ values = { value = "internet" }
+ }
+
+ assert {
+ condition = kubernetes_network_policy_v1.workspace[0].spec[0].egress[1].to[0].ip_block[0].cidr == "0.0.0.0/0" && toset(kubernetes_network_policy_v1.workspace[0].spec[0].egress[1].to[0].ip_block[0].except) == toset(local.private_cidrs)
+ error_message = "Public internet must exclude private ranges and cloud metadata."
+ }
+ assert {
+ condition = [for p in kubernetes_network_policy_v1.workspace[0].spec[0].egress[1].ports : p.port] == ["443", "80"]
+ error_message = "Public internet must allow only ports 443 and 80."
+ }
+}
+
+run "kata" {
+ command = plan
+
+ override_data {
+ target = data.coder_parameter.runtime
+ values = { value = "kata-qemu" }
+ }
+
+ assert {
+ condition = kubernetes_manifest.sandbox[0].manifest.spec.podTemplate.spec.runtimeClassName == "kata-qemu"
+ error_message = "Kata QEMU must set the kata-qemu RuntimeClass."
+ }
+}
+
+run "scratch" {
+ command = plan
+
+ override_data {
+ target = data.coder_parameter.home
+ values = { value = "scratch" }
+ }
+ override_data {
+ target = data.coder_parameter.runtime
+ values = { value = "kata-clh" }
+ }
+
+ assert {
+ condition = length(kubernetes_manifest.claim) == 1 && length(kubernetes_manifest.sandbox) == 0
+ error_message = "A scratch home must claim from the warm pool."
+ }
+ assert {
+ condition = kubernetes_manifest.claim[0].manifest.spec.warmPoolRef.name == "coder-kata-clh"
+ error_message = "The claim must use the warm pool of the runtime."
+ }
+ assert {
+ condition = length(kubernetes_persistent_volume_claim_v1.home) == 0 && length(kubernetes_network_policy_v1.workspace) == 0 && length(kubernetes_secret_v1.agent_token) == 0
+ error_message = "A scratch home needs no PVC, Secret, or Terraform NetworkPolicy."
+ }
+ assert {
+ condition = kubernetes_manifest.claim[0].manifest.spec.additionalPodMetadata.annotations["coder.com/agent-url"] == "https://203.0.113.10"
+ error_message = "The claim must pass the access URL to the pooled Pod."
+ }
+}
+
+run "scratch_github" {
+ command = plan
+
+ override_data {
+ target = data.coder_parameter.home
+ values = { value = "scratch" }
+ }
+ override_data {
+ target = data.coder_parameter.network_isolation
+ values = { value = "github" }
+ }
+
+ assert {
+ condition = kubernetes_manifest.claim[0].manifest.spec.warmPoolRef.name == "coder-gvisor-github"
+ error_message = "GitHub only must claim from the GitHub only warm pool."
+ }
+}
+
+run "scratch_rejects_public_internet" {
+ command = plan
+
+ override_data {
+ target = data.coder_parameter.home
+ values = { value = "scratch" }
+ }
+ override_data {
+ target = data.coder_parameter.network_isolation
+ values = { value = "internet" }
+ }
+
+ expect_failures = [kubernetes_manifest.claim]
+}
+
+run "stop_keeps_home" {
+ command = plan
+
+ override_data {
+ target = data.coder_workspace.me
+ values = {
+ id = "0b6b6a5e-0000-4000-8000-000000000001"
+ name = "test"
+ start_count = 0
+ transition = "stop"
+ access_url = "https://203.0.113.10"
+ }
+ }
+
+ assert {
+ condition = length(kubernetes_manifest.sandbox) == 0 && length(kubernetes_secret_v1.agent_token) == 0
+ error_message = "Stop must delete the Sandbox and the token Secret."
+ }
+ assert {
+ condition = length(kubernetes_persistent_volume_claim_v1.home) == 1 && length(kubernetes_network_policy_v1.workspace) == 1
+ error_message = "Stop must keep the home disk."
+ }
+}
+
+run "host_name_needs_ip" {
+ command = plan
+
+ override_data {
+ target = data.coder_workspace.me
+ values = {
+ id = "0b6b6a5e-0000-4000-8000-000000000001"
+ name = "test"
+ start_count = 1
+ transition = "start"
+ access_url = "https://coder.example.com"
+ }
+ }
+
+ expect_failures = [kubernetes_manifest.sandbox]
+}
+
+run "host_name_alias" {
+ command = plan
+
+ variables {
+ coder_host_ip = "203.0.113.10"
+ }
+
+ override_data {
+ target = data.coder_workspace.me
+ values = {
+ id = "0b6b6a5e-0000-4000-8000-000000000001"
+ name = "test"
+ start_count = 1
+ transition = "start"
+ access_url = "https://coder.example.com:8443/"
+ }
+ }
+
+ assert {
+ condition = kubernetes_manifest.sandbox[0].manifest.spec.podTemplate.spec.hostAliases[0].ip == "203.0.113.10" && kubernetes_manifest.sandbox[0].manifest.spec.podTemplate.spec.hostAliases[0].hostnames[0] == "coder.example.com"
+ error_message = "coder_host_ip must map the access URL host in /etc/hosts."
+ }
+}
+
+run "presets" {
+ command = plan
+
+ assert {
+ condition = length(data.coder_workspace_preset.persistent) == 3 && alltrue([for p in data.coder_workspace_preset.persistent : p.parameters.runtime == "gvisor" && p.parameters.home == "persistent"])
+ error_message = "There must be one gVisor persistent preset per network profile."
+ }
+ assert {
+ condition = alltrue([for p in data.coder_workspace_preset.persistent : one(p.prebuilds).instances == 1])
+ error_message = "Each persistent preset must prebuild prebuild_instances workspaces."
+ }
+ assert {
+ condition = data.coder_workspace_preset.persistent["isolated"].default == true && data.coder_workspace_preset.persistent["github"].default == false
+ error_message = "Fully isolated must be the default preset."
+ }
+ assert {
+ condition = toset(keys(data.coder_workspace_preset.scratch)) == toset(["isolated", "github"]) && alltrue([for p in data.coder_workspace_preset.scratch : p.parameters.home == "scratch" && length(p.prebuilds) == 0])
+ error_message = "Fully isolated and GitHub only must each have a scratch preset without prebuilds."
+ }
+}
+
+run "no_prebuilds" {
+ command = plan
+
+ variables {
+ prebuild_instances = 0
+ }
+
+ assert {
+ condition = alltrue([for p in data.coder_workspace_preset.persistent : length(p.prebuilds) == 0])
+ error_message = "prebuild_instances = 0 must turn prebuilds off."
+ }
+}
+
+run "no_policy_gate" {
+ command = plan
+
+ variables {
+ network_policy_gate = false
+ }
+
+ assert {
+ condition = !strcontains(kubernetes_manifest.sandbox[0].manifest.spec.podTemplate.spec.containers[0].command[2], "KUBERNETES_SERVICE_HOST")
+ error_message = "network_policy_gate = false must remove the gate."
+ }
+ assert {
+ condition = endswith(kubernetes_manifest.sandbox[0].manifest.spec.podTemplate.spec.containers[0].command[2], "exec /usr/local/bin/coder agent")
+ error_message = "Without the gate, the container must exec the agent."
+ }
+}
+
+run "rejects_latest_image" {
+ command = plan
+
+ variables {
+ workspace_image = "registry.example.com/coder/workspace:latest"
+ }
+
+ expect_failures = [var.workspace_image]
+}
+
+run "rejects_empty_namespace" {
+ command = plan
+
+ variables {
+ namespace = " "
+ }
+
+ expect_failures = [var.namespace]
+}
+
+run "rejects_bad_cidr" {
+ command = plan
+
+ variables {
+ coder_egress_cidrs = ["coder.example.com"]
+ }
+
+ expect_failures = [var.coder_egress_cidrs]
+}
+
+run "rejects_bad_port" {
+ command = plan
+
+ variables {
+ coder_egress_port = 70000
+ }
+
+ expect_failures = [var.coder_egress_port]
+}
+
+run "rejects_negative_prebuilds" {
+ command = plan
+
+ variables {
+ prebuild_instances = -1
+ }
+
+ expect_failures = [var.prebuild_instances]
+}
+
+run "rejects_untagged_image" {
+ command = plan
+
+ variables {
+ workspace_image = "registry.example.com:5000/coder/workspace"
+ }
+
+ expect_failures = [var.workspace_image]
+}
+
+run "accepts_digest_image" {
+ command = plan
+
+ variables {
+ workspace_image = "registry.example.com:5000/coder/workspace@sha256:32e560b86242d4ac65e7a777953ed3ec991d4db8368e24dea7e9ea0817a3410e"
+ }
+
+ assert {
+ condition = kubernetes_manifest.sandbox[0].manifest.spec.podTemplate.spec.containers[0].image == var.workspace_image
+ error_message = "A digest must be accepted."
+ }
+}
+
+run "rejects_ipv6_cidr" {
+ command = plan
+
+ variables {
+ coder_egress_cidrs = ["2001:db8::1/128"]
+ }
+
+ expect_failures = [var.coder_egress_cidrs]
+}
+
+run "rejects_bad_host_ip" {
+ command = plan
+
+ variables {
+ coder_host_ip = "coder.example.com"
+ }
+
+ expect_failures = [var.coder_host_ip]
+}
+
+run "ai_gateway_env" {
+ command = plan
+
+ assert {
+ condition = coder_env.anthropic_base_url[0].value == "https://203.0.113.10/api/v2/aibridge/anthropic"
+ error_message = "ANTHROPIC_BASE_URL must point at the Coder AI Gateway."
+ }
+
+ assert {
+ condition = length(coder_env.anthropic_auth_token) == 1 && length(coder_env.claude_disable_autoupdater) == 1
+ error_message = "The AI Gateway token and DISABLE_AUTOUPDATER must be set on start."
+ }
+}
+
+run "ai_gateway_scoped_token" {
+ command = apply
+
+ assert {
+ condition = nonsensitive(coder_env.anthropic_auth_token[0].value) == "scoped-id-scoped-secret"
+ error_message = "ANTHROPIC_AUTH_TOKEN must be the scoped token, not the owner's session token."
+ }
+
+ assert {
+ condition = jsonencode(jsondecode(data.http.ai_gateway_token[0].request_body).scopes) == jsonencode(["user:read_personal"]) && nonsensitive(data.http.ai_gateway_token[0].request_headers["Coder-Session-Token"]) == "owner-full-session-token"
+ error_message = "The build must create a token with only the user:read_personal scope, using the owner's session token."
+ }
+}
+
+run "ai_gateway_prebuild" {
+ command = plan
+
+ override_data {
+ target = data.coder_workspace.me
+ values = {
+ id = "0b6b6a5e-0000-4000-8000-000000000001"
+ name = "test"
+ start_count = 1
+ transition = "start"
+ access_url = "https://203.0.113.10"
+ is_prebuild = true
+ }
+ }
+
+ assert {
+ condition = length(data.http.ai_gateway_token) == 0 && length(coder_env.anthropic_auth_token) == 0
+ error_message = "A prebuild must not create a token. The claim build does."
+ }
+}
+
+run "ai_gateway_off" {
+ command = plan
+
+ variables {
+ enable_ai_gateway = false
+ }
+
+ assert {
+ condition = length(coder_env.anthropic_base_url) == 0 && length(coder_env.anthropic_auth_token) == 0 && length(data.http.ai_gateway_token) == 0
+ error_message = "No AI Gateway env or token when enable_ai_gateway is false."
+ }
+}
+
+run "claude_code_off" {
+ command = plan
+
+ variables {
+ enable_claude_code = false
+ }
+
+ assert {
+ condition = length(coder_app.claude) == 0 && length(coder_env.claude_disable_autoupdater) == 0 && length(coder_env.anthropic_base_url) == 0 && length(coder_env.anthropic_auth_token) == 0
+ error_message = "No Claude Code app or env when enable_claude_code is false."
+ }
+}
+
+run "dns_servers" {
+ command = plan
+
+ override_data {
+ target = data.coder_parameter.network_isolation
+ values = { value = "github" }
+ }
+
+ variables {
+ dns_servers = "10.0.0.2, 10.0.0.3"
+ }
+
+ assert {
+ condition = kubernetes_manifest.sandbox[0].manifest.spec.podTemplate.spec.dnsConfig.nameservers == tolist(["10.0.0.2", "10.0.0.3"])
+ error_message = "The Pod must use dns_servers."
+ }
+ assert {
+ condition = kubernetes_network_policy_v1.workspace[0].spec[0].egress[2].to[0].ip_block[0].cidr == "10.0.0.2/32"
+ error_message = "The NetworkPolicy must allow DNS to dns_servers."
+ }
+ assert {
+ condition = kubernetes_manifest.sandbox[0].manifest.spec.podTemplate.spec.containers[0].command[0] == "bash"
+ error_message = "The agent command must run with bash."
+ }
+}
+
+run "rejects_ipv6_dns" {
+ command = plan
+
+ variables {
+ dns_servers = "2001:4860:4860::8888"
+ }
+
+ expect_failures = [var.dns_servers]
+}
+
+run "runtimes_variable" {
+ command = plan
+
+ variables {
+ runtimes = "kata-clh, gvisor"
+ }
+
+ assert {
+ condition = [for o in data.coder_parameter.runtime.option : o.value] == ["kata-clh", "gvisor"] && data.coder_parameter.runtime.default == "kata-clh"
+ error_message = "The runtime options must follow runtimes, with the first as the default."
+ }
+ assert {
+ condition = data.coder_workspace_preset.persistent["isolated"].parameters.runtime == "kata-clh" && data.coder_workspace_preset.scratch["github"].parameters.runtime == "kata-clh"
+ error_message = "The presets must use the first runtime."
+ }
+}
+
+run "no_scratch_profiles" {
+ command = plan
+
+ variables {
+ scratch_profiles = ""
+ }
+
+ assert {
+ condition = length(data.coder_workspace_preset.scratch) == 0 && length(data.coder_parameter.home.option) == 1
+ error_message = "Empty scratch_profiles must hide scratch homes."
+ }
+}
+
+run "rejects_unknown_runtime" {
+ command = plan
+
+ variables {
+ runtimes = "gvisor,runc"
+ }
+
+ expect_failures = [var.runtimes]
+}
+
+run "port_from_access_url" {
+ command = plan
+
+ override_data {
+ target = data.coder_workspace.me
+ values = {
+ id = "0b6b6a5e-0000-4000-8000-000000000001"
+ name = "test"
+ start_count = 1
+ transition = "start"
+ access_url = "http://203.0.113.10:8080"
+ }
+ }
+
+ assert {
+ condition = kubernetes_network_policy_v1.workspace[0].spec[0].egress[0].ports[0].port == "8080"
+ error_message = "The Coder port must come from the access URL."
+ }
+}
+
+run "port_override" {
+ command = plan
+
+ variables {
+ coder_egress_port = 3000
+ }
+
+ assert {
+ condition = kubernetes_network_policy_v1.workspace[0].spec[0].egress[0].ports[0].port == "3000"
+ error_message = "coder_egress_port must override the access URL."
+ }
+}