From 3817ab42b6f8cfe31f45177c409edfed5fe54b02 Mon Sep 17 00:00:00 2001 From: Nick Vigilante Date: Tue, 14 Jul 2026 21:23:47 +0000 Subject: [PATCH 01/10] feat(coderd): add Coder Quickstart base to the template builder Port the existing `quickstart` example template into the template builder's base catalog so it can be selected in the guided wizard, and surface it first in the base list as the recommended starting point. - Add coderd/templatebuilder/bases/quickstart (base.json, README with prerequisite markers, main.tf.tmpl, install-languages.sh.tftpl), ported as-is from examples/templates/quickstart. - Pin the quickstart base first in the bases endpoint; other bases stay alphabetical. - Extend base ID/OS/snapshot tests and add the rendered golden file. Scaffold for DOCS-558. First-position placement and the template's scope/contents are still pending sign-off; see the PR description. --- .../bases/quickstart/README.md | 69 +++ .../bases/quickstart/base.json | 6 + .../quickstart/install-languages.sh.tftpl | 88 ++++ .../bases/quickstart/main.tf.tmpl | 437 ++++++++++++++++++ coderd/templatebuilder/bases_test.go | 3 +- coderd/templatebuilder/render_test.go | 1 + .../testdata/quickstart.tf.golden | 437 ++++++++++++++++++ coderd/templatebuilder_handler.go | 9 + 8 files changed, 1049 insertions(+), 1 deletion(-) create mode 100644 coderd/templatebuilder/bases/quickstart/README.md create mode 100644 coderd/templatebuilder/bases/quickstart/base.json create mode 100644 coderd/templatebuilder/bases/quickstart/install-languages.sh.tftpl create mode 100644 coderd/templatebuilder/bases/quickstart/main.tf.tmpl create mode 100644 coderd/templatebuilder/testdata/quickstart.tf.golden diff --git a/coderd/templatebuilder/bases/quickstart/README.md b/coderd/templatebuilder/bases/quickstart/README.md new file mode 100644 index 00000000000..356a0863ed2 --- /dev/null +++ b/coderd/templatebuilder/bases/quickstart/README.md @@ -0,0 +1,69 @@ +--- +display_name: Coder Quickstart +description: Get started with Coder by picking your languages, editors, and a repo +icon: ../../../site/static/icon/coder.svg +maintainer_github: coder +verified: true +tags: [docker, quickstart] +--- + +# Coder Quickstart + +Get up and running with Coder in minutes. Choose your programming languages, pick your preferred editors, optionally clone a Git repository, and start coding. + +## How It Works + +When you create a workspace from this template, you select: + +1. **Languages** to pre-install (Python, Node.js, Go, Rust, Java, C/C++) +2. **Editors** to connect (VS Code in the browser, Cursor, JetBrains, Zed, Windsurf) +3. **A Git repository** to clone (optional) + +Coder provisions a workspace with your selections and you can start developing immediately. + + + +## Prerequisites + +The host running Coder must have a Docker daemon accessible to the `coder` user: + +```sh +# Add coder user to Docker group +sudo adduser coder docker + +# Restart Coder server +sudo systemctl restart coder + +# Verify access +sudo -u coder docker ps +``` + + + +## Architecture + +This template provisions: + +- **Docker container** (ephemeral) running Ubuntu with the Coder agent +- **Docker volume** (persistent) mounted at `/home/coder` + +Files in your home directory persist across workspace restarts. Selected languages are installed on first start and cached for subsequent starts. + +## Presets + +Select a preset to auto-fill languages and editors for common workflows: + +| Preset | Languages | Editors | +| ------------------- | ------------------- | ----------------------------------- | +| **Web Development** | Python, Node.js | VS Code (Browser) | +| **Backend (Go)** | Go | VS Code (Browser), JetBrains GoLand | +| **Data Science** | Python | VS Code (Browser) | +| **Full Stack** | Python, Node.js, Go | VS Code (Browser), Cursor | + +## IDE Notes + +- **VS Code (Browser)**: Opens directly in your browser with no local install required. +- **VS Code Desktop**: Available on every workspace by default (Coder enables the VS Code Desktop display app automatically), so it is not listed as a separate editor option. +- **Cursor, Windsurf**: Require the desktop application installed on your local machine. Coder opens them via protocol handler. +- **JetBrains IDEs**: Filtered by your language selection (e.g. PyCharm for Python, GoLand for Go). Requires JetBrains Toolbox or Gateway on your local machine. +- **Zed**: Connects over SSH. Requires Zed installed on your local machine. diff --git a/coderd/templatebuilder/bases/quickstart/base.json b/coderd/templatebuilder/bases/quickstart/base.json new file mode 100644 index 00000000000..d535bebbbd7 --- /dev/null +++ b/coderd/templatebuilder/bases/quickstart/base.json @@ -0,0 +1,6 @@ +{ + "id": "quickstart", + "display_name": "Coder Quickstart", + "os": "linux", + "default_context": {} +} diff --git a/coderd/templatebuilder/bases/quickstart/install-languages.sh.tftpl b/coderd/templatebuilder/bases/quickstart/install-languages.sh.tftpl new file mode 100644 index 00000000000..e986bf12270 --- /dev/null +++ b/coderd/templatebuilder/bases/quickstart/install-languages.sh.tftpl @@ -0,0 +1,88 @@ +#!/bin/bash +set -e + +LANGUAGES="${LANGUAGES}" +APT_UPDATED=false + +apt_update() { + if [ "$APT_UPDATED" = "false" ]; then + sudo apt-get update -qq + APT_UPDATED=true + fi +} + +if echo "$LANGUAGES" | grep -q "python"; then + if command -v python3 >/dev/null 2>&1; then + echo "Python: $(python3 --version)" + else + echo "Installing Python..." + apt_update + sudo apt-get install -y -qq python3 python3-pip python3-venv + echo "Installed Python: $(python3 --version)" + fi +fi + +if echo "$LANGUAGES" | grep -q "nodejs"; then + if command -v node >/dev/null 2>&1; then + echo "Node.js: $(node --version)" + else + echo "Installing Node.js 22..." + curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - + sudo apt-get install -y -qq nodejs + echo "Installed Node.js: $(node --version)" + fi +fi + +if echo "$LANGUAGES" | grep -q "go"; then + if command -v /usr/local/go/bin/go >/dev/null 2>&1; then + echo "Go: $(/usr/local/go/bin/go version)" + else + echo "Installing Go..." + ARCH=$(uname -m) + case $ARCH in + x86_64) GOARCH="amd64" ;; + aarch64) GOARCH="arm64" ;; + *) echo "Unsupported architecture: $ARCH"; exit 1 ;; + esac + GO_VERSION=$(curl -fsSL "https://go.dev/VERSION?m=text" | head -1) + curl -fsSL "https://go.dev/dl/$${GO_VERSION}.linux-$${GOARCH}.tar.gz" | sudo tar -C /usr/local -xz + echo 'export PATH=$PATH:/usr/local/go/bin:$HOME/go/bin' | sudo tee /etc/profile.d/go.sh >/dev/null + echo "Installed Go: $(/usr/local/go/bin/go version)" + fi +fi + +if echo "$LANGUAGES" | grep -q "rust"; then + if command -v rustc >/dev/null 2>&1 || [ -f "$HOME/.cargo/bin/rustc" ]; then + RUSTC=$${HOME}/.cargo/bin/rustc + command -v rustc >/dev/null 2>&1 && RUSTC=rustc + echo "Rust: $($RUSTC --version)" + else + echo "Installing Rust..." + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + echo "Installed Rust: $($HOME/.cargo/bin/rustc --version)" + fi +fi + +if echo "$LANGUAGES" | grep -q "java"; then + if command -v java >/dev/null 2>&1; then + echo "Java: $(java --version 2>&1 | head -1)" + else + echo "Installing Java (OpenJDK 21)..." + apt_update + sudo apt-get install -y -qq openjdk-21-jdk + echo "Installed Java: $(java --version 2>&1 | head -1)" + fi +fi + +if echo "$LANGUAGES" | grep -q "cpp"; then + if command -v gcc >/dev/null 2>&1; then + echo "C/C++: $(gcc --version | head -1)" + else + echo "Installing C/C++ toolchain..." + apt_update + sudo apt-get install -y -qq gcc g++ make cmake + echo "Installed C/C++: $(gcc --version | head -1)" + fi +fi + +echo "Language setup complete." diff --git a/coderd/templatebuilder/bases/quickstart/main.tf.tmpl b/coderd/templatebuilder/bases/quickstart/main.tf.tmpl new file mode 100644 index 00000000000..1d84750d048 --- /dev/null +++ b/coderd/templatebuilder/bases/quickstart/main.tf.tmpl @@ -0,0 +1,437 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + } + docker = { + source = "kreuzwerker/docker" + } + external = { + source = "hashicorp/external" + } + } +} + +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" {} + +# --- Parameters --- + +data "coder_parameter" "languages" { + name = "languages" + display_name = "Programming Languages" + description = "Select the languages to pre-install in your workspace" + type = "list(string)" + form_type = "multi-select" + default = jsonencode(["python"]) + mutable = true + icon = "/icon/code.svg" + order = 1 + + option { + name = "Python" + value = "python" + icon = "/icon/python.svg" + } + option { + name = "Node.js" + value = "nodejs" + icon = "/icon/nodejs.svg" + } + option { + name = "Go" + value = "go" + icon = "/icon/go.svg" + } + option { + name = "Rust" + value = "rust" + icon = "/icon/rust.svg" + } + option { + name = "Java" + value = "java" + icon = "/icon/java.svg" + } + option { + name = "C/C++" + value = "cpp" + icon = "/icon/cpp.svg" + } +} + +data "coder_parameter" "ides" { + name = "ides" + display_name = "IDEs & Editors" + description = "Select the development environments for your workspace" + type = "list(string)" + form_type = "multi-select" + default = jsonencode(["code-server"]) + mutable = true + icon = "/icon/code.svg" + order = 2 + + option { + name = "VS Code (Browser)" + value = "code-server" + icon = "/icon/code.svg" + } + option { + name = "Cursor" + value = "cursor" + icon = "/icon/cursor.svg" + } + option { + name = "JetBrains IDEs" + value = "jetbrains" + icon = "/icon/jetbrains.svg" + } + option { + name = "Zed" + value = "zed" + icon = "/icon/zed.svg" + } + option { + name = "Windsurf" + value = "windsurf" + icon = "/icon/windsurf.svg" + } +} + +# Shown only when "JetBrains IDEs" is selected in the IDEs parameter. +# Pre-selects IDEs that match the chosen languages. +data "coder_parameter" "jetbrains_ides" { + count = contains(local.ides, "jetbrains") ? 1 : 0 + name = "jetbrains_ides" + display_name = "JetBrains IDEs" + description = "Select the JetBrains IDEs to install" + type = "list(string)" + form_type = "multi-select" + default = jsonencode(local.jetbrains_ides_from_languages) + mutable = true + icon = "/icon/jetbrains.svg" + order = 3 + + option { + name = "IntelliJ IDEA" + value = "IU" + icon = "/icon/intellij.svg" + } + option { + name = "PyCharm" + value = "PY" + icon = "/icon/pycharm.svg" + } + option { + name = "GoLand" + value = "GO" + icon = "/icon/goland.svg" + } + option { + name = "WebStorm" + value = "WS" + icon = "/icon/webstorm.svg" + } + option { + name = "RustRover" + value = "RR" + icon = "/icon/rustrover.svg" + } + option { + name = "CLion" + value = "CL" + icon = "/icon/clion.svg" + } + option { + name = "PhpStorm" + value = "PS" + icon = "/icon/phpstorm.svg" + } + option { + name = "RubyMine" + value = "RM" + icon = "/icon/rubymine.svg" + } + option { + name = "Rider" + value = "RD" + icon = "/icon/rider.svg" + } +} + +data "coder_parameter" "git_repo" { + name = "git_repo" + display_name = "Git Repository (Optional)" + description = "URL of a Git repository to clone into your workspace (leave empty to skip)" + type = "string" + default = "" + mutable = true + icon = "/icon/git.svg" + order = 4 +} + +# --- Locals --- + +locals { + username = data.coder_workspace_owner.me.name + languages = jsondecode(data.coder_parameter.languages.value) + ides = jsondecode(data.coder_parameter.ides.value) + + # Map selected languages to the relevant JetBrains IDE product codes. + # Used as the default for the JetBrains IDE selector parameter. + jetbrains_by_language = { + python = ["PY"] + go = ["GO"] + java = ["IU"] + nodejs = ["WS"] + rust = ["RR"] + cpp = ["CL"] + } + jetbrains_ides_from_languages = distinct(flatten([ + for lang in local.languages : lookup(local.jetbrains_by_language, lang, []) + ])) + + # The actual JetBrains IDEs to install, from the user's selection + # in the conditional JetBrains parameter (or empty if not shown). + jetbrains_selected = contains(local.ides, "jetbrains") ? jsondecode(data.coder_parameter.jetbrains_ides[0].value) : [] +} + +# --- Agent --- + +resource "coder_agent" "main" { + 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 + } +} + +# --- Language installation --- +# All languages install in a single script to avoid apt-get lock +# conflicts (coder_script resources run in parallel). + +resource "coder_script" "install_languages" { + count = length(local.languages) > 0 ? 1 : 0 + agent_id = coder_agent.main.id + display_name = "Install Languages" + icon = "/icon/code.svg" + run_on_start = true + start_blocks_login = true + script = templatefile("${path.module}/install-languages.sh.tftpl", { + LANGUAGES = join(",", local.languages) + }) +} + +# --- IDE modules --- + +module "code-server" { + count = data.coder_workspace.me.start_count * (contains(local.ides, "code-server") ? 1 : 0) + source = "registry.coder.com/coder/code-server/coder" + version = "~> 1.0" + agent_id = coder_agent.main.id + order = 1 +} + + +module "cursor" { + count = data.coder_workspace.me.start_count * (contains(local.ides, "cursor") ? 1 : 0) + source = "registry.coder.com/coder/cursor/coder" + version = "~> 1.0" + agent_id = coder_agent.main.id + folder = "/home/coder" + order = 3 +} + +# TODO: Re-add the coder/jetbrains module once Coder's dynamic +# parameter system respects module count for parameter visibility. +# The module's internal coder_parameter appears even when count = 0, +# creating a ghost parameter in the workspace creation form. +# module "jetbrains" { +# count = data.coder_workspace.me.start_count * (contains(local.ides, "jetbrains") && length(local.jetbrains_selected) > 0 ? 1 : 0) +# source = "registry.coder.com/coder/jetbrains/coder" +# version = "~> 1.0" +# agent_id = coder_agent.main.id +# folder = "/home/coder" +# default = toset(local.jetbrains_selected) +# } + +module "zed" { + count = data.coder_workspace.me.start_count * (contains(local.ides, "zed") ? 1 : 0) + source = "registry.coder.com/coder/zed/coder" + version = "~> 1.0" + agent_id = coder_agent.main.id + folder = "/home/coder" + order = 5 +} + +module "windsurf" { + count = data.coder_workspace.me.start_count * (contains(local.ides, "windsurf") ? 1 : 0) + source = "registry.coder.com/coder/windsurf/coder" + version = "~> 1.0" + agent_id = coder_agent.main.id + folder = "/home/coder" + order = 6 +} + +# --- Git clone --- + +module "git-clone" { + count = data.coder_workspace.me.start_count * (data.coder_parameter.git_repo.value != "" ? 1 : 0) + source = "registry.coder.com/coder/git-clone/coder" + version = "~> 2.0" + agent_id = coder_agent.main.id + url = data.coder_parameter.git_repo.value +} + +# --- Presets --- + +data "coder_workspace_preset" "web_dev" { + name = "Web Development" + icon = "/icon/nodejs.svg" + parameters = { + languages = jsonencode(["python", "nodejs"]) + ides = jsonencode(["code-server"]) + git_repo = "" + } +} + +data "coder_workspace_preset" "backend_go" { + name = "Backend (Go)" + icon = "/icon/go.svg" + parameters = { + languages = jsonencode(["go"]) + ides = jsonencode(["code-server", "jetbrains"]) + jetbrains_ides = jsonencode(["GO"]) + git_repo = "" + } +} + +data "coder_workspace_preset" "data_science" { + name = "Data Science" + icon = "/icon/python.svg" + parameters = { + languages = jsonencode(["python"]) + ides = jsonencode(["code-server"]) + git_repo = "" + } +} + +data "coder_workspace_preset" "full_stack" { + name = "Full Stack" + icon = "/icon/code.svg" + parameters = { + languages = jsonencode(["python", "nodejs", "go"]) + ides = jsonencode(["code-server", "cursor"]) + git_repo = "" + } +} + +# --- Docker resources --- + +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 + } + depends_on = [] +} + +resource "docker_container" "workspace" { + 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.main.init_script, "/localhost|127\\.0\\.0\\.1/", "host.docker.internal"), + ] + env = ["CODER_AGENT_TOKEN=${coder_agent.main.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 + } + depends_on = [] +} diff --git a/coderd/templatebuilder/bases_test.go b/coderd/templatebuilder/bases_test.go index f01f6f48a04..14f15b5dd68 100644 --- a/coderd/templatebuilder/bases_test.go +++ b/coderd/templatebuilder/bases_test.go @@ -18,6 +18,7 @@ var allBaseIDs = []string{ "gcp-linux", "gcp-windows", "kubernetes", + "quickstart", "scratch", } @@ -26,7 +27,7 @@ func TestBaseTemplateOS(t *testing.T) { linuxBases := []string{ "aws-linux", "azure-linux", "digitalocean-linux", - "docker", "gcp-linux", "kubernetes", "scratch", + "docker", "gcp-linux", "kubernetes", "quickstart", "scratch", } for _, id := range linuxBases { t.Run(id, func(t *testing.T) { diff --git a/coderd/templatebuilder/render_test.go b/coderd/templatebuilder/render_test.go index 12927737e2d..9bbbadc9f02 100644 --- a/coderd/templatebuilder/render_test.go +++ b/coderd/templatebuilder/render_test.go @@ -344,6 +344,7 @@ func TestBaseTemplateSnapshot(t *testing.T) { {exampleID: "digitalocean-linux"}, {exampleID: "gcp-linux"}, {exampleID: "gcp-windows"}, + {exampleID: "quickstart"}, {exampleID: "scratch"}, } diff --git a/coderd/templatebuilder/testdata/quickstart.tf.golden b/coderd/templatebuilder/testdata/quickstart.tf.golden new file mode 100644 index 00000000000..1d84750d048 --- /dev/null +++ b/coderd/templatebuilder/testdata/quickstart.tf.golden @@ -0,0 +1,437 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + } + docker = { + source = "kreuzwerker/docker" + } + external = { + source = "hashicorp/external" + } + } +} + +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" {} + +# --- Parameters --- + +data "coder_parameter" "languages" { + name = "languages" + display_name = "Programming Languages" + description = "Select the languages to pre-install in your workspace" + type = "list(string)" + form_type = "multi-select" + default = jsonencode(["python"]) + mutable = true + icon = "/icon/code.svg" + order = 1 + + option { + name = "Python" + value = "python" + icon = "/icon/python.svg" + } + option { + name = "Node.js" + value = "nodejs" + icon = "/icon/nodejs.svg" + } + option { + name = "Go" + value = "go" + icon = "/icon/go.svg" + } + option { + name = "Rust" + value = "rust" + icon = "/icon/rust.svg" + } + option { + name = "Java" + value = "java" + icon = "/icon/java.svg" + } + option { + name = "C/C++" + value = "cpp" + icon = "/icon/cpp.svg" + } +} + +data "coder_parameter" "ides" { + name = "ides" + display_name = "IDEs & Editors" + description = "Select the development environments for your workspace" + type = "list(string)" + form_type = "multi-select" + default = jsonencode(["code-server"]) + mutable = true + icon = "/icon/code.svg" + order = 2 + + option { + name = "VS Code (Browser)" + value = "code-server" + icon = "/icon/code.svg" + } + option { + name = "Cursor" + value = "cursor" + icon = "/icon/cursor.svg" + } + option { + name = "JetBrains IDEs" + value = "jetbrains" + icon = "/icon/jetbrains.svg" + } + option { + name = "Zed" + value = "zed" + icon = "/icon/zed.svg" + } + option { + name = "Windsurf" + value = "windsurf" + icon = "/icon/windsurf.svg" + } +} + +# Shown only when "JetBrains IDEs" is selected in the IDEs parameter. +# Pre-selects IDEs that match the chosen languages. +data "coder_parameter" "jetbrains_ides" { + count = contains(local.ides, "jetbrains") ? 1 : 0 + name = "jetbrains_ides" + display_name = "JetBrains IDEs" + description = "Select the JetBrains IDEs to install" + type = "list(string)" + form_type = "multi-select" + default = jsonencode(local.jetbrains_ides_from_languages) + mutable = true + icon = "/icon/jetbrains.svg" + order = 3 + + option { + name = "IntelliJ IDEA" + value = "IU" + icon = "/icon/intellij.svg" + } + option { + name = "PyCharm" + value = "PY" + icon = "/icon/pycharm.svg" + } + option { + name = "GoLand" + value = "GO" + icon = "/icon/goland.svg" + } + option { + name = "WebStorm" + value = "WS" + icon = "/icon/webstorm.svg" + } + option { + name = "RustRover" + value = "RR" + icon = "/icon/rustrover.svg" + } + option { + name = "CLion" + value = "CL" + icon = "/icon/clion.svg" + } + option { + name = "PhpStorm" + value = "PS" + icon = "/icon/phpstorm.svg" + } + option { + name = "RubyMine" + value = "RM" + icon = "/icon/rubymine.svg" + } + option { + name = "Rider" + value = "RD" + icon = "/icon/rider.svg" + } +} + +data "coder_parameter" "git_repo" { + name = "git_repo" + display_name = "Git Repository (Optional)" + description = "URL of a Git repository to clone into your workspace (leave empty to skip)" + type = "string" + default = "" + mutable = true + icon = "/icon/git.svg" + order = 4 +} + +# --- Locals --- + +locals { + username = data.coder_workspace_owner.me.name + languages = jsondecode(data.coder_parameter.languages.value) + ides = jsondecode(data.coder_parameter.ides.value) + + # Map selected languages to the relevant JetBrains IDE product codes. + # Used as the default for the JetBrains IDE selector parameter. + jetbrains_by_language = { + python = ["PY"] + go = ["GO"] + java = ["IU"] + nodejs = ["WS"] + rust = ["RR"] + cpp = ["CL"] + } + jetbrains_ides_from_languages = distinct(flatten([ + for lang in local.languages : lookup(local.jetbrains_by_language, lang, []) + ])) + + # The actual JetBrains IDEs to install, from the user's selection + # in the conditional JetBrains parameter (or empty if not shown). + jetbrains_selected = contains(local.ides, "jetbrains") ? jsondecode(data.coder_parameter.jetbrains_ides[0].value) : [] +} + +# --- Agent --- + +resource "coder_agent" "main" { + 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 + } +} + +# --- Language installation --- +# All languages install in a single script to avoid apt-get lock +# conflicts (coder_script resources run in parallel). + +resource "coder_script" "install_languages" { + count = length(local.languages) > 0 ? 1 : 0 + agent_id = coder_agent.main.id + display_name = "Install Languages" + icon = "/icon/code.svg" + run_on_start = true + start_blocks_login = true + script = templatefile("${path.module}/install-languages.sh.tftpl", { + LANGUAGES = join(",", local.languages) + }) +} + +# --- IDE modules --- + +module "code-server" { + count = data.coder_workspace.me.start_count * (contains(local.ides, "code-server") ? 1 : 0) + source = "registry.coder.com/coder/code-server/coder" + version = "~> 1.0" + agent_id = coder_agent.main.id + order = 1 +} + + +module "cursor" { + count = data.coder_workspace.me.start_count * (contains(local.ides, "cursor") ? 1 : 0) + source = "registry.coder.com/coder/cursor/coder" + version = "~> 1.0" + agent_id = coder_agent.main.id + folder = "/home/coder" + order = 3 +} + +# TODO: Re-add the coder/jetbrains module once Coder's dynamic +# parameter system respects module count for parameter visibility. +# The module's internal coder_parameter appears even when count = 0, +# creating a ghost parameter in the workspace creation form. +# module "jetbrains" { +# count = data.coder_workspace.me.start_count * (contains(local.ides, "jetbrains") && length(local.jetbrains_selected) > 0 ? 1 : 0) +# source = "registry.coder.com/coder/jetbrains/coder" +# version = "~> 1.0" +# agent_id = coder_agent.main.id +# folder = "/home/coder" +# default = toset(local.jetbrains_selected) +# } + +module "zed" { + count = data.coder_workspace.me.start_count * (contains(local.ides, "zed") ? 1 : 0) + source = "registry.coder.com/coder/zed/coder" + version = "~> 1.0" + agent_id = coder_agent.main.id + folder = "/home/coder" + order = 5 +} + +module "windsurf" { + count = data.coder_workspace.me.start_count * (contains(local.ides, "windsurf") ? 1 : 0) + source = "registry.coder.com/coder/windsurf/coder" + version = "~> 1.0" + agent_id = coder_agent.main.id + folder = "/home/coder" + order = 6 +} + +# --- Git clone --- + +module "git-clone" { + count = data.coder_workspace.me.start_count * (data.coder_parameter.git_repo.value != "" ? 1 : 0) + source = "registry.coder.com/coder/git-clone/coder" + version = "~> 2.0" + agent_id = coder_agent.main.id + url = data.coder_parameter.git_repo.value +} + +# --- Presets --- + +data "coder_workspace_preset" "web_dev" { + name = "Web Development" + icon = "/icon/nodejs.svg" + parameters = { + languages = jsonencode(["python", "nodejs"]) + ides = jsonencode(["code-server"]) + git_repo = "" + } +} + +data "coder_workspace_preset" "backend_go" { + name = "Backend (Go)" + icon = "/icon/go.svg" + parameters = { + languages = jsonencode(["go"]) + ides = jsonencode(["code-server", "jetbrains"]) + jetbrains_ides = jsonencode(["GO"]) + git_repo = "" + } +} + +data "coder_workspace_preset" "data_science" { + name = "Data Science" + icon = "/icon/python.svg" + parameters = { + languages = jsonencode(["python"]) + ides = jsonencode(["code-server"]) + git_repo = "" + } +} + +data "coder_workspace_preset" "full_stack" { + name = "Full Stack" + icon = "/icon/code.svg" + parameters = { + languages = jsonencode(["python", "nodejs", "go"]) + ides = jsonencode(["code-server", "cursor"]) + git_repo = "" + } +} + +# --- Docker resources --- + +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 + } + depends_on = [] +} + +resource "docker_container" "workspace" { + 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.main.init_script, "/localhost|127\\.0\\.0\\.1/", "host.docker.internal"), + ] + env = ["CODER_AGENT_TOKEN=${coder_agent.main.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 + } + depends_on = [] +} diff --git a/coderd/templatebuilder_handler.go b/coderd/templatebuilder_handler.go index adc4da2d77c..f49665bfdb3 100644 --- a/coderd/templatebuilder_handler.go +++ b/coderd/templatebuilder_handler.go @@ -85,7 +85,16 @@ func (api *API) templateBuilderBases(rw http.ResponseWriter, r *http.Request) { }) } + // The Coder Quickstart base is surfaced first as the recommended starting + // point (mirroring its position in the registry); the remaining bases follow + // alphabetically by name. + const quickstartBaseID = "quickstart" sort.Slice(bases, func(i, j int) bool { + iQuickstart := bases[i].ID == quickstartBaseID + jQuickstart := bases[j].ID == quickstartBaseID + if iQuickstart != jQuickstart { + return iQuickstart + } return bases[i].Name < bases[j].Name }) From 8d43dd6156116825e5166b8d03fda026f92b0b89 Mon Sep 17 00:00:00 2001 From: Nick Vigilante Date: Tue, 14 Jul 2026 22:01:39 +0000 Subject: [PATCH 02/10] test(coderd): assert quickstart-first ordering in TemplateBuilderBases The quickstart-first pin changed the bases ordering contract, which broke the existing TestTemplateBuilderBases/Sorted assertion (strict alphabetical across all bases). Update Sorted to encode the new contract: quickstart is pinned first, and the remaining bases stay sorted by name. Addresses coder-agents-review CRF-1 (P0) and the missing positive-ordering coverage noted in CRF-2. --- coderd/templatebuilder_handler_test.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/coderd/templatebuilder_handler_test.go b/coderd/templatebuilder_handler_test.go index bbac79a0ea7..94d1d9eb264 100644 --- a/coderd/templatebuilder_handler_test.go +++ b/coderd/templatebuilder_handler_test.go @@ -107,10 +107,15 @@ func TestTemplateBuilderBases(t *testing.T) { resp, err := client.TemplateBuilderBases(ctx) require.NoError(t, err) + require.NotEmpty(t, resp.Bases) - for i := 1; i < len(resp.Bases); i++ { + // The Coder Quickstart base is pinned first as the recommended + // starting point; the remaining bases are sorted by name. + require.Equal(t, "quickstart", resp.Bases[0].ID, + "quickstart base should be pinned first") + for i := 2; i < len(resp.Bases); i++ { require.LessOrEqual(t, resp.Bases[i-1].Name, resp.Bases[i].Name, - "bases should be sorted by name") + "bases after quickstart should be sorted by name") } }) From ce51b17c9da4356a00ccb7e800f525e53ef67e6c Mon Sep 17 00:00:00 2001 From: Nick Vigilante Date: Tue, 14 Jul 2026 22:34:38 +0000 Subject: [PATCH 03/10] fix(coderd): make template builder base ordering a total order Tiebreak the bases comparator on ID when display names are equal, so the sort is total and deterministic regardless of sort.Slice's instability. Addresses coder-agents-review CRF-9. --- coderd/templatebuilder_handler.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/coderd/templatebuilder_handler.go b/coderd/templatebuilder_handler.go index f49665bfdb3..c812834f49b 100644 --- a/coderd/templatebuilder_handler.go +++ b/coderd/templatebuilder_handler.go @@ -95,7 +95,12 @@ func (api *API) templateBuilderBases(rw http.ResponseWriter, r *http.Request) { if iQuickstart != jQuickstart { return iQuickstart } - return bases[i].Name < bases[j].Name + if bases[i].Name != bases[j].Name { + return bases[i].Name < bases[j].Name + } + // Tiebreak on ID so the order is total and deterministic even if two + // bases ever share a display name. + return bases[i].ID < bases[j].ID }) httpapi.Write(ctx, rw, http.StatusOK, codersdk.TemplateBuilderBasesResponse{ From e438dce626ce9c09160db3d8da3f03aad33711c8 Mon Sep 17 00:00:00 2001 From: Nick Vigilante Date: Wed, 15 Jul 2026 15:08:28 +0000 Subject: [PATCH 04/10] test(coderd): cover quickstart in the template builder bases spec table Quickstart was absent from the curated baseSpec table in TestTemplateBuilderBases/OK, so its API surface (name, icon, OS, and variable presence) was only exercised by the generic response loops. Add an explicit row asserting it exposes no builder variables today, locking the current answer so drift (e.g. later exposing a container_image selector) is flagged. --- coderd/templatebuilder_handler_test.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/coderd/templatebuilder_handler_test.go b/coderd/templatebuilder_handler_test.go index 94d1d9eb264..d5dc4e039ca 100644 --- a/coderd/templatebuilder_handler_test.go +++ b/coderd/templatebuilder_handler_test.go @@ -43,6 +43,14 @@ func TestTemplateBuilderBases(t *testing.T) { } specs := []baseSpec{ + { + // Quickstart exposes no builder variables today: its base.json + // declares none. Locking that here flags drift if it changes, + // e.g. if it later exposes a container_image selector. + id: "quickstart", + expectedOS: "linux", + hasVariables: false, + }, { id: "docker", expectedOS: "linux", From ce7a13ac6ef4463ffe12f176063ec8cd301ce7fe Mon Sep 17 00:00:00 2001 From: Nick Vigilante Date: Thu, 16 Jul 2026 14:22:07 +0000 Subject: [PATCH 05/10] feat(coderd): trim quickstart base and group it next to Docker Addresses review feedback on the Coder Quickstart base: - Remove the IDE selector: drop the ides parameter, the dependent jetbrains_ides parameter, the IDE locals, and the IDE modules (code-server, cursor, jetbrains, zed, windsurf); strip the ides/ jetbrains_ides keys from the presets; and update the README, which no longer advertises editor selection. Editors are added via the builder's module step instead. This also removes the dead JetBrains selector and the IDE base/module name collisions. - Placement: sort bases alphabetically by name and group the quickstart base immediately before the Docker base instead of pinning it first. Regenerate the quickstart golden and update the Sorted test for the new ordering. --- .../bases/quickstart/README.md | 29 ++- .../bases/quickstart/main.tf.tmpl | 180 +----------------- .../testdata/quickstart.tf.golden | 180 +----------------- coderd/templatebuilder_handler.go | 60 +++++- coderd/templatebuilder_handler_test.go | 34 +++- 5 files changed, 96 insertions(+), 387 deletions(-) diff --git a/coderd/templatebuilder/bases/quickstart/README.md b/coderd/templatebuilder/bases/quickstart/README.md index 356a0863ed2..9259fa42dd0 100644 --- a/coderd/templatebuilder/bases/quickstart/README.md +++ b/coderd/templatebuilder/bases/quickstart/README.md @@ -1,6 +1,6 @@ --- display_name: Coder Quickstart -description: Get started with Coder by picking your languages, editors, and a repo +description: Get started with Coder by picking your languages and a repo icon: ../../../site/static/icon/coder.svg maintainer_github: coder verified: true @@ -9,15 +9,14 @@ tags: [docker, quickstart] # Coder Quickstart -Get up and running with Coder in minutes. Choose your programming languages, pick your preferred editors, optionally clone a Git repository, and start coding. +Get up and running with Coder in minutes. Choose your programming languages, optionally clone a Git repository, and start coding. ## How It Works When you create a workspace from this template, you select: 1. **Languages** to pre-install (Python, Node.js, Go, Rust, Java, C/C++) -2. **Editors** to connect (VS Code in the browser, Cursor, JetBrains, Zed, Windsurf) -3. **A Git repository** to clone (optional) +2. **A Git repository** to clone (optional) Coder provisions a workspace with your selections and you can start developing immediately. @@ -51,19 +50,15 @@ Files in your home directory persist across workspace restarts. Selected languag ## Presets -Select a preset to auto-fill languages and editors for common workflows: +Select a preset to auto-fill languages for common workflows: -| Preset | Languages | Editors | -| ------------------- | ------------------- | ----------------------------------- | -| **Web Development** | Python, Node.js | VS Code (Browser) | -| **Backend (Go)** | Go | VS Code (Browser), JetBrains GoLand | -| **Data Science** | Python | VS Code (Browser) | -| **Full Stack** | Python, Node.js, Go | VS Code (Browser), Cursor | +| Preset | Languages | +| ------------------- | ------------------- | +| **Web Development** | Python, Node.js | +| **Backend (Go)** | Go | +| **Data Science** | Python | +| **Full Stack** | Python, Node.js, Go | -## IDE Notes +## Editors -- **VS Code (Browser)**: Opens directly in your browser with no local install required. -- **VS Code Desktop**: Available on every workspace by default (Coder enables the VS Code Desktop display app automatically), so it is not listed as a separate editor option. -- **Cursor, Windsurf**: Require the desktop application installed on your local machine. Coder opens them via protocol handler. -- **JetBrains IDEs**: Filtered by your language selection (e.g. PyCharm for Python, GoLand for Go). Requires JetBrains Toolbox or Gateway on your local machine. -- **Zed**: Connects over SSH. Requires Zed installed on your local machine. +VS Code Desktop is available on every workspace by default (Coder enables the VS Code Desktop display app automatically). To add more editors (VS Code in the browser, Cursor, JetBrains, Zed, Windsurf) or other tools, add them as modules in the next step of the template builder. diff --git a/coderd/templatebuilder/bases/quickstart/main.tf.tmpl b/coderd/templatebuilder/bases/quickstart/main.tf.tmpl index 1d84750d048..a530e09d40b 100644 --- a/coderd/templatebuilder/bases/quickstart/main.tf.tmpl +++ b/coderd/templatebuilder/bases/quickstart/main.tf.tmpl @@ -71,105 +71,6 @@ data "coder_parameter" "languages" { } } -data "coder_parameter" "ides" { - name = "ides" - display_name = "IDEs & Editors" - description = "Select the development environments for your workspace" - type = "list(string)" - form_type = "multi-select" - default = jsonencode(["code-server"]) - mutable = true - icon = "/icon/code.svg" - order = 2 - - option { - name = "VS Code (Browser)" - value = "code-server" - icon = "/icon/code.svg" - } - option { - name = "Cursor" - value = "cursor" - icon = "/icon/cursor.svg" - } - option { - name = "JetBrains IDEs" - value = "jetbrains" - icon = "/icon/jetbrains.svg" - } - option { - name = "Zed" - value = "zed" - icon = "/icon/zed.svg" - } - option { - name = "Windsurf" - value = "windsurf" - icon = "/icon/windsurf.svg" - } -} - -# Shown only when "JetBrains IDEs" is selected in the IDEs parameter. -# Pre-selects IDEs that match the chosen languages. -data "coder_parameter" "jetbrains_ides" { - count = contains(local.ides, "jetbrains") ? 1 : 0 - name = "jetbrains_ides" - display_name = "JetBrains IDEs" - description = "Select the JetBrains IDEs to install" - type = "list(string)" - form_type = "multi-select" - default = jsonencode(local.jetbrains_ides_from_languages) - mutable = true - icon = "/icon/jetbrains.svg" - order = 3 - - option { - name = "IntelliJ IDEA" - value = "IU" - icon = "/icon/intellij.svg" - } - option { - name = "PyCharm" - value = "PY" - icon = "/icon/pycharm.svg" - } - option { - name = "GoLand" - value = "GO" - icon = "/icon/goland.svg" - } - option { - name = "WebStorm" - value = "WS" - icon = "/icon/webstorm.svg" - } - option { - name = "RustRover" - value = "RR" - icon = "/icon/rustrover.svg" - } - option { - name = "CLion" - value = "CL" - icon = "/icon/clion.svg" - } - option { - name = "PhpStorm" - value = "PS" - icon = "/icon/phpstorm.svg" - } - option { - name = "RubyMine" - value = "RM" - icon = "/icon/rubymine.svg" - } - option { - name = "Rider" - value = "RD" - icon = "/icon/rider.svg" - } -} - data "coder_parameter" "git_repo" { name = "git_repo" display_name = "Git Repository (Optional)" @@ -178,7 +79,7 @@ data "coder_parameter" "git_repo" { default = "" mutable = true icon = "/icon/git.svg" - order = 4 + order = 2 } # --- Locals --- @@ -186,25 +87,6 @@ data "coder_parameter" "git_repo" { locals { username = data.coder_workspace_owner.me.name languages = jsondecode(data.coder_parameter.languages.value) - ides = jsondecode(data.coder_parameter.ides.value) - - # Map selected languages to the relevant JetBrains IDE product codes. - # Used as the default for the JetBrains IDE selector parameter. - jetbrains_by_language = { - python = ["PY"] - go = ["GO"] - java = ["IU"] - nodejs = ["WS"] - rust = ["RR"] - cpp = ["CL"] - } - jetbrains_ides_from_languages = distinct(flatten([ - for lang in local.languages : lookup(local.jetbrains_by_language, lang, []) - ])) - - # The actual JetBrains IDEs to install, from the user's selection - # in the conditional JetBrains parameter (or empty if not shown). - jetbrains_selected = contains(local.ides, "jetbrains") ? jsondecode(data.coder_parameter.jetbrains_ides[0].value) : [] } # --- Agent --- @@ -268,57 +150,6 @@ resource "coder_script" "install_languages" { }) } -# --- IDE modules --- - -module "code-server" { - count = data.coder_workspace.me.start_count * (contains(local.ides, "code-server") ? 1 : 0) - source = "registry.coder.com/coder/code-server/coder" - version = "~> 1.0" - agent_id = coder_agent.main.id - order = 1 -} - - -module "cursor" { - count = data.coder_workspace.me.start_count * (contains(local.ides, "cursor") ? 1 : 0) - source = "registry.coder.com/coder/cursor/coder" - version = "~> 1.0" - agent_id = coder_agent.main.id - folder = "/home/coder" - order = 3 -} - -# TODO: Re-add the coder/jetbrains module once Coder's dynamic -# parameter system respects module count for parameter visibility. -# The module's internal coder_parameter appears even when count = 0, -# creating a ghost parameter in the workspace creation form. -# module "jetbrains" { -# count = data.coder_workspace.me.start_count * (contains(local.ides, "jetbrains") && length(local.jetbrains_selected) > 0 ? 1 : 0) -# source = "registry.coder.com/coder/jetbrains/coder" -# version = "~> 1.0" -# agent_id = coder_agent.main.id -# folder = "/home/coder" -# default = toset(local.jetbrains_selected) -# } - -module "zed" { - count = data.coder_workspace.me.start_count * (contains(local.ides, "zed") ? 1 : 0) - source = "registry.coder.com/coder/zed/coder" - version = "~> 1.0" - agent_id = coder_agent.main.id - folder = "/home/coder" - order = 5 -} - -module "windsurf" { - count = data.coder_workspace.me.start_count * (contains(local.ides, "windsurf") ? 1 : 0) - source = "registry.coder.com/coder/windsurf/coder" - version = "~> 1.0" - agent_id = coder_agent.main.id - folder = "/home/coder" - order = 6 -} - # --- Git clone --- module "git-clone" { @@ -336,7 +167,6 @@ data "coder_workspace_preset" "web_dev" { icon = "/icon/nodejs.svg" parameters = { languages = jsonencode(["python", "nodejs"]) - ides = jsonencode(["code-server"]) git_repo = "" } } @@ -345,10 +175,8 @@ data "coder_workspace_preset" "backend_go" { name = "Backend (Go)" icon = "/icon/go.svg" parameters = { - languages = jsonencode(["go"]) - ides = jsonencode(["code-server", "jetbrains"]) - jetbrains_ides = jsonencode(["GO"]) - git_repo = "" + languages = jsonencode(["go"]) + git_repo = "" } } @@ -357,7 +185,6 @@ data "coder_workspace_preset" "data_science" { icon = "/icon/python.svg" parameters = { languages = jsonencode(["python"]) - ides = jsonencode(["code-server"]) git_repo = "" } } @@ -367,7 +194,6 @@ data "coder_workspace_preset" "full_stack" { icon = "/icon/code.svg" parameters = { languages = jsonencode(["python", "nodejs", "go"]) - ides = jsonencode(["code-server", "cursor"]) git_repo = "" } } diff --git a/coderd/templatebuilder/testdata/quickstart.tf.golden b/coderd/templatebuilder/testdata/quickstart.tf.golden index 1d84750d048..a530e09d40b 100644 --- a/coderd/templatebuilder/testdata/quickstart.tf.golden +++ b/coderd/templatebuilder/testdata/quickstart.tf.golden @@ -71,105 +71,6 @@ data "coder_parameter" "languages" { } } -data "coder_parameter" "ides" { - name = "ides" - display_name = "IDEs & Editors" - description = "Select the development environments for your workspace" - type = "list(string)" - form_type = "multi-select" - default = jsonencode(["code-server"]) - mutable = true - icon = "/icon/code.svg" - order = 2 - - option { - name = "VS Code (Browser)" - value = "code-server" - icon = "/icon/code.svg" - } - option { - name = "Cursor" - value = "cursor" - icon = "/icon/cursor.svg" - } - option { - name = "JetBrains IDEs" - value = "jetbrains" - icon = "/icon/jetbrains.svg" - } - option { - name = "Zed" - value = "zed" - icon = "/icon/zed.svg" - } - option { - name = "Windsurf" - value = "windsurf" - icon = "/icon/windsurf.svg" - } -} - -# Shown only when "JetBrains IDEs" is selected in the IDEs parameter. -# Pre-selects IDEs that match the chosen languages. -data "coder_parameter" "jetbrains_ides" { - count = contains(local.ides, "jetbrains") ? 1 : 0 - name = "jetbrains_ides" - display_name = "JetBrains IDEs" - description = "Select the JetBrains IDEs to install" - type = "list(string)" - form_type = "multi-select" - default = jsonencode(local.jetbrains_ides_from_languages) - mutable = true - icon = "/icon/jetbrains.svg" - order = 3 - - option { - name = "IntelliJ IDEA" - value = "IU" - icon = "/icon/intellij.svg" - } - option { - name = "PyCharm" - value = "PY" - icon = "/icon/pycharm.svg" - } - option { - name = "GoLand" - value = "GO" - icon = "/icon/goland.svg" - } - option { - name = "WebStorm" - value = "WS" - icon = "/icon/webstorm.svg" - } - option { - name = "RustRover" - value = "RR" - icon = "/icon/rustrover.svg" - } - option { - name = "CLion" - value = "CL" - icon = "/icon/clion.svg" - } - option { - name = "PhpStorm" - value = "PS" - icon = "/icon/phpstorm.svg" - } - option { - name = "RubyMine" - value = "RM" - icon = "/icon/rubymine.svg" - } - option { - name = "Rider" - value = "RD" - icon = "/icon/rider.svg" - } -} - data "coder_parameter" "git_repo" { name = "git_repo" display_name = "Git Repository (Optional)" @@ -178,7 +79,7 @@ data "coder_parameter" "git_repo" { default = "" mutable = true icon = "/icon/git.svg" - order = 4 + order = 2 } # --- Locals --- @@ -186,25 +87,6 @@ data "coder_parameter" "git_repo" { locals { username = data.coder_workspace_owner.me.name languages = jsondecode(data.coder_parameter.languages.value) - ides = jsondecode(data.coder_parameter.ides.value) - - # Map selected languages to the relevant JetBrains IDE product codes. - # Used as the default for the JetBrains IDE selector parameter. - jetbrains_by_language = { - python = ["PY"] - go = ["GO"] - java = ["IU"] - nodejs = ["WS"] - rust = ["RR"] - cpp = ["CL"] - } - jetbrains_ides_from_languages = distinct(flatten([ - for lang in local.languages : lookup(local.jetbrains_by_language, lang, []) - ])) - - # The actual JetBrains IDEs to install, from the user's selection - # in the conditional JetBrains parameter (or empty if not shown). - jetbrains_selected = contains(local.ides, "jetbrains") ? jsondecode(data.coder_parameter.jetbrains_ides[0].value) : [] } # --- Agent --- @@ -268,57 +150,6 @@ resource "coder_script" "install_languages" { }) } -# --- IDE modules --- - -module "code-server" { - count = data.coder_workspace.me.start_count * (contains(local.ides, "code-server") ? 1 : 0) - source = "registry.coder.com/coder/code-server/coder" - version = "~> 1.0" - agent_id = coder_agent.main.id - order = 1 -} - - -module "cursor" { - count = data.coder_workspace.me.start_count * (contains(local.ides, "cursor") ? 1 : 0) - source = "registry.coder.com/coder/cursor/coder" - version = "~> 1.0" - agent_id = coder_agent.main.id - folder = "/home/coder" - order = 3 -} - -# TODO: Re-add the coder/jetbrains module once Coder's dynamic -# parameter system respects module count for parameter visibility. -# The module's internal coder_parameter appears even when count = 0, -# creating a ghost parameter in the workspace creation form. -# module "jetbrains" { -# count = data.coder_workspace.me.start_count * (contains(local.ides, "jetbrains") && length(local.jetbrains_selected) > 0 ? 1 : 0) -# source = "registry.coder.com/coder/jetbrains/coder" -# version = "~> 1.0" -# agent_id = coder_agent.main.id -# folder = "/home/coder" -# default = toset(local.jetbrains_selected) -# } - -module "zed" { - count = data.coder_workspace.me.start_count * (contains(local.ides, "zed") ? 1 : 0) - source = "registry.coder.com/coder/zed/coder" - version = "~> 1.0" - agent_id = coder_agent.main.id - folder = "/home/coder" - order = 5 -} - -module "windsurf" { - count = data.coder_workspace.me.start_count * (contains(local.ides, "windsurf") ? 1 : 0) - source = "registry.coder.com/coder/windsurf/coder" - version = "~> 1.0" - agent_id = coder_agent.main.id - folder = "/home/coder" - order = 6 -} - # --- Git clone --- module "git-clone" { @@ -336,7 +167,6 @@ data "coder_workspace_preset" "web_dev" { icon = "/icon/nodejs.svg" parameters = { languages = jsonencode(["python", "nodejs"]) - ides = jsonencode(["code-server"]) git_repo = "" } } @@ -345,10 +175,8 @@ data "coder_workspace_preset" "backend_go" { name = "Backend (Go)" icon = "/icon/go.svg" parameters = { - languages = jsonencode(["go"]) - ides = jsonencode(["code-server", "jetbrains"]) - jetbrains_ides = jsonencode(["GO"]) - git_repo = "" + languages = jsonencode(["go"]) + git_repo = "" } } @@ -357,7 +185,6 @@ data "coder_workspace_preset" "data_science" { icon = "/icon/python.svg" parameters = { languages = jsonencode(["python"]) - ides = jsonencode(["code-server"]) git_repo = "" } } @@ -367,7 +194,6 @@ data "coder_workspace_preset" "full_stack" { icon = "/icon/code.svg" parameters = { languages = jsonencode(["python", "nodejs", "go"]) - ides = jsonencode(["code-server", "cursor"]) git_repo = "" } } diff --git a/coderd/templatebuilder_handler.go b/coderd/templatebuilder_handler.go index c812834f49b..cbf390d03d7 100644 --- a/coderd/templatebuilder_handler.go +++ b/coderd/templatebuilder_handler.go @@ -85,16 +85,11 @@ func (api *API) templateBuilderBases(rw http.ResponseWriter, r *http.Request) { }) } - // The Coder Quickstart base is surfaced first as the recommended starting - // point (mirroring its position in the registry); the remaining bases follow - // alphabetically by name. - const quickstartBaseID = "quickstart" + // Order bases alphabetically by display name, then group the Coder + // Quickstart base directly before the Docker base. Quickstart is a + // Docker-based "start here" template, so it belongs next to Docker rather + // than in its default alphabetical slot. sort.Slice(bases, func(i, j int) bool { - iQuickstart := bases[i].ID == quickstartBaseID - jQuickstart := bases[j].ID == quickstartBaseID - if iQuickstart != jQuickstart { - return iQuickstart - } if bases[i].Name != bases[j].Name { return bases[i].Name < bases[j].Name } @@ -102,6 +97,7 @@ func (api *API) templateBuilderBases(rw http.ResponseWriter, r *http.Request) { // bases ever share a display name. return bases[i].ID < bases[j].ID }) + bases = groupQuickstartBeforeDocker(bases) httpapi.Write(ctx, rw, http.StatusOK, codersdk.TemplateBuilderBasesResponse{ Bases: bases, @@ -128,6 +124,52 @@ func baseVariablesToSDK(vars []templatebuilder.ModuleVariable) []codersdk.Templa return out } +const ( + quickstartBaseID = "quickstart" + dockerBaseID = "docker" +) + +// groupQuickstartBeforeDocker returns bases reordered so the Coder Quickstart +// base sits immediately before the Docker base, preserving the relative order +// of all other bases. Quickstart is a Docker-based starter template, so it is +// grouped next to Docker rather than left in its default alphabetical slot. +// The input is returned unchanged if either base is absent. +func groupQuickstartBeforeDocker(bases []codersdk.TemplateBuilderBase) []codersdk.TemplateBuilderBase { + var ( + quickstart codersdk.TemplateBuilderBase + haveQuickstart bool + ) + rest := make([]codersdk.TemplateBuilderBase, 0, len(bases)) + for _, b := range bases { + if b.ID == quickstartBaseID { + quickstart, haveQuickstart = b, true + continue + } + rest = append(rest, b) + } + if !haveQuickstart { + return bases + } + + dockerIdx := -1 + for i, b := range rest { + if b.ID == dockerBaseID { + dockerIdx = i + break + } + } + if dockerIdx == -1 { + // Docker base not present; leave quickstart in its sorted position. + return bases + } + + out := make([]codersdk.TemplateBuilderBase, 0, len(bases)) + out = append(out, rest[:dockerIdx]...) + out = append(out, quickstart) + out = append(out, rest[dockerIdx:]...) + return out +} + // @Summary List template builder modules // @ID list-template-builder-modules // @Security CoderSessionToken diff --git a/coderd/templatebuilder_handler_test.go b/coderd/templatebuilder_handler_test.go index d5dc4e039ca..9ac86b7553c 100644 --- a/coderd/templatebuilder_handler_test.go +++ b/coderd/templatebuilder_handler_test.go @@ -117,13 +117,33 @@ func TestTemplateBuilderBases(t *testing.T) { require.NoError(t, err) require.NotEmpty(t, resp.Bases) - // The Coder Quickstart base is pinned first as the recommended - // starting point; the remaining bases are sorted by name. - require.Equal(t, "quickstart", resp.Bases[0].ID, - "quickstart base should be pinned first") - for i := 2; i < len(resp.Bases); i++ { - require.LessOrEqual(t, resp.Bases[i-1].Name, resp.Bases[i].Name, - "bases after quickstart should be sorted by name") + // The Coder Quickstart base is grouped immediately before the Docker + // base; every other base is ordered alphabetically by name. + quickstartIdx, dockerIdx := -1, -1 + for i, b := range resp.Bases { + switch b.ID { + case "quickstart": + quickstartIdx = i + case "docker": + dockerIdx = i + } + } + require.NotEqual(t, -1, quickstartIdx, "quickstart base should be present") + require.NotEqual(t, -1, dockerIdx, "docker base should be present") + require.Equal(t, dockerIdx-1, quickstartIdx, + "quickstart base should be immediately before the docker base") + + // The remaining bases (excluding quickstart) are sorted by name. + var names []string + for _, b := range resp.Bases { + if b.ID == "quickstart" { + continue + } + names = append(names, b.Name) + } + for i := 1; i < len(names); i++ { + require.LessOrEqual(t, names[i-1], names[i], + "non-quickstart bases should be sorted by name") } }) From 8d078dd90b39bbd48a2ee4b8d0f95b8c72100e69 Mon Sep 17 00:00:00 2001 From: Nick Vigilante Date: Tue, 21 Jul 2026 15:49:16 +0000 Subject: [PATCH 06/10] feat(coderd): guard against base/module name collisions in the builder Keep the quickstart base's git-clone module (build-time repo cloning is a useful quickstart affordance) but stop it from colliding with the wizard's own Git Clone module. - Add an optional included_modules field to base.json (BaseManifest) listing the catalog module IDs a base already declares in its own Terraform. Quickstart declares ["git-clone"]. - Seed validateModules' seen-set with those IDs so a wizard-selected module the base already renders is rejected with a clear error, enforcing a disjoint base/module namespace for every base. - Filter GET /templatebuilder/modules?base= to omit the base's included modules, so the wizard never offers a colliding module. Tests: compose rejects quickstart + git-clone and still allows a non-included module; the modules endpoint excludes git-clone for quickstart while keeping it for docker. --- coderd/templatebuilder/bases.go | 18 +++++++++++ .../bases/quickstart/base.json | 3 +- coderd/templatebuilder/compose.go | 24 +++++++++++--- coderd/templatebuilder/compose_test.go | 31 +++++++++++++++++++ coderd/templatebuilder_handler.go | 17 ++++++++-- coderd/templatebuilder_handler_test.go | 31 +++++++++++++++++++ 6 files changed, 117 insertions(+), 7 deletions(-) diff --git a/coderd/templatebuilder/bases.go b/coderd/templatebuilder/bases.go index 774167a619f..9ccd9f94957 100644 --- a/coderd/templatebuilder/bases.go +++ b/coderd/templatebuilder/bases.go @@ -44,6 +44,11 @@ type BaseManifest struct { OS string `json:"os"` DefaultContext BaseDefaultContext `json:"default_context"` Variables []ModuleVariable `json:"variables"` + // IncludedModules lists the catalog module IDs this base already + // declares in its own Terraform (e.g. "git-clone"). Compose treats + // these names as occupied so a wizard-selected module cannot collide + // with one the base already renders. + IncludedModules []string `json:"included_modules,omitempty"` } // BaseDefaultContext holds default render values stored in base.json. @@ -260,6 +265,19 @@ func BaseVariables(exampleID string) []ModuleVariable { return bases[exampleID].Manifest.Variables } +// BaseIncludedModules returns the catalog module IDs that the given base +// template already declares in its own Terraform. Compose uses these to +// reject a wizard-selected module that would collide with a module the +// base already renders. Returns nil if the base is unknown or declares +// none. +func BaseIncludedModules(exampleID string) []string { + bases, err := loadBases() + if err != nil || bases[exampleID] == nil { + return nil + } + return bases[exampleID].Manifest.IncludedModules +} + // BaseTemplateFS returns a filesystem rooted at the given base template // directory within the embedded bases catalog. Returns an error if // exampleID is not a known base template. diff --git a/coderd/templatebuilder/bases/quickstart/base.json b/coderd/templatebuilder/bases/quickstart/base.json index d535bebbbd7..1b41f3eb34a 100644 --- a/coderd/templatebuilder/bases/quickstart/base.json +++ b/coderd/templatebuilder/bases/quickstart/base.json @@ -2,5 +2,6 @@ "id": "quickstart", "display_name": "Coder Quickstart", "os": "linux", - "default_context": {} + "default_context": {}, + "included_modules": ["git-clone"] } diff --git a/coderd/templatebuilder/compose.go b/coderd/templatebuilder/compose.go index 2fbfc5c619d..f77c5a0a0e0 100644 --- a/coderd/templatebuilder/compose.go +++ b/coderd/templatebuilder/compose.go @@ -79,7 +79,8 @@ func Compose(req ComposeRequest) (*ComposeResult, error) { } baseOS := BaseTemplateOS(req.BaseTemplateID) - if err := validateModules(req.Modules, catalog, baseOS); err != nil { + baseModules := BaseIncludedModules(req.BaseTemplateID) + if err := validateModules(req.Modules, catalog, baseOS, baseModules); err != nil { return nil, err } @@ -199,10 +200,25 @@ func loadCatalogMap() (map[string]ModuleManifest, error) { } // validateModules checks that all requested modules exist, are -// OS-compatible, have no duplicates, and have no conflicts. -func validateModules(requested []ComposeModule, catalog map[string]ModuleManifest, baseOS BaseOS) error { - seen := make(map[string]bool, len(requested)) +// OS-compatible, have no duplicates, do not collide with a module the +// base template already includes, and have no conflicts. baseModules +// lists the catalog module IDs the base declares in its own Terraform; +// requesting one of them would render a duplicate module block, so it is +// rejected here. +func validateModules(requested []ComposeModule, catalog map[string]ModuleManifest, baseOS BaseOS, baseModules []string) error { + // Seed the seen-set with the modules the base already declares so the + // base and wizard-selected modules occupy a disjoint namespace. + seen := make(map[string]bool, len(requested)+len(baseModules)) + baseIncluded := make(map[string]bool, len(baseModules)) + for _, id := range baseModules { + seen[id] = true + baseIncluded[id] = true + } + for _, cm := range requested { + if baseIncluded[cm.ID] { + return xerrors.Errorf("module %q is already included by this base template", cm.ID) + } if seen[cm.ID] { return xerrors.Errorf("duplicate module %q", cm.ID) } diff --git a/coderd/templatebuilder/compose_test.go b/coderd/templatebuilder/compose_test.go index 51e1c7a2c98..9063599066e 100644 --- a/coderd/templatebuilder/compose_test.go +++ b/coderd/templatebuilder/compose_test.go @@ -195,6 +195,37 @@ func TestCompose(t *testing.T) { require.Contains(t, err.Error(), `duplicate module "code-server"`) }) + t.Run("BaseIncludedModuleCollisionError", func(t *testing.T) { + t.Parallel() + // The quickstart base already declares module "git-clone"; selecting + // the catalog git-clone module in the wizard would render a duplicate + // module block, so compose must reject it. + _, err := templatebuilder.Compose(templatebuilder.ComposeRequest{ + BaseTemplateID: "quickstart", + RegistryURL: "https://registry.coder.com", + Modules: []templatebuilder.ComposeModule{ + {ID: "git-clone"}, + }, + }) + require.Error(t, err) + require.Contains(t, err.Error(), `module "git-clone" is already included by this base template`) + }) + + t.Run("BaseAllowsNonIncludedModule", func(t *testing.T) { + t.Parallel() + // Quickstart only includes git-clone; other catalog modules such as + // code-server compose normally on top of it. + result, err := templatebuilder.Compose(templatebuilder.ComposeRequest{ + BaseTemplateID: "quickstart", + RegistryURL: "https://registry.coder.com", + Modules: []templatebuilder.ComposeModule{ + {ID: "code-server"}, + }, + }) + require.NoError(t, err) + require.Contains(t, string(result.ModulesTF), `module "code-server"`) + }) + t.Run("ConflictingModuleError", func(t *testing.T) { t.Parallel() _, err := templatebuilder.Compose(templatebuilder.ComposeRequest{ diff --git a/coderd/templatebuilder_handler.go b/coderd/templatebuilder_handler.go index cbf390d03d7..890ed1b0b3f 100644 --- a/coderd/templatebuilder_handler.go +++ b/coderd/templatebuilder_handler.go @@ -195,8 +195,11 @@ func (api *API) templateBuilderModules(rw http.ResponseWriter, r *http.Request) return } - // Resolve OS filter from the base query param. - var filterOS templatebuilder.BaseOS + // Resolve OS filter and base-included modules from the base query param. + var ( + filterOS templatebuilder.BaseOS + baseModules map[string]bool + ) if base := r.URL.Query().Get("base"); base != "" { filterOS = templatebuilder.BaseTemplateOS(base) if filterOS == "" { @@ -206,6 +209,11 @@ func (api *API) templateBuilderModules(rw http.ResponseWriter, r *http.Request) }) return } + included := templatebuilder.BaseIncludedModules(base) + baseModules = make(map[string]bool, len(included)) + for _, id := range included { + baseModules[id] = true + } } modules := make([]codersdk.TemplateBuilderModule, 0, len(manifests)) @@ -213,6 +221,11 @@ func (api *API) templateBuilderModules(rw http.ResponseWriter, r *http.Request) if filterOS != "" && !m.CompatibleWithOS(string(filterOS)) { continue } + // Skip modules the base template already includes; selecting one + // would collide with the base's own module block. + if baseModules[m.ID] { + continue + } modules = append(modules, m.ToSDK()) } diff --git a/coderd/templatebuilder_handler_test.go b/coderd/templatebuilder_handler_test.go index 9ac86b7553c..99fb5b4959f 100644 --- a/coderd/templatebuilder_handler_test.go +++ b/coderd/templatebuilder_handler_test.go @@ -209,6 +209,37 @@ func TestTemplateBuilderModules(t *testing.T) { } }) + t.Run("BaseExcludesIncludedModules", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // The quickstart base bundles the git-clone module, so the module + // list for that base must omit git-clone to avoid a collision. The + // docker base does not bundle it, so it stays available there. + quickstartResp, err := client.TemplateBuilderModules(ctx, "quickstart") + require.NoError(t, err) + for _, m := range quickstartResp.Modules { + require.NotEqual(t, "git-clone", m.ID, + "git-clone should be excluded for the quickstart base") + } + + dockerResp, err := client.TemplateBuilderModules(ctx, "docker") + require.NoError(t, err) + var dockerHasGitClone bool + for _, m := range dockerResp.Modules { + if m.ID == "git-clone" { + dockerHasGitClone = true + break + } + } + require.True(t, dockerHasGitClone, + "git-clone should remain available for bases that do not bundle it") + }) + t.Run("ComputedVariablesExcluded", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) From f9cfc4f25c487f6126ac2711c682bb35c3a59d9d Mon Sep 17 00:00:00 2001 From: Nick Vigilante Date: Tue, 4 Aug 2026 18:00:45 +0000 Subject: [PATCH 07/10] test(coderd): enforce base included_modules match rendered blocks Add ExtractModuleNames and a per-base test asserting each base's included_modules manifest field exactly lists the catalog modules it renders. Without this, adding a `module ""` block to a base and forgetting the manifest line silently re-opens the duplicate-module collision the guard exists to prevent. Also assert the quickstart modules endpoint returns a non-empty list, so the git-clone exclusion test can no longer pass vacuously. Addresses CRF-12 and CRF-15. --- coderd/templatebuilder/compose_test.go | 40 ++++++++++++++++++++++++++ coderd/templatebuilder/render.go | 18 ++++++++++++ coderd/templatebuilder_handler_test.go | 2 ++ 3 files changed, 60 insertions(+) diff --git a/coderd/templatebuilder/compose_test.go b/coderd/templatebuilder/compose_test.go index 9063599066e..15a90b78b95 100644 --- a/coderd/templatebuilder/compose_test.go +++ b/coderd/templatebuilder/compose_test.go @@ -551,6 +551,46 @@ func TestBundleTar(t *testing.T) { }) } +// TestBaseIncludedModulesMatchRendered enforces that each base's +// included_modules manifest field exactly lists the catalog modules the base +// actually renders. This keeps the collision guard's seen-set in sync with the +// base's Terraform: if a base adds a `module ""` block without +// listing it (or lists one it no longer renders), this test fails instead of +// silently re-opening the duplicate-module hazard the guard exists to prevent. +func TestBaseIncludedModulesMatchRendered(t *testing.T) { + t.Parallel() + + manifests, err := templatebuilder.LoadModules() + require.NoError(t, err) + catalogIDs := make(map[string]bool, len(manifests)) + for _, m := range manifests { + catalogIDs[m.ID] = true + } + + for _, id := range templatebuilder.BaseTemplateIDs() { + t.Run(id, func(t *testing.T) { + t.Parallel() + + mainTF, err := templatebuilder.RenderBaseTemplate( + id, "main.tf.tmpl", templatebuilder.DefaultBaseRenderContext(id)) + require.NoError(t, err) + + // Only catalog-named module blocks can collide with a + // wizard-selected module, so ignore any non-catalog modules a base + // renders (e.g. region helpers that are not in the catalog). + var renderedCatalog []string + for _, name := range templatebuilder.ExtractModuleNames(mainTF) { + if catalogIDs[name] { + renderedCatalog = append(renderedCatalog, name) + } + } + + require.ElementsMatch(t, templatebuilder.BaseIncludedModules(id), renderedCatalog, + "base %q: included_modules must match the catalog modules it renders", id) + }) + } +} + // extractTar reads a tar archive and returns a map of filename to content. func extractTar(t *testing.T, data []byte) map[string]string { t.Helper() diff --git a/coderd/templatebuilder/render.go b/coderd/templatebuilder/render.go index 8e342a58c43..9ad343e5788 100644 --- a/coderd/templatebuilder/render.go +++ b/coderd/templatebuilder/render.go @@ -136,3 +136,21 @@ func ExtractAgentResourceName(hcl []byte) (string, error) { len(matches), names) } } + +// moduleBlockPattern matches a `module ""` block declaration anchored to +// the start of a line in HCL. +var moduleBlockPattern = regexp.MustCompile(`(?m)^[ \t]*module[ \t]+"([^"]+)"`) + +// ExtractModuleNames returns the labels of every module block declared in +// rendered HCL, in declaration order. Matching is anchored to the start of a +// line so commented-out references or string literals are ignored. The input +// is expected to be rendered output from our own curated base templates, not +// arbitrary user HCL. +func ExtractModuleNames(hcl []byte) []string { + matches := moduleBlockPattern.FindAllSubmatch(hcl, -1) + names := make([]string, 0, len(matches)) + for _, m := range matches { + names = append(names, string(m[1])) + } + return names +} diff --git a/coderd/templatebuilder_handler_test.go b/coderd/templatebuilder_handler_test.go index 99fb5b4959f..51a1562c5bb 100644 --- a/coderd/templatebuilder_handler_test.go +++ b/coderd/templatebuilder_handler_test.go @@ -222,6 +222,8 @@ func TestTemplateBuilderModules(t *testing.T) { // docker base does not bundle it, so it stays available there. quickstartResp, err := client.TemplateBuilderModules(ctx, "quickstart") require.NoError(t, err) + require.NotEmpty(t, quickstartResp.Modules, + "quickstart should still offer modules other than the ones it bundles") for _, m := range quickstartResp.Modules { require.NotEqual(t, "git-clone", m.ID, "git-clone should be excluded for the quickstart base") From 53472c4ee2b6035a40a846171a28a65c42492580 Mon Sep 17 00:00:00 2001 From: Nick Vigilante Date: Tue, 4 Aug 2026 18:00:45 +0000 Subject: [PATCH 08/10] refactor(coderd): modernize base ordering, dedupe guard comments Replace the hand-rolled base sort and quickstart/docker regroup with slices.SortFunc + cmp.Or and slices.IndexFunc/Insert. Behavior is unchanged (verified by TestTemplateBuilderBases). Consolidate the base/module collision rationale onto BaseManifest.IncludedModules and point the other sites at it instead of re-explaining it. Correct the conflict-check comment: base-included modules seed the seen-set, but a base module's own ConflictsWith is not consulted. Addresses CRF-16, CRF-17, and CRF-18. --- coderd/templatebuilder/bases.go | 8 ++--- coderd/templatebuilder/compose.go | 13 ++++---- coderd/templatebuilder_handler.go | 53 +++++++++++-------------------- 3 files changed, 29 insertions(+), 45 deletions(-) diff --git a/coderd/templatebuilder/bases.go b/coderd/templatebuilder/bases.go index 9ccd9f94957..efbb39f30f2 100644 --- a/coderd/templatebuilder/bases.go +++ b/coderd/templatebuilder/bases.go @@ -265,11 +265,9 @@ func BaseVariables(exampleID string) []ModuleVariable { return bases[exampleID].Manifest.Variables } -// BaseIncludedModules returns the catalog module IDs that the given base -// template already declares in its own Terraform. Compose uses these to -// reject a wizard-selected module that would collide with a module the -// base already renders. Returns nil if the base is unknown or declares -// none. +// BaseIncludedModules returns the catalog module IDs the given base declares +// in its own Terraform (see BaseManifest.IncludedModules). Returns nil if the +// base is unknown or declares none. func BaseIncludedModules(exampleID string) []string { bases, err := loadBases() if err != nil || bases[exampleID] == nil { diff --git a/coderd/templatebuilder/compose.go b/coderd/templatebuilder/compose.go index f77c5a0a0e0..9037ca92ac6 100644 --- a/coderd/templatebuilder/compose.go +++ b/coderd/templatebuilder/compose.go @@ -200,11 +200,8 @@ func loadCatalogMap() (map[string]ModuleManifest, error) { } // validateModules checks that all requested modules exist, are -// OS-compatible, have no duplicates, do not collide with a module the -// base template already includes, and have no conflicts. baseModules -// lists the catalog module IDs the base declares in its own Terraform; -// requesting one of them would render a duplicate module block, so it is -// rejected here. +// OS-compatible, have no duplicates, do not collide with a module the base +// already includes (see BaseManifest.IncludedModules), and have no conflicts. func validateModules(requested []ComposeModule, catalog map[string]ModuleManifest, baseOS BaseOS, baseModules []string) error { // Seed the seen-set with the modules the base already declares so the // base and wizard-selected modules occupy a disjoint namespace. @@ -233,7 +230,11 @@ func validateModules(requested []ComposeModule, catalog map[string]ModuleManifes } } - // Check conflicts bidirectionally so that order does not matter. + // Reject a requested module whose ConflictsWith names an already-seen + // module. Every requested module is in `seen` by now, so order does not + // matter, and base-included modules seed `seen` too. A base-included + // module's own ConflictsWith list is not consulted, which is safe because + // base modules are curated. for _, cm := range requested { manifest := catalog[cm.ID] for _, conflict := range manifest.ConflictsWith { diff --git a/coderd/templatebuilder_handler.go b/coderd/templatebuilder_handler.go index 890ed1b0b3f..b3a96fd236b 100644 --- a/coderd/templatebuilder_handler.go +++ b/coderd/templatebuilder_handler.go @@ -1,6 +1,7 @@ package coderd import ( + "cmp" "context" "crypto/sha256" "database/sql" @@ -8,7 +9,7 @@ import ( "encoding/json" "errors" "net/http" - "sort" + "slices" "time" "github.com/google/uuid" @@ -89,13 +90,13 @@ func (api *API) templateBuilderBases(rw http.ResponseWriter, r *http.Request) { // Quickstart base directly before the Docker base. Quickstart is a // Docker-based "start here" template, so it belongs next to Docker rather // than in its default alphabetical slot. - sort.Slice(bases, func(i, j int) bool { - if bases[i].Name != bases[j].Name { - return bases[i].Name < bases[j].Name - } + slices.SortFunc(bases, func(a, b codersdk.TemplateBuilderBase) int { // Tiebreak on ID so the order is total and deterministic even if two // bases ever share a display name. - return bases[i].ID < bases[j].ID + return cmp.Or( + cmp.Compare(a.Name, b.Name), + cmp.Compare(a.ID, b.ID), + ) }) bases = groupQuickstartBeforeDocker(bases) @@ -135,39 +136,24 @@ const ( // grouped next to Docker rather than left in its default alphabetical slot. // The input is returned unchanged if either base is absent. func groupQuickstartBeforeDocker(bases []codersdk.TemplateBuilderBase) []codersdk.TemplateBuilderBase { - var ( - quickstart codersdk.TemplateBuilderBase - haveQuickstart bool - ) - rest := make([]codersdk.TemplateBuilderBase, 0, len(bases)) - for _, b := range bases { - if b.ID == quickstartBaseID { - quickstart, haveQuickstart = b, true - continue - } - rest = append(rest, b) - } - if !haveQuickstart { + qsIdx := slices.IndexFunc(bases, func(b codersdk.TemplateBuilderBase) bool { + return b.ID == quickstartBaseID + }) + if qsIdx == -1 { return bases } + quickstart := bases[qsIdx] - dockerIdx := -1 - for i, b := range rest { - if b.ID == dockerBaseID { - dockerIdx = i - break - } - } + // Remove quickstart from a copy, then reinsert it directly before docker. + rest := slices.Delete(slices.Clone(bases), qsIdx, qsIdx+1) + dockerIdx := slices.IndexFunc(rest, func(b codersdk.TemplateBuilderBase) bool { + return b.ID == dockerBaseID + }) if dockerIdx == -1 { // Docker base not present; leave quickstart in its sorted position. return bases } - - out := make([]codersdk.TemplateBuilderBase, 0, len(bases)) - out = append(out, rest[:dockerIdx]...) - out = append(out, quickstart) - out = append(out, rest[dockerIdx:]...) - return out + return slices.Insert(rest, dockerIdx, quickstart) } // @Summary List template builder modules @@ -221,8 +207,7 @@ func (api *API) templateBuilderModules(rw http.ResponseWriter, r *http.Request) if filterOS != "" && !m.CompatibleWithOS(string(filterOS)) { continue } - // Skip modules the base template already includes; selecting one - // would collide with the base's own module block. + // Skip modules the base already includes (see BaseManifest.IncludedModules). if baseModules[m.ID] { continue } From 7f65dc48de2b718b202e43e4e449078c139cd3ca Mon Sep 17 00:00:00 2001 From: Nick Vigilante Date: Tue, 4 Aug 2026 18:00:45 +0000 Subject: [PATCH 09/10] fix(coderd): correct quickstart install docs and language dispatch Correct the README claim that languages are cached across starts: most tools install into the ephemeral workspace container, so they reinstall from the network on every start and block login until done. Match selected languages against whole comma-separated entries instead of an unanchored grep substring, so a future value can never partially match another. Note in the base that its git-clone module renders the public registry verbatim and does not yet honor a deployment's registry mirror; threading the registry through base rendering is tracked in DOCS-610 as a stacked follow-up. Regenerate the quickstart golden for the added comment. Addresses CRF-13, CRF-14, and CRF-6. --- .../bases/quickstart/README.md | 2 +- .../quickstart/install-languages.sh.tftpl | 22 ++++++++++++++----- .../bases/quickstart/main.tf.tmpl | 5 +++++ .../testdata/quickstart.tf.golden | 5 +++++ 4 files changed, 27 insertions(+), 7 deletions(-) diff --git a/coderd/templatebuilder/bases/quickstart/README.md b/coderd/templatebuilder/bases/quickstart/README.md index 9259fa42dd0..c34a1a0c7bb 100644 --- a/coderd/templatebuilder/bases/quickstart/README.md +++ b/coderd/templatebuilder/bases/quickstart/README.md @@ -46,7 +46,7 @@ This template provisions: - **Docker container** (ephemeral) running Ubuntu with the Coder agent - **Docker volume** (persistent) mounted at `/home/coder` -Files in your home directory persist across workspace restarts. Selected languages are installed on first start and cached for subsequent starts. +Files in your home directory (`/home/coder`) persist across workspace restarts. The selected languages are reinstalled on every start by a script that blocks login until it finishes: most language tools install into the workspace container rather than your home directory, so they do not persist and are fetched from the network each time the workspace starts. ## Presets diff --git a/coderd/templatebuilder/bases/quickstart/install-languages.sh.tftpl b/coderd/templatebuilder/bases/quickstart/install-languages.sh.tftpl index e986bf12270..6babbf6288e 100644 --- a/coderd/templatebuilder/bases/quickstart/install-languages.sh.tftpl +++ b/coderd/templatebuilder/bases/quickstart/install-languages.sh.tftpl @@ -11,7 +11,17 @@ apt_update() { fi } -if echo "$LANGUAGES" | grep -q "python"; then +# has_language reports whether NAME is one of the selected languages. It matches +# whole comma-separated entries, so a value can never partially match another +# (guards against a future language whose name contains an existing one). +has_language() { + case ",$LANGUAGES," in + *",$1,"*) return 0 ;; + *) return 1 ;; + esac +} + +if has_language python; then if command -v python3 >/dev/null 2>&1; then echo "Python: $(python3 --version)" else @@ -22,7 +32,7 @@ if echo "$LANGUAGES" | grep -q "python"; then fi fi -if echo "$LANGUAGES" | grep -q "nodejs"; then +if has_language nodejs; then if command -v node >/dev/null 2>&1; then echo "Node.js: $(node --version)" else @@ -33,7 +43,7 @@ if echo "$LANGUAGES" | grep -q "nodejs"; then fi fi -if echo "$LANGUAGES" | grep -q "go"; then +if has_language go; then if command -v /usr/local/go/bin/go >/dev/null 2>&1; then echo "Go: $(/usr/local/go/bin/go version)" else @@ -51,7 +61,7 @@ if echo "$LANGUAGES" | grep -q "go"; then fi fi -if echo "$LANGUAGES" | grep -q "rust"; then +if has_language rust; then if command -v rustc >/dev/null 2>&1 || [ -f "$HOME/.cargo/bin/rustc" ]; then RUSTC=$${HOME}/.cargo/bin/rustc command -v rustc >/dev/null 2>&1 && RUSTC=rustc @@ -63,7 +73,7 @@ if echo "$LANGUAGES" | grep -q "rust"; then fi fi -if echo "$LANGUAGES" | grep -q "java"; then +if has_language java; then if command -v java >/dev/null 2>&1; then echo "Java: $(java --version 2>&1 | head -1)" else @@ -74,7 +84,7 @@ if echo "$LANGUAGES" | grep -q "java"; then fi fi -if echo "$LANGUAGES" | grep -q "cpp"; then +if has_language cpp; then if command -v gcc >/dev/null 2>&1; then echo "C/C++: $(gcc --version | head -1)" else diff --git a/coderd/templatebuilder/bases/quickstart/main.tf.tmpl b/coderd/templatebuilder/bases/quickstart/main.tf.tmpl index a530e09d40b..0172fd5fcf2 100644 --- a/coderd/templatebuilder/bases/quickstart/main.tf.tmpl +++ b/coderd/templatebuilder/bases/quickstart/main.tf.tmpl @@ -151,6 +151,11 @@ resource "coder_script" "install_languages" { } # --- Git clone --- +# NOTE: base templates render their module sources verbatim, so this pins the +# public registry (registry.coder.com). Unlike wizard-composed modules, a +# base-embedded module does not yet honor a deployment's configured module +# registry mirror; threading that registry through base rendering is tracked as +# a follow-up. module "git-clone" { count = data.coder_workspace.me.start_count * (data.coder_parameter.git_repo.value != "" ? 1 : 0) diff --git a/coderd/templatebuilder/testdata/quickstart.tf.golden b/coderd/templatebuilder/testdata/quickstart.tf.golden index a530e09d40b..0172fd5fcf2 100644 --- a/coderd/templatebuilder/testdata/quickstart.tf.golden +++ b/coderd/templatebuilder/testdata/quickstart.tf.golden @@ -151,6 +151,11 @@ resource "coder_script" "install_languages" { } # --- Git clone --- +# NOTE: base templates render their module sources verbatim, so this pins the +# public registry (registry.coder.com). Unlike wizard-composed modules, a +# base-embedded module does not yet honor a deployment's configured module +# registry mirror; threading that registry through base rendering is tracked as +# a follow-up. module "git-clone" { count = data.coder_workspace.me.start_count * (data.coder_parameter.git_repo.value != "" ? 1 : 0) From bc740d249b42db0a9bdb84eca4819bc75eff175c Mon Sep 17 00:00:00 2001 From: Nick Vigilante Date: Tue, 4 Aug 2026 19:29:53 +0000 Subject: [PATCH 10/10] feat(coderd): address R5 review nits on the quickstart base - CRF-20: bind the quickstart language selector to the install script. Add ExtractParameterOptionValues and a test asserting the selector's option values match the script's has_language branches, so the two hand-maintained lists cannot drift silently. - CRF-21: document that TestBaseIncludedModulesMatchRendered renders with DefaultBaseRenderContext (no variable overlay) and to extend it if bases gain variables. - CRF-22: make the README precise that Rust's ~/.cargo persists while other toolchains reinstall each start. - CRF-24: drop the duplicated grouping rationale at the sort site (kept on groupQuickstartBeforeDocker). --- .../bases/quickstart/README.md | 2 +- coderd/templatebuilder/compose_test.go | 44 ++++++++++++++ coderd/templatebuilder/render.go | 58 +++++++++++++++++++ coderd/templatebuilder_handler.go | 6 +- 4 files changed, 105 insertions(+), 5 deletions(-) diff --git a/coderd/templatebuilder/bases/quickstart/README.md b/coderd/templatebuilder/bases/quickstart/README.md index c34a1a0c7bb..a5ad78db2ae 100644 --- a/coderd/templatebuilder/bases/quickstart/README.md +++ b/coderd/templatebuilder/bases/quickstart/README.md @@ -46,7 +46,7 @@ This template provisions: - **Docker container** (ephemeral) running Ubuntu with the Coder agent - **Docker volume** (persistent) mounted at `/home/coder` -Files in your home directory (`/home/coder`) persist across workspace restarts. The selected languages are reinstalled on every start by a script that blocks login until it finishes: most language tools install into the workspace container rather than your home directory, so they do not persist and are fetched from the network each time the workspace starts. +Files in your home directory (`/home/coder`) persist across workspace restarts. The language install script runs on every start and blocks login until it finishes. Most toolchains install into the ephemeral workspace container rather than your home directory, so they are reinstalled from the network on each start; the exception is Rust, whose toolchain lives under `~/.cargo` in your home directory and is detected and reused. ## Presets diff --git a/coderd/templatebuilder/compose_test.go b/coderd/templatebuilder/compose_test.go index 15a90b78b95..b70e51dac84 100644 --- a/coderd/templatebuilder/compose_test.go +++ b/coderd/templatebuilder/compose_test.go @@ -5,6 +5,8 @@ import ( "bytes" "errors" "io" + "io/fs" + "regexp" "testing" "github.com/stretchr/testify/require" @@ -557,6 +559,12 @@ func TestBundleTar(t *testing.T) { // base's Terraform: if a base adds a `module ""` block without // listing it (or lists one it no longer renders), this test fails instead of // silently re-opening the duplicate-module hazard the guard exists to prevent. +// +// This renders with DefaultBaseRenderContext, which applies no variable +// overlay. A base that gated a catalog `module` block on a variable would not +// render that block here, so the guard could be evaded; that is dormant today +// because no base declares variables. When bases gain variables, extend this to +// also render with representative variable contexts. func TestBaseIncludedModulesMatchRendered(t *testing.T) { t.Parallel() @@ -591,6 +599,42 @@ func TestBaseIncludedModulesMatchRendered(t *testing.T) { } } +// hasLanguageDispatchPattern matches the `if has_language ` call sites in +// the quickstart language-install script (not the has_language definition). +var hasLanguageDispatchPattern = regexp.MustCompile(`(?m)^\s*if has_language (\S+?);`) + +// TestQuickstartLanguageSelectorMatchesInstallScript enforces that the +// quickstart "languages" selector options and the language-install script's +// has_language dispatch branches describe the same set of languages. They are +// two hand-maintained lists with nothing else binding them: if one gains or +// loses a language without the other, a selected language would silently +// install nothing (or a branch would be dead). This test fails on that drift. +func TestQuickstartLanguageSelectorMatchesInstallScript(t *testing.T) { + t.Parallel() + + mainTF, err := templatebuilder.RenderBaseTemplate( + "quickstart", "main.tf.tmpl", templatebuilder.DefaultBaseRenderContext("quickstart")) + require.NoError(t, err) + selectorValues := templatebuilder.ExtractParameterOptionValues(mainTF, "languages") + require.NotEmpty(t, selectorValues, + "expected the quickstart languages selector to declare options") + + fsys, err := templatebuilder.BaseTemplateFS("quickstart") + require.NoError(t, err) + script, err := fs.ReadFile(fsys, "install-languages.sh.tftpl") + require.NoError(t, err) + + var dispatchNames []string + for _, m := range hasLanguageDispatchPattern.FindAllSubmatch(script, -1) { + dispatchNames = append(dispatchNames, string(m[1])) + } + require.NotEmpty(t, dispatchNames, + "expected the install script to dispatch on has_language") + + require.ElementsMatch(t, selectorValues, dispatchNames, + "quickstart languages selector options must match the install script's has_language branches") +} + // extractTar reads a tar archive and returns a map of filename to content. func extractTar(t *testing.T, data []byte) map[string]string { t.Helper() diff --git a/coderd/templatebuilder/render.go b/coderd/templatebuilder/render.go index 9ad343e5788..014a93e0c0d 100644 --- a/coderd/templatebuilder/render.go +++ b/coderd/templatebuilder/render.go @@ -154,3 +154,61 @@ func ExtractModuleNames(hcl []byte) []string { } return names } + +// coderParameterOpenPattern matches the opening of a `data "coder_parameter" +// ""` block and captures the parameter name. +var coderParameterOpenPattern = regexp.MustCompile(`data\s+"coder_parameter"\s+"([^"]+)"\s*\{`) + +// coderParameterOptionValuePattern matches the `value = ""` assignment inside +// an `option { ... }` block. Option blocks in our curated bases contain no +// nested braces, so [^{}] keeps each match within a single option. +var coderParameterOptionValuePattern = regexp.MustCompile(`(?s)option\s*\{[^{}]*?\bvalue\s*=\s*"([^"]+)"`) + +// ExtractParameterOptionValues returns the value of every option block declared +// inside the `data "coder_parameter" ""` block in rendered HCL, in +// declaration order. It scopes to that one parameter by scanning from its +// opening brace to the matching close, so option values from other parameters +// are not included. Returns nil if the parameter is absent. The input is +// expected to be rendered output from our own curated base templates, not +// arbitrary user HCL. +func ExtractParameterOptionValues(hcl []byte, paramName string) []string { + start := -1 + for _, m := range coderParameterOpenPattern.FindAllSubmatchIndex(hcl, -1) { + // m[2]:m[3] bounds the captured name; m[1] is just past the opening brace. + if string(hcl[m[2]:m[3]]) == paramName { + start = m[1] - 1 // index of the opening '{' + break + } + } + if start == -1 { + return nil + } + + // Walk from the parameter's opening brace to its matching close, tracking + // nesting depth, so we only read option values that belong to it. + depth := 0 + end := -1 +scan: + for i := start; i < len(hcl); i++ { + switch hcl[i] { + case '{': + depth++ + case '}': + depth-- + if depth == 0 { + end = i + break scan + } + } + } + if end == -1 { + return nil + } + + matches := coderParameterOptionValuePattern.FindAllSubmatch(hcl[start:end+1], -1) + values := make([]string, 0, len(matches)) + for _, m := range matches { + values = append(values, string(m[1])) + } + return values +} diff --git a/coderd/templatebuilder_handler.go b/coderd/templatebuilder_handler.go index b3a96fd236b..cda2f02ff3e 100644 --- a/coderd/templatebuilder_handler.go +++ b/coderd/templatebuilder_handler.go @@ -86,10 +86,8 @@ func (api *API) templateBuilderBases(rw http.ResponseWriter, r *http.Request) { }) } - // Order bases alphabetically by display name, then group the Coder - // Quickstart base directly before the Docker base. Quickstart is a - // Docker-based "start here" template, so it belongs next to Docker rather - // than in its default alphabetical slot. + // Order bases alphabetically by display name, then group Quickstart next to + // Docker (see groupQuickstartBeforeDocker for the rationale). slices.SortFunc(bases, func(a, b codersdk.TemplateBuilderBase) int { // Tiebreak on ID so the order is total and deterministic even if two // bases ever share a display name.