mirror of
https://github.com/coder/coder.git
synced 2026-11-03 14:21:04 +08:00
Validates caller-supplied module variable keys and values in the template builder compose endpoint before template rendering. Previously, `mergeModuleVariables` accepted any caller-supplied key and value without validation, allowing unknown keys, computed/sensitive variable overrides, and malformed HCL literals (including injection payloads) to pass through to rendered output. Now `mergeModuleVariables` rejects unknown keys (those not in the manifest's non-computed, non-sensitive variables) and type-checks values: strings must be quoted HCL literals without interpolation markers or unescaped newlines, numbers must be strict numeric literals, and bools must be exactly `true` or `false`. The literal `null` is accepted for any type. Closes https://linear.app/codercom/issue/DEVEX-278 <details> <summary>Implementation details</summary> - Changed `mergeModuleVariables` signature from `map[string]string` to `(map[string]string, error)` to surface validation failures - Added `validateVariableValue`, `validateStringValue`, `validateNumberValue`, `validateBoolValue` in `compose.go` - String validation rejects: unquoted values, HCL interpolation (`${`, `%{`), unescaped newlines/quotes, trailing backslashes (which would escape the closing delimiter), and values exceeding 4096 bytes - Errors wrap the module ID and variable name for clear diagnostics (e.g. `module "code-server": variable "port": invalid number value`) - Tests cover key validation, type validation, injection attempts, and full Compose flow integration > Generated with the help of [Coder Agents](https://coder.com) by @jeremyruppel </details>
334 lines
9.4 KiB
Go
334 lines
9.4 KiB
Go
package templatebuilder_test
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"errors"
|
|
"io"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/coder/coder/v2/coderd/templatebuilder"
|
|
)
|
|
|
|
func TestCompose(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("BaseOnly", func(t *testing.T) {
|
|
t.Parallel()
|
|
result, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
|
|
BaseTemplateID: "docker",
|
|
RegistryURL: "https://registry.coder.com",
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, result.MainTF)
|
|
require.Contains(t, string(result.MainTF), `resource "coder_agent" "main"`)
|
|
require.Empty(t, result.ModulesTF)
|
|
})
|
|
|
|
t.Run("BaseWithModuleAndVariableOverride", func(t *testing.T) {
|
|
t.Parallel()
|
|
result, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
|
|
BaseTemplateID: "docker",
|
|
RegistryURL: "https://registry.coder.com",
|
|
Modules: []templatebuilder.ComposeModule{
|
|
{
|
|
ID: "code-server",
|
|
Variables: map[string]string{
|
|
"port": "9999",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, result.MainTF)
|
|
require.NotEmpty(t, result.ModulesTF)
|
|
|
|
modules := string(result.ModulesTF)
|
|
require.Contains(t, modules, `module "code-server"`)
|
|
require.Contains(t, modules, `coder_agent.main.id`)
|
|
require.Contains(t, modules, `registry.coder.com`)
|
|
require.Contains(t, modules, `port = 9999`)
|
|
})
|
|
|
|
t.Run("AWSLinuxAgentName", func(t *testing.T) {
|
|
t.Parallel()
|
|
result, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
|
|
BaseTemplateID: "aws-linux",
|
|
RegistryURL: "https://registry.coder.com",
|
|
Modules: []templatebuilder.ComposeModule{
|
|
{ID: "git-commit-signing"},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
require.Contains(t, string(result.ModulesTF), `coder_agent.dev.id`)
|
|
})
|
|
|
|
t.Run("SensitiveVariable", func(t *testing.T) {
|
|
t.Parallel()
|
|
result, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
|
|
BaseTemplateID: "docker",
|
|
RegistryURL: "https://registry.coder.com",
|
|
Modules: []templatebuilder.ComposeModule{
|
|
{ID: "claude-code"},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
modules := string(result.ModulesTF)
|
|
// claude-code has a sensitive variable (claude_code_oauth_token)
|
|
// that renders as a top-level variable block + var. reference.
|
|
require.Contains(t, modules, `variable "claude_code_oauth_token"`)
|
|
require.Contains(t, modules, `sensitive = true`)
|
|
require.Contains(t, modules, `var.claude_code_oauth_token`)
|
|
})
|
|
|
|
t.Run("MultipleModulesWithRequiredVariable", func(t *testing.T) {
|
|
t.Parallel()
|
|
result, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
|
|
BaseTemplateID: "docker",
|
|
RegistryURL: "https://registry.coder.com",
|
|
Modules: []templatebuilder.ComposeModule{
|
|
{ID: "code-server"},
|
|
{
|
|
ID: "git-clone",
|
|
Variables: map[string]string{
|
|
"url": `"https://github.com/coder/coder"`,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
modules := string(result.ModulesTF)
|
|
require.Contains(t, modules, `module "code-server"`)
|
|
require.Contains(t, modules, `module "git-clone"`)
|
|
require.Contains(t, modules, `"https://github.com/coder/coder"`)
|
|
})
|
|
|
|
t.Run("CustomRegistryURL", func(t *testing.T) {
|
|
t.Parallel()
|
|
result, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
|
|
BaseTemplateID: "docker",
|
|
RegistryURL: "https://registry.internal.corp",
|
|
Modules: []templatebuilder.ComposeModule{
|
|
{ID: "code-server"},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
require.Contains(t, string(result.ModulesTF), `registry.internal.corp`)
|
|
})
|
|
|
|
t.Run("DuplicateModuleError", func(t *testing.T) {
|
|
t.Parallel()
|
|
_, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
|
|
BaseTemplateID: "docker",
|
|
RegistryURL: "https://registry.coder.com",
|
|
Modules: []templatebuilder.ComposeModule{
|
|
{ID: "code-server"},
|
|
{ID: "code-server"},
|
|
},
|
|
})
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), `duplicate module "code-server"`)
|
|
})
|
|
|
|
t.Run("ConflictingModuleError", func(t *testing.T) {
|
|
t.Parallel()
|
|
_, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
|
|
BaseTemplateID: "docker",
|
|
RegistryURL: "https://registry.coder.com",
|
|
Modules: []templatebuilder.ComposeModule{
|
|
{ID: "code-server"},
|
|
{ID: "vscode-web"},
|
|
},
|
|
})
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "conflicts with")
|
|
})
|
|
|
|
t.Run("UnknownBase", func(t *testing.T) {
|
|
t.Parallel()
|
|
_, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
|
|
BaseTemplateID: "nonexistent",
|
|
RegistryURL: "https://registry.coder.com",
|
|
})
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "unknown base template")
|
|
})
|
|
|
|
t.Run("UnknownModule", func(t *testing.T) {
|
|
t.Parallel()
|
|
_, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
|
|
BaseTemplateID: "docker",
|
|
RegistryURL: "https://registry.coder.com",
|
|
Modules: []templatebuilder.ComposeModule{
|
|
{ID: "nonexistent-module"},
|
|
},
|
|
})
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), `unknown module "nonexistent-module"`)
|
|
})
|
|
|
|
t.Run("UnknownVariableKeyRejected", func(t *testing.T) {
|
|
t.Parallel()
|
|
_, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
|
|
BaseTemplateID: "docker",
|
|
RegistryURL: "https://registry.coder.com",
|
|
Modules: []templatebuilder.ComposeModule{
|
|
{
|
|
ID: "code-server",
|
|
Variables: map[string]string{
|
|
"nonexistent_var": `"value"`,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), `module "code-server"`)
|
|
require.Contains(t, err.Error(), `unknown variable "nonexistent_var"`)
|
|
})
|
|
|
|
t.Run("InvalidVariableValueRejected", func(t *testing.T) {
|
|
t.Parallel()
|
|
_, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
|
|
BaseTemplateID: "docker",
|
|
RegistryURL: "https://registry.coder.com",
|
|
Modules: []templatebuilder.ComposeModule{
|
|
{
|
|
ID: "code-server",
|
|
Variables: map[string]string{
|
|
"port": "not-a-number",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), `module "code-server"`)
|
|
require.Contains(t, err.Error(), `variable "port"`)
|
|
})
|
|
|
|
t.Run("HCLInjectionRejected", func(t *testing.T) {
|
|
t.Parallel()
|
|
_, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
|
|
BaseTemplateID: "docker",
|
|
RegistryURL: "https://registry.coder.com",
|
|
Modules: []templatebuilder.ComposeModule{
|
|
{
|
|
ID: "code-server",
|
|
Variables: map[string]string{
|
|
"folder": `"${var.evil}"`,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "interpolation")
|
|
})
|
|
|
|
t.Run("MissingRequiredVariable", func(t *testing.T) {
|
|
t.Parallel()
|
|
// git-clone has a required "url" variable with no default.
|
|
// Omitting it should cause a render error from missingkey=error.
|
|
_, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
|
|
BaseTemplateID: "docker",
|
|
RegistryURL: "https://registry.coder.com",
|
|
Modules: []templatebuilder.ComposeModule{
|
|
{ID: "git-clone"},
|
|
},
|
|
})
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "render module")
|
|
})
|
|
}
|
|
|
|
func TestBundleTar(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("NilResult", func(t *testing.T) {
|
|
t.Parallel()
|
|
_, err := templatebuilder.BundleTar(nil)
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "nil")
|
|
})
|
|
|
|
t.Run("MainOnly", func(t *testing.T) {
|
|
t.Parallel()
|
|
result := &templatebuilder.ComposeResult{
|
|
MainTF: []byte("resource {}"),
|
|
}
|
|
data, err := templatebuilder.BundleTar(result)
|
|
require.NoError(t, err)
|
|
|
|
files := extractTar(t, data)
|
|
require.Contains(t, files, "main.tf")
|
|
require.NotContains(t, files, "modules.tf")
|
|
require.Equal(t, "resource {}", files["main.tf"])
|
|
})
|
|
|
|
t.Run("MainAndModules", func(t *testing.T) {
|
|
t.Parallel()
|
|
result := &templatebuilder.ComposeResult{
|
|
MainTF: []byte("resource {}"),
|
|
ModulesTF: []byte("module {}"),
|
|
}
|
|
data, err := templatebuilder.BundleTar(result)
|
|
require.NoError(t, err)
|
|
|
|
files := extractTar(t, data)
|
|
require.Contains(t, files, "main.tf")
|
|
require.Contains(t, files, "modules.tf")
|
|
require.Equal(t, "resource {}", files["main.tf"])
|
|
require.Equal(t, "module {}", files["modules.tf"])
|
|
})
|
|
|
|
t.Run("RoundTrip", func(t *testing.T) {
|
|
t.Parallel()
|
|
result, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
|
|
BaseTemplateID: "docker",
|
|
RegistryURL: "https://registry.coder.com",
|
|
Modules: []templatebuilder.ComposeModule{
|
|
{ID: "code-server"},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
data, err := templatebuilder.BundleTar(result)
|
|
require.NoError(t, err)
|
|
|
|
files := extractTar(t, data)
|
|
require.Equal(t, string(result.MainTF), files["main.tf"])
|
|
require.Equal(t, string(result.ModulesTF), files["modules.tf"])
|
|
})
|
|
|
|
t.Run("ReproducibleArchive", func(t *testing.T) {
|
|
t.Parallel()
|
|
result := &templatebuilder.ComposeResult{
|
|
MainTF: []byte("resource {}"),
|
|
ModulesTF: []byte("module {}"),
|
|
}
|
|
data1, err := templatebuilder.BundleTar(result)
|
|
require.NoError(t, err)
|
|
data2, err := templatebuilder.BundleTar(result)
|
|
require.NoError(t, err)
|
|
require.Equal(t, data1, data2, "identical inputs should produce identical archives")
|
|
})
|
|
}
|
|
|
|
// 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()
|
|
tr := tar.NewReader(bytes.NewReader(data))
|
|
files := make(map[string]string)
|
|
for {
|
|
hdr, err := tr.Next()
|
|
if errors.Is(err, io.EOF) {
|
|
break
|
|
}
|
|
require.NoError(t, err)
|
|
body, err := io.ReadAll(tr)
|
|
require.NoError(t, err)
|
|
files[hdr.Name] = string(body)
|
|
}
|
|
return files
|
|
}
|