From 3d966d48b57e7404a756e77549045bbe59fb1c01 Mon Sep 17 00:00:00 2001 From: Jeremy Ruppel Date: Tue, 30 Jun 2026 19:37:30 -0400 Subject: [PATCH] fix(coderd/templatebuilder): fix archive bundling for nested static files and counted agents (#26901) Fixes two template builder bugs that caused AWS EC2 (Linux) template imports to fail: 1. **Missing directory entries in tar archive**: `BundleTar` wrote static files with nested paths (e.g. `cloud-init/cloud-config.yaml.tftpl`) without emitting `TypeDir` entries for parent directories. The provisioner's archive extractor requires explicit directory entries and failed with "no such file or directory". 2. **Incorrect agent reference for counted resources**: `ExtractAgentResourceName` returned `dev` for the AWS Linux base template, but the agent uses `count = data.coder_workspace.me.start_count`, so module templates need `coder_agent.dev[0].id`. The function now detects `count`/`for_each` and appends `[0]`. > [!NOTE] > Generated by Coder Agents (on behalf of @jeremyruppel) --- coderd/templatebuilder/compose.go | 33 ++++++++++++++++++++++++++ coderd/templatebuilder/compose_test.go | 24 ++++++++++++++++++- coderd/templatebuilder/render.go | 23 ++++++++++++++---- coderd/templatebuilder/render_test.go | 23 +++++++++++++++++- 4 files changed, 96 insertions(+), 7 deletions(-) diff --git a/coderd/templatebuilder/compose.go b/coderd/templatebuilder/compose.go index dfe90d103c..2fbfc5c619 100644 --- a/coderd/templatebuilder/compose.go +++ b/coderd/templatebuilder/compose.go @@ -4,6 +4,7 @@ import ( "archive/tar" "bytes" "maps" + "path" "slices" "time" @@ -365,6 +366,27 @@ func BundleTar(result *ComposeResult) ([]byte, error) { names = append(names, name) } slices.Sort(names) + + // Emit directory entries for any subdirectories so that + // extractors that do not implicitly create parents can + // unpack the archive. + dirs := make(map[string]bool) + for _, name := range names { + for dir := path.Dir(name); dir != "." && !dirs[dir]; dir = path.Dir(dir) { + dirs[dir] = true + } + } + sortedDirs := make([]string, 0, len(dirs)) + for d := range dirs { + sortedDirs = append(sortedDirs, d) + } + slices.Sort(sortedDirs) + for _, d := range sortedDirs { + if err := writeTarDir(tw, d); err != nil { + return nil, xerrors.Errorf("write dir %s to tar: %w", d, err) + } + } + 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) @@ -378,6 +400,17 @@ func BundleTar(result *ComposeResult) ([]byte, error) { return buf.Bytes(), nil } +// writeTarDir adds a directory entry to a tar writer. +func writeTarDir(tw *tar.Writer, name string) error { + hdr := &tar.Header{ + Typeflag: tar.TypeDir, + Name: name + "/", + Mode: 0o755, + ModTime: time.Unix(0, 0), + } + return tw.WriteHeader(hdr) +} + // 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 { diff --git a/coderd/templatebuilder/compose_test.go b/coderd/templatebuilder/compose_test.go index 83f933b987..b15692036f 100644 --- a/coderd/templatebuilder/compose_test.go +++ b/coderd/templatebuilder/compose_test.go @@ -63,7 +63,7 @@ func TestCompose(t *testing.T) { }, }) require.NoError(t, err) - require.Contains(t, string(result.ModulesTF), `coder_agent.dev.id`) + require.Contains(t, string(result.ModulesTF), `coder_agent.dev[0].id`) }) t.Run("AWSLinuxExtraFiles", func(t *testing.T) { @@ -397,6 +397,7 @@ func TestBundleTar(t *testing.T) { require.NoError(t, err) files := extractTar(t, data) + require.Contains(t, files, "cloud-init/", "directory entry should be present for subdirectories") require.Contains(t, files, "main.tf") require.Contains(t, files, "cloud-init/config.yaml.tftpl") require.Contains(t, files, "cloud-init/userdata.sh.tftpl") @@ -404,6 +405,27 @@ func TestBundleTar(t *testing.T) { require.Equal(t, "userdata", files["cloud-init/userdata.sh.tftpl"]) }) + t.Run("NestedStaticFileDirEntries", func(t *testing.T) { + t.Parallel() + result := &templatebuilder.ComposeResult{ + MainTF: []byte("resource {}"), + ExtraFiles: map[string][]byte{ + "a/b/c/deep.txt": []byte("deep"), + "top.txt": []byte("top"), + }, + } + data, err := templatebuilder.BundleTar(result) + require.NoError(t, err) + + files := extractTar(t, data) + require.Contains(t, files, "a/", "top-level parent dir entry") + require.Contains(t, files, "a/b/", "intermediate parent dir entry") + require.Contains(t, files, "a/b/c/", "leaf parent dir entry") + require.Contains(t, files, "a/b/c/deep.txt") + require.Contains(t, files, "top.txt") + // top.txt is at root, so no extra directory entry needed. + }) + t.Run("AWSLinuxRoundTrip", func(t *testing.T) { t.Parallel() result, err := templatebuilder.Compose(templatebuilder.ComposeRequest{ diff --git a/coderd/templatebuilder/render.go b/coderd/templatebuilder/render.go index 5893ac24c6..8e342a58c4 100644 --- a/coderd/templatebuilder/render.go +++ b/coderd/templatebuilder/render.go @@ -102,18 +102,31 @@ func renderTemplate(fsys fs.FS, templatePath string, data any) ([]byte, error) { // agentResourcePattern matches `resource "coder_agent" ""` in HCL. var agentResourcePattern = regexp.MustCompile(`resource\s+"coder_agent"\s+"(\w+)"`) +// agentCountPattern detects whether a coder_agent block uses count or +// for_each, which means references to it require an index (e.g. [0]). +var agentCountPattern = regexp.MustCompile( + `resource\s+"coder_agent"\s+"\w+"\s*\{[^}]*\b(?:count|for_each)\s*=`, +) + // 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. +// 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..id. 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 + name := string(matches[0][1]) + if agentCountPattern.Match(hcl) { + name += "[0]" + } + return name, nil default: names := make([]string, 0, len(matches)) for _, m := range matches { diff --git a/coderd/templatebuilder/render_test.go b/coderd/templatebuilder/render_test.go index 47ba3fb2b6..fd75eb1b1d 100644 --- a/coderd/templatebuilder/render_test.go +++ b/coderd/templatebuilder/render_test.go @@ -215,7 +215,28 @@ func TestExtractAgentResourceName(t *testing.T) { name, err := templatebuilder.ExtractAgentResourceName(rendered) require.NoError(t, err) - require.Equal(t, "dev", name) + require.Equal(t, "dev[0]", name, "counted agent should include index") + }) + + t.Run("CountedAgent", func(t *testing.T) { + t.Parallel() + hcl := []byte(`resource "coder_agent" "myagent" { + count = data.coder_workspace.me.start_count + arch = "amd64" +}`) + name, err := templatebuilder.ExtractAgentResourceName(hcl) + require.NoError(t, err) + require.Equal(t, "myagent[0]", name) + }) + + t.Run("UncountedAgent", func(t *testing.T) { + t.Parallel() + hcl := []byte(`resource "coder_agent" "main" { + arch = "amd64" +}`) + name, err := templatebuilder.ExtractAgentResourceName(hcl) + require.NoError(t, err) + require.Equal(t, "main", name) }) t.Run("NoAgent", func(t *testing.T) {