mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(coderd/templatebuilder): validation extraction, static file bundling, Windows OS (#26633)
Extract validation helpers to a dedicated file, add static file bundling for base templates, and add Windows OS support. **Commit 1: Extract validation to `validate.go`** Move `validateVariableValue`, `validateStringValue`, `validateNumberValue`, `validateBoolValue`, `toHCLLiteral`, `hclQuote`, and `isSimpleJSONValue` from `compose.go` into `validate.go`. Corresponding tests move to `validate_internal_test.go`. This keeps `compose.go` focused on the compose pipeline. **Commit 2: Static file bundling** Add `StaticFiles` field to `ComposeResult` and a `collectStaticFiles` helper that walks the base template FS to collect non-template files (e.g. cloud-init `.tftpl` inputs). `BundleTar` now writes these files into the output archive in sorted order for deterministic output. This fixes `aws-linux`, whose cloud-init files were embedded but never included in the tar. **Commit 3: Windows OS support** Add `BaseOSWindows` constant and register `"windows"` in `validBaseOS` so that base templates with `os="windows"` can be loaded and used for module compatibility filtering. > [!NOTE] > This PR was authored by Coder Agents on behalf of @jeremyruppel.
This commit is contained in:
+118
-47
@@ -17,12 +17,14 @@ import (
|
||||
type BaseOS string
|
||||
|
||||
const (
|
||||
BaseOSLinux BaseOS = "linux"
|
||||
BaseOSLinux BaseOS = "linux"
|
||||
BaseOSWindows BaseOS = "windows"
|
||||
)
|
||||
|
||||
// validBaseOS maps base.json os strings to their typed equivalents.
|
||||
var validBaseOS = map[string]BaseOS{
|
||||
"linux": BaseOSLinux,
|
||||
"linux": BaseOSLinux,
|
||||
"windows": BaseOSWindows,
|
||||
}
|
||||
|
||||
//go:embed bases
|
||||
@@ -55,8 +57,9 @@ type parsedBase struct {
|
||||
Manifest BaseManifest
|
||||
Templates map[string]*template.Template
|
||||
FS fs.FS
|
||||
Readme string // full README.md content (including frontmatter)
|
||||
Prerequisites string // content between prerequisite comment markers
|
||||
Readme string // full README.md content (including frontmatter)
|
||||
Prerequisites string // content between prerequisite comment markers
|
||||
ExtraFiles map[string][]byte // non-template, non-manifest files (e.g. .tftpl)
|
||||
}
|
||||
|
||||
var loadBases = sync.OnceValues(func() (map[string]*parsedBase, error) {
|
||||
@@ -83,57 +86,85 @@ func parseBasesFromFS(fsys fs.FS) (map[string]*parsedBase, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
manifestPath := path.Join(dir.Name(), "base.json")
|
||||
data, err := fs.ReadFile(sub, manifestPath)
|
||||
base, err := parseBaseDir(sub, dir.Name())
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("read %s: %w", manifestPath, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var manifest BaseManifest
|
||||
dec := json.NewDecoder(bytes.NewReader(data))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&manifest); err != nil {
|
||||
return nil, xerrors.Errorf("decode %s: %w", manifestPath, err)
|
||||
}
|
||||
|
||||
if manifest.ID == "" {
|
||||
return nil, xerrors.Errorf("base in %s has empty id", dir.Name())
|
||||
}
|
||||
if _, ok := validBaseOS[manifest.OS]; !ok && manifest.OS != "" {
|
||||
return nil, xerrors.Errorf("base %q has unknown os %q", manifest.ID, manifest.OS)
|
||||
}
|
||||
if bases[manifest.ID] != nil {
|
||||
return nil, xerrors.Errorf("duplicate base id %q", manifest.ID)
|
||||
}
|
||||
|
||||
baseFS, err := fs.Sub(sub, dir.Name())
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("sub fs for %s: %w", dir.Name(), err)
|
||||
}
|
||||
|
||||
templates, err := parseTemplatesFromFS(baseFS)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("parse templates for base %q: %w", manifest.ID, err)
|
||||
}
|
||||
|
||||
readmeData, err := fs.ReadFile(baseFS, "README.md")
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("read README.md for base %q: %w", manifest.ID, err)
|
||||
}
|
||||
readme := string(readmeData)
|
||||
|
||||
bases[manifest.ID] = &parsedBase{
|
||||
Manifest: manifest,
|
||||
Templates: templates,
|
||||
FS: baseFS,
|
||||
Readme: readme,
|
||||
Prerequisites: ExtractPrerequisites(readme),
|
||||
if bases[base.Manifest.ID] != nil {
|
||||
return nil, xerrors.Errorf("duplicate base id %q", base.Manifest.ID)
|
||||
}
|
||||
bases[base.Manifest.ID] = base
|
||||
}
|
||||
|
||||
return bases, nil
|
||||
}
|
||||
|
||||
// parseBaseDir loads a single base template directory: reads the
|
||||
// manifest, pre-parses Go templates, reads the README, and collects
|
||||
// extra files.
|
||||
func parseBaseDir(parent fs.FS, dirName string) (*parsedBase, error) {
|
||||
manifest, err := parseManifest(parent, dirName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
baseFS, err := fs.Sub(parent, dirName)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("sub fs for %s: %w", dirName, err)
|
||||
}
|
||||
|
||||
templates, err := parseTemplatesFromFS(baseFS)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("parse templates for base %q: %w", manifest.ID, err)
|
||||
}
|
||||
|
||||
readmeData, err := fs.ReadFile(baseFS, "README.md")
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("read README.md for base %q: %w", manifest.ID, err)
|
||||
}
|
||||
readme := string(readmeData)
|
||||
|
||||
extraFiles, err := collectExtraFilesFromFS(baseFS)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("collect extra files for base %q: %w", manifest.ID, err)
|
||||
}
|
||||
|
||||
return &parsedBase{
|
||||
Manifest: manifest,
|
||||
Templates: templates,
|
||||
FS: baseFS,
|
||||
Readme: readme,
|
||||
Prerequisites: ExtractPrerequisites(readme),
|
||||
ExtraFiles: extraFiles,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// parseManifest reads and validates a base.json file from the given
|
||||
// directory within parent.
|
||||
func parseManifest(parent fs.FS, dirName string) (BaseManifest, error) {
|
||||
manifestPath := path.Join(dirName, "base.json")
|
||||
data, err := fs.ReadFile(parent, manifestPath)
|
||||
if err != nil {
|
||||
return BaseManifest{}, xerrors.Errorf("read %s: %w", manifestPath, err)
|
||||
}
|
||||
|
||||
var manifest BaseManifest
|
||||
dec := json.NewDecoder(bytes.NewReader(data))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&manifest); err != nil {
|
||||
return BaseManifest{}, xerrors.Errorf("decode %s: %w", manifestPath, err)
|
||||
}
|
||||
|
||||
if manifest.ID == "" {
|
||||
return BaseManifest{}, xerrors.Errorf("base in %s has empty id", dirName)
|
||||
}
|
||||
if _, ok := validBaseOS[manifest.OS]; !ok && manifest.OS != "" {
|
||||
return BaseManifest{}, xerrors.Errorf("base %q has unknown os %q", manifest.ID, manifest.OS)
|
||||
}
|
||||
|
||||
return manifest, nil
|
||||
}
|
||||
|
||||
// parseTemplatesFromFS walks the filesystem and pre-parses all .tf.tmpl files
|
||||
// into Go templates. Returned keys are paths relative to the FS root.
|
||||
func parseTemplatesFromFS(fsys fs.FS) (map[string]*template.Template, error) {
|
||||
@@ -264,3 +295,43 @@ func BasePrerequisites(exampleID string) string {
|
||||
}
|
||||
return bases[exampleID].Prerequisites
|
||||
}
|
||||
|
||||
// BaseExtraFiles returns the non-template, non-manifest files embedded
|
||||
// in the base template directory (e.g. cloud-init .tftpl files). Returns
|
||||
// nil if the base is unknown or has no extra files.
|
||||
func BaseExtraFiles(exampleID string) map[string][]byte {
|
||||
bases, err := loadBases()
|
||||
if err != nil || bases[exampleID] == nil {
|
||||
return nil
|
||||
}
|
||||
return bases[exampleID].ExtraFiles
|
||||
}
|
||||
|
||||
// collectExtraFilesFromFS walks a base template filesystem and returns
|
||||
// all files that are not Go templates (.tf.tmpl), the manifest
|
||||
// (base.json), or the README. These are raw files that must be included
|
||||
// in the output archive (e.g. Terraform templatefile() inputs).
|
||||
func collectExtraFilesFromFS(baseFS fs.FS) (map[string][]byte, error) {
|
||||
files := make(map[string][]byte)
|
||||
err := fs.WalkDir(baseFS, ".", func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if p == "base.json" || p == "README.md" || strings.HasSuffix(p, ".tf.tmpl") {
|
||||
return nil
|
||||
}
|
||||
data, err := fs.ReadFile(baseFS, p)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("read %s: %w", p, err)
|
||||
}
|
||||
files[p] = data
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
@@ -250,4 +250,25 @@ func TestParseBasesFromFS(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "", bases["nospec"].Manifest.OS)
|
||||
})
|
||||
|
||||
t.Run("AcceptsWindowsOS", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fsys := fstest.MapFS{
|
||||
"bases/winbox/base.json": &fstest.MapFile{
|
||||
Data: []byte(`{"id": "winbox", "os": "windows"}`),
|
||||
},
|
||||
"bases/winbox/main.tf.tmpl": &fstest.MapFile{
|
||||
Data: []byte(`resource "coder_agent" "main" {}`),
|
||||
},
|
||||
"bases/winbox/README.md": &fstest.MapFile{
|
||||
Data: []byte("# Windows\n"),
|
||||
},
|
||||
}
|
||||
|
||||
bases, err := parseBasesFromFS(fsys)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "windows", bases["winbox"].Manifest.OS)
|
||||
require.Equal(t, BaseOSWindows, validBaseOS[bases["winbox"].Manifest.OS])
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,22 +3,14 @@ package templatebuilder
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"maps"
|
||||
"regexp"
|
||||
"strings"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/hcl/v2/hclwrite"
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
// maxStringValueLen is the maximum byte length for a string variable value.
|
||||
const maxStringValueLen = 4096
|
||||
|
||||
// numberPattern matches valid HCL number literals (integers and decimals).
|
||||
var numberPattern = regexp.MustCompile(`^-?[0-9]+(\.[0-9]+)?$`)
|
||||
|
||||
// ComposeRequest describes which base template and modules to render.
|
||||
type ComposeRequest struct {
|
||||
BaseTemplateID string
|
||||
@@ -50,6 +42,10 @@ type ComposeResult struct {
|
||||
// Readme is the full README.md content from the base template.
|
||||
// Empty when the base has no README.
|
||||
Readme []byte
|
||||
// ExtraFiles holds non-template files from the base directory
|
||||
// (e.g. cloud-init .tftpl files). Keys are paths relative to the
|
||||
// base directory.
|
||||
ExtraFiles map[string][]byte
|
||||
}
|
||||
|
||||
// Compose renders a base template and selected modules into Terraform
|
||||
@@ -61,10 +57,13 @@ func Compose(req ComposeRequest) (*ComposeResult, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
extraFiles := BaseExtraFiles(req.BaseTemplateID)
|
||||
|
||||
if len(req.Modules) == 0 {
|
||||
return &ComposeResult{
|
||||
MainTF: formatHCL(mainTF),
|
||||
Readme: []byte(BaseReadme(req.BaseTemplateID)),
|
||||
MainTF: formatHCL(mainTF),
|
||||
Readme: []byte(BaseReadme(req.BaseTemplateID)),
|
||||
ExtraFiles: extraFiles,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -89,9 +88,10 @@ func Compose(req ComposeRequest) (*ComposeResult, error) {
|
||||
}
|
||||
|
||||
result := &ComposeResult{
|
||||
MainTF: formatHCL(mainTF),
|
||||
ModulesTF: formatHCL(modulesTF),
|
||||
Readme: []byte(BaseReadme(req.BaseTemplateID)),
|
||||
MainTF: formatHCL(mainTF),
|
||||
ModulesTF: formatHCL(modulesTF),
|
||||
Readme: []byte(BaseReadme(req.BaseTemplateID)),
|
||||
ExtraFiles: extraFiles,
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -333,109 +333,6 @@ func mergeModuleVariables(manifest ModuleManifest, callerVars map[string]string)
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
switch v.Type {
|
||||
case "string":
|
||||
return validateStringValue(value)
|
||||
case "number":
|
||||
return validateNumberValue(value)
|
||||
case "bool":
|
||||
return validateBoolValue(value)
|
||||
default:
|
||||
return xerrors.Errorf("unsupported variable type %q", v.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 strings.Contains(value, "${") || strings.Contains(value, "%{") {
|
||||
return xerrors.New("must not contain HCL interpolation or directive sequences")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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])
|
||||
}
|
||||
}
|
||||
_, _ = b.WriteRune('"')
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// validateNumberValue checks that value is a valid HCL number literal.
|
||||
func validateNumberValue(value string) error {
|
||||
if !numberPattern.MatchString(value) {
|
||||
return xerrors.Errorf("invalid number value %q, must be a numeric literal (e.g. 42, 3.14)", value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateBoolValue checks that value is exactly "true" or "false".
|
||||
func validateBoolValue(value string) error {
|
||||
if value != "true" && value != "false" {
|
||||
return xerrors.Errorf("invalid bool value %q, must be true or false", value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isSimpleJSONValue returns true if raw is a valid JSON string, number,
|
||||
// bool, or null. Arrays and objects are rejected; the template builder
|
||||
// only supports simple variable types.
|
||||
func isSimpleJSONValue(raw json.RawMessage) bool {
|
||||
var v interface{}
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
return false
|
||||
}
|
||||
switch v.(type) {
|
||||
case string, float64, bool, nil:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// BundleTar packages the compose result into a tar archive suitable for
|
||||
// the Coder file store.
|
||||
func BundleTar(result *ComposeResult) ([]byte, error) {
|
||||
@@ -462,6 +359,18 @@ func BundleTar(result *ComposeResult) ([]byte, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Write extra files in sorted order for reproducible archives.
|
||||
names := make([]string, 0, len(result.ExtraFiles))
|
||||
for name := range result.ExtraFiles {
|
||||
names = append(names, name)
|
||||
}
|
||||
slices.Sort(names)
|
||||
for _, name := range names {
|
||||
if err := writeTarFile(tw, name, result.ExtraFiles[name]); err != nil {
|
||||
return nil, xerrors.Errorf("write %s to tar: %w", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tw.Close(); err != nil {
|
||||
return nil, xerrors.Errorf("close tar writer: %w", err)
|
||||
}
|
||||
|
||||
@@ -2,45 +2,11 @@ package templatebuilder
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIsSimpleJSONValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
raw json.RawMessage
|
||||
want bool
|
||||
}{
|
||||
{"String", json.RawMessage(`"hello"`), true},
|
||||
{"EmptyString", json.RawMessage(`""`), true},
|
||||
{"True", json.RawMessage(`true`), true},
|
||||
{"False", json.RawMessage(`false`), true},
|
||||
{"Null", json.RawMessage(`null`), true},
|
||||
{"PositiveInt", json.RawMessage(`42`), true},
|
||||
{"NegativeInt", json.RawMessage(`-1`), true},
|
||||
{"Float", json.RawMessage(`3.14`), true},
|
||||
{"Array", json.RawMessage(`[1,2]`), false},
|
||||
{"Object", json.RawMessage(`{"a":1}`), false},
|
||||
{"Empty", json.RawMessage(``), false},
|
||||
{"Nil", nil, false},
|
||||
{"MalformedString", json.RawMessage(`"unclosed`), false},
|
||||
{"MalformedBool", json.RawMessage(`truesomething`), false},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := isSimpleJSONValue(tc.raw)
|
||||
require.Equal(t, tc.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeModuleVariables(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -189,130 +155,3 @@ func TestMergeModuleVariables(t *testing.T) {
|
||||
require.Equal(t, "13337", merged["port"])
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateStringValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
wantErr string
|
||||
}{
|
||||
{"ValidEmpty", "", ""},
|
||||
{"ValidSimple", "hello", ""},
|
||||
{"ValidPath", "/home/coder", ""},
|
||||
{"ValidURL", "https://github.com/coder/coder", ""},
|
||||
{"ValidWithQuotes", `say "hi"`, ""},
|
||||
{"ValidWithNewlines", "line\nbreak", ""},
|
||||
{"ValidWithBackslash", `path\to\file`, ""},
|
||||
|
||||
{"RejectedHCLInterpolation", "${var.foo}", "interpolation"},
|
||||
{"RejectedHCLDirective", "%{if true}yes%{endif}", "interpolation"},
|
||||
{"RejectedOverlong", strings.Repeat("a", maxStringValueLen+1), "maximum length"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := validateStringValue(tc.value)
|
||||
if tc.wantErr == "" {
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateNumberValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
wantErr bool
|
||||
}{
|
||||
{"Zero", "0", false},
|
||||
{"Positive", "42", false},
|
||||
{"Negative", "-1", false},
|
||||
{"Decimal", "3.14", false},
|
||||
{"NegativeDecimal", "-0.5", false},
|
||||
|
||||
{"Scientific", "1e10", true},
|
||||
{"Hex", "0x1F", true},
|
||||
{"Underscore", "1_000", true},
|
||||
{"Expression", "1 + 1", true},
|
||||
{"Empty", "", true},
|
||||
{"Letters", "abc", true},
|
||||
{"TrailingDot", "1.", true},
|
||||
{"LeadingDot", ".5", true},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := validateNumberValue(tc.value)
|
||||
if tc.wantErr {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateBoolValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
wantErr bool
|
||||
}{
|
||||
{"True", "true", false},
|
||||
{"False", "false", false},
|
||||
|
||||
{"UpperTrue", "True", true},
|
||||
{"UpperFALSE", "FALSE", true},
|
||||
{"QuotedTrue", `"true"`, true},
|
||||
{"One", "1", true},
|
||||
{"Yes", "yes", true},
|
||||
{"Empty", "", true},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := validateBoolValue(tc.value)
|
||||
if tc.wantErr {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateVariableValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("NullAcceptedForAllTypes", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, typ := range []string{"string", "number", "bool"} {
|
||||
t.Run(typ, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
v := ModuleVariable{Name: "test", Type: typ}
|
||||
require.NoError(t, validateVariableValue(v, "null"))
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("UnsupportedTypeRejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
v := ModuleVariable{Name: "test", Type: "list"}
|
||||
err := validateVariableValue(v, "val")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "unsupported variable type")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -66,6 +66,18 @@ func TestCompose(t *testing.T) {
|
||||
require.Contains(t, string(result.ModulesTF), `coder_agent.dev.id`)
|
||||
})
|
||||
|
||||
t.Run("AWSLinuxExtraFiles", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
result, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
|
||||
BaseTemplateID: "aws-linux",
|
||||
RegistryURL: "https://registry.coder.com",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result.ExtraFiles, "aws-linux should have extra files")
|
||||
require.Contains(t, result.ExtraFiles, "cloud-init/cloud-config.yaml.tftpl")
|
||||
require.Contains(t, result.ExtraFiles, "cloud-init/userdata.sh.tftpl")
|
||||
})
|
||||
|
||||
t.Run("SensitiveVariable", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
result, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
|
||||
@@ -147,6 +159,16 @@ func TestCompose(t *testing.T) {
|
||||
require.Contains(t, err.Error(), "conflicts with")
|
||||
})
|
||||
|
||||
t.Run("DockerNoExtraFiles", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
result, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
|
||||
BaseTemplateID: "docker",
|
||||
RegistryURL: "https://registry.coder.com",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, result.ExtraFiles, "docker should have no extra files")
|
||||
})
|
||||
|
||||
t.Run("UnknownBase", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
|
||||
@@ -320,6 +342,44 @@ func TestBundleTar(t *testing.T) {
|
||||
require.Equal(t, string(result.Readme), files["README.md"])
|
||||
})
|
||||
|
||||
t.Run("ExtraFilesInTar", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
result := &templatebuilder.ComposeResult{
|
||||
MainTF: []byte("resource {}"),
|
||||
ExtraFiles: map[string][]byte{
|
||||
"cloud-init/config.yaml.tftpl": []byte("cloud config"),
|
||||
"cloud-init/userdata.sh.tftpl": []byte("userdata"),
|
||||
},
|
||||
}
|
||||
data, err := templatebuilder.BundleTar(result)
|
||||
require.NoError(t, err)
|
||||
|
||||
files := extractTar(t, data)
|
||||
require.Contains(t, files, "main.tf")
|
||||
require.Contains(t, files, "cloud-init/config.yaml.tftpl")
|
||||
require.Contains(t, files, "cloud-init/userdata.sh.tftpl")
|
||||
require.Equal(t, "cloud config", files["cloud-init/config.yaml.tftpl"])
|
||||
require.Equal(t, "userdata", files["cloud-init/userdata.sh.tftpl"])
|
||||
})
|
||||
|
||||
t.Run("AWSLinuxRoundTrip", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
result, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
|
||||
BaseTemplateID: "aws-linux",
|
||||
RegistryURL: "https://registry.coder.com",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
data, err := templatebuilder.BundleTar(result)
|
||||
require.NoError(t, err)
|
||||
|
||||
files := extractTar(t, data)
|
||||
require.Contains(t, files, "main.tf")
|
||||
require.Contains(t, files, "README.md")
|
||||
require.Contains(t, files, "cloud-init/cloud-config.yaml.tftpl")
|
||||
require.Contains(t, files, "cloud-init/userdata.sh.tftpl")
|
||||
})
|
||||
|
||||
t.Run("ReproducibleArchive", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
result := &templatebuilder.ComposeResult{
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package templatebuilder
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
// maxStringValueLen is the maximum byte length for a string variable value.
|
||||
const maxStringValueLen = 4096
|
||||
|
||||
// numberPattern matches valid HCL number literals (integers and decimals).
|
||||
var numberPattern = regexp.MustCompile(`^-?[0-9]+(\.[0-9]+)?$`)
|
||||
|
||||
// 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
|
||||
}
|
||||
switch v.Type {
|
||||
case "string":
|
||||
return validateStringValue(value)
|
||||
case "number":
|
||||
return validateNumberValue(value)
|
||||
case "bool":
|
||||
return validateBoolValue(value)
|
||||
default:
|
||||
return xerrors.Errorf("unsupported variable type %q", v.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 strings.Contains(value, "${") || strings.Contains(value, "%{") {
|
||||
return xerrors.New("must not contain HCL interpolation or directive sequences")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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])
|
||||
}
|
||||
}
|
||||
_, _ = b.WriteRune('"')
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// validateNumberValue checks that value is a valid HCL number literal.
|
||||
func validateNumberValue(value string) error {
|
||||
if !numberPattern.MatchString(value) {
|
||||
return xerrors.Errorf("invalid number value %q, must be a numeric literal (e.g. 42, 3.14)", value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateBoolValue checks that value is exactly "true" or "false".
|
||||
func validateBoolValue(value string) error {
|
||||
if value != "true" && value != "false" {
|
||||
return xerrors.Errorf("invalid bool value %q, must be true or false", value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isSimpleJSONValue returns true if raw is a valid JSON string, number,
|
||||
// bool, or null. Arrays and objects are rejected; the template builder
|
||||
// only supports simple variable types.
|
||||
func isSimpleJSONValue(raw json.RawMessage) bool {
|
||||
var v interface{}
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
return false
|
||||
}
|
||||
switch v.(type) {
|
||||
case string, float64, bool, nil:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package templatebuilder
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIsSimpleJSONValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
raw json.RawMessage
|
||||
want bool
|
||||
}{
|
||||
{"String", json.RawMessage(`"hello"`), true},
|
||||
{"EmptyString", json.RawMessage(`""`), true},
|
||||
{"True", json.RawMessage(`true`), true},
|
||||
{"False", json.RawMessage(`false`), true},
|
||||
{"Null", json.RawMessage(`null`), true},
|
||||
{"PositiveInt", json.RawMessage(`42`), true},
|
||||
{"NegativeInt", json.RawMessage(`-1`), true},
|
||||
{"Float", json.RawMessage(`3.14`), true},
|
||||
{"Array", json.RawMessage(`[1,2]`), false},
|
||||
{"Object", json.RawMessage(`{"a":1}`), false},
|
||||
{"Empty", json.RawMessage(``), false},
|
||||
{"Nil", nil, false},
|
||||
{"MalformedString", json.RawMessage(`"unclosed`), false},
|
||||
{"MalformedBool", json.RawMessage(`truesomething`), false},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := isSimpleJSONValue(tc.raw)
|
||||
require.Equal(t, tc.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStringValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
wantErr string
|
||||
}{
|
||||
{"ValidEmpty", "", ""},
|
||||
{"ValidSimple", "hello", ""},
|
||||
{"ValidPath", "/home/coder", ""},
|
||||
{"ValidURL", "https://github.com/coder/coder", ""},
|
||||
{"ValidWithQuotes", `say "hi"`, ""},
|
||||
{"ValidWithNewlines", "line\nbreak", ""},
|
||||
{"ValidWithBackslash", `path\to\file`, ""},
|
||||
|
||||
{"RejectedHCLInterpolation", "${var.foo}", "interpolation"},
|
||||
{"RejectedHCLDirective", "%{if true}yes%{endif}", "interpolation"},
|
||||
{"RejectedOverlong", strings.Repeat("a", maxStringValueLen+1), "maximum length"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := validateStringValue(tc.value)
|
||||
if tc.wantErr == "" {
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateNumberValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
wantErr bool
|
||||
}{
|
||||
{"Zero", "0", false},
|
||||
{"Positive", "42", false},
|
||||
{"Negative", "-1", false},
|
||||
{"Decimal", "3.14", false},
|
||||
{"NegativeDecimal", "-0.5", false},
|
||||
|
||||
{"Scientific", "1e10", true},
|
||||
{"Hex", "0x1F", true},
|
||||
{"Underscore", "1_000", true},
|
||||
{"Expression", "1 + 1", true},
|
||||
{"Empty", "", true},
|
||||
{"Letters", "abc", true},
|
||||
{"TrailingDot", "1.", true},
|
||||
{"LeadingDot", ".5", true},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := validateNumberValue(tc.value)
|
||||
if tc.wantErr {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateBoolValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
wantErr bool
|
||||
}{
|
||||
{"True", "true", false},
|
||||
{"False", "false", false},
|
||||
|
||||
{"UpperTrue", "True", true},
|
||||
{"UpperFALSE", "FALSE", true},
|
||||
{"QuotedTrue", `"true"`, true},
|
||||
{"One", "1", true},
|
||||
{"Yes", "yes", true},
|
||||
{"Empty", "", true},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := validateBoolValue(tc.value)
|
||||
if tc.wantErr {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateVariableValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("NullAcceptedForAllTypes", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, typ := range []string{"string", "number", "bool"} {
|
||||
t.Run(typ, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
v := ModuleVariable{Name: "test", Type: typ}
|
||||
require.NoError(t, validateVariableValue(v, "null"))
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("UnsupportedTypeRejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
v := ModuleVariable{Name: "test", Type: "list"}
|
||||
err := validateVariableValue(v, "val")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "unsupported variable type")
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user