diff --git a/coderd/templatebuilder/bases.go b/coderd/templatebuilder/bases.go index 774167a619fb6..efbb39f30f2af 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,17 @@ func BaseVariables(exampleID string) []ModuleVariable { return bases[exampleID].Manifest.Variables } +// 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 { + 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/README.md b/coderd/templatebuilder/bases/quickstart/README.md new file mode 100644 index 0000000000000..a5ad78db2ae38 --- /dev/null +++ b/coderd/templatebuilder/bases/quickstart/README.md @@ -0,0 +1,64 @@ +--- +display_name: Coder Quickstart +description: Get started with Coder by picking your languages 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, 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. **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 (`/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 + +Select a preset to auto-fill languages for common workflows: + +| Preset | Languages | +| ------------------- | ------------------- | +| **Web Development** | Python, Node.js | +| **Backend (Go)** | Go | +| **Data Science** | Python | +| **Full Stack** | Python, Node.js, Go | + +## Editors + +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/base.json b/coderd/templatebuilder/bases/quickstart/base.json new file mode 100644 index 0000000000000..1b41f3eb34a32 --- /dev/null +++ b/coderd/templatebuilder/bases/quickstart/base.json @@ -0,0 +1,7 @@ +{ + "id": "quickstart", + "display_name": "Coder Quickstart", + "os": "linux", + "default_context": {}, + "included_modules": ["git-clone"] +} 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 0000000000000..6babbf6288e67 --- /dev/null +++ b/coderd/templatebuilder/bases/quickstart/install-languages.sh.tftpl @@ -0,0 +1,98 @@ +#!/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 +} + +# 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 + echo "Installing Python..." + apt_update + sudo apt-get install -y -qq python3 python3-pip python3-venv + echo "Installed Python: $(python3 --version)" + fi +fi + +if has_language 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 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 + 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 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 + 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 has_language 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 has_language 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 0000000000000..0172fd5fcf23a --- /dev/null +++ b/coderd/templatebuilder/bases/quickstart/main.tf.tmpl @@ -0,0 +1,268 @@ +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" "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 = 2 +} + +# --- Locals --- + +locals { + username = data.coder_workspace_owner.me.name + languages = jsondecode(data.coder_parameter.languages.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) + }) +} + +# --- 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) + 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"]) + git_repo = "" + } +} + +data "coder_workspace_preset" "backend_go" { + name = "Backend (Go)" + icon = "/icon/go.svg" + parameters = { + languages = jsonencode(["go"]) + git_repo = "" + } +} + +data "coder_workspace_preset" "data_science" { + name = "Data Science" + icon = "/icon/python.svg" + parameters = { + languages = jsonencode(["python"]) + git_repo = "" + } +} + +data "coder_workspace_preset" "full_stack" { + name = "Full Stack" + icon = "/icon/code.svg" + parameters = { + languages = jsonencode(["python", "nodejs", "go"]) + 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 f01f6f48a043d..14f15b5dd68bd 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/compose.go b/coderd/templatebuilder/compose.go index 2fbfc5c619d42..9037ca92ac634 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,22 @@ 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 +// 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. + 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) } @@ -217,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/compose_test.go b/coderd/templatebuilder/compose_test.go index 51e1c7a2c98d2..b70e51dac84c4 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" @@ -195,6 +197,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{ @@ -520,6 +553,88 @@ 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. +// +// 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() + + 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) + }) + } +} + +// 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 8e342a58c4389..014a93e0c0db3 100644 --- a/coderd/templatebuilder/render.go +++ b/coderd/templatebuilder/render.go @@ -136,3 +136,79 @@ 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 +} + +// 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/render_test.go b/coderd/templatebuilder/render_test.go index 12927737e2d6f..9bbbadc9f027f 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 0000000000000..0172fd5fcf23a --- /dev/null +++ b/coderd/templatebuilder/testdata/quickstart.tf.golden @@ -0,0 +1,268 @@ +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" "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 = 2 +} + +# --- Locals --- + +locals { + username = data.coder_workspace_owner.me.name + languages = jsondecode(data.coder_parameter.languages.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) + }) +} + +# --- 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) + 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"]) + git_repo = "" + } +} + +data "coder_workspace_preset" "backend_go" { + name = "Backend (Go)" + icon = "/icon/go.svg" + parameters = { + languages = jsonencode(["go"]) + git_repo = "" + } +} + +data "coder_workspace_preset" "data_science" { + name = "Data Science" + icon = "/icon/python.svg" + parameters = { + languages = jsonencode(["python"]) + git_repo = "" + } +} + +data "coder_workspace_preset" "full_stack" { + name = "Full Stack" + icon = "/icon/code.svg" + parameters = { + languages = jsonencode(["python", "nodejs", "go"]) + 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 adc4da2d77c2d..cda2f02ff3eda 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" @@ -85,9 +86,17 @@ func (api *API) templateBuilderBases(rw http.ResponseWriter, r *http.Request) { }) } - sort.Slice(bases, func(i, j int) bool { - return bases[i].Name < bases[j].Name + // 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. + return cmp.Or( + cmp.Compare(a.Name, b.Name), + cmp.Compare(a.ID, b.ID), + ) }) + bases = groupQuickstartBeforeDocker(bases) httpapi.Write(ctx, rw, http.StatusOK, codersdk.TemplateBuilderBasesResponse{ Bases: bases, @@ -114,6 +123,37 @@ 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 { + qsIdx := slices.IndexFunc(bases, func(b codersdk.TemplateBuilderBase) bool { + return b.ID == quickstartBaseID + }) + if qsIdx == -1 { + return bases + } + quickstart := bases[qsIdx] + + // 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 + } + return slices.Insert(rest, dockerIdx, quickstart) +} + // @Summary List template builder modules // @ID list-template-builder-modules // @Security CoderSessionToken @@ -139,8 +179,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 == "" { @@ -150,6 +193,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)) @@ -157,6 +205,10 @@ func (api *API) templateBuilderModules(rw http.ResponseWriter, r *http.Request) if filterOS != "" && !m.CompatibleWithOS(string(filterOS)) { continue } + // Skip modules the base already includes (see BaseManifest.IncludedModules). + 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 bbac79a0ea7a0..51a1562c5bb2e 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", @@ -107,10 +115,35 @@ 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++ { - require.LessOrEqual(t, resp.Bases[i-1].Name, resp.Bases[i].Name, - "bases 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") } }) @@ -176,6 +209,39 @@ 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) + 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") + } + + 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)