feat: honor the deployment registry mirror in template builder bases (#28727)

## Summary

Threads the deployment's configured module registry
(`CODER_TEMPLATE_BUILDER_REGISTRY_URL`) into template-builder **base**
rendering, so a base that embeds a catalog module (e.g. the quickstart
base's `git-clone`) resolves its `source` through a registry mirror the
same way wizard-composed modules already do. Previously base-embedded
module sources hardcoded `registry.coder.com`, so on a
mirror/private-registry deployment a base-embedded module reached the
public registry while wizard modules used the mirror.

Implements [DOCS-610](https://linear.app/codercom/issue/DOCS-610).

## What changed

* `BaseRenderContext.RegistryBase` (new field), mirroring
`ModuleRenderContext.RegistryBase`. `Compose` threads the deployment
registry through `renderBase` into the base render context, and the four
hardcoded base module sources (`quickstart` git-clone,
`azure-linux`/`gcp-linux`/`gcp-windows` region modules) now use `{{
.RegistryBase }}`. Because the value defaults back to
`registry.coder.com`, rendered sources stay byte-identical; the only
golden change is the removal of the now-stale quickstart "follow-up"
note.
* **Boot-time validation.**
`codersdk.ValidateTemplateBuilderRegistryURL` runs in
`DeploymentValues.Validate()`, gated on the builder being enabled,
matching the chat-hook precedent. A malformed value fails at server
start naming the option, instead of surfacing as a per-request error to
a template author who cannot fix it. It uses `net/url` in authority form
to reject a scheme, path, query, fragment, trailing slash, or
credentials and accept a bare `host[:port]`; it does not canonicalize
the host.
* `Compose` **trusts the validated value**, defaulting an unset one and
defensively rejecting a malformed one for direct callers that bypass
boot.

## Tests

* `codersdk`: validator accept/reject matrix (incl.
credentials-without-echo) and a `DeploymentValues.Validate` boundary
test with a disabled-skips-validation case.
* `templatebuilder`: a base honors the mirror and falls back to the
default when unset, plus a defensive-reject case; existing compose tests
updated to bare-host registry values.

<details><summary>Design decision log</summary>

This PR is a deliberately minimal reimplementation of the
[DOCS-610](https://linear.app/codercom/issue/DOCS-610/template-builder-base-embedded-modules-should-honor-the-deployment)
scope, started fresh from `main` rather than carrying forward an
earlier, larger draft (coder/coder#28480). Key decisions:

* **Scope kept to the ticket.** The ticket's fix is three bullets: add a
registry field to `BaseRenderContext`, thread the deployment
`RegistryURL` through `Compose` -> `renderBase`, and use it in base
module sources. Independent hardening explored in the earlier draft was
dropped as out of scope: `terraform-svchost`-based IDN/punycode/port
canonicalization, a rendered-HCL backstop with a 500 mapping, and HCL
module-source extraction helpers. Telemetry misclassification of mirror
modules is tracked separately
([DOCS-712](https://linear.app/codercom/issue/DOCS-712/telemetry-misclassifies-registry-mirror-modules-as-non-coder))
and is not addressed here.
* **Default lives in the render layer.**
`templatebuilder.DefaultRegistryBase` holds the fallback host;
`codersdk` keeps its own option-default literal. An earlier version
exported the constant from `codersdk`, which only emitted a dead
`typesGenerated.ts` entry that nothing in the frontend consumed, so it
was moved out. The `codersdk` validator is a function, so it generates
no TS type.
* **Validate at the config boundary, fail fast.** The repo convention is
to hard-fail structurally invalid config in
`DeploymentValues.Validate()` (access URL, OAuth lifetimes, chat hook
URL) and to warn only for functional-but-risky settings. A malformed
registry host is structurally invalid, so it fails at boot. This also
puts the error in front of the admin who set it, rather than a template
author hitting a per-request error they cannot fix.
* `net/url`**, not a regex.** The codebase validates identifiers/names
with regexes but host/URL-shaped config with `url.Parse` (chat hook URL,
OAuth redirect URIs, AI provider base URL). This value is host-shaped,
so it follows that convention. `url.Parse("//" + v)` with `u.Host == v`
rejects a scheme/path/query/fragment/trailing-slash in one check.

</details>

> This PR was created with AI assistance (Coder Agents).
This commit is contained in:
Jeremy Ruppel
2026-08-31 09:32:24 -04:00
committed by GitHub
parent 0bc7b1a957
commit c73f34c35d
19 changed files with 303 additions and 62 deletions
+4 -2
View File
@@ -927,8 +927,10 @@ TEMPLATE BUILDER OPTIONS:
When disabled, all /api/v2/templatebuilder/* endpoints return 404.
--template-builder-registry-url string, $CODER_TEMPLATE_BUILDER_REGISTRY_URL (default: registry.coder.com)
The base URL of the module registry used by the template builder for
module source paths.
The module registry host the template builder uses for module source
paths (for example, "registry.coder.com" or "mirror.internal:8443").
An http(s):// scheme and trailing slash are stripped; a path, query,
fragment, or credentials is rejected.
USER QUIET HOURS SCHEDULE OPTIONS:
Allow users to set quiet hours schedules each day for workspaces to avoid
+4 -2
View File
@@ -1200,7 +1200,9 @@ templateBuilder:
# disabled, all /api/v2/templatebuilder/* endpoints return 404.
# (default: <unset>, type: bool)
disabled: false
# The base URL of the module registry used by the template builder for module
# source paths.
# The module registry host the template builder uses for module source paths (for
# example, "registry.coder.com" or "mirror.internal:8443"). An http(s):// scheme
# and trailing slash are stripped; a path, query, fragment, or credentials is
# rejected.
# (default: registry.coder.com, type: string)
registryURL: registry.coder.com
+1
View File
@@ -232,6 +232,7 @@ func DefaultBaseRenderContext(exampleID string) BaseRenderContext {
return BaseRenderContext{
ContainerImage: dc.ContainerImage,
RegistryBase: DefaultRegistryBase,
Variables: vars,
}
}
@@ -14,7 +14,7 @@ terraform {
# See https://registry.coder.com/modules/coder/azure-region
module "azure_region" {
source = "registry.coder.com/coder/azure-region/coder"
source = "{{ .RegistryBase }}/coder/azure-region/coder"
# This ensures that the latest non-breaking version of the module gets downloaded, you can also pin the module version to prevent breaking changes in production.
version = "~> 1.0"
@@ -18,7 +18,7 @@ variable "project_id" {
# See https://registry.coder.com/modules/coder/gcp-region
module "gcp_region" {
source = "registry.coder.com/coder/gcp-region/coder"
source = "{{ .RegistryBase }}/coder/gcp-region/coder"
# This ensures that the latest non-breaking version of the module gets downloaded, you can also pin the module version to prevent breaking changes in production.
version = "~> 1.0"
@@ -18,7 +18,7 @@ variable "project_id" {
# See https://registry.coder.com/modules/coder/gcp-region
module "gcp_region" {
source = "registry.coder.com/coder/gcp-region/coder"
source = "{{ .RegistryBase }}/coder/gcp-region/coder"
# This ensures that the latest non-breaking version of the module gets downloaded, you can also pin the module version to prevent breaking changes in production.
version = "~> 1.0"
@@ -151,15 +151,9 @@ 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)
source = "registry.coder.com/coder/git-clone/coder"
source = "{{ .RegistryBase }}/coder/git-clone/coder"
version = "~> 2.0"
agent_id = coder_agent.main.id
url = data.coder_parameter.git_repo.value
+17 -3
View File
@@ -10,6 +10,8 @@ import (
"github.com/hashicorp/hcl/v2/hclwrite"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/codersdk"
)
// ComposeRequest describes which base template and modules to render.
@@ -53,7 +55,18 @@ type ComposeResult struct {
// source files. It extracts the coder_agent resource name from the
// rendered base HCL and wires it into each module block.
func Compose(req ComposeRequest) (*ComposeResult, error) {
mainTF, err := renderBase(req.BaseTemplateID, req.BaseVariableValues)
// Validated at server start (codersdk.DeploymentValues.Validate). Normalize
// here too so a direct caller that bypasses the boot check gets the same
// scheme-stripping and rejection; default an unset value.
registryBase, err := codersdk.NormalizeTemplateBuilderRegistryURL(req.RegistryURL)
if err != nil {
return nil, err
}
if registryBase == "" {
registryBase = DefaultRegistryBase
}
mainTF, err := renderBase(req.BaseTemplateID, req.BaseVariableValues, registryBase)
if err != nil {
return nil, err
}
@@ -84,7 +97,7 @@ func Compose(req ComposeRequest) (*ComposeResult, error) {
return nil, err
}
modulesTF, err := renderModules(req.Modules, catalog, req.RegistryURL, agentName)
modulesTF, err := renderModules(req.Modules, catalog, registryBase, agentName)
if err != nil {
return nil, err
}
@@ -109,11 +122,12 @@ func formatHCL(src []byte) []byte {
// renderBase renders the base template for the given example ID,
// merging any user-supplied variable values into the render context.
func renderBase(baseTemplateID string, baseVars map[string]string) ([]byte, error) {
func renderBase(baseTemplateID string, baseVars map[string]string, registryBase string) ([]byte, error) {
renderCtx := DefaultBaseRenderContext(baseTemplateID)
if renderCtx.Variables == nil {
renderCtx.Variables = make(map[string]string)
}
renderCtx.RegistryBase = registryBase
vars, err := mergeBaseVariables(baseTemplateID, baseVars)
if err != nil {
+81 -28
View File
@@ -21,7 +21,7 @@ func TestCompose(t *testing.T) {
t.Parallel()
result, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
BaseTemplateID: "docker",
RegistryURL: "https://registry.coder.com",
RegistryURL: "registry.coder.com",
})
require.NoError(t, err)
require.NotEmpty(t, result.MainTF)
@@ -34,7 +34,7 @@ func TestCompose(t *testing.T) {
t.Parallel()
result, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
BaseTemplateID: "docker",
RegistryURL: "https://registry.coder.com",
RegistryURL: "registry.coder.com",
Modules: []templatebuilder.ComposeModule{
{
ID: "code-server",
@@ -59,7 +59,7 @@ func TestCompose(t *testing.T) {
t.Parallel()
result, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
BaseTemplateID: "aws-linux",
RegistryURL: "https://registry.coder.com",
RegistryURL: "registry.coder.com",
Modules: []templatebuilder.ComposeModule{
{ID: "git-commit-signing"},
},
@@ -72,7 +72,7 @@ func TestCompose(t *testing.T) {
t.Parallel()
result, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
BaseTemplateID: "aws-linux",
RegistryURL: "https://registry.coder.com",
RegistryURL: "registry.coder.com",
})
require.NoError(t, err)
require.NotNil(t, result.ExtraFiles, "aws-linux should have extra files")
@@ -85,7 +85,7 @@ func TestCompose(t *testing.T) {
result, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
BaseTemplateID: "gcp-linux",
BaseVariableValues: map[string]string{"project_id": "my-gcp-project"},
RegistryURL: "https://registry.coder.com",
RegistryURL: "registry.coder.com",
})
require.NoError(t, err)
require.NotEmpty(t, result.MainTF)
@@ -100,7 +100,7 @@ func TestCompose(t *testing.T) {
result, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
BaseTemplateID: "gcp-windows",
BaseVariableValues: map[string]string{"project_id": "my-gcp-project"},
RegistryURL: "https://registry.coder.com",
RegistryURL: "registry.coder.com",
})
require.NoError(t, err)
require.NotEmpty(t, result.MainTF)
@@ -114,7 +114,7 @@ func TestCompose(t *testing.T) {
t.Parallel()
_, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
BaseTemplateID: "gcp-linux",
RegistryURL: "https://registry.coder.com",
RegistryURL: "registry.coder.com",
})
require.Error(t, err)
require.Contains(t, err.Error(), `variable "project_id" is required`)
@@ -124,7 +124,7 @@ func TestCompose(t *testing.T) {
t.Parallel()
result, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
BaseTemplateID: "azure-linux",
RegistryURL: "https://registry.coder.com",
RegistryURL: "registry.coder.com",
})
require.NoError(t, err)
require.Contains(t, result.ExtraFiles, "cloud-init/cloud-config.yaml.tftpl")
@@ -134,7 +134,7 @@ func TestCompose(t *testing.T) {
t.Parallel()
result, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
BaseTemplateID: "docker",
RegistryURL: "https://registry.coder.com",
RegistryURL: "registry.coder.com",
Modules: []templatebuilder.ComposeModule{
{ID: "claude-code"},
},
@@ -152,7 +152,7 @@ func TestCompose(t *testing.T) {
t.Parallel()
result, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
BaseTemplateID: "docker",
RegistryURL: "https://registry.coder.com",
RegistryURL: "registry.coder.com",
Modules: []templatebuilder.ComposeModule{
{ID: "code-server"},
{
@@ -174,7 +174,7 @@ func TestCompose(t *testing.T) {
t.Parallel()
result, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
BaseTemplateID: "docker",
RegistryURL: "https://registry.internal.corp",
RegistryURL: "registry.internal.corp",
Modules: []templatebuilder.ComposeModule{
{ID: "code-server"},
},
@@ -187,7 +187,7 @@ func TestCompose(t *testing.T) {
t.Parallel()
_, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
BaseTemplateID: "docker",
RegistryURL: "https://registry.coder.com",
RegistryURL: "registry.coder.com",
Modules: []templatebuilder.ComposeModule{
{ID: "code-server"},
{ID: "code-server"},
@@ -204,7 +204,7 @@ func TestCompose(t *testing.T) {
// module block, so compose must reject it.
_, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
BaseTemplateID: "quickstart",
RegistryURL: "https://registry.coder.com",
RegistryURL: "registry.coder.com",
Modules: []templatebuilder.ComposeModule{
{ID: "git-clone"},
},
@@ -219,7 +219,7 @@ func TestCompose(t *testing.T) {
// code-server compose normally on top of it.
result, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
BaseTemplateID: "quickstart",
RegistryURL: "https://registry.coder.com",
RegistryURL: "registry.coder.com",
Modules: []templatebuilder.ComposeModule{
{ID: "code-server"},
},
@@ -232,7 +232,7 @@ func TestCompose(t *testing.T) {
t.Parallel()
_, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
BaseTemplateID: "docker",
RegistryURL: "https://registry.coder.com",
RegistryURL: "registry.coder.com",
Modules: []templatebuilder.ComposeModule{
{ID: "code-server"},
{ID: "vscode-web"},
@@ -246,7 +246,7 @@ func TestCompose(t *testing.T) {
t.Parallel()
result, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
BaseTemplateID: "docker",
RegistryURL: "https://registry.coder.com",
RegistryURL: "registry.coder.com",
})
require.NoError(t, err)
require.Empty(t, result.ExtraFiles, "docker should have no extra files")
@@ -256,7 +256,7 @@ func TestCompose(t *testing.T) {
t.Parallel()
_, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
BaseTemplateID: "nonexistent",
RegistryURL: "https://registry.coder.com",
RegistryURL: "registry.coder.com",
})
require.Error(t, err)
require.Contains(t, err.Error(), "unknown base template")
@@ -266,7 +266,7 @@ func TestCompose(t *testing.T) {
t.Parallel()
_, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
BaseTemplateID: "docker",
RegistryURL: "https://registry.coder.com",
RegistryURL: "registry.coder.com",
Modules: []templatebuilder.ComposeModule{
{ID: "nonexistent-module"},
},
@@ -279,7 +279,7 @@ func TestCompose(t *testing.T) {
t.Parallel()
_, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
BaseTemplateID: "docker",
RegistryURL: "https://registry.coder.com",
RegistryURL: "registry.coder.com",
Modules: []templatebuilder.ComposeModule{
{
ID: "code-server",
@@ -298,7 +298,7 @@ func TestCompose(t *testing.T) {
t.Parallel()
_, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
BaseTemplateID: "docker",
RegistryURL: "https://registry.coder.com",
RegistryURL: "registry.coder.com",
Modules: []templatebuilder.ComposeModule{
{
ID: "code-server",
@@ -317,7 +317,7 @@ func TestCompose(t *testing.T) {
t.Parallel()
_, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
BaseTemplateID: "docker",
RegistryURL: "https://registry.coder.com",
RegistryURL: "registry.coder.com",
Modules: []templatebuilder.ComposeModule{
{
ID: "code-server",
@@ -335,7 +335,7 @@ func TestCompose(t *testing.T) {
t.Parallel()
result, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
BaseTemplateID: "docker",
RegistryURL: "https://registry.coder.com",
RegistryURL: "registry.coder.com",
})
require.NoError(t, err)
require.Contains(t, string(result.MainTF), `"codercom/example-base:ubuntu"`)
@@ -345,7 +345,7 @@ func TestCompose(t *testing.T) {
t.Parallel()
result, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
BaseTemplateID: "docker",
RegistryURL: "https://registry.coder.com",
RegistryURL: "registry.coder.com",
BaseVariableValues: map[string]string{
"container_image": "myregistry/myimage:v2",
},
@@ -360,7 +360,7 @@ func TestCompose(t *testing.T) {
t.Parallel()
result, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
BaseTemplateID: "kubernetes",
RegistryURL: "https://registry.coder.com",
RegistryURL: "registry.coder.com",
BaseVariableValues: map[string]string{
"namespace": "default",
},
@@ -373,7 +373,7 @@ func TestCompose(t *testing.T) {
t.Parallel()
result, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
BaseTemplateID: "kubernetes",
RegistryURL: "https://registry.coder.com",
RegistryURL: "registry.coder.com",
BaseVariableValues: map[string]string{
"namespace": "default",
"container_image": "custom/workspace:latest",
@@ -391,7 +391,7 @@ func TestCompose(t *testing.T) {
// Omitting it should cause a render error from missingkey=error.
_, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
BaseTemplateID: "docker",
RegistryURL: "https://registry.coder.com",
RegistryURL: "registry.coder.com",
Modules: []templatebuilder.ComposeModule{
{ID: "git-clone"},
},
@@ -463,7 +463,7 @@ func TestBundleTar(t *testing.T) {
t.Parallel()
result, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
BaseTemplateID: "docker",
RegistryURL: "https://registry.coder.com",
RegistryURL: "registry.coder.com",
Modules: []templatebuilder.ComposeModule{
{ID: "code-server"},
},
@@ -525,7 +525,7 @@ func TestBundleTar(t *testing.T) {
t.Parallel()
result, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
BaseTemplateID: "aws-linux",
RegistryURL: "https://registry.coder.com",
RegistryURL: "registry.coder.com",
})
require.NoError(t, err)
@@ -600,6 +600,59 @@ func TestQuickstartLanguageSelectorMatchesInstallScript(t *testing.T) {
"quickstart languages selector options must match the install script's has_language branches")
}
// TestComposeBaseHonorsRegistryMirror verifies a base-embedded module source is
// rendered against ComposeRequest.RegistryURL, and falls back to the public
// default when it is unset. The quickstart base embeds the git-clone module.
func TestComposeBaseHonorsRegistryMirror(t *testing.T) {
t.Parallel()
t.Run("Mirror", func(t *testing.T) {
t.Parallel()
result, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
BaseTemplateID: "quickstart",
RegistryURL: "mirror.internal.example",
})
require.NoError(t, err)
require.Contains(t, string(result.MainTF), "mirror.internal.example/coder/git-clone/coder")
require.NotContains(t, string(result.MainTF),
templatebuilder.DefaultRegistryBase+"/coder/git-clone/coder")
})
t.Run("DefaultsWhenUnset", func(t *testing.T) {
t.Parallel()
result, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
BaseTemplateID: "quickstart",
})
require.NoError(t, err)
require.Contains(t, string(result.MainTF),
templatebuilder.DefaultRegistryBase+"/coder/git-clone/coder")
})
t.Run("RejectsMalformed", func(t *testing.T) {
t.Parallel()
// The deployment value is validated at server start; this is the
// defense-in-depth reject for a direct caller that bypasses the boot check.
_, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
BaseTemplateID: "quickstart",
RegistryURL: "https://user:s3cr3t-token@mirror.example.com/coder",
})
require.ErrorContains(t, err, "bare host")
require.NotContains(t, err.Error(), "s3cr3t-token")
})
t.Run("StripsScheme", func(t *testing.T) {
t.Parallel()
// A value pasted with a scheme is normalized to a bare host in the source.
result, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
BaseTemplateID: "quickstart",
RegistryURL: "https://mirror.internal.example/",
})
require.NoError(t, err)
require.Contains(t, string(result.MainTF), "mirror.internal.example/coder/git-clone/coder")
require.NotContains(t, string(result.MainTF), "https://mirror.internal.example")
})
}
// 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()
+11 -1
View File
@@ -15,11 +15,21 @@ type ImageOption struct {
Value string
}
// DefaultRegistryBase is the module registry host used in rendered module source
// paths when a ComposeRequest does not carry the deployment's configured
// registry (CODER_TEMPLATE_BUILDER_REGISTRY_URL). It mirrors the default of the
// codersdk template-builder registry option.
const DefaultRegistryBase = "registry.coder.com"
// BaseRenderContext is the data passed to base template .tf.tmpl files.
type BaseRenderContext struct {
ContainerImage string
ImageOptions []ImageOption
Variables map[string]string
// RegistryBase is the module registry host used in rendered module source
// paths, mirroring ModuleRenderContext.RegistryBase so a base-embedded
// module honors CODER_TEMPLATE_BUILDER_REGISTRY_URL like a wizard module.
RegistryBase string
Variables map[string]string
}
// ModuleRenderContext is the data passed to module .tf.tmpl files.
+5 -5
View File
@@ -121,7 +121,7 @@ func TestRenderModuleTemplate(t *testing.T) {
},
}
ctx := templatebuilder.ModuleRenderContext{
RegistryBase: "https://registry.coder.com",
RegistryBase: "registry.coder.com",
PinnedVersion: "1.5.0",
AgentResourceName: "main",
Variables: map[string]string{"port": "8080"},
@@ -129,7 +129,7 @@ func TestRenderModuleTemplate(t *testing.T) {
out, err := templatebuilder.RenderModuleTemplate(fsys, "test.tf.tmpl", ctx)
require.NoError(t, err)
rendered := string(out)
require.Contains(t, rendered, `"https://registry.coder.com/coder/test/coder"`)
require.Contains(t, rendered, `"registry.coder.com/coder/test/coder"`)
require.Contains(t, rendered, `"1.5.0"`)
require.Contains(t, rendered, `coder_agent.main.id`)
require.Contains(t, rendered, `port = 8080`)
@@ -146,10 +146,10 @@ func TestRenderModuleTemplate(t *testing.T) {
},
}
out, err := templatebuilder.RenderModuleTemplate(fsys, "test.tf.tmpl", templatebuilder.ModuleRenderContext{
RegistryBase: "https://registry.coder.com",
RegistryBase: "registry.coder.com",
})
require.NoError(t, err)
require.Contains(t, string(out), "https://registry.coder.com")
require.Contains(t, string(out), "registry.coder.com")
})
t.Run("MissingKeyErrors", func(t *testing.T) {
@@ -202,7 +202,7 @@ func TestRenderModuleTemplate(t *testing.T) {
}
ctx := templatebuilder.ModuleRenderContext{
RegistryBase: "https://registry.coder.com",
RegistryBase: "registry.coder.com",
PinnedVersion: csMod.PinnedVersion,
AgentResourceName: "main",
Variables: vars,
-6
View File
@@ -151,12 +151,6 @@ 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)
source = "registry.coder.com/coder/git-clone/coder"
+37 -1
View File
@@ -6,6 +6,7 @@ import (
"flag"
"fmt"
"net/http"
"net/url"
"os"
"path/filepath"
"reflect"
@@ -5004,7 +5005,7 @@ Write out the current server config as YAML to stdout.`,
},
{
Name: "Template Builder Registry URL",
Description: "The base URL of the module registry used by the template builder for module source paths.",
Description: "The module registry host the template builder uses for module source paths (for example, \"registry.coder.com\" or \"mirror.internal:8443\"). An http(s):// scheme and trailing slash are stripped; a path, query, fragment, or credentials is rejected.",
Flag: "template-builder-registry-url",
Env: "CODER_TEMPLATE_BUILDER_REGISTRY_URL",
Value: &c.TemplateBuilder.RegistryURL,
@@ -5143,6 +5144,33 @@ type TemplateBuilderConfig struct {
RegistryURL serpent.String `json:"registry_url,omitempty"`
}
// NormalizeTemplateBuilderRegistryURL canonicalizes a configured template
// builder registry value into the bare host used verbatim in a Terraform module
// source. An empty value returns empty (the caller defaults it). An accidental
// http(s):// scheme and trailing slashes are stripped so a value pasted as a URL
// still resolves to a host; any other scheme, a path, query, fragment, or
// embedded credentials is rejected so a misconfiguration fails at server start
// rather than rendering broken or unsafe module sources. It does not otherwise
// canonicalize the host.
func NormalizeTemplateBuilderRegistryURL(raw string) (string, error) {
v := strings.TrimSpace(raw)
if v == "" {
return "", nil
}
v = strings.TrimPrefix(v, "https://")
v = strings.TrimPrefix(v, "http://")
v = strings.TrimRight(v, "/")
// net/url isolates a bare host:port only in authority form. Requiring the
// parsed host to equal the whole remainder rejects a leftover path, query,
// fragment, non-http scheme, or embedded credentials, and never echoes the
// input.
u, err := url.Parse("//" + v)
if err != nil || u.Host != v || u.Hostname() == "" || u.User != nil {
return "", xerrors.New(`template builder registry URL must be a bare host such as "registry.coder.com", optionally with a port`)
}
return v, nil
}
type SupportConfig struct {
Links serpent.Struct[[]LinkConfig] `json:"links" typescript:",notnull"`
}
@@ -5215,6 +5243,14 @@ func (c *DeploymentValues) Validate() error {
}
}
// Gated on the builder being enabled and run here rather than as a per-option
// serpent validator, which only fires in Set and so misses the YAML path.
if !c.TemplateBuilder.Disabled.Value() {
if _, err := NormalizeTemplateBuilderRegistryURL(c.TemplateBuilder.RegistryURL.Value()); err != nil {
return err
}
}
return nil
}
+128
View File
@@ -0,0 +1,128 @@
package codersdk_test
import (
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/serpent"
)
func TestNormalizeTemplateBuilderRegistryURL(t *testing.T) {
t.Parallel()
t.Run("Accepts", func(t *testing.T) {
t.Parallel()
cases := []struct {
name string
in string
want string
}{
{"Empty", "", ""},
{"WhitespaceOnly", " ", ""},
{"BareHost", "registry.coder.com", "registry.coder.com"},
{"SurroundingSpaceTrimmed", " mirror.internal ", "mirror.internal"},
{"HostPort", "mirror.example.com:8443", "mirror.example.com:8443"},
{"IPv6HostPort", "[::1]:8443", "[::1]:8443"},
// An accidental scheme and trailing slash are stripped rather than
// rejected, so a value pasted as a URL still resolves to a host.
{"HTTPSStripped", "https://mirror.example.com", "mirror.example.com"},
{"HTTPStripped", "http://mirror.example.com", "mirror.example.com"},
{"TrailingSlashStripped", "mirror.example.com/", "mirror.example.com"},
{"SchemePortAndSlash", "https://mirror.example.com:8443/", "mirror.example.com:8443"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, err := codersdk.NormalizeTemplateBuilderRegistryURL(tc.in)
require.NoError(t, err)
require.Equal(t, tc.want, got)
})
}
})
t.Run("Rejects", func(t *testing.T) {
t.Parallel()
cases := []struct {
name string
in string
}{
{"Path", "mirror.example.com/coder"},
{"NonHTTPScheme", "git://mirror.example.com"},
{"UppercaseScheme", "HTTPS://mirror.example.com"},
{"SchemeAndPath", "https://mirror.example.com/coder"},
{"Query", "mirror.example.com?a=b"},
{"Fragment", "mirror.example.com#frag"},
{"DoubledScheme", "https://https://mirror.example.com"},
{"InteriorSpace", "mirror .example.com"},
{"PortOnly", ":8443"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
_, err := codersdk.NormalizeTemplateBuilderRegistryURL(tc.in)
require.ErrorContains(t, err, "bare host")
})
}
})
t.Run("RejectsCredentialsWithoutEcho", func(t *testing.T) {
t.Parallel()
// Userinfo survives the scheme strip and must be rejected, not accepted,
// and the rejection must not echo the credential.
_, err := codersdk.NormalizeTemplateBuilderRegistryURL("https://user:s3cr3t-token@mirror.example.com")
require.ErrorContains(t, err, "bare host")
require.NotContains(t, err.Error(), "s3cr3t-token")
})
}
// TestDeploymentValues_Validate_TemplateBuilderRegistryURL covers the config
// boundary: a malformed CODER_TEMPLATE_BUILDER_REGISTRY_URL must fail at server
// start naming the option, but must not block boot when the builder is disabled.
func TestDeploymentValues_Validate_TemplateBuilderRegistryURL(t *testing.T) {
t.Parallel()
// mkValid returns a DeploymentValues that passes Validate() except for the
// template builder registry URL, so that check is what each case exercises.
mkValid := func() *codersdk.DeploymentValues {
dv := &codersdk.DeploymentValues{}
dv.Sessions.DefaultDuration = serpent.Duration(time.Hour)
dv.Sessions.RefreshDefaultDuration = serpent.Duration(48 * time.Hour)
return dv
}
cases := []struct {
name string
url string
disabled bool
wantErr string
}{
{name: "EmptyOK"},
{name: "BareHostOK", url: "mirror.internal.example"},
{name: "HostPortOK", url: "mirror.internal.example:8443"},
// A scheme is stripped, not rejected, so an existing deployment that set
// a scheme'd value keeps booting after upgrade.
{name: "SchemeStrippedOK", url: "https://mirror.internal.example"},
{name: "PathRejected", url: "mirror.internal.example/coder", wantErr: "bare host"},
{name: "CredentialsRejected", url: "https://user:tok@mirror.example.com", wantErr: "bare host"},
// A disabled template builder must not block boot on an inert value, so
// tightening the check in an upgrade cannot take down a disabled deployment.
{name: "DisabledSkipsValidation", url: "https://user:tok@mirror.example.com/bad", disabled: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
dv := mkValid()
dv.TemplateBuilder.Disabled = serpent.Bool(tc.disabled)
dv.TemplateBuilder.RegistryURL = serpent.String(tc.url)
err := dv.Validate()
if tc.wantErr == "" {
require.NoError(t, err)
return
}
require.ErrorContains(t, err, tc.wantErr)
})
}
}
+1 -1
View File
@@ -1807,7 +1807,7 @@ Disable the template builder feature for guided template creation. When disabled
### Registry URL
The base URL of the module registry used by the template builder for module source paths.
The module registry host the template builder uses for module source paths (for example, "registry.coder.com" or "mirror.internal:8443"). An http(s):// scheme and trailing slash are stripped; a path, query, fragment, or credentials is rejected.
- Environment variable: `CODER_TEMPLATE_BUILDER_REGISTRY_URL`
- CLI flag: [`--template-builder-registry-url`](../../reference/cli/server.md#--template-builder-registry-url)
@@ -80,6 +80,8 @@ button links to the starter templates page instead, and the
Deployments using a self-hosted module registry mirror can set
`CODER_TEMPLATE_BUILDER_REGISTRY_URL` to point generated module source paths at
the mirror instead of `registry.coder.com`.
The value is a bare host, optionally with a port (for example `mirror.internal:8443`).
A leading `http(s)://` scheme and trailing slash are stripped, and a path, query, fragment, or credentials is rejected at server start.
#### Alternative creation methods
+3
View File
@@ -261,6 +261,9 @@ CODER_TEMPLATE_BUILDER_REGISTRY_URL=registry.internal.example.com
This makes the builder generate module source paths pointing at your mirror
rather than `registry.coder.com`.
The value is a bare host, optionally with a port (for example, `mirror.internal:8443`).
A leading `http(s)://` scheme and trailing slash are stripped, and a path, query, fragment, or credentials is rejected at server start.
## Coder Modules
To use Coder modules in offline installations, you can either:
+1 -1
View File
@@ -2164,4 +2164,4 @@ Disable the template builder feature for guided template creation. When disabled
| YAML | <code>templateBuilder.registryURL</code> |
| Default | <code>registry.coder.com</code> |
The base URL of the module registry used by the template builder for module source paths.
The module registry host the template builder uses for module source paths (for example, "registry.coder.com" or "mirror.internal:8443"). An http(s):// scheme and trailing slash are stripped; a path, query, fragment, or credentials is rejected.
+4 -2
View File
@@ -928,8 +928,10 @@ TEMPLATE BUILDER OPTIONS:
When disabled, all /api/v2/templatebuilder/* endpoints return 404.
--template-builder-registry-url string, $CODER_TEMPLATE_BUILDER_REGISTRY_URL (default: registry.coder.com)
The base URL of the module registry used by the template builder for
module source paths.
The module registry host the template builder uses for module source
paths (for example, "registry.coder.com" or "mirror.internal:8443").
An http(s):// scheme and trailing slash are stripped; a path, query,
fragment, or credentials is rejected.
USER QUIET HOURS SCHEDULE OPTIONS:
Allow users to set quiet hours schedules each day for workspaces to avoid