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)
This commit is contained in:
Jeremy Ruppel
2026-06-30 19:37:30 -04:00
committed by GitHub
parent 15d4eb9db8
commit 3d966d48b5
4 changed files with 96 additions and 7 deletions
+33
View File
@@ -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 {
+23 -1
View File
@@ -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{
+18 -5
View File
@@ -102,18 +102,31 @@ func renderTemplate(fsys fs.FS, templatePath string, data any) ([]byte, error) {
// agentResourcePattern matches `resource "coder_agent" "<name>"` 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.<name>.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 {
+22 -1
View File
@@ -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) {