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:
Jeremy Ruppel
2026-06-30 18:08:02 -04:00
committed by GitHub
parent 2ea0d5f8ef
commit 6f0b81cfbc
7 changed files with 512 additions and 325 deletions
+26 -117
View File
@@ -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)
}