diff --git a/docs/resources/template.md b/docs/resources/template.md index 6e761c2..7115b9a 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)) diff --git a/internal/provider/template_resource.go b/internal/provider/template_resource.go index f053773..2c5018a 100644 --- a/internal/provider/template_resource.go +++ b/internal/provider/template_resource.go @@ -6,6 +6,8 @@ import ( "encoding/json" "fmt" "io" + "os" + "path/filepath" "slices" "strings" "time" @@ -162,6 +164,7 @@ 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"` Active types.Bool `tfsdk:"active"` TerraformVariables []Variable `tfsdk:"tf_vars"` ProvisionerTags []Variable `tfsdk:"provisioner_tags"` @@ -456,12 +459,16 @@ 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).", + }, "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 +601,22 @@ 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) + // If the plan modifier couldn't compute the hash (source path was unknown + // at plan time), compute it now that all values are resolved. + if data.Versions[idx].DirectoryHash.IsUnknown() { + var hash string + var hashErr error + if !data.Versions[idx].ArchivePath.IsNull() { + hash, hashErr = computeArchiveHash(data.Versions[idx].ArchivePath.ValueString()) + } else { + hash, hashErr = computeDirectoryHash(data.Versions[idx].Directory.ValueString()) + } + if hashErr != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to compute content hash: %s", hashErr)) + return + } + data.Versions[idx].DirectoryHash = types.StringValue(hash) + } } data.ID = UUIDValue(templateResp.ID) data.DisplayName = types.StringValue(templateResp.DisplayName) @@ -812,6 +835,22 @@ 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) + // If the plan modifier couldn't compute the hash (source path was unknown + // at plan time), compute it now that all values are resolved. + if newState.Versions[idx].DirectoryHash.IsUnknown() { + var hash string + var hashErr error + if !newState.Versions[idx].ArchivePath.IsNull() { + hash, hashErr = computeArchiveHash(newState.Versions[idx].ArchivePath.ValueString()) + } else { + hash, hashErr = computeDirectoryHash(newState.Versions[idx].Directory.ValueString()) + } + if hashErr != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to compute content hash: %s", hashErr)) + return + } + newState.Versions[idx].DirectoryHash = types.StringValue(hash) + } if newState.Versions[idx].Active.ValueBool() { err := markActive(ctx, client, templateID, newState.Versions[idx].ID.ValueUUID()) if err != nil { @@ -845,6 +884,22 @@ func (r *TemplateResource) Update(ctx context.Context, req resource.UpdateReques return } } + // If the plan modifier couldn't compute the hash (source path was unknown + // at plan time), compute it now that all values are resolved. + if newState.Versions[idx].DirectoryHash.IsUnknown() { + var hash string + var hashErr error + if !newState.Versions[idx].ArchivePath.IsNull() { + hash, hashErr = computeArchiveHash(newState.Versions[idx].ArchivePath.ValueString()) + } else { + hash, hashErr = computeDirectoryHash(newState.Versions[idx].Directory.ValueString()) + } + if hashErr != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to compute content hash: %s", hashErr)) + return + } + newState.Versions[idx].DirectoryHash = types.StringValue(hash) + } } } // TODO(ethanndickson): Remove this once the provider requires a Coder @@ -948,7 +1003,44 @@ 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. + // 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() + 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 && !archiveUnknown { + 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 +1103,67 @@ 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].DirectoryHash = 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].DirectoryHash = 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. 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, + ), + ) + } + } } - planVersions[i].DirectoryHash = types.StringValue(hash) } var lv LastVersionsByHash @@ -1104,6 +1251,36 @@ 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)) + } +} + +func uploadArchive(ctx context.Context, client *codersdk.Client, archivePath string) (*codersdk.UploadResponse, error) { + contentType, err := archiveContentType(archivePath) + if err != nil { + return nil, err + } + + 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. + + resp, err := client.Upload(ctx, contentType, bufio.NewReader(f)) + 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 +1357,39 @@ 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() + if err := validateArchiveSize(archivePath); err != nil { + return nil, logs, err + } + 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, diff --git a/internal/provider/template_resource_test.go b/internal/provider/template_resource_test.go index 971de6e..1ed7192 100644 --- a/internal/provider/template_resource_test.go +++ b/internal/provider/template_resource_test.go @@ -1,9 +1,12 @@ package provider import ( + "archive/tar" + "archive/zip" "context" "fmt" "os" + "path/filepath" "regexp" "slices" "strings" @@ -1158,9 +1161,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 +1198,7 @@ type testAccTemplateVersionConfig struct { Name *string Message *string Directory *string + ArchivePath *string Active *bool TerraformVariables []testAccTemplateKeyValueConfig } @@ -1592,6 +1597,72 @@ func TestReconcileVersionIDs(t *testing.T) { cfgHasActiveVersion: false, expectError: true, }, + { + Name: "ArchiveHashMatching", + planVersions: []TemplateVersion{ + { + Name: types.StringValue("archive-ver"), + DirectoryHash: 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"), + DirectoryHash: types.StringValue("archivehash123"), + ID: UUIDValue(aUUID), + TerraformVariables: []Variable{}, + }, + }, + }, + { + Name: "ArchiveHashChanged", + planVersions: []TemplateVersion{ + { + Name: types.StringValue("archive-ver"), + DirectoryHash: 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"), + DirectoryHash: types.StringValue("newhash456"), + ID: NewUUIDUnknown(), + TerraformVariables: []Variable{}, + }, + }, + }, } for _, c := range cases { @@ -1609,3 +1680,104 @@ func TestReconcileVersionIDs(t *testing.T) { } } + +func TestAccArchiveUploadFlow(t *testing.T) { + t.Parallel() + if os.Getenv("TF_ACC") == "" { + t.Skip("Acceptance tests are disabled.") + } + + t.Run("TarArchive", func(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + + // Create a test file + testFile := filepath.Join(tmpDir, "template.tf") + err := os.WriteFile(testFile, []byte("resource \"null_resource\" \"test\" {}"), 0644) + require.NoError(t, err) + + // Create a tar archive + tarPath := filepath.Join(tmpDir, "template.tar") + tarFile, err := os.Create(tarPath) + require.NoError(t, err) + defer tarFile.Close() //nolint:errcheck + + tw := tar.NewWriter(tarFile) + defer tw.Close() //nolint:errcheck + + fileInfo, err := os.Stat(testFile) + require.NoError(t, err) + + header := &tar.Header{ + Name: "template.tf", + Size: fileInfo.Size(), + Mode: 0644, + } + err = tw.WriteHeader(header) + require.NoError(t, err) + + content, err := os.ReadFile(testFile) + require.NoError(t, err) + _, err = tw.Write(content) + require.NoError(t, err) + + // Verify archive content type + ct, err := archiveContentType(tarPath) + require.NoError(t, err) + require.Equal(t, "application/x-tar", ct) + + // Verify archive passes size validation + err = validateArchiveSize(tarPath) + require.NoError(t, err) + + // Verify archive hash is consistent + hash1, err := computeArchiveHash(tarPath) + require.NoError(t, err) + hash2, err := computeArchiveHash(tarPath) + require.NoError(t, err) + require.Equal(t, hash1, hash2) + }) + + t.Run("ZipArchive", func(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + + // Create a test file + testFile := filepath.Join(tmpDir, "template.tf") + err := os.WriteFile(testFile, []byte("resource \"null_resource\" \"test\" {}"), 0644) + require.NoError(t, err) + + // Create a zip archive + zipPath := filepath.Join(tmpDir, "template.zip") + zipFile, err := os.Create(zipPath) + require.NoError(t, err) + defer zipFile.Close() //nolint:errcheck + + zw := zip.NewWriter(zipFile) + defer zw.Close() //nolint:errcheck + + content, err := os.ReadFile(testFile) + require.NoError(t, err) + + w, err := zw.Create("template.tf") + require.NoError(t, err) + _, err = w.Write(content) + require.NoError(t, err) + + // Verify archive content type + ct, err := archiveContentType(zipPath) + require.NoError(t, err) + require.Equal(t, "application/zip", ct) + + // Verify archive passes size validation + err = validateArchiveSize(zipPath) + require.NoError(t, err) + + // Verify archive hash is consistent + hash1, err := computeArchiveHash(zipPath) + require.NoError(t, err) + hash2, err := computeArchiveHash(zipPath) + require.NoError(t, err) + require.Equal(t, hash1, hash2) + }) +} diff --git a/internal/provider/util.go b/internal/provider/util.go index dbc3441..a3cedee 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 @@ -144,3 +159,16 @@ func corsPtr(v types.String) *codersdk.CORSBehavior { b := codersdk.CORSBehavior(v.ValueString()) return &b } + +// validateArchiveSize checks that an archive file does not exceed the server upload limit. +func validateArchiveSize(archivePath string) error { + const maxArchiveSize = 10 * (10 << 20) // 100 MiB + fileInfo, err := os.Stat(archivePath) + if err != nil { + return fmt.Errorf("failed to stat archive: %s", err) + } + if fileInfo.Size() > maxArchiveSize { + return fmt.Errorf("archive file exceeds 100 MiB limit: %d bytes", fileInfo.Size()) + } + return nil +} diff --git a/internal/provider/util_test.go b/internal/provider/util_test.go new file mode 100644 index 0000000..65de828 --- /dev/null +++ b/internal/provider/util_test.go @@ -0,0 +1,195 @@ +package provider + +import ( + "archive/tar" + "archive/zip" + "bytes" + "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 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) + } + }) + } +} + +func TestValidateArchiveSize(t *testing.T) { + t.Parallel() + + t.Run("ValidArchiveUnder100MiB", func(t *testing.T) { + t.Parallel() + tmpFile, err := os.CreateTemp(t.TempDir(), "archive_*.tar") + require.NoError(t, err) + defer tmpFile.Close() //nolint:errcheck + + // Create a 10 MiB file + _, err = tmpFile.Write(make([]byte, 10*1024*1024)) + require.NoError(t, err) + + err = validateArchiveSize(tmpFile.Name()) + require.NoError(t, err) + }) + + t.Run("InvalidArchiveExceeds100MiB", func(t *testing.T) { + t.Parallel() + tmpFile, err := os.CreateTemp(t.TempDir(), "archive_*.tar") + require.NoError(t, err) + defer tmpFile.Close() //nolint:errcheck + + // Create a 101 MiB file (exceeds limit) + _, err = tmpFile.Write(make([]byte, 101*1024*1024)) + require.NoError(t, err) + + err = validateArchiveSize(tmpFile.Name()) + require.Error(t, err) + require.Contains(t, err.Error(), "exceeds 100 MiB limit") + }) + + t.Run("NonexistentFile", func(t *testing.T) { + t.Parallel() + err := validateArchiveSize("/nonexistent/path/archive.tar") + require.Error(t, err) + require.Contains(t, err.Error(), "failed to stat archive") + }) + + t.Run("ExactlyAtLimit", func(t *testing.T) { + t.Parallel() + tmpFile, err := os.CreateTemp(t.TempDir(), "archive_*.tar") + require.NoError(t, err) + defer tmpFile.Close() //nolint:errcheck + + // Create exactly 100 MiB file + _, err = tmpFile.Write(make([]byte, 100*1024*1024)) + require.NoError(t, err) + + err = validateArchiveSize(tmpFile.Name()) + require.NoError(t, err) + }) +}