diff --git a/coderd/templatebuilder/catalog.go b/coderd/templatebuilder/catalog.go index 316316e5c4..e4aef22cb1 100644 --- a/coderd/templatebuilder/catalog.go +++ b/coderd/templatebuilder/catalog.go @@ -194,3 +194,20 @@ func (m ModuleManifest) ToSDK() codersdk.TemplateBuilderModule { Variables: variables, } } + +// ModuleTemplateFS returns an fs.FS rooted at the embedded directory for +// the given module ID, providing access to its .tf.tmpl file. +func ModuleTemplateFS(moduleID string) (fs.FS, error) { + modPath := modulesDir + "/" + moduleID + // Verify the directory exists. fs.Sub on embed.FS silently succeeds + // for nonexistent paths, so we check for the expected .tf.tmpl file. + tmplName := moduleID + ".tf.tmpl" + if _, err := fs.Stat(modulesFS, modPath+"/"+tmplName); err != nil { + return nil, xerrors.Errorf("module %q not found in embedded catalog: %w", moduleID, err) + } + sub, err := fs.Sub(modulesFS, modPath) + if err != nil { + return nil, xerrors.Errorf("module %q sub-filesystem: %w", moduleID, err) + } + return sub, nil +} diff --git a/coderd/templatebuilder/render.go b/coderd/templatebuilder/render.go index a92512cdfb..5893ac24c6 100644 --- a/coderd/templatebuilder/render.go +++ b/coderd/templatebuilder/render.go @@ -2,6 +2,9 @@ package templatebuilder import ( "bytes" + "io/fs" + "regexp" + "text/template" "golang.org/x/xerrors" ) @@ -19,10 +22,29 @@ type BaseRenderContext struct { 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 -// startup; parse errors surface on first access rather than at render time. +// 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) @@ -45,3 +67,59 @@ func RenderBaseTemplate(exampleID, templatePath string, renderCtx BaseRenderCont 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 "". +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 +} + +// agentResourcePattern matches `resource "coder_agent" ""` in HCL. +var agentResourcePattern = regexp.MustCompile(`resource\s+"coder_agent"\s+"(\w+)"`) + +// ExtractAgentResourceName finds the coder_agent resource declaration in +// rendered HCL and returns its name. Returns an error unless exactly +// one coder_agent resource is found; the builder only supports +// single-agent templates. The input is expected to be rendered output +// from our own curated base templates, not arbitrary user HCL. +func ExtractAgentResourceName(hcl []byte) (string, error) { + matches := agentResourcePattern.FindAllSubmatch(hcl, -1) + switch len(matches) { + case 0: + return "", xerrors.New("no coder_agent resource found in rendered template") + case 1: + return string(matches[0][1]), nil + default: + names := make([]string, 0, len(matches)) + for _, m := range matches { + names = append(names, string(m[1])) + } + return "", xerrors.Errorf("expected exactly one coder_agent resource, found %d: %v", + len(matches), names) + } +} diff --git a/coderd/templatebuilder/render_test.go b/coderd/templatebuilder/render_test.go index c9223c3584..0c616a7148 100644 --- a/coderd/templatebuilder/render_test.go +++ b/coderd/templatebuilder/render_test.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "testing" + "testing/fstest" "github.com/stretchr/testify/require" @@ -65,13 +66,209 @@ func TestRenderBaseTemplate(t *testing.T) { require.Contains(t, rendered, `name = "Ubuntu"`) require.Contains(t, rendered, `coder_parameter`) }) + + // MissingKeyErrors is tested via RenderModuleTemplate since base templates + // are pre-parsed from the embedded catalog and cannot use ad-hoc filesystems. +} + +func TestRenderModuleTemplate(t *testing.T) { + t.Parallel() + + t.Run("InvalidPath", func(t *testing.T) { + t.Parallel() + fsys := fstest.MapFS{} + _, err := templatebuilder.RenderModuleTemplate(fsys, "missing.tf.tmpl", templatebuilder.ModuleRenderContext{}) + require.Error(t, err) + require.Contains(t, err.Error(), "read template") + }) + + t.Run("RendersAllFields", func(t *testing.T) { + t.Parallel() + fsys := fstest.MapFS{ + "test.tf.tmpl": &fstest.MapFile{ + Data: []byte(`module "test" { + source = "{{ .RegistryBase }}/coder/test/coder" + version = "{{ .PinnedVersion }}" + agent_id = coder_agent.{{ .AgentResourceName }}.id + port = {{ .Variables.port }} +} +`), + }, + } + ctx := templatebuilder.ModuleRenderContext{ + RegistryBase: "https://registry.coder.com", + PinnedVersion: "1.5.0", + AgentResourceName: "main", + Variables: map[string]string{"port": "8080"}, + } + out, err := templatebuilder.RenderModuleTemplate(fsys, "test.tf.tmpl", ctx) + require.NoError(t, err) + rendered := string(out) + require.Contains(t, rendered, `"https://registry.coder.com/coder/test/coder"`) + require.Contains(t, rendered, `"1.5.0"`) + require.Contains(t, rendered, `coder_agent.main.id`) + require.Contains(t, rendered, `port = 8080`) + }) + + t.Run("NilVariablesDoesNotPanic", func(t *testing.T) { + t.Parallel() + fsys := fstest.MapFS{ + "test.tf.tmpl": &fstest.MapFile{ + Data: []byte(`module "test" { + source = "{{ .RegistryBase }}" +} +`), + }, + } + out, err := templatebuilder.RenderModuleTemplate(fsys, "test.tf.tmpl", templatebuilder.ModuleRenderContext{ + RegistryBase: "https://registry.coder.com", + }) + require.NoError(t, err) + require.Contains(t, string(out), "https://registry.coder.com") + }) + + t.Run("MissingKeyErrors", func(t *testing.T) { + t.Parallel() + fsys := fstest.MapFS{ + "test.tf.tmpl": &fstest.MapFile{ + Data: []byte(`{{ .Variables.missing_key }}`), + }, + } + _, err := templatebuilder.RenderModuleTemplate(fsys, "test.tf.tmpl", templatebuilder.ModuleRenderContext{ + Variables: map[string]string{"other": "value"}, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "execute template") + }) + + t.Run("ParseError", func(t *testing.T) { + t.Parallel() + fsys := fstest.MapFS{ + "bad.tf.tmpl": &fstest.MapFile{ + Data: []byte(`{{ .Invalid {{ syntax`), + }, + } + _, err := templatebuilder.RenderModuleTemplate(fsys, "bad.tf.tmpl", templatebuilder.ModuleRenderContext{}) + require.Error(t, err) + require.Contains(t, err.Error(), "parse template") + }) + t.Run("RealModuleTemplate", func(t *testing.T) { + t.Parallel() + modules, err := templatebuilder.LoadModules() + require.NoError(t, err) + + var csMod templatebuilder.ModuleManifest + for _, m := range modules { + if m.ID == "code-server" { + csMod = m + break + } + } + require.NotEmpty(t, csMod.ID, "code-server module must exist") + + fsys, err := templatebuilder.ModuleTemplateFS(csMod.ID) + require.NoError(t, err) + + vars := make(map[string]string) + for _, v := range csMod.Variables { + if !v.Computed && !v.Sensitive { + vars[v.Name] = `"test-value"` + } + } + + ctx := templatebuilder.ModuleRenderContext{ + RegistryBase: "https://registry.coder.com", + PinnedVersion: csMod.PinnedVersion, + AgentResourceName: "main", + Variables: vars, + } + out, err := templatebuilder.RenderModuleTemplate(fsys, csMod.ID+".tf.tmpl", ctx) + require.NoError(t, err) + rendered := string(out) + require.Contains(t, rendered, `module "code-server"`) + require.Contains(t, rendered, `coder_agent.main.id`) + require.Contains(t, rendered, csMod.PinnedVersion) + }) +} + +func TestExtractAgentResourceName(t *testing.T) { + t.Parallel() + + t.Run("DockerBase", func(t *testing.T) { + t.Parallel() + rendered, err := templatebuilder.RenderBaseTemplate("docker", "main.tf.tmpl", templatebuilder.DefaultBaseRenderContext("docker")) + require.NoError(t, err) + + name, err := templatebuilder.ExtractAgentResourceName(rendered) + require.NoError(t, err) + require.Equal(t, "main", name) + }) + + t.Run("AWSLinuxBase", func(t *testing.T) { + t.Parallel() + rendered, err := templatebuilder.RenderBaseTemplate("aws-linux", "main.tf.tmpl", templatebuilder.DefaultBaseRenderContext("aws-linux")) + require.NoError(t, err) + + name, err := templatebuilder.ExtractAgentResourceName(rendered) + require.NoError(t, err) + require.Equal(t, "dev", name) + }) + + t.Run("NoAgent", func(t *testing.T) { + t.Parallel() + _, err := templatebuilder.ExtractAgentResourceName([]byte(`resource "docker_container" "workspace" {}`)) + require.Error(t, err) + require.Contains(t, err.Error(), "no coder_agent") + }) + + t.Run("MultipleAgents", func(t *testing.T) { + t.Parallel() + hcl := []byte(` +resource "coder_agent" "first" {} +resource "coder_agent" "second" {} +`) + _, err := templatebuilder.ExtractAgentResourceName(hcl) + require.Error(t, err) + require.Contains(t, err.Error(), "expected exactly one") + require.Contains(t, err.Error(), "found 2") + }) + + t.Run("NilInput", func(t *testing.T) { + t.Parallel() + _, err := templatebuilder.ExtractAgentResourceName(nil) + require.Error(t, err) + require.Contains(t, err.Error(), "no coder_agent") + }) + + t.Run("EmptyInput", func(t *testing.T) { + t.Parallel() + _, err := templatebuilder.ExtractAgentResourceName([]byte{}) + require.Error(t, err) + require.Contains(t, err.Error(), "no coder_agent") + }) +} + +func TestModuleTemplateFS(t *testing.T) { + t.Parallel() + + t.Run("ValidModule", func(t *testing.T) { + t.Parallel() + fsys, err := templatebuilder.ModuleTemplateFS("code-server") + require.NoError(t, err) + require.NotNil(t, fsys) + }) + + t.Run("UnknownModule", func(t *testing.T) { + t.Parallel() + _, err := templatebuilder.ModuleTemplateFS("nonexistent-module") + require.Error(t, err) + require.Contains(t, err.Error(), "not found in embedded catalog") + }) } func TestBaseTemplateSnapshot(t *testing.T) { t.Parallel() - // This test table must cover every known base template. - // BaseTemplateIDs() is the source of truth; this list must match. tests := []struct { exampleID string }{