mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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
399 lines
12 KiB
Go
399 lines
12 KiB
Go
package templatebuilder
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"encoding/json"
|
|
"maps"
|
|
"regexp"
|
|
"strings"
|
|
"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
|
|
// BaseVariableValues maps base template variable names to their
|
|
// user-supplied values.
|
|
BaseVariableValues map[string]string
|
|
// RegistryURL is the module registry base URL from the deployment
|
|
// config (CODER_TEMPLATE_BUILDER_REGISTRY_URL).
|
|
RegistryURL string
|
|
Modules []ComposeModule
|
|
}
|
|
|
|
// ComposeModule identifies a module to include and the variable values
|
|
// to render into its module block.
|
|
type ComposeModule struct {
|
|
ID string
|
|
// Variables maps variable names to HCL literal values for
|
|
// non-sensitive, non-computed variables.
|
|
Variables map[string]string
|
|
}
|
|
|
|
// ComposeResult holds the rendered Terraform files ready for bundling.
|
|
type ComposeResult struct {
|
|
// MainTF is the rendered base template.
|
|
MainTF []byte
|
|
// ModulesTF is the concatenated rendered module blocks. Empty when
|
|
// no modules are selected.
|
|
ModulesTF []byte
|
|
}
|
|
|
|
// Compose renders a base template and selected modules into Terraform
|
|
// source files. It extracts the coder_agent resource name from the
|
|
// rendered base HCL and wires it into each module block.
|
|
func Compose(req ComposeRequest) (*ComposeResult, error) {
|
|
mainTF, err := renderBase(req.BaseTemplateID, req.BaseVariableValues)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(req.Modules) == 0 {
|
|
return &ComposeResult{MainTF: formatHCL(mainTF)}, nil
|
|
}
|
|
|
|
agentName, err := ExtractAgentResourceName(mainTF)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("extract agent name: %w", err)
|
|
}
|
|
|
|
catalog, err := loadCatalogMap()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
baseOS := BaseTemplateOS(req.BaseTemplateID)
|
|
if err := validateModules(req.Modules, catalog, baseOS); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
modulesTF, err := renderModules(req.Modules, catalog, req.RegistryURL, agentName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &ComposeResult{
|
|
MainTF: formatHCL(mainTF),
|
|
ModulesTF: formatHCL(modulesTF),
|
|
}, nil
|
|
}
|
|
|
|
// formatHCL applies canonical HCL formatting to src. If src is not valid
|
|
// HCL the input is returned unchanged.
|
|
func formatHCL(src []byte) []byte {
|
|
if len(src) == 0 {
|
|
return src
|
|
}
|
|
return hclwrite.Format(src)
|
|
}
|
|
|
|
// renderBase renders the base template for the given example ID,
|
|
// merging any user-supplied variable values into the render context.
|
|
func renderBase(baseTemplateID string, baseVars map[string]string) ([]byte, error) {
|
|
renderCtx := DefaultBaseRenderContext(baseTemplateID)
|
|
if renderCtx.Variables == nil {
|
|
renderCtx.Variables = make(map[string]string)
|
|
}
|
|
maps.Copy(renderCtx.Variables, baseVars)
|
|
mainTF, err := RenderBaseTemplate(baseTemplateID, "main.tf.tmpl", renderCtx)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("render base template: %w", err)
|
|
}
|
|
return mainTF, nil
|
|
}
|
|
|
|
// loadCatalogMap loads the module catalog and returns it as a map keyed
|
|
// by module ID.
|
|
func loadCatalogMap() (map[string]ModuleManifest, error) {
|
|
modules, err := LoadModules()
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("load module catalog: %w", err)
|
|
}
|
|
catalog := make(map[string]ModuleManifest, len(modules))
|
|
for _, m := range modules {
|
|
catalog[m.ID] = m
|
|
}
|
|
return catalog, nil
|
|
}
|
|
|
|
// validateModules checks that all requested modules exist, are
|
|
// OS-compatible, have no duplicates, and have no conflicts.
|
|
func validateModules(requested []ComposeModule, catalog map[string]ModuleManifest, baseOS BaseOS) error {
|
|
seen := make(map[string]bool, len(requested))
|
|
for _, cm := range requested {
|
|
if seen[cm.ID] {
|
|
return xerrors.Errorf("duplicate module %q", cm.ID)
|
|
}
|
|
seen[cm.ID] = true
|
|
|
|
manifest, ok := catalog[cm.ID]
|
|
if !ok {
|
|
return xerrors.Errorf("unknown module %q", cm.ID)
|
|
}
|
|
if !manifest.CompatibleWithOS(string(baseOS)) {
|
|
return xerrors.Errorf("module %q is not compatible with OS %q", cm.ID, baseOS)
|
|
}
|
|
}
|
|
|
|
// Check conflicts bidirectionally so that order does not matter.
|
|
for _, cm := range requested {
|
|
manifest := catalog[cm.ID]
|
|
for _, conflict := range manifest.ConflictsWith {
|
|
if seen[conflict] {
|
|
return xerrors.Errorf("module %q conflicts with %q", cm.ID, conflict)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// renderModules renders each module template and concatenates the
|
|
// results with newline separators.
|
|
func renderModules(
|
|
requested []ComposeModule,
|
|
catalog map[string]ModuleManifest,
|
|
registryURL, agentName string,
|
|
) ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
for _, cm := range requested {
|
|
manifest := catalog[cm.ID]
|
|
|
|
modFS, err := ModuleTemplateFS(cm.ID)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("module template FS for %q: %w", cm.ID, err)
|
|
}
|
|
|
|
vars, err := mergeModuleVariables(manifest, cm.Variables)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("module %q: %w", cm.ID, err)
|
|
}
|
|
modCtx := ModuleRenderContext{
|
|
RegistryBase: registryURL,
|
|
PinnedVersion: manifest.PinnedVersion,
|
|
AgentResourceName: agentName,
|
|
Variables: vars,
|
|
}
|
|
|
|
rendered, err := RenderModuleTemplate(modFS, cm.ID+".tf.tmpl", modCtx)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("render module %q: %w", cm.ID, err)
|
|
}
|
|
|
|
if buf.Len() > 0 {
|
|
_ = buf.WriteByte('\n')
|
|
}
|
|
_, _ = buf.Write(rendered)
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// mergeModuleVariables builds the final Variables map for a module template.
|
|
// It starts with manifest defaults for all non-computed, non-sensitive
|
|
// variables, then overlays caller-supplied values. Caller-supplied keys
|
|
// are validated against the manifest and values are checked for type
|
|
// correctness before being accepted.
|
|
func mergeModuleVariables(manifest ModuleManifest, callerVars map[string]string) (map[string]string, error) {
|
|
// Build lookup structures for the manifest variables.
|
|
allowedVars := make(map[string]ModuleVariable, len(manifest.Variables))
|
|
for _, v := range manifest.Variables {
|
|
if v.Computed || v.Sensitive {
|
|
continue
|
|
}
|
|
allowedVars[v.Name] = v
|
|
}
|
|
|
|
// Validate caller-supplied keys and values before merging.
|
|
for k, val := range callerVars {
|
|
v, ok := allowedVars[k]
|
|
if !ok {
|
|
return nil, xerrors.Errorf("unknown variable %q", k)
|
|
}
|
|
if err := validateVariableValue(v, val); err != nil {
|
|
return nil, xerrors.Errorf("variable %q: %w", k, err)
|
|
}
|
|
}
|
|
|
|
// Build merged map from manifest defaults.
|
|
merged := make(map[string]string, len(manifest.Variables))
|
|
for _, v := range manifest.Variables {
|
|
if v.Computed || v.Sensitive {
|
|
continue
|
|
}
|
|
if len(v.Default) > 0 && isSimpleJSONValue(v.Default) {
|
|
// json.RawMessage values for simple types (e.g. `""`,
|
|
// `false`, `13337`) are valid HCL literals.
|
|
merged[v.Name] = string(v.Default)
|
|
} else if !v.Required {
|
|
// Non-required variables without an explicit default use
|
|
// null, which tells Terraform to apply the module's own
|
|
// default.
|
|
merged[v.Name] = "null"
|
|
}
|
|
// Required variables without defaults are left out so that
|
|
// missingkey=error surfaces the omission at render time.
|
|
}
|
|
|
|
// Overlay validated caller values, converting to HCL literals.
|
|
for k, val := range callerVars {
|
|
merged[k] = toHCLLiteral(allowedVars[k], val)
|
|
}
|
|
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) {
|
|
if result == nil {
|
|
return nil, xerrors.New("nil ComposeResult")
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
tw := tar.NewWriter(&buf)
|
|
|
|
if err := writeTarFile(tw, "main.tf", result.MainTF); err != nil {
|
|
return nil, xerrors.Errorf("write main.tf to tar: %w", err)
|
|
}
|
|
|
|
if len(result.ModulesTF) > 0 {
|
|
if err := writeTarFile(tw, "modules.tf", result.ModulesTF); err != nil {
|
|
return nil, xerrors.Errorf("write modules.tf to tar: %w", err)
|
|
}
|
|
}
|
|
|
|
if err := tw.Close(); err != nil {
|
|
return nil, xerrors.Errorf("close tar writer: %w", err)
|
|
}
|
|
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// writeTarFile adds a single file entry to a tar writer. It uses a zero
|
|
// timestamp for reproducible archives.
|
|
func writeTarFile(tw *tar.Writer, name string, data []byte) error {
|
|
hdr := &tar.Header{
|
|
Name: name,
|
|
Mode: 0o644,
|
|
Size: int64(len(data)),
|
|
ModTime: time.Unix(0, 0),
|
|
}
|
|
if err := tw.WriteHeader(hdr); err != nil {
|
|
return err
|
|
}
|
|
_, err := tw.Write(data)
|
|
return err
|
|
}
|