feat: template builder backend fixes (DEVEX-287) (#26432)

Part of the Template Builder wizard PR stack.

## Backend fixes

1. **Registry URL scheme fix**: Default
`CODER_TEMPLATE_BUILDER_REGISTRY_URL` was `https://registry.coder.com`
but Terraform module registry addresses must be scheme-less. Changed to
`registry.coder.com`.

2. **Sensitive variable defaults**: Module `.tf.tmpl` files for
claude-code, aider, amazon-q had sensitive `variable` blocks without
`default`, causing `terraform plan` to fail during template import. Also
fixed the `templatebuildermodulegen` script.

3. **Auto-quote string variables**: The backend now accepts raw string
values from callers and wraps them in HCL quotes automatically.
Previously callers were required to send pre-quoted HCL literals, which
is not a reasonable API contract.

---
> [!NOTE]
> Generated by Coder Agents on behalf of @jeremyruppel
This commit is contained in:
Jeremy Ruppel
2026-06-23 09:17:14 -04:00
committed by GitHub
parent eeb2624549
commit a30631198d
12 changed files with 73 additions and 68 deletions
+1 -1
View File
@@ -911,7 +911,7 @@ TEMPLATE BUILDER OPTIONS:
Disable the template builder feature for guided template creation.
When disabled, all /api/v2/templatebuilder/* endpoints return 404.
--template-builder-registry-url string, $CODER_TEMPLATE_BUILDER_REGISTRY_URL (default: https://registry.coder.com)
--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.
+2 -2
View File
@@ -1145,5 +1145,5 @@ templateBuilder:
disabled: false
# The base URL of the module registry used by the template builder for module
# source paths.
# (default: https://registry.coder.com, type: string)
registryURL: https://registry.coder.com
# (default: registry.coder.com, type: string)
registryURL: registry.coder.com
+46 -34
View File
@@ -244,15 +244,17 @@ func mergeModuleVariables(manifest ModuleManifest, callerVars map[string]string)
// missingkey=error surfaces the omission at render time.
}
// Overlay validated caller values.
// Overlay validated caller values, converting to HCL literals.
for k, val := range callerVars {
merged[k] = val
merged[k] = toHCLLiteral(allowedVars[k], val)
}
return merged, nil
}
// validateVariableValue checks that value is a valid HCL literal for the
// variable's declared type. The literal "null" is accepted for any type.
// validateVariableValue checks that the caller-supplied value is valid for
// the variable's declared type. String values are plain text (not
// pre-quoted); quoting for HCL happens later in toHCLLiteral.
// The literal "null" is accepted for any type.
func validateVariableValue(v ModuleVariable, value string) error {
if value == "null" {
return nil
@@ -269,45 +271,55 @@ func validateVariableValue(v ModuleVariable, value string) error {
}
}
// validateStringValue checks that value is a valid quoted HCL string literal.
// It must start and end with '"', contain no unescaped newlines or quotes,
// and must not contain HCL interpolation/directive markers.
// toHCLLiteral converts a validated caller value into an HCL literal.
// The literal "null" is passed through for any type. Strings are wrapped
// in quotes with interior characters escaped; bools and numbers are
// already valid HCL literals.
func toHCLLiteral(v ModuleVariable, value string) string {
if value == "null" {
return value
}
if v.Type == "string" {
return hclQuote(value)
}
return value
}
// validateStringValue checks that a raw (unquoted) string value is safe
// to embed in an HCL quoted string. It rejects HCL interpolation/directive
// markers and values that exceed the maximum length.
func validateStringValue(value string) error {
if len(value) > maxStringValueLen {
return xerrors.Errorf("value exceeds maximum length of %d bytes", maxStringValueLen)
}
if len(value) < 2 || value[0] != '"' || value[len(value)-1] != '"' {
return xerrors.New("must be a quoted string (e.g. \"value\")")
}
inner := value[1 : len(value)-1]
if strings.Contains(inner, "${") || strings.Contains(inner, "%{") {
if strings.Contains(value, "${") || strings.Contains(value, "%{") {
return xerrors.New("must not contain HCL interpolation or directive sequences")
}
return nil
}
// Walk the inner content to reject unescaped newlines and quotes.
for i := 0; i < len(inner); i++ {
ch := inner[i]
if ch == '\\' {
i++
if i >= len(inner) {
// Trailing backslash with no character to escape.
// In HCL this would escape the closing quote delimiter,
// producing an unterminated string.
return xerrors.New("must not end with a trailing backslash")
}
continue
}
if ch == '"' {
return xerrors.New("must not contain unescaped quotes")
}
if ch == '\n' || ch == '\r' {
return xerrors.New("must not contain unescaped newlines")
// hclQuote wraps a raw string in HCL double-quotes, escaping backslashes,
// double-quotes, and newlines so the result is a valid HCL string literal.
func hclQuote(s string) string {
var b strings.Builder
b.Grow(len(s) + 2)
_, _ = b.WriteRune('"')
for i := 0; i < len(s); i++ {
switch s[i] {
case '\\':
_, _ = b.WriteString("\\\\")
case '"':
_, _ = b.WriteString("\\\"")
case '\n':
_, _ = b.WriteString("\\n")
case '\r':
_, _ = b.WriteString("\\r")
default:
_ = b.WriteByte(s[i])
}
}
return nil
_, _ = b.WriteRune('"')
return b.String()
}
// validateNumberValue checks that value is a valid HCL number literal.
+14 -25
View File
@@ -97,7 +97,7 @@ func TestMergeModuleVariables(t *testing.T) {
t.Run("CallerProvidesRequired", func(t *testing.T) {
t.Parallel()
merged, err := mergeModuleVariables(manifest, map[string]string{
"required_no_default": `"value"`,
"required_no_default": "value",
})
require.NoError(t, err)
require.Equal(t, `"value"`, merged["required_no_default"])
@@ -153,11 +153,11 @@ func TestMergeModuleVariables(t *testing.T) {
t.Run("InvalidStringValueRejected", func(t *testing.T) {
t.Parallel()
_, err := mergeModuleVariables(manifest, map[string]string{
"optional_no_default": "unquoted",
"optional_no_default": "${var.foo}",
})
require.Error(t, err)
require.Contains(t, err.Error(), `variable "optional_no_default"`)
require.Contains(t, err.Error(), "quoted string")
require.Contains(t, err.Error(), "interpolation")
})
t.Run("NullAcceptedForAnyType", func(t *testing.T) {
@@ -189,28 +189,17 @@ func TestValidateStringValue(t *testing.T) {
value string
wantErr string
}{
{"ValidEmpty", `""`, ""},
{"ValidSimple", `"hello"`, ""},
{"ValidPath", `"/home/coder"`, ""},
{"ValidURL", `"https://github.com/coder/coder"`, ""},
{"ValidLiteralBackslashN", `"line\\nbreak"`, ""},
{"ValidEscapedQuote", `"say \"hi\""`, ""},
{"ValidEscapedBackslash", `"path\\to\\file"`, ""},
{"ValidEmpty", "", ""},
{"ValidSimple", "hello", ""},
{"ValidPath", "/home/coder", ""},
{"ValidURL", "https://github.com/coder/coder", ""},
{"ValidWithQuotes", `say "hi"`, ""},
{"ValidWithNewlines", "line\nbreak", ""},
{"ValidWithBackslash", `path\to\file`, ""},
{"RejectedUnquoted", "hello", "quoted string"},
{"RejectedMissingOpenQuote", `hello"`, "quoted string"},
{"RejectedMissingCloseQuote", `"hello`, "quoted string"},
{"RejectedEmpty", "", "quoted string"},
{"RejectedSingleChar", `"`, "quoted string"},
{"RejectedUnescapedNewline", "\"line\nbreak\"", "unescaped newlines"},
{"RejectedCarriageReturn", "\"line\rbreak\"", "unescaped newlines"},
{"RejectedUnescapedQuote", `"say "hi""`, "unescaped quotes"},
{"RejectedHCLInterpolation", `"${var.foo}"`, "interpolation"},
{"RejectedHCLDirective", `"%{if true}yes%{endif}"`, "interpolation"},
{"RejectedOverlong", `"` + strings.Repeat("a", maxStringValueLen) + `"`, "maximum length"},
{"RejectedTrailingBackslash", `"test\"`, "trailing backslash"},
{"RejectedTrailingBackslashOnly", `"\"`, "trailing backslash"},
{"ValidEvenTrailingBackslashes", `"test\\\\"`, ""},
{"RejectedHCLInterpolation", "${var.foo}", "interpolation"},
{"RejectedHCLDirective", "%{if true}yes%{endif}", "interpolation"},
{"RejectedOverlong", strings.Repeat("a", maxStringValueLen+1), "maximum length"},
}
for _, tc := range tests {
@@ -313,7 +302,7 @@ func TestValidateVariableValue(t *testing.T) {
t.Run("UnsupportedTypeRejected", func(t *testing.T) {
t.Parallel()
v := ModuleVariable{Name: "test", Type: "list"}
err := validateVariableValue(v, `"val"`)
err := validateVariableValue(v, "val")
require.Error(t, err)
require.Contains(t, err.Error(), "unsupported variable type")
})
+3 -3
View File
@@ -93,7 +93,7 @@ func TestCompose(t *testing.T) {
{
ID: "git-clone",
Variables: map[string]string{
"url": `"https://github.com/coder/coder"`,
"url": "https://github.com/coder/coder",
},
},
},
@@ -178,7 +178,7 @@ func TestCompose(t *testing.T) {
{
ID: "code-server",
Variables: map[string]string{
"nonexistent_var": `"value"`,
"nonexistent_var": "value",
},
},
},
@@ -216,7 +216,7 @@ func TestCompose(t *testing.T) {
{
ID: "code-server",
Variables: map[string]string{
"folder": `"${var.evil}"`,
"folder": "${var.evil}",
},
},
},
@@ -2,6 +2,7 @@
variable "api_key" {
description = "API key for the selected AI provider. This will be set as the appropriate environment variable based on the provider."
type = string
default = ""
sensitive = true
}
module "aider" {
@@ -2,6 +2,7 @@
variable "auth_tarball" {
description = "Base64 encoded, zstd compressed tarball of a pre-authenticated ~/.local/share/amazon-q directory."
type = string
default = ""
sensitive = true
}
module "amazon-q" {
@@ -2,6 +2,7 @@
variable "claude_code_oauth_token" {
description = "OAuth token passed to Claude Code via the CLAUDE_CODE_OAUTH_TOKEN env var. Generate one with `claude setup-token`."
type = string
default = ""
sensitive = true
}
module "claude-code" {
+1 -1
View File
@@ -4776,7 +4776,7 @@ Write out the current server config as YAML to stdout.`,
Flag: "template-builder-registry-url",
Env: "CODER_TEMPLATE_BUILDER_REGISTRY_URL",
Value: &c.TemplateBuilder.RegistryURL,
Default: "https://registry.coder.com",
Default: "registry.coder.com",
Group: &deploymentGroupTemplateBuilder,
YAML: "registryURL",
},
+1 -1
View File
@@ -2129,6 +2129,6 @@ Disable the template builder feature for guided template creation. When disabled
| Type | <code>string</code> |
| Environment | <code>$CODER_TEMPLATE_BUILDER_REGISTRY_URL</code> |
| YAML | <code>templateBuilder.registryURL</code> |
| Default | <code>https://registry.coder.com</code> |
| Default | <code>registry.coder.com</code> |
The base URL of the module registry used by the template builder for module source paths.
+1 -1
View File
@@ -912,7 +912,7 @@ TEMPLATE BUILDER OPTIONS:
Disable the template builder feature for guided template creation.
When disabled, all /api/v2/templatebuilder/* endpoints return 404.
--template-builder-registry-url string, $CODER_TEMPLATE_BUILDER_REGISTRY_URL (default: https://registry.coder.com)
--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.
@@ -27,6 +27,7 @@ variable "{{ .Name }}" {
description = "{{ .Description }}"
type = {{ .Type }}
sensitive = true
default = ""
}
{{ end -}}
module "{{ .ID }}" {