mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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 {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user