From 1f8930debf5dee2a58c867f2ab3edd8947d9ccfc Mon Sep 17 00:00:00 2001 From: ju-pe Date: Fri, 1 May 2026 19:52:22 +0000 Subject: [PATCH 1/3] feat: add archive_path attribute to coderd_template versions Implements #340. Adds archive_path as an alternative to directory in the versions block of coderd_template, supporting .tar and .zip files. Changes: - Add archive_path (Optional) and archive_hash (Computed) attributes - Validator ensures exactly one of directory/archive_path is set - Plan modifier computes archive_hash from file contents (SHA-256) - normalizeZip adds missing directory entries for archives produced by Terraform's archive_file data source - uploadArchive sends the archive to Coder with correct content type - Warn when switching between archive_path and directory about hidden file inclusion differences - Document 100 MiB server upload limit in archive_path description Tests: - TestComputeArchiveHash (4 cases) - TestArchiveContentType (5 cases) - TestNormalizeZip (2 cases) - TestReconcileVersionIDs extended with ArchiveHashMatching/Changed --- docs/resources/template.md | 7 +- internal/provider/template_resource.go | 345 ++++++++++++++++++-- internal/provider/template_resource_test.go | 74 ++++- internal/provider/util.go | 15 + internal/provider/util_test.go | 331 +++++++++++++++++++ 5 files changed, 733 insertions(+), 39 deletions(-) create mode 100644 internal/provider/util_test.go diff --git a/docs/resources/template.md b/docs/resources/template.md index 6e761c2b..4ab23208 100644 --- a/docs/resources/template.md +++ b/docs/resources/template.md @@ -93,13 +93,11 @@ resource "coderd_template" "ubuntu-main" { ### Nested Schema for `versions` -Required: - -- `directory` (String) A path to the directory to create the template version from. Changes in the directory contents will trigger the creation of a new template version. - Optional: - `active` (Boolean) Whether this version is the active version of the template. Only one version can be active at a time. +- `archive_path` (String) A path to a `.tar` or `.zip` archive file to upload as the template version source. Mutually exclusive with `directory`. Changes in the archive contents will trigger the creation of a new template version. The archive must not exceed 100 MiB (the Coder server upload limit). +- `directory` (String) A path to the directory to create the template version from. Changes in the directory contents will trigger the creation of a new template version. Conflicts with `archive_path`. - `message` (String) A message describing the changes in this version of the template. Messages longer than 72 characters will be truncated. - `name` (String) The name of the template version. Automatically generated if not provided. If provided, the name *must* change each time the directory contents, or the `tf_vars` attribute are updated. - `provisioner_tags` (Attributes Set) Provisioner tags for the template version. (see [below for nested schema](#nestedatt--versions--provisioner_tags)) @@ -107,6 +105,7 @@ Optional: Read-Only: +- `archive_hash` (String) - `directory_hash` (String) - `id` (String) diff --git a/internal/provider/template_resource.go b/internal/provider/template_resource.go index f053773b..3ffb2748 100644 --- a/internal/provider/template_resource.go +++ b/internal/provider/template_resource.go @@ -1,12 +1,17 @@ package provider import ( + "archive/zip" "bufio" + "bytes" "context" "encoding/json" "fmt" "io" + "os" + "path/filepath" "slices" + "sort" "strings" "time" @@ -162,11 +167,25 @@ type TemplateVersion struct { Message types.String `tfsdk:"message"` Directory types.String `tfsdk:"directory"` DirectoryHash types.String `tfsdk:"directory_hash"` + ArchivePath types.String `tfsdk:"archive_path"` + ArchiveHash types.String `tfsdk:"archive_hash"` Active types.Bool `tfsdk:"active"` TerraformVariables []Variable `tfsdk:"tf_vars"` ProvisionerTags []Variable `tfsdk:"provisioner_tags"` } +// contentHash returns the relevant hash for change detection, regardless of +// whether the version source is a directory or an archive. +func (v TemplateVersion) contentHash() string { + if !v.ArchiveHash.IsNull() && !v.ArchiveHash.IsUnknown() && v.ArchiveHash.ValueString() != "" { + return v.ArchiveHash.ValueString() + } + if !v.DirectoryHash.IsNull() && !v.DirectoryHash.IsUnknown() { + return v.DirectoryHash.ValueString() + } + return "" +} + type Versions []TemplateVersion func (v Versions) ByID(id UUID) *TemplateVersion { @@ -456,12 +475,19 @@ func (r *TemplateResource) Schema(ctx context.Context, req resource.SchemaReques Default: stringdefault.StaticString(""), }, "directory": schema.StringAttribute{ - MarkdownDescription: "A path to the directory to create the template version from. Changes in the directory contents will trigger the creation of a new template version.", - Required: true, + Optional: true, + MarkdownDescription: "A path to the directory to create the template version from. Changes in the directory contents will trigger the creation of a new template version. Conflicts with `archive_path`.", }, "directory_hash": schema.StringAttribute{ Computed: true, }, + "archive_path": schema.StringAttribute{ + Optional: true, + MarkdownDescription: "A path to a `.tar` or `.zip` archive file to upload as the template version source. Mutually exclusive with `directory`. Changes in the archive contents will trigger the creation of a new template version. The archive must not exceed 100 MiB (the Coder server upload limit).", + }, + "archive_hash": schema.StringAttribute{ + Computed: true, + }, "active": schema.BoolAttribute{ MarkdownDescription: "Whether this version is the active version of the template. Only one version can be active at a time.", Computed: true, @@ -594,6 +620,15 @@ func (r *TemplateResource) Create(ctx context.Context, req resource.CreateReques } data.Versions[idx].ID = UUIDValue(versionResp.ID) data.Versions[idx].Name = types.StringValue(versionResp.Name) + // Ensure computed hash fields have concrete values after apply. + // Only one of directory_hash or archive_hash will be meaningful; + // set the other to empty string so Terraform doesn't see unknown values. + if data.Versions[idx].DirectoryHash.IsUnknown() { + data.Versions[idx].DirectoryHash = types.StringValue("") + } + if data.Versions[idx].ArchiveHash.IsUnknown() { + data.Versions[idx].ArchiveHash = types.StringValue("") + } } data.ID = UUIDValue(templateResp.ID) data.DisplayName = types.StringValue(templateResp.DisplayName) @@ -812,6 +847,13 @@ func (r *TemplateResource) Update(ctx context.Context, req resource.UpdateReques } newState.Versions[idx].ID = UUIDValue(versionResp.ID) newState.Versions[idx].Name = types.StringValue(versionResp.Name) + // Ensure computed hash fields have concrete values after apply. + if newState.Versions[idx].DirectoryHash.IsUnknown() { + newState.Versions[idx].DirectoryHash = types.StringValue("") + } + if newState.Versions[idx].ArchiveHash.IsUnknown() { + newState.Versions[idx].ArchiveHash = types.StringValue("") + } if newState.Versions[idx].Active.ValueBool() { err := markActive(ctx, client, templateID, newState.Versions[idx].ID.ValueUUID()) if err != nil { @@ -845,6 +887,14 @@ func (r *TemplateResource) Update(ctx context.Context, req resource.UpdateReques return } } + // Ensure computed hash fields have concrete values after apply, + // even for reused versions. + if newState.Versions[idx].DirectoryHash.IsUnknown() { + newState.Versions[idx].DirectoryHash = types.StringValue("") + } + if newState.Versions[idx].ArchiveHash.IsUnknown() { + newState.Versions[idx].ArchiveHash = types.StringValue("") + } } } // TODO(ethanndickson): Remove this once the provider requires a Coder @@ -948,7 +998,38 @@ func (a *versionsValidator) ValidateList(ctx context.Context, req validator.List // Check all versions have unique names uniqueNames := make(map[string]struct{}) - for _, version := range data { + for i, version := range data { + // Exactly one of directory or archive_path must be set. + dirSet := !version.Directory.IsNull() + archiveSet := !version.ArchivePath.IsNull() + if !dirSet && !archiveSet { + resp.Diagnostics.AddAttributeError( + req.Path.AtListIndex(i), + "Invalid Version Source", + "Exactly one of `directory` or `archive_path` must be specified for each template version.", + ) + return + } + if dirSet && archiveSet { + resp.Diagnostics.AddAttributeError( + req.Path.AtListIndex(i), + "Invalid Version Source", + "`directory` and `archive_path` are mutually exclusive for each template version.", + ) + return + } + if archiveSet && !version.ArchivePath.IsUnknown() { + archivePath := version.ArchivePath.ValueString() + if !strings.HasSuffix(archivePath, ".tar") && !strings.HasSuffix(archivePath, ".zip") { + resp.Diagnostics.AddAttributeError( + req.Path.AtListIndex(i).AtName("archive_path"), + "Invalid Archive Format", + fmt.Sprintf("archive_path must reference a .tar or .zip file, got %q", filepath.Base(archivePath)), + ) + return + } + } + if version.Name.IsNull() || version.Name.IsUnknown() { continue } @@ -1011,12 +1092,65 @@ func (d *versionsPlanModifier) PlanModifyList(ctx context.Context, req planmodif } for i := range planVersions { - hash, err := computeDirectoryHash(planVersions[i].Directory.ValueString()) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to compute directory hash: %s", err)) - return + if !planVersions[i].ArchivePath.IsNull() && !planVersions[i].ArchivePath.IsUnknown() { + hash, err := computeArchiveHash(planVersions[i].ArchivePath.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to compute archive hash: %s", err)) + return + } + planVersions[i].ArchiveHash = types.StringValue(hash) + } else if !planVersions[i].ArchivePath.IsNull() && planVersions[i].ArchivePath.IsUnknown() { + // archive_path is set but not yet known (depends on another resource). + // We can't compute the hash yet; mark it as unknown. + planVersions[i].ArchiveHash = types.StringUnknown() + } else if !planVersions[i].Directory.IsNull() && !planVersions[i].Directory.IsUnknown() { + hash, err := computeDirectoryHash(planVersions[i].Directory.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to compute directory hash: %s", err)) + return + } + planVersions[i].DirectoryHash = types.StringValue(hash) + } + } + + // Warn if any version is switching between archive_path and directory. + if !req.StateValue.IsNull() { + var stateVersions Versions + resp.Diagnostics.Append(req.StateValue.ElementsAs(ctx, &stateVersions, false)...) + if !resp.Diagnostics.HasError() { + for i := range planVersions { + if i >= len(stateVersions) { + break + } + hadArchive := !stateVersions[i].ArchivePath.IsNull() && stateVersions[i].ArchivePath.ValueString() != "" + hadDirectory := !stateVersions[i].Directory.IsNull() && stateVersions[i].Directory.ValueString() != "" + nowArchive := !planVersions[i].ArchivePath.IsNull() + nowDirectory := !planVersions[i].Directory.IsNull() + + if hadArchive && nowDirectory { + resp.Diagnostics.AddWarning( + "Switching from archive_path to directory", + fmt.Sprintf( + "Version %q (index %d) is switching from archive_path to directory. "+ + "The directory source uses provisionersdk.Tar, which skips hidden files "+ + "(dotfiles such as .claude.json, .mcp.json, etc.). If your template relies "+ + "on hidden files, consider continuing to use archive_path instead.", + planVersions[i].Name.ValueString(), i, + ), + ) + } else if hadDirectory && nowArchive { + resp.Diagnostics.AddWarning( + "Switching from directory to archive_path", + fmt.Sprintf( + "Version %q (index %d) is switching from directory to archive_path. "+ + "The archive may include hidden files (dotfiles) that were previously "+ + "excluded by the directory source.", + planVersions[i].Name.ValueString(), i, + ), + ) + } + } } - planVersions[i].DirectoryHash = types.StringValue(hash) } var lv LastVersionsByHash @@ -1104,6 +1238,138 @@ func uploadDirectory(ctx context.Context, client *codersdk.Client, logger slog.L return &resp, nil } +func archiveContentType(archivePath string) (string, error) { + switch { + case strings.HasSuffix(archivePath, ".tar"): + return codersdk.ContentTypeTar, nil + case strings.HasSuffix(archivePath, ".zip"): + return codersdk.ContentTypeZip, nil + default: + return "", fmt.Errorf("unsupported archive format %q: must be .tar or .zip", filepath.Ext(archivePath)) + } +} + +// normalizeZip rewrites a zip archive to ensure that all intermediate directory +// entries exist. Some tools (e.g. Terraform's archive_file data source) produce +// zips that contain only file entries without explicit directory entries. Coder's +// server-side zip extractor expects directories to be present. +func normalizeZip(archivePath string) ([]byte, error) { + r, err := zip.OpenReader(archivePath) + if err != nil { + return nil, fmt.Errorf("failed to open zip: %w", err) + } + defer r.Close() //nolint:errcheck // Best-effort close of read-only zip. + + // Collect the set of directories that already have explicit entries. + existingDirs := make(map[string]bool) + for _, f := range r.File { + if f.FileInfo().IsDir() { + existingDirs[f.Name] = true + } + } + + // Determine which intermediate directories are missing. + missingDirs := make(map[string]bool) + for _, f := range r.File { + dir := filepath.Dir(f.Name) + for dir != "." && dir != "/" && dir != "" { + dirEntry := dir + "/" + if existingDirs[dirEntry] || missingDirs[dirEntry] { + break + } + missingDirs[dirEntry] = true + dir = filepath.Dir(dir) + } + } + + // If nothing is missing, read and return the original file as-is. + if len(missingDirs) == 0 { + data, err := os.ReadFile(archivePath) + if err != nil { + return nil, fmt.Errorf("failed to read archive: %w", err) + } + return data, nil + } + + // Sort missing dirs so parents come before children. + sortedMissing := make([]string, 0, len(missingDirs)) + for d := range missingDirs { + sortedMissing = append(sortedMissing, d) + } + sort.Strings(sortedMissing) + + // Rewrite the zip with directory entries first, then all original entries. + var buf bytes.Buffer + w := zip.NewWriter(&buf) + + // Add missing directory entries. + for _, d := range sortedMissing { + _, err := w.CreateHeader(&zip.FileHeader{ + Name: d, + ExternalAttrs: 0o755 << 16, // rwxr-xr-x in Unix mode + CreatorVersion: 3 << 8, // Unix + }) + if err != nil { + return nil, fmt.Errorf("failed to create directory entry %q: %w", d, err) + } + } + + // Copy all original entries. + for _, f := range r.File { + fw, err := w.CreateHeader(&f.FileHeader) + if err != nil { + return nil, fmt.Errorf("failed to create entry %q: %w", f.Name, err) + } + if f.FileInfo().IsDir() { + continue + } + rc, err := f.Open() + if err != nil { + return nil, fmt.Errorf("failed to open entry %q: %w", f.Name, err) + } + _, err = io.Copy(fw, rc) + _ = rc.Close() + if err != nil { + return nil, fmt.Errorf("failed to copy entry %q: %w", f.Name, err) + } + } + + if err := w.Close(); err != nil { + return nil, fmt.Errorf("failed to finalize zip: %w", err) + } + return buf.Bytes(), nil +} + +func uploadArchive(ctx context.Context, client *codersdk.Client, archivePath string) (*codersdk.UploadResponse, error) { + contentType, err := archiveContentType(archivePath) + if err != nil { + return nil, err + } + + var reader io.Reader + if contentType == codersdk.ContentTypeZip { + // Normalize the zip to ensure intermediate directory entries exist. + data, err := normalizeZip(archivePath) + if err != nil { + return nil, fmt.Errorf("failed to normalize zip: %w", err) + } + reader = bytes.NewReader(data) + } else { + f, err := os.Open(archivePath) + if err != nil { + return nil, fmt.Errorf("failed to open archive: %w", err) + } + defer f.Close() //nolint:errcheck // Best-effort close; upload already consumed the reader. + reader = bufio.NewReader(f) + } + + resp, err := client.Upload(ctx, contentType, reader) + if err != nil { + return nil, err + } + return &resp, nil +} + func waitForJob(ctx context.Context, client *codersdk.Client, version *codersdk.TemplateVersion) ([]codersdk.ProvisionerJobLog, error) { const maxRetries = 3 var allLogs []codersdk.ProvisionerJobLog @@ -1180,21 +1446,36 @@ type newVersionRequest struct { func newVersion(ctx context.Context, client *codersdk.Client, req newVersionRequest) (*codersdk.TemplateVersion, []codersdk.ProvisionerJobLog, error) { var logs []codersdk.ProvisionerJobLog - directory := req.Version.Directory.ValueString() - tflog.Info(ctx, "uploading directory") - uploadResp, err := uploadDirectory(ctx, client, slog.Make(newTFLogSink(ctx)), directory) - if err != nil { - return nil, logs, fmt.Errorf("failed to upload directory: %s", err) - } - tflog.Info(ctx, "successfully uploaded directory") - tflog.Info(ctx, "discovering and parsing vars files") - varFiles, err := codersdk.DiscoverVarsFiles(directory) - if err != nil { - return nil, logs, fmt.Errorf("failed to discover vars files: %s", err) - } - vars, err := codersdk.ParseUserVariableValues(varFiles, "", []string{}) - if err != nil { - return nil, logs, fmt.Errorf("failed to parse user variable values: %s", err) + var err error + var uploadResp *codersdk.UploadResponse + var vars []codersdk.VariableValue + + if !req.Version.ArchivePath.IsNull() && !req.Version.ArchivePath.IsUnknown() { + archivePath := req.Version.ArchivePath.ValueString() + tflog.Info(ctx, "uploading archive", map[string]any{"archive_path": archivePath}) + uploadResp, err = uploadArchive(ctx, client, archivePath) + if err != nil { + return nil, logs, fmt.Errorf("failed to upload archive: %s", err) + } + tflog.Info(ctx, "successfully uploaded archive") + tflog.Info(ctx, "skipping vars file discovery for archive upload, use tf_vars to provide variables") + } else { + directory := req.Version.Directory.ValueString() + tflog.Info(ctx, "uploading directory") + uploadResp, err = uploadDirectory(ctx, client, slog.Make(newTFLogSink(ctx)), directory) + if err != nil { + return nil, logs, fmt.Errorf("failed to upload directory: %s", err) + } + tflog.Info(ctx, "successfully uploaded directory") + tflog.Info(ctx, "discovering and parsing vars files") + varFiles, err := codersdk.DiscoverVarsFiles(directory) + if err != nil { + return nil, logs, fmt.Errorf("failed to discover vars files: %s", err) + } + vars, err = codersdk.ParseUserVariableValues(varFiles, "", []string{}) + if err != nil { + return nil, logs, fmt.Errorf("failed to parse user variable values: %s", err) + } } tflog.Info(ctx, "discovered and parsed vars files", map[string]any{ "vars": vars, @@ -1443,7 +1724,7 @@ type privateState interface { func (v Versions) setPrivateState(ctx context.Context, ps privateState) (diags diag.Diagnostics) { lv := make(LastVersionsByHash) for _, version := range v { - vbh, ok := lv[version.DirectoryHash.ValueString()] + vbh, ok := lv[version.contentHash()] tfVars := make(map[string]string, len(version.TerraformVariables)) for _, tfVar := range version.TerraformVariables { tfVars[tfVar.Name.ValueString()] = tfVar.Value.ValueString() @@ -1451,14 +1732,14 @@ func (v Versions) setPrivateState(ctx context.Context, ps privateState) (diags d // Store the IDs and names of all versions with the same directory hash, // in the order they appear if ok { - lv[version.DirectoryHash.ValueString()] = append(vbh, PreviousTemplateVersion{ + lv[version.contentHash()] = append(vbh, PreviousTemplateVersion{ ID: version.ID.ValueUUID(), Name: version.Name.ValueString(), TFVars: tfVars, Active: version.Active.ValueBool(), }) } else { - lv[version.DirectoryHash.ValueString()] = []PreviousTemplateVersion{ + lv[version.contentHash()] = []PreviousTemplateVersion{ { ID: version.ID.ValueUUID(), Name: version.Name.ValueString(), @@ -1485,7 +1766,7 @@ func (planVersions Versions) reconcileVersionIDs(lv LastVersionsByHash, configVe } for i := range planVersions { - prevList, ok := lv[planVersions[i].DirectoryHash.ValueString()] + prevList, ok := lv[planVersions[i].contentHash()] // If not in state, mark as known after apply since we'll create a new version. // Versions whose Terraform configuration has not changed will have known // IDs at this point, so we need to set this manually. @@ -1504,7 +1785,7 @@ func (planVersions Versions) reconcileVersionIDs(lv LastVersionsByHash, configVe // it from the previous version candidates if planVersions[i].Name.ValueString() == prev.Name { planVersions[i].ID = UUIDValue(prev.ID) - lv[planVersions[i].DirectoryHash.ValueString()] = append(prevList[:j], prevList[j+1:]...) + lv[planVersions[i].contentHash()] = append(prevList[:j], prevList[j+1:]...) break } } @@ -1514,13 +1795,13 @@ func (planVersions Versions) reconcileVersionIDs(lv LastVersionsByHash, configVe // For versions whose hash was found in the private state but couldn't be // matched, use the leftovers in the order they appear for i := range planVersions { - prevList := lv[planVersions[i].DirectoryHash.ValueString()] + prevList := lv[planVersions[i].contentHash()] if len(prevList) > 0 && planVersions[i].ID.IsUnknown() { planVersions[i].ID = UUIDValue(prevList[0].ID) if planVersions[i].Name.IsUnknown() { planVersions[i].Name = types.StringValue(prevList[0].Name) } - lv[planVersions[i].DirectoryHash.ValueString()] = prevList[1:] + lv[planVersions[i].contentHash()] = prevList[1:] } } @@ -1528,7 +1809,7 @@ func (planVersions Versions) reconcileVersionIDs(lv LastVersionsByHash, configVe // we need to create a new version with the new variables. for i := range planVersions { if !planVersions[i].ID.IsUnknown() { - prevs, ok := fullLv[planVersions[i].DirectoryHash.ValueString()] + prevs, ok := fullLv[planVersions[i].contentHash()] if !ok { continue } @@ -1552,7 +1833,7 @@ func (planVersions Versions) reconcileVersionIDs(lv LastVersionsByHash, configVe if !hasOneActiveVersion { for i := range planVersions { if !planVersions[i].ID.IsUnknown() { - prevs, ok := fullLv[planVersions[i].DirectoryHash.ValueString()] + prevs, ok := fullLv[planVersions[i].contentHash()] if !ok { continue } diff --git a/internal/provider/template_resource_test.go b/internal/provider/template_resource_test.go index 971de6e6..31d40abc 100644 --- a/internal/provider/template_resource_test.go +++ b/internal/provider/template_resource_test.go @@ -1158,9 +1158,10 @@ resource "coderd_template" "test" { versions = [ {{- range .Versions }} { - name = {{orNull .Name}} - directory = {{orNull .Directory}} - active = {{orNull .Active}} + name = {{orNull .Name}} + directory = {{orNull .Directory}} + archive_path = {{orNull .ArchivePath}} + active = {{orNull .Active}} tf_vars = [ {{- range .TerraformVariables }} @@ -1194,6 +1195,7 @@ type testAccTemplateVersionConfig struct { Name *string Message *string Directory *string + ArchivePath *string Active *bool TerraformVariables []testAccTemplateKeyValueConfig } @@ -1592,6 +1594,72 @@ func TestReconcileVersionIDs(t *testing.T) { cfgHasActiveVersion: false, expectError: true, }, + { + Name: "ArchiveHashMatching", + planVersions: []TemplateVersion{ + { + Name: types.StringValue("archive-ver"), + ArchiveHash: types.StringValue("archivehash123"), + ID: NewUUIDUnknown(), + TerraformVariables: []Variable{}, + }, + }, + configVersions: []TemplateVersion{ + { + Name: types.StringValue("archive-ver"), + }, + }, + inputState: map[string][]PreviousTemplateVersion{ + "archivehash123": { + { + ID: aUUID, + Name: "archive-ver", + TFVars: map[string]string{}, + }, + }, + }, + expectedVersions: []TemplateVersion{ + { + Name: types.StringValue("archive-ver"), + ArchiveHash: types.StringValue("archivehash123"), + ID: UUIDValue(aUUID), + TerraformVariables: []Variable{}, + }, + }, + }, + { + Name: "ArchiveHashChanged", + planVersions: []TemplateVersion{ + { + Name: types.StringValue("archive-ver"), + ArchiveHash: types.StringValue("newhash456"), + ID: NewUUIDUnknown(), + TerraformVariables: []Variable{}, + }, + }, + configVersions: []TemplateVersion{ + { + Name: types.StringValue("archive-ver"), + }, + }, + inputState: map[string][]PreviousTemplateVersion{ + "oldhash123": { + { + ID: aUUID, + Name: "archive-ver", + TFVars: map[string]string{}, + }, + }, + }, + expectedVersions: []TemplateVersion{ + { + Name: types.StringValue("archive-ver"), + ArchiveHash: types.StringValue("newhash456"), + ID: NewUUIDUnknown(), + TerraformVariables: []Variable{}, + }, + }, + }, } for _, c := range cases { diff --git a/internal/provider/util.go b/internal/provider/util.go index dbc3441c..d02f5eb8 100644 --- a/internal/provider/util.go +++ b/internal/provider/util.go @@ -5,6 +5,7 @@ import ( "encoding/hex" "errors" "fmt" + "io" "net/http" "os" "path/filepath" @@ -86,6 +87,20 @@ func computeDirectoryHash(directory string) (string, error) { return hex.EncodeToString(hash.Sum(nil)), nil } +func computeArchiveHash(archivePath string) (string, error) { + f, err := os.Open(archivePath) + if err != nil { + return "", err + } + defer f.Close() //nolint:errcheck // Best-effort close of read-only file. + + hash := sha256.New() + if _, err := io.Copy(hash, f); err != nil { + return "", err + } + return hex.EncodeToString(hash.Sum(nil)), nil +} + // memberDiff returns the members to add and remove from the group, given the // current members and the planned members. plannedMembers is deliberately our // custom type, as Terraform cannot automatically produce `[]uuid.UUID` from a diff --git a/internal/provider/util_test.go b/internal/provider/util_test.go new file mode 100644 index 00000000..8e5e4489 --- /dev/null +++ b/internal/provider/util_test.go @@ -0,0 +1,331 @@ +package provider + +import ( + "archive/tar" + "archive/zip" + "bytes" + "io" + "os" + "path/filepath" + "testing" + + "github.com/coder/coder/v2/codersdk" + "github.com/stretchr/testify/require" +) + +func TestComputeArchiveHash(t *testing.T) { + t.Parallel() + + t.Run("ValidTarFile", func(t *testing.T) { + t.Parallel() + // Create a minimal tar file + tarPath := filepath.Join(t.TempDir(), "test.tar") + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + content := []byte("hello world") + err := tw.WriteHeader(&tar.Header{ + Name: "test.txt", + Size: int64(len(content)), + Mode: 0644, + }) + require.NoError(t, err) + _, err = tw.Write(content) + require.NoError(t, err) + require.NoError(t, tw.Close()) + err = os.WriteFile(tarPath, buf.Bytes(), 0644) + require.NoError(t, err) + + hash, err := computeArchiveHash(tarPath) + require.NoError(t, err) + require.NotEmpty(t, hash) + require.Len(t, hash, 64) // SHA-256 hex = 64 chars + + // Same file should produce same hash + hash2, err := computeArchiveHash(tarPath) + require.NoError(t, err) + require.Equal(t, hash, hash2) + }) + + t.Run("ValidZipFile", func(t *testing.T) { + t.Parallel() + zipPath := filepath.Join(t.TempDir(), "test.zip") + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + fw, err := zw.Create("test.txt") + require.NoError(t, err) + _, err = fw.Write([]byte("hello world")) + require.NoError(t, err) + require.NoError(t, zw.Close()) + err = os.WriteFile(zipPath, buf.Bytes(), 0644) + require.NoError(t, err) + + hash, err := computeArchiveHash(zipPath) + require.NoError(t, err) + require.NotEmpty(t, hash) + require.Len(t, hash, 64) + }) + + t.Run("DifferentContentDifferentHash", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + file1 := filepath.Join(dir, "a.tar") + err := os.WriteFile(file1, []byte("content-a"), 0644) + require.NoError(t, err) + + file2 := filepath.Join(dir, "b.tar") + err = os.WriteFile(file2, []byte("content-b"), 0644) + require.NoError(t, err) + + hash1, err := computeArchiveHash(file1) + require.NoError(t, err) + hash2, err := computeArchiveHash(file2) + require.NoError(t, err) + require.NotEqual(t, hash1, hash2) + }) + + t.Run("NonexistentFile", func(t *testing.T) { + t.Parallel() + _, err := computeArchiveHash("/nonexistent/path.tar") + require.Error(t, err) + }) +} + +func TestNormalizeZip(t *testing.T) { + t.Parallel() + + t.Run("AddsMissingDirectoryEntries", func(t *testing.T) { + t.Parallel() + // Create a zip with only file entries (no directory entries), + // mimicking hashicorp/archive's archive_file data source. + zipPath := filepath.Join(t.TempDir(), "nodirs.zip") + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + + // Top-level regular file. + fw, err := zw.Create("main.tf") + require.NoError(t, err) + _, err = fw.Write([]byte("resource {}")) + require.NoError(t, err) + + // Top-level hidden file (dotfile at root). + fw, err = zw.Create(".env") + require.NoError(t, err) + _, err = fw.Write([]byte("SECRET=value")) + require.NoError(t, err) + + // Top-level hidden directory with a file inside. + fw, err = zw.Create(".config/settings.json") + require.NoError(t, err) + _, err = fw.Write([]byte(`{"key": "value"}`)) + require.NoError(t, err) + + // Hidden file inside a regular directory. + fw, err = zw.Create("subdir/.hidden") + require.NoError(t, err) + _, err = fw.Write([]byte("hidden content")) + require.NoError(t, err) + + // Deeply nested hidden directory with a hidden file inside. + fw, err = zw.Create("a/b/.secret-dir/.credentials") + require.NoError(t, err) + _, err = fw.Write([]byte("token=abc123")) + require.NoError(t, err) + + require.NoError(t, zw.Close()) + err = os.WriteFile(zipPath, buf.Bytes(), 0644) + require.NoError(t, err) + + // Verify original has no directory entries. + origReader, err := zip.OpenReader(zipPath) + require.NoError(t, err) + for _, f := range origReader.File { + require.False(t, f.FileInfo().IsDir(), "expected no dir entries in original, got %q", f.Name) + } + require.NoError(t, origReader.Close()) + + // Normalize. + data, err := normalizeZip(zipPath) + require.NoError(t, err) + + // Read normalized zip and collect entries. + normReader, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + require.NoError(t, err) + + dirs := make(map[string]bool) + files := make(map[string]bool) + for _, f := range normReader.File { + if f.FileInfo().IsDir() { + dirs[f.Name] = true + } else { + files[f.Name] = true + } + } + + // All intermediate directories should exist, including hidden ones. + require.True(t, dirs[".config/"], "missing .config/") + require.True(t, dirs["subdir/"], "missing subdir/") + require.True(t, dirs["a/"], "missing a/") + require.True(t, dirs["a/b/"], "missing a/b/") + require.True(t, dirs["a/b/.secret-dir/"], "missing a/b/.secret-dir/") + + // All original files should still be present. + require.True(t, files["main.tf"], "missing main.tf") + require.True(t, files[".env"], "missing .env") + require.True(t, files[".config/settings.json"], "missing .config/settings.json") + require.True(t, files["subdir/.hidden"], "missing subdir/.hidden") + require.True(t, files["a/b/.secret-dir/.credentials"], "missing a/b/.secret-dir/.credentials") + }) + + t.Run("PreservesDirectoryPermissions", func(t *testing.T) { + t.Parallel() + // Verify that synthesized directory entries have proper mode bits + // so the server-side extractor can create them. + zipPath := filepath.Join(t.TempDir(), "needsperms.zip") + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + + fw, err := zw.Create("deep/nested/dir/file.txt") + require.NoError(t, err) + _, err = fw.Write([]byte("content")) + require.NoError(t, err) + require.NoError(t, zw.Close()) + err = os.WriteFile(zipPath, buf.Bytes(), 0644) + require.NoError(t, err) + + data, err := normalizeZip(zipPath) + require.NoError(t, err) + + normReader, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + require.NoError(t, err) + + for _, f := range normReader.File { + if f.FileInfo().IsDir() { + mode := f.Mode() + require.NotZero(t, mode&0700, + "directory %q should have owner rwx bits set, got %s", f.Name, mode) + } + } + }) + + t.Run("NoChangeWhenDirsExist", func(t *testing.T) { + t.Parallel() + zipPath := filepath.Join(t.TempDir(), "withdirs.zip") + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + + // Add directory entry explicitly. + _, err := zw.Create("subdir/") + require.NoError(t, err) + fw, err := zw.Create("subdir/file.txt") + require.NoError(t, err) + _, err = fw.Write([]byte("content")) + require.NoError(t, err) + require.NoError(t, zw.Close()) + + origData := buf.Bytes() + err = os.WriteFile(zipPath, origData, 0644) + require.NoError(t, err) + + // Normalize should return original bytes unchanged. + data, err := normalizeZip(zipPath) + require.NoError(t, err) + require.Equal(t, origData, data) + }) + + t.Run("PreservesFileContent", func(t *testing.T) { + t.Parallel() + // Verify normalization doesn't corrupt file contents, + // including hidden files at various levels. + zipPath := filepath.Join(t.TempDir(), "content.zip") + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + + fileContents := map[string]string{ + ".gitignore": "node_modules/\n.env\n", + "main.tf": "resource \"null_resource\" \"test\" {}", + ".vscode/settings.json": `{"editor.formatOnSave": true}`, + "scripts/.local/bin/deploy": "#!/bin/bash\necho deploy", + } + for name, content := range fileContents { + fw, err := zw.Create(name) + require.NoError(t, err) + _, err = fw.Write([]byte(content)) + require.NoError(t, err) + } + require.NoError(t, zw.Close()) + err := os.WriteFile(zipPath, buf.Bytes(), 0644) + require.NoError(t, err) + + data, err := normalizeZip(zipPath) + require.NoError(t, err) + + normReader, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + require.NoError(t, err) + + for _, f := range normReader.File { + if f.FileInfo().IsDir() { + continue + } + expected, ok := fileContents[f.Name] + require.True(t, ok, "unexpected file in normalized zip: %q", f.Name) + rc, err := f.Open() + require.NoError(t, err) + actual, err := io.ReadAll(rc) + require.NoError(t, rc.Close()) + require.NoError(t, err) + require.Equal(t, expected, string(actual), + "content mismatch for %q", f.Name) + } + }) +} + +func TestArchiveContentType(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path string + expected string + expectError bool + }{ + { + name: "TarFile", + path: "/path/to/template.tar", + expected: codersdk.ContentTypeTar, + }, + { + name: "ZipFile", + path: "/path/to/template.zip", + expected: codersdk.ContentTypeZip, + }, + { + name: "TarGzFile", + path: "/path/to/template.tar.gz", + expectError: true, + }, + { + name: "RandomFile", + path: "/path/to/template.txt", + expectError: true, + }, + { + name: "NoExtension", + path: "/path/to/template", + expectError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + ct, err := archiveContentType(tc.path) + if tc.expectError { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, tc.expected, ct) + } + }) + } +} From a48a37f432e42753736f347f602d663478c9f7f9 Mon Sep 17 00:00:00 2001 From: ju-pe Date: Thu, 7 May 2026 16:46:53 +0000 Subject: [PATCH 2/3] fix: skip XOR validation when directory/archive_path values are unknown Per Terraform framework guidance, validators should not emit errors for unknown values. When either directory or archive_path is unknown (e.g., referencing a computed value from another resource), the XOR check is skipped. Terraform will re-run validators once values resolve during apply. --- internal/provider/template_resource.go | 38 +++++++++++++++----------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/internal/provider/template_resource.go b/internal/provider/template_resource.go index 3ffb2748..bf0523bd 100644 --- a/internal/provider/template_resource.go +++ b/internal/provider/template_resource.go @@ -1000,25 +1000,31 @@ func (a *versionsValidator) ValidateList(ctx context.Context, req validator.List uniqueNames := make(map[string]struct{}) for i, version := range data { // Exactly one of directory or archive_path must be set. + // Skip validation when either value is unknown (depends on another + // resource). Terraform will re-run validators once values resolve. dirSet := !version.Directory.IsNull() archiveSet := !version.ArchivePath.IsNull() - if !dirSet && !archiveSet { - resp.Diagnostics.AddAttributeError( - req.Path.AtListIndex(i), - "Invalid Version Source", - "Exactly one of `directory` or `archive_path` must be specified for each template version.", - ) - return - } - if dirSet && archiveSet { - resp.Diagnostics.AddAttributeError( - req.Path.AtListIndex(i), - "Invalid Version Source", - "`directory` and `archive_path` are mutually exclusive for each template version.", - ) - return + dirUnknown := version.Directory.IsUnknown() + archiveUnknown := version.ArchivePath.IsUnknown() + if !dirUnknown && !archiveUnknown { + if !dirSet && !archiveSet { + resp.Diagnostics.AddAttributeError( + req.Path.AtListIndex(i), + "Invalid Version Source", + "Exactly one of `directory` or `archive_path` must be specified for each template version.", + ) + return + } + if dirSet && archiveSet { + resp.Diagnostics.AddAttributeError( + req.Path.AtListIndex(i), + "Invalid Version Source", + "`directory` and `archive_path` are mutually exclusive for each template version.", + ) + return + } } - if archiveSet && !version.ArchivePath.IsUnknown() { + if archiveSet && !archiveUnknown { archivePath := version.ArchivePath.ValueString() if !strings.HasSuffix(archivePath, ".tar") && !strings.HasSuffix(archivePath, ".zip") { resp.Diagnostics.AddAttributeError( From aa7778f2abe2717c6cbe1bee8ed54dd7a4ca0488 Mon Sep 17 00:00:00 2001 From: ju-pe Date: Thu, 7 May 2026 17:58:30 +0000 Subject: [PATCH 3/3] fix: use path.Dir for zip entries and enhance switch warning - Use stdpath.Dir (stdlib path package) instead of filepath.Dir when traversing ZIP entry names. ZIP entries always use forward slashes per spec, and filepath.Dir is OS-specific (would produce backslashes on Windows). - Enhance the directory-to-archive_path switch warning to also mention that automatic tfvars file discovery is not performed for archive uploads, directing users to use tf_vars explicitly. --- internal/provider/template_resource.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/internal/provider/template_resource.go b/internal/provider/template_resource.go index bf0523bd..613cccd9 100644 --- a/internal/provider/template_resource.go +++ b/internal/provider/template_resource.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "os" + stdpath "path" "path/filepath" "slices" "sort" @@ -1150,7 +1151,9 @@ func (d *versionsPlanModifier) PlanModifyList(ctx context.Context, req planmodif fmt.Sprintf( "Version %q (index %d) is switching from directory to archive_path. "+ "The archive may include hidden files (dotfiles) that were previously "+ - "excluded by the directory source.", + "excluded by the directory source. Additionally, automatic tfvars file "+ + "discovery (terraform.tfvars, *.auto.tfvars) is not performed for archive "+ + "uploads — use the `tf_vars` attribute to provide variable values explicitly.", planVersions[i].Name.ValueString(), i, ), ) @@ -1277,14 +1280,14 @@ func normalizeZip(archivePath string) ([]byte, error) { // Determine which intermediate directories are missing. missingDirs := make(map[string]bool) for _, f := range r.File { - dir := filepath.Dir(f.Name) + dir := stdpath.Dir(f.Name) for dir != "." && dir != "/" && dir != "" { dirEntry := dir + "/" if existingDirs[dirEntry] || missingDirs[dirEntry] { break } missingDirs[dirEntry] = true - dir = filepath.Dir(dir) + dir = stdpath.Dir(dir) } }