From cf500b95b9c1d5a8ccb9e40367e7c0ece26f93bb Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 30 Mar 2026 15:17:55 +0200 Subject: [PATCH] chore: move docker-chat-sandbox under templates/x (#23777) Adds the experimental `docker-chat-sandbox` example template under `examples/templates/x/`. It provisions a regular dev agent plus a chat-designated agent that runs inside bubblewrap with a read-only root, writable `/home/coder`, and outbound TCP restricted to the Coder control-plane endpoint via `iptables`. The chat agent still appears in dashboard and API responses, but the template reserves it for chatd-managed sessions rather than normal user interaction. `lint/examples` now walks nested template directories, so experimental templates can live under `examples/templates/x/` without treating `x/` itself as a template. --- examples/templates/x/README.md | 5 + .../x/docker-chat-sandbox/Dockerfile.chat | 20 ++ .../templates/x/docker-chat-sandbox/README.md | 123 ++++++++ .../x/docker-chat-sandbox/bwrap-agent.sh | 190 +++++++++++ .../templates/x/docker-chat-sandbox/main.tf | 298 ++++++++++++++++++ scripts/examplegen/main.go | 36 ++- 6 files changed, 664 insertions(+), 8 deletions(-) create mode 100644 examples/templates/x/README.md create mode 100644 examples/templates/x/docker-chat-sandbox/Dockerfile.chat create mode 100644 examples/templates/x/docker-chat-sandbox/README.md create mode 100644 examples/templates/x/docker-chat-sandbox/bwrap-agent.sh create mode 100644 examples/templates/x/docker-chat-sandbox/main.tf diff --git a/examples/templates/x/README.md b/examples/templates/x/README.md new file mode 100644 index 0000000000..d0bd14e20f --- /dev/null +++ b/examples/templates/x/README.md @@ -0,0 +1,5 @@ +# Experimental templates + +Templates in this directory are experimental and may change or be removed without notice. + +They are useful for validating new or unstable Coder behaviors before we commit to them as stable example templates. diff --git a/examples/templates/x/docker-chat-sandbox/Dockerfile.chat b/examples/templates/x/docker-chat-sandbox/Dockerfile.chat new file mode 100644 index 0000000000..2b02edbdd7 --- /dev/null +++ b/examples/templates/x/docker-chat-sandbox/Dockerfile.chat @@ -0,0 +1,20 @@ +FROM codercom/enterprise-base:ubuntu + +USER root + +# Install bubblewrap and iptables for sandboxed agent execution. +RUN apt-get update && \ + apt-get install -y --no-install-recommends bubblewrap iptables && \ + rm -rf /var/lib/apt/lists/* + +# Wrapper script that starts the agent inside a bwrap sandbox. +# Everything the agent spawns (tool calls, SSH, etc.) inherits +# the restricted namespace. +COPY bwrap-agent.sh /usr/local/bin/bwrap-agent +RUN chmod 755 /usr/local/bin/bwrap-agent + +# Run as root so bwrap can create mount namespaces without needing +# user namespace support (which Docker blocks). The bwrap sandbox +# itself provides filesystem isolation (read-only root). +# The coder user home is still /home/coder (writable via bind mount). +ENV HOME=/home/coder diff --git a/examples/templates/x/docker-chat-sandbox/README.md b/examples/templates/x/docker-chat-sandbox/README.md new file mode 100644 index 0000000000..642ff6b789 --- /dev/null +++ b/examples/templates/x/docker-chat-sandbox/README.md @@ -0,0 +1,123 @@ +--- +display_name: Docker + Chat Sandbox +description: Two-agent Docker template with a bubblewrap-sandboxed chat agent +icon: ../../../../site/static/icon/docker.png +maintainer_github: coder +tags: [docker, container, chat] +--- + +> **Experimental**: This template depends on the `-coderd-chat` agent +> naming convention, which is an internal PoC mechanism subject to +> change. Do not rely on this for production workloads. + +# Docker + Chat Sandbox + +This template provisions a workspace with two agents: + +| Agent | Purpose | Visible in UI | +|-------------------|---------------------------------------------------|---------------| +| `dev` | Regular development agent with code-server | Yes | +| `dev-coderd-chat` | AI chat agent running inside a bubblewrap sandbox | Yes | + +## How it works + +The `dev` agent is a standard workspace agent with code-server and +full filesystem access. Users interact with it normally through the +dashboard, SSH, and Coder Connect. + +The `dev-coderd-chat` agent is designated for AI chat sessions via the +`-coderd-chat` naming suffix. Chatd routes chat traffic to this agent +automatically. The dashboard and REST API still expose it like any other +agent, but this template treats it as a chatd-managed sandbox rather +than a normal user interaction surface. + +## Bubblewrap sandbox + +The chat agent's init script is wrapped with +[bubblewrap](https://github.com/containers/bubblewrap) so the **entire +agent process** runs inside a restricted mount namespace with **all +capabilities dropped**. Every child process the agent spawns (tool calls +via `sh -c`, SSH sessions) inherits the same restrictions. + +The Coder agent hardcodes `sh -c` for tool call execution and ignores +the `SHELL` environment variable, so wrapping only the shell would be +ineffective. Wrapping the agent binary means the `/bin/bash`, `python3`, +or any other binary the model invokes is the one inside the read-only +namespace. + +### Sandbox policy + +- **Read-only root filesystem**: cannot install packages, modify system + config, or tamper with binaries. Enforced by the kernel mount + namespace, applies even to the root user. +- **Read-write /home/coder**: project files are editable (shared with + the dev agent via a Docker volume). +- **Read-write /tmp**: scratch space (the agent binary downloads here + during startup, tool calls can use it). +- **Shared /proc and /dev**: bind-mounted from the container so CLI + tools and the agent work normally. +- **Outbound TCP allowlist**: before entering bwrap, the wrapper + installs `iptables` and `ip6tables` OUTPUT rules that allow loopback, + `ESTABLISHED,RELATED`, and new TCP connections only to the + control-plane host and port used by the agent. All other outbound TCP + is rejected over both IPv4 and IPv6. +- **Near-zero capabilities**: bwrap drops all Linux capabilities + except `CAP_DAC_OVERRIDE` before exec'ing the agent. This prevents + mount escape (`mount --bind`), ptrace, raw network access, and all + other privileged operations. `DAC_OVERRIDE` is retained so the + sandbox process (root) can read/write files owned by uid 1000 + (coder) on the shared home volume without changing ownership. + +### How the capability lifecycle works + +1. Docker starts the container as root with `CAP_SYS_ADMIN`, + `CAP_NET_ADMIN`, and `CAP_DAC_OVERRIDE`. +2. The entrypoint runs `bwrap-agent`, which resolves the control-plane + host and installs the outbound TCP allowlist with `iptables` and + `ip6tables`. +3. bwrap creates the mount namespace using `CAP_SYS_ADMIN`. +4. bwrap drops all capabilities except `DAC_OVERRIDE`. +5. bwrap exec's the agent binary with only `DAC_OVERRIDE`. +6. All tool calls spawned by the agent inherit only `DAC_OVERRIDE`. + +After step 4, the process cannot remount filesystems, change ownership, +ptrace other processes, or perform any other privileged operation. It +can read and write files regardless of Unix permissions, which is needed +because the shared home volume is owned by uid 1000 (coder) but the +sandbox runs as root. + +### Limitations + +- **No PID namespace isolation**: Docker's namespace setup conflicts + with nested PID namespaces (`--unshare-pid`). Processes inside the + sandbox can see other container processes via `/proc`. +- **No user namespace isolation**: Docker blocks nested user namespaces. + The container runs as root uid 0, but with zero capabilities the + effective privilege level is lower than an unprivileged user. +- **Only outbound TCP is filtered**: UDP, ICMP, and inbound traffic + still follow Docker's normal container networking rules. DNS usually + continues to work over UDP, but DNS-over-TCP is blocked unless it uses + the control-plane endpoint. +- **IP resolution at startup**: the outbound allowlist resolves the + control-plane hostname once with `getent ahostsv4` and, when IPv6 is + enabled, `getent ahostsv6`. If those lookups fail, or if the endpoint + later moves to a different IP, the chat container must restart to + refresh the rules. +- **seccomp=unconfined**: Docker's default seccomp profile blocks + `pivot_root`, which bwrap needs. A custom seccomp profile that allows + only `pivot_root` and `mount` would be more restrictive. + +Template authors can adjust the sandbox policy in `bwrap-agent.sh` by +adding `--bind` flags for additional writable paths. + +## Usage + +After starting `./scripts/develop.sh`, push this template: + +```bash +cd examples/templates/x/docker-chat-sandbox +coder templates push docker-chat-sandbox \ + --var docker_socket="$(docker context inspect --format '{{ .Endpoints.docker.Host }}')" +``` + +Then create a workspace from it and start a chat session. diff --git a/examples/templates/x/docker-chat-sandbox/bwrap-agent.sh b/examples/templates/x/docker-chat-sandbox/bwrap-agent.sh new file mode 100644 index 0000000000..33386c1a0f --- /dev/null +++ b/examples/templates/x/docker-chat-sandbox/bwrap-agent.sh @@ -0,0 +1,190 @@ +#!/bin/bash +# bwrap-agent.sh: Start the Coder agent inside a bubblewrap sandbox. +# +# This script wraps the agent binary and all its children in a bwrap +# mount namespace with almost all capabilities dropped. +# +# Sandbox policy: +# - Root filesystem is read-only (prevents system modification) +# - /home/coder is read-write (project files, shared with dev agent) +# - /tmp is read-write (scratch space, bind from container /tmp) +# - /proc is bind-mounted from host (needed by CLI tools) +# - /dev is bind-mounted from host (devices) +# - Outbound TCP is restricted to the control-plane endpoint +# over IPv4 and IPv6. +# - All capabilities dropped except DAC_OVERRIDE. +# +# DAC_OVERRIDE is retained so the sandbox process (running as root) +# can read and write files owned by uid 1000 (coder) on the shared +# home volume without chowning them. This preserves correct +# ownership for the dev agent, which runs as the coder user. +# +# The container must run as root with CAP_SYS_ADMIN and CAP_NET_ADMIN +# so bwrap can create the mount namespace and this wrapper can install +# iptables/ip6tables rules. bwrap then drops all caps except +# DAC_OVERRIDE before exec'ing the child process. + +set -euo pipefail + +fail() { + echo "bwrap-agent: $*" >&2 + exit 1 +} + +discover_control_plane_url() { + if [ -n "${CODER_SANDBOX_CONTROL_PLANE_URL:-}" ]; then + printf '%s\n' "$CODER_SANDBOX_CONTROL_PLANE_URL" + return 0 + fi + + local arg url + for arg in "$@"; do + if [ -f "$arg" ]; then + url=$(grep -aoE "https?://[^\"'[:space:]]+" "$arg" | head -n1 || true) + if [ -n "$url" ]; then + printf '%s\n' "$url" + return 0 + fi + fi + done + + return 1 +} + +parse_control_plane_host_port() { + local url="$1" + local host_port host port + + host_port="${url#*://}" + host_port="${host_port%%/*}" + if [ -z "$host_port" ]; then + fail "control-plane URL is missing a host: $url" + fi + + case "$host_port" in + \[*\]:*) + host="${host_port#\[}" + host="${host%%\]*}" + port="${host_port##*:}" + ;; + \[*\]) + host="${host_port#\[}" + host="${host%\]}" + case "$url" in + https://*) port=443 ;; + http://*) port=80 ;; + *) fail "unsupported control-plane URL scheme: $url" ;; + esac + ;; + *:*:*) + fail "IPv6 control-plane URLs must use brackets: $url" + ;; + *:*) + host="${host_port%%:*}" + port="${host_port##*:}" + ;; + *) + host="$host_port" + case "$url" in + https://*) port=443 ;; + http://*) port=80 ;; + *) fail "unsupported control-plane URL scheme: $url" ;; + esac + ;; + esac + + if [[ -z "$host" || -z "$port" || ! "$port" =~ ^[0-9]+$ ]]; then + fail "failed to parse control-plane host and port from: $url" + fi + + printf '%s %s\n' "$host" "$port" +} + +ipv6_enabled() { + [ -s /proc/net/if_inet6 ] +} + +install_family_tcp_egress_rules() { + local family="$1" + local port="$2" + shift 2 + local -a control_plane_ips=("$@") + local chain ip + local -a table_cmd + + case "$family" in + ipv4) + chain="CODER_CHAT_SANDBOX_OUT4" + table_cmd=(iptables -w 5) + ;; + ipv6) + chain="CODER_CHAT_SANDBOX_OUT6" + table_cmd=(ip6tables -w 5) + ;; + *) + fail "unsupported IP family: $family" + ;; + esac + + "${table_cmd[@]}" -N "$chain" 2>/dev/null || true + "${table_cmd[@]}" -F "$chain" + while "${table_cmd[@]}" -C OUTPUT -j "$chain" >/dev/null 2>&1; do + "${table_cmd[@]}" -D OUTPUT -j "$chain" + done + "${table_cmd[@]}" -I OUTPUT 1 -j "$chain" + + "${table_cmd[@]}" -A "$chain" -o lo -j ACCEPT + "${table_cmd[@]}" -A "$chain" -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT + for ip in "${control_plane_ips[@]}"; do + [ -n "$ip" ] || continue + "${table_cmd[@]}" -A "$chain" -p tcp -d "$ip" --dport "$port" -j ACCEPT + done + "${table_cmd[@]}" -A "$chain" -p tcp -j REJECT --reject-with tcp-reset + "${table_cmd[@]}" -A "$chain" -j RETURN +} + +install_tcp_egress_rules() { + local url="$1" + local host port + local -a control_plane_ipv4s=() + local -a control_plane_ipv6s=() + + read -r host port < <(parse_control_plane_host_port "$url") + mapfile -t control_plane_ipv4s < <(getent ahostsv4 "$host" | awk '{print $1}' | sort -u) + if ipv6_enabled; then + mapfile -t control_plane_ipv6s < <(getent ahostsv6 "$host" | awk '{print $1}' | sort -u) + fi + if [ "${#control_plane_ipv4s[@]}" -eq 0 ] && [ "${#control_plane_ipv6s[@]}" -eq 0 ]; then + fail "failed to resolve control-plane host: $host" + fi + + install_family_tcp_egress_rules ipv4 "$port" "${control_plane_ipv4s[@]}" + if ipv6_enabled; then + install_family_tcp_egress_rules ipv6 "$port" "${control_plane_ipv6s[@]}" + fi +} + +command -v bwrap >/dev/null 2>&1 || fail "bubblewrap not found" +command -v getent >/dev/null 2>&1 || fail "getent not found" +command -v iptables >/dev/null 2>&1 || fail "iptables not found" +if ipv6_enabled; then + command -v ip6tables >/dev/null 2>&1 || fail "ip6tables not found" +fi + +control_plane_url=$(discover_control_plane_url "$@" || true) +if [ -z "$control_plane_url" ]; then + fail "failed to determine control-plane URL" +fi + +install_tcp_egress_rules "$control_plane_url" + +exec bwrap \ + --ro-bind / / \ + --bind /home/coder /home/coder \ + --bind /tmp /tmp \ + --bind /proc /proc \ + --dev-bind /dev /dev \ + --die-with-parent \ + --cap-drop ALL \ + --cap-add cap_dac_override \ + "$@" diff --git a/examples/templates/x/docker-chat-sandbox/main.tf b/examples/templates/x/docker-chat-sandbox/main.tf new file mode 100644 index 0000000000..2557ab60e0 --- /dev/null +++ b/examples/templates/x/docker-chat-sandbox/main.tf @@ -0,0 +1,298 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + } + docker = { + source = "kreuzwerker/docker" + } + } +} + +locals { + username = data.coder_workspace_owner.me.name + chat_control_plane_url = replace(data.coder_workspace.me.access_url, "/localhost|127\\.0\\.0\\.1/", "host.docker.internal") +} + +variable "docker_socket" { + default = "" + description = "(Optional) Docker socket URI" + type = string +} + +provider "docker" { + host = var.docker_socket != "" ? var.docker_socket : null +} + +data "coder_provisioner" "me" {} +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +# ------------------------------------------------------------------- +# Agent 1: Regular dev agent (user-facing, appears in the dashboard) +# ------------------------------------------------------------------- +resource "coder_agent" "dev" { + arch = data.coder_provisioner.me.arch + os = "linux" + startup_script = <<-EOT + set -e + if [ ! -f ~/.init_done ]; then + cp -rT /etc/skel ~ + touch ~/.init_done + fi + EOT + + env = { + GIT_AUTHOR_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name) + GIT_AUTHOR_EMAIL = "${data.coder_workspace_owner.me.email}" + GIT_COMMITTER_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name) + GIT_COMMITTER_EMAIL = "${data.coder_workspace_owner.me.email}" + } + + 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 = "3_home_disk" + script = "coder stat disk --path $${HOME}" + interval = 60 + timeout = 1 + } +} + +# See https://registry.coder.com/modules/coder/code-server +module "code-server" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/code-server/coder" + version = "~> 1.0" + agent_id = coder_agent.dev.id + order = 1 +} + +# ------------------------------------------------------------------- +# Agent 2: Chat agent (designated for chatd-managed AI chat) +# +# This agent runs inside a bubblewrap (bwrap) sandbox. The entire +# agent process and all its children (tool calls, SSH sessions, etc.) +# execute in a restricted mount namespace. There is no escape path +# because the sandbox wraps the agent binary itself, not just the +# shell. +# +# The agent name "dev-coderd-chat" ends with the -coderd-chat suffix +# that tells chatd to route chats here. The dashboard still shows the +# agent, but the template reserves it for chatd-managed sessions rather +# than normal user interaction. +# +# NOTE: Terraform resource labels cannot contain hyphens, but the +# Coder provisioner uses the label as the agent name (and rejects +# underscores). To work around this, the resource label uses hyphens +# and all references go through the local.chat_agent indirection +# below. +# ------------------------------------------------------------------- + +# Terraform parses "coder_agent.dev-coderd-chat.X" as subtraction, +# so we capture the agent attributes in locals for clean references. +locals { + # The resource block below uses a hyphenated label so the Coder + # provisioner registers the agent name as "dev-coderd-chat". + # These locals let the rest of the config reference its attributes + # without Terraform misinterpreting the hyphens. + chat_agent_init = replace(coder_agent.dev-coderd-chat.init_script, "/localhost|127\\.0\\.0\\.1/", "host.docker.internal") + chat_agent_token = coder_agent.dev-coderd-chat.token +} + +resource "coder_agent" "dev-coderd-chat" { + arch = data.coder_provisioner.me.arch + os = "linux" + order = 99 + startup_script = <<-EOT + set -e + if [ ! -f ~/.init_done ]; then + cp -rT /etc/skel ~ + touch ~/.init_done + fi + EOT + + env = { + GIT_AUTHOR_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name) + GIT_AUTHOR_EMAIL = "${data.coder_workspace_owner.me.email}" + GIT_COMMITTER_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name) + GIT_COMMITTER_EMAIL = "${data.coder_workspace_owner.me.email}" + } +} + +# ------------------------------------------------------------------- +# Docker image with bubblewrap pre-installed +# ------------------------------------------------------------------- +resource "docker_image" "chat_sandbox" { + name = "coder-chat-sandbox:latest" + + build { + context = "." + dockerfile = "Dockerfile.chat" + } +} + +# ------------------------------------------------------------------- +# Shared home volume +# ------------------------------------------------------------------- +resource "docker_volume" "home_volume" { + name = "coder-${data.coder_workspace.me.id}-home" + lifecycle { + ignore_changes = all + } + labels { + label = "coder.owner" + value = data.coder_workspace_owner.me.name + } + labels { + label = "coder.owner_id" + value = data.coder_workspace_owner.me.id + } + labels { + label = "coder.workspace_id" + value = data.coder_workspace.me.id + } + labels { + label = "coder.workspace_name_at_creation" + value = data.coder_workspace.me.name + } +} + +# ------------------------------------------------------------------- +# Container 1: Dev workspace (regular agent, no sandbox) +# ------------------------------------------------------------------- +resource "docker_container" "dev" { + count = data.coder_workspace.me.start_count + image = "codercom/enterprise-base:ubuntu" + name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}" + hostname = data.coder_workspace.me.name + entrypoint = [ + "sh", "-c", + replace(coder_agent.dev.init_script, "/localhost|127\\.0\\.0\\.1/", "host.docker.internal") + ] + env = ["CODER_AGENT_TOKEN=${coder_agent.dev.token}"] + + host { + host = "host.docker.internal" + ip = "host-gateway" + } + + volumes { + container_path = "/home/coder" + volume_name = docker_volume.home_volume.name + read_only = false + } + + labels { + label = "coder.owner" + value = data.coder_workspace_owner.me.name + } + labels { + label = "coder.owner_id" + value = data.coder_workspace_owner.me.id + } + labels { + label = "coder.workspace_id" + value = data.coder_workspace.me.id + } + labels { + label = "coder.workspace_name" + value = data.coder_workspace.me.name + } +} + +# ------------------------------------------------------------------- +# Container 2: Chat sandbox (agent runs inside bubblewrap) +# +# The entrypoint pipes the agent init script through bwrap-agent, +# which starts the entire agent binary inside a bwrap namespace. +# Every process the agent spawns (sh -c for tool calls, SSH +# sessions, etc.) inherits the restricted mount namespace: +# +# - Read-only root filesystem (cannot modify system files) +# - Read-write /home/coder (shared project files) +# - Private /tmp (tmpfs scratch space) +# - Shared network namespace with outbound TCP restricted to the +# Coder control-plane endpoint used by the agent over IPv4 and IPv6 +# +# Because the agent itself runs inside bwrap, there is no way for +# a tool call to escape the sandbox by invoking /bin/bash or any +# other binary directly. All binaries are inside the same namespace. +# ------------------------------------------------------------------- +resource "docker_container" "chat" { + count = data.coder_workspace.me.start_count + image = docker_image.chat_sandbox.image_id + name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}-chat" + hostname = "${data.coder_workspace.me.name}-chat" + + # Capability budget: + # - SYS_ADMIN: bwrap needs this to create mount namespaces. + # - NET_ADMIN: the wrapper needs this to install iptables OUTPUT + # rules before entering bwrap. + # - DAC_OVERRIDE: passed through to the sandbox so the agent + # (running as root) can read/write files owned by uid 1000 on + # the shared home volume without changing ownership. + # - seccomp=unconfined: Docker's default seccomp profile blocks + # pivot_root, which bwrap uses during namespace setup. + capabilities { + add = ["SYS_ADMIN", "NET_ADMIN", "DAC_OVERRIDE"] + drop = ["ALL"] + } + security_opts = ["seccomp=unconfined"] + + # Wrap the init script through bwrap-agent so the agent binary + # and all its children run inside the sandbox namespace. + # The init script is base64-encoded to avoid nested shell quoting + # issues, then decoded and executed at container startup. + entrypoint = [ + "sh", "-c", + "echo ${base64encode(local.chat_agent_init)} | base64 -d > /tmp/coder-init.sh && chmod +x /tmp/coder-init.sh && exec bwrap-agent sh /tmp/coder-init.sh" + ] + env = [ + "CODER_AGENT_TOKEN=${local.chat_agent_token}", + "CODER_SANDBOX_CONTROL_PLANE_URL=${local.chat_control_plane_url}", + ] + + host { + host = "host.docker.internal" + ip = "host-gateway" + } + + volumes { + container_path = "/home/coder" + volume_name = docker_volume.home_volume.name + read_only = false + } + + labels { + label = "coder.owner" + value = data.coder_workspace_owner.me.name + } + labels { + label = "coder.owner_id" + value = data.coder_workspace_owner.me.id + } + labels { + label = "coder.workspace_id" + value = data.coder_workspace.me.id + } + labels { + label = "coder.workspace_name" + value = data.coder_workspace.me.name + } +} diff --git a/scripts/examplegen/main.go b/scripts/examplegen/main.go index 97ff02db82..242c0f9bf6 100644 --- a/scripts/examplegen/main.go +++ b/scripts/examplegen/main.go @@ -49,17 +49,25 @@ func run(lint bool) error { var paths []string if lint { - files, err := fs.ReadDir(examplesFS, "templates") + err := fs.WalkDir(examplesFS, "templates", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() { + return nil + } + if path == "templates" { + return nil + } + if !isTemplateExampleDir(examplesFS, path) { + return nil + } + paths = append(paths, path) + return fs.SkipDir + }) if err != nil { return err } - - for _, f := range files { - if !f.IsDir() { - continue - } - paths = append(paths, filepath.Join("templates", f.Name())) - } } else { for _, comment := range src.Comments { for _, line := range comment.List { @@ -102,6 +110,18 @@ func run(lint bool) error { return enc.Encode(examples) } +func isTemplateExampleDir(examplesFS fs.FS, name string) bool { + readmePath := path.Join(name, "README.md") + mainTFPath := path.Join(name, "main.tf") + if _, err := fs.Stat(examplesFS, readmePath); err != nil { + return false + } + if _, err := fs.Stat(examplesFS, mainTFPath); err != nil { + return false + } + return true +} + func parseTemplateExample(projectFS, examplesFS fs.FS, name string) (te *codersdk.TemplateExample, err error) { var errs []error defer func() {