mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
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:
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
|
||||
@@ -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" {
|
||||
|
||||
Reference in New Issue
Block a user