Files
coder/coderd/templatebuilder/render.go
T
Jeremy RuppelandCoder Agents a93b2258cf refactor(coderd/templatebuilder): parse rendered HCL with hclparse and support multiple agents (#28787)
Part of [DEVEX-556](https://linear.app/codercom/issue/DEVEX-556).

## Summary

Replaces the Template Builder's regex-based extraction of information
from base-template Terraform with an `hclsyntax` parse of the rendered
output, and removes the single-`coder_agent` assumption from the compose
core.

## What changed

- `render.go`: parse rendered Terraform via `hclsyntax.ParseConfig`
instead of regex.
- Added `ExtractedAgent{Name, Reference}` and
`ExtractAgentResourceNames`, which enumerates every `coder_agent` in
declaration order and reads `count`/`for_each` from each block body.
This is robust to nested blocks (`env {}`, `metadata {}`), fixing a case
the old `[^}]*` regex missed.
- Kept singular `ExtractAgentResourceName` as a compatibility wrapper
that still errors unless exactly one agent exists.
- `ExtractModuleNames` now walks parsed `module` blocks rather than
line-anchored regex.
- `compose.go`: `Compose` wires modules to the base's first/default
agent instead of erroring when a base declares multiple agents.
Single-agent output is byte-identical.
- `render_test.go`: added `TestExtractAgentResourceNames`
(multiple-in-order, `count` after a nested block, no agents).

## Scope

Parsing/compose layer only. Per-module agent **selection** (SDK/UI) is
intentionally out of scope and left as follow-up.

## Testing

- `go test ./coderd/templatebuilder/` passes (golden snapshots
unchanged).
- `golangci-lint run ./coderd/templatebuilder/` reports 0 issues.
- `go build ./coderd/...` succeeds. No new dependencies
(`hashicorp/hcl/v2` already vendored).

<details>
<summary>Design context</summary>

- The `.tf.tmpl` files are Go templates and only become valid HCL after
rendering, so parsing happens on rendered output (which the extractors
already operated on).
- `base.json` stays authoritative; no manifest fields are inferred or
removed here. A survey found `os` is mostly inferable from
`coder_agent.os` (except `scratch`, which uses a dynamic
`data.coder_provisioner.me.os`), and that `variables` cannot be safely
inferred because base variables are Go-template inputs, not Terraform
`variable` blocks. These were left out to keep the change minimal.
- Multiple agents are supported by wiring modules to the first agent in
document order; single-agent bases are unaffected because first == only.

</details>

---
🤖 Generated with Coder Agents on behalf of @jeremyruppel.

---------

Co-authored-by: Coder Agents <agents@coder.com>
2026-08-31 16:59:36 -04:00

211 lines
7.5 KiB
Go

package templatebuilder
import (
"bytes"
"io/fs"
"text/template"
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/hclsyntax"
"golang.org/x/xerrors"
)
// ImageOption represents a container image choice for base template parameters.
type ImageOption struct {
Name string
Value string
}
// DefaultRegistryBase is the module registry host used in rendered module source
// paths when a ComposeRequest does not carry the deployment's configured
// registry (CODER_TEMPLATE_BUILDER_REGISTRY_URL). It mirrors the default of the
// codersdk template-builder registry option.
const DefaultRegistryBase = "registry.coder.com"
// BaseRenderContext is the data passed to base template .tf.tmpl files.
type BaseRenderContext struct {
ContainerImage string
ImageOptions []ImageOption
// RegistryBase is the module registry host used in rendered module source
// paths, mirroring ModuleRenderContext.RegistryBase so a base-embedded
// module honors CODER_TEMPLATE_BUILDER_REGISTRY_URL like a wizard module.
RegistryBase string
Variables map[string]string
}
// ModuleRenderContext is the data passed to module .tf.tmpl files.
type ModuleRenderContext struct {
// RegistryBase is the module registry URL from the deployment config
// (CODER_TEMPLATE_BUILDER_REGISTRY_URL).
RegistryBase string
// PinnedVersion is the module version from the catalog manifest.
PinnedVersion string
// AgentResourceName is the Terraform resource name of the coder_agent
// declared in the base template (e.g. "main" or "dev").
AgentResourceName string
// Variables maps variable names to their HCL expressions.
Variables map[string]string
}
// RenderBaseTemplate executes a pre-parsed .tf.tmpl template for the given
// base, applying the provided render context. Templates are parsed once at
// first access via sync.OnceValues, so parse errors surface early instead
// of at render time.
func RenderBaseTemplate(exampleID, templatePath string, renderCtx BaseRenderContext) ([]byte, error) {
if renderCtx.Variables == nil {
renderCtx.Variables = make(map[string]string)
}
bases, err := loadBases()
if err != nil {
return nil, xerrors.Errorf("load base catalog: %w", err)
}
base, ok := bases[exampleID]
if !ok {
return nil, xerrors.Errorf("unknown base template %q", exampleID)
}
tmpl, ok := base.Templates[templatePath]
if !ok {
return nil, xerrors.Errorf("template %s not found in base %q", templatePath, exampleID)
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, renderCtx); err != nil {
return nil, xerrors.Errorf("execute template %s: %w", templatePath, err)
}
return buf.Bytes(), nil
}
// RenderModuleTemplate parses and executes a module .tf.tmpl file from
// the given filesystem, applying the provided render context.
func RenderModuleTemplate(fsys fs.FS, templatePath string, renderCtx ModuleRenderContext) ([]byte, error) {
if renderCtx.Variables == nil {
renderCtx.Variables = make(map[string]string)
}
return renderTemplate(fsys, templatePath, renderCtx)
}
// renderTemplate is the shared implementation for module template rendering.
// It sets missingkey=error so that references to undefined variable keys fail
// loudly instead of producing "<no value>".
func renderTemplate(fsys fs.FS, templatePath string, data any) ([]byte, error) {
raw, err := fs.ReadFile(fsys, templatePath)
if err != nil {
return nil, xerrors.Errorf("read template %s: %w", templatePath, err)
}
tmpl, err := template.New(templatePath).Option("missingkey=error").Parse(string(raw))
if err != nil {
return nil, xerrors.Errorf("parse template %s: %w", templatePath, err)
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return nil, xerrors.Errorf("execute template %s: %w", templatePath, err)
}
return buf.Bytes(), nil
}
// parseRenderedHCL parses rendered Terraform into an hclsyntax body. The input
// is expected to be rendered output from our own curated base templates, so a
// parse error indicates a bug in a template rather than untrusted user input.
func parseRenderedHCL(rendered []byte) (*hclsyntax.Body, error) {
file, diags := hclsyntax.ParseConfig(rendered, "rendered.tf", hcl.Pos{Line: 1, Column: 1})
if diags.HasErrors() {
return nil, xerrors.Errorf("parse rendered template HCL: %s", diags.Error())
}
body, ok := file.Body.(*hclsyntax.Body)
if !ok {
return nil, xerrors.New("unexpected HCL body type")
}
return body, nil
}
// ExtractedAgent describes a coder_agent resource found in rendered HCL.
type ExtractedAgent struct {
// Name is the Terraform resource name (the second block label).
Name string
// Reference is the form to use in module templates: Name, suffixed with
// [0] when the agent uses count or for_each and therefore requires an
// index (e.g. coder_agent.dev[0].id).
Reference string
}
// ExtractAgentResourceNames returns every coder_agent resource declared in
// rendered HCL, in declaration order. Unlike the singular helper it does not
// error on multiple agents: enumerating them all lets a base declare more than
// one agent. Reading count/for_each from each block body is robust to nested
// blocks (env {}, metadata {}). The input is expected to be rendered output
// from our own curated base templates, not arbitrary user HCL.
func ExtractAgentResourceNames(rendered []byte) ([]ExtractedAgent, error) {
body, err := parseRenderedHCL(rendered)
if err != nil {
return nil, err
}
var agents []ExtractedAgent
for _, block := range body.Blocks {
if block.Type != "resource" || len(block.Labels) < 2 || block.Labels[0] != "coder_agent" {
continue
}
ref := block.Labels[1]
if _, ok := block.Body.Attributes["count"]; ok {
ref += "[0]"
} else if _, ok := block.Body.Attributes["for_each"]; ok {
ref += "[0]"
}
agents = append(agents, ExtractedAgent{Name: block.Labels[1], Reference: ref})
}
return agents, nil
}
// ExtractAgentResourceName finds the coder_agent resource declaration in
// rendered HCL and returns the reference form to use in module templates.
// When the agent uses count or for_each, the returned name includes an
// index suffix (e.g. "dev[0]") so that module templates can reference it
// as coder_agent.<name>.id. Returns an error unless exactly one
// coder_agent resource is found; callers that support multiple agents
// should use ExtractAgentResourceNames instead. The input is expected to be
// rendered output from our own curated base templates, not arbitrary user HCL.
func ExtractAgentResourceName(rendered []byte) (string, error) {
agents, err := ExtractAgentResourceNames(rendered)
if err != nil {
return "", err
}
switch len(agents) {
case 0:
return "", xerrors.New("no coder_agent resource found in rendered template")
case 1:
return agents[0].Reference, nil
default:
names := make([]string, 0, len(agents))
for _, a := range agents {
names = append(names, a.Name)
}
return "", xerrors.Errorf("expected exactly one coder_agent resource, found %d: %v",
len(agents), names)
}
}
// ExtractModuleNames returns the labels of every module block declared in
// rendered HCL, in declaration order. Because it walks parsed blocks,
// commented-out references and module strings inside heredocs are naturally
// ignored. The input is expected to be rendered output from our own curated
// base templates, not arbitrary user HCL; unparsable input yields no names.
func ExtractModuleNames(rendered []byte) []string {
body, err := parseRenderedHCL(rendered)
if err != nil {
return nil
}
var names []string
for _, block := range body.Blocks {
if block.Type == "module" && len(block.Labels) >= 1 {
names = append(names, block.Labels[0])
}
}
return names
}