mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
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:
+4
-2
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Generated
+1
-1
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user