diff --git a/docs/resources/template.md b/docs/resources/template.md index fdd31ae..3da89fc 100644 --- a/docs/resources/template.md +++ b/docs/resources/template.md @@ -24,8 +24,8 @@ A Coder template - `acl` (Attributes) Access control list for the template. Requires an enterprise Coder deployment. If null, ACL policies will not be added or removed by Terraform. (see [below for nested schema](#nestedatt--acl)) - `activity_bump_ms` (Number) The activity bump duration for all workspaces created from this template, in milliseconds. Defaults to one hour. -- `allow_user_auto_start` (Boolean) Whether users can auto-start workspaces created from this template. Defaults to true. -- `allow_user_auto_stop` (Boolean) Whether users can auto-start workspaces created from this template. Defaults to true. +- `allow_user_auto_start` (Boolean) Whether users can auto-start workspaces created from this template. Defaults to true. Requires an enterprise Coder deployment. +- `allow_user_auto_stop` (Boolean) Whether users can auto-start workspaces created from this template. Defaults to true. Requires an enterprise Coder deployment. - `allow_user_cancel_workspace_jobs` (Boolean) Whether users can cancel in-progress workspace jobs using this template. Defaults to true. - `auto_start_permitted_days_of_week` (Set of String) List of days of the week in which autostart is allowed to happen, for all workspaces created from this template. Defaults to all days. If no days are specified, autostart is not allowed. Requires an enterprise Coder deployment. - `auto_stop_requirement` (Attributes) The auto-stop requirement for all workspaces created from this template. Requires an enterprise Coder deployment. (see [below for nested schema](#nestedatt--auto_stop_requirement)) @@ -55,7 +55,7 @@ Optional: - `active` (Boolean) Whether this version is the active version of the template. Only one version can be active at a time. - `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. +- `name` (String) The name of the template version. Automatically generated if not provided. If provided, the name *must* change each time the directory contents are updated. - `provisioner_tags` (Attributes Set) Provisioner tags for the template version. (see [below for nested schema](#nestedatt--versions--provisioner_tags)) - `tf_vars` (Attributes Set) Terraform variables for the template version. (see [below for nested schema](#nestedatt--versions--tf_vars)) diff --git a/go.mod b/go.mod index 2d090bd..b7b261a 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/hashicorp/terraform-plugin-go v0.23.0 github.com/hashicorp/terraform-plugin-log v0.9.0 github.com/hashicorp/terraform-plugin-testing v1.9.0 + github.com/otiai10/copy v1.14.0 github.com/stretchr/testify v1.9.0 ) diff --git a/go.sum b/go.sum index 8155435..17b7013 100644 --- a/go.sum +++ b/go.sum @@ -339,6 +339,10 @@ github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQ github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= +github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU= +github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w= +github.com/otiai10/mint v1.5.1 h1:XaPLeE+9vGbuyEHem1JNk3bYc7KKqyI/na0/mLd/Kks= +github.com/otiai10/mint v1.5.1/go.mod h1:MJm72SBthJjz8qhefc4z1PYEieWmy8Bku7CjcAqyUSM= github.com/outcaste-io/ristretto v0.2.3 h1:AK4zt/fJ76kjlYObOeNwh4T3asEuaCmp26pOvUOL9w0= github.com/outcaste-io/ristretto v0.2.3/go.mod h1:W8HywhmtlopSB1jeMg3JtdIhf+DYkLAr0VN/s4+MHac= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= diff --git a/integration/template-test/main.tf b/integration/template-test/main.tf index e7cfa01..556335b 100644 --- a/integration/template-test/main.tf +++ b/integration/template-test/main.tf @@ -7,10 +7,15 @@ terraform { } } +provider "coderd" { + url = "http://localhost:3000" + token = "NbRNSwdzeb-Npwlm9TIOX3bpEQIsgt2KI" +} + resource "coderd_user" "ethan" { - username = "ethan" - name = "Ethan Coolguy" - email = "test@coder.com" + username = "dean" + name = "Dean Coolguy" + email = "deantest@coder.com" roles = ["owner", "template-admin"] login_type = "password" password = "SomeSecurePassword!" @@ -41,8 +46,7 @@ resource "coderd_template" "sample" { } versions = [ { - name = "latest" - directory = "./example-template" + directory = "./example-template-2" active = true tf_vars = [ { @@ -52,7 +56,6 @@ resource "coderd_template" "sample" { ] }, { - name = "legacy" directory = "./example-template-2" active = false tf_vars = [ diff --git a/internal/provider/template_resource.go b/internal/provider/template_resource.go index c5fe708..0eed781 100644 --- a/internal/provider/template_resource.go +++ b/internal/provider/template_resource.go @@ -3,6 +3,7 @@ package provider import ( "bufio" "context" + "encoding/json" "fmt" "io" @@ -21,7 +22,6 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default" "github.com/hashicorp/terraform-plugin-framework/resource/schema/objectdefault" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/objectplanmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/setdefault" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" @@ -284,13 +284,13 @@ func (r *TemplateResource) Schema(ctx context.Context, req resource.SchemaReques Default: booldefault.StaticBool(true), }, "allow_user_auto_start": schema.BoolAttribute{ - MarkdownDescription: "Whether users can auto-start workspaces created from this template. Defaults to true.", + MarkdownDescription: "Whether users can auto-start workspaces created from this template. Defaults to true. Requires an enterprise Coder deployment.", Optional: true, Computed: true, Default: booldefault.StaticBool(true), }, "allow_user_auto_stop": schema.BoolAttribute{ - MarkdownDescription: "Whether users can auto-start workspaces created from this template. Defaults to true.", + MarkdownDescription: "Whether users can auto-start workspaces created from this template. Defaults to true. Requires an enterprise Coder deployment.", Optional: true, Computed: true, Default: booldefault.StaticBool(true), @@ -346,9 +346,12 @@ func (r *TemplateResource) Schema(ctx context.Context, req resource.SchemaReques Computed: true, }, "name": schema.StringAttribute{ - MarkdownDescription: "The name of the template version. Automatically generated if not provided.", + MarkdownDescription: "The name of the template version. Automatically generated if not provided. If provided, the name *must* change each time the directory contents are updated.", Optional: true, Computed: true, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, }, "message": schema.StringAttribute{ MarkdownDescription: "A message describing the changes in this version of the template. Messages longer than 72 characters will be truncated.", @@ -380,10 +383,9 @@ func (r *TemplateResource) Schema(ctx context.Context, req resource.SchemaReques NestedObject: variableNestedObject, }, }, - PlanModifiers: []planmodifier.Object{ - NewDirectoryHashPlanModifier(), - objectplanmodifier.UseStateForUnknown(), - }, + }, + PlanModifiers: []planmodifier.List{ + NewVersionsPlanModifier(), }, }, }, @@ -483,18 +485,11 @@ func (r *TemplateResource) Create(ctx context.Context, req resource.CreateReques } } if version.Active.ValueBool() { - tflog.Trace(ctx, "marking template version as active", map[string]any{ - "version_id": versionResp.ID, - "template_id": templateResp.ID, - }) - err := client.UpdateActiveTemplateVersion(ctx, templateResp.ID, codersdk.UpdateActiveTemplateVersion{ - ID: versionResp.ID, - }) + err := markActive(ctx, client, templateResp.ID, versionResp.ID) if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to set active template version: %s", err)) + resp.Diagnostics.AddError("Client Error", err.Error()) return } - tflog.Trace(ctx, "marked template version as active") } data.Versions[idx].ID = UUIDValue(versionResp.ID) data.Versions[idx].Name = types.StringValue(versionResp.Name) @@ -502,7 +497,12 @@ func (r *TemplateResource) Create(ctx context.Context, req resource.CreateReques data.ID = UUIDValue(templateResp.ID) data.DisplayName = types.StringValue(templateResp.DisplayName) - // Save data into Terraform sutate + resp.Diagnostics.Append(data.Versions.setPrivateState(ctx, resp.Private)...) + if resp.Diagnostics.HasError() { + return + } + + // Save data into Terraform state resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } @@ -569,11 +569,11 @@ func (r *TemplateResource) Read(ctx context.Context, req resource.ReadRequest, r } func (r *TemplateResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { - var planState TemplateResourceModel + var newState TemplateResourceModel var curState TemplateResourceModel // Read Terraform plan data into the model - resp.Diagnostics.Append(req.Plan.Get(ctx, &planState)...) + resp.Diagnostics.Append(req.Plan.Get(ctx, &newState)...) if resp.Diagnostics.HasError() { return @@ -585,25 +585,25 @@ func (r *TemplateResource) Update(ctx context.Context, req resource.UpdateReques return } - if planState.OrganizationID.IsUnknown() { - planState.OrganizationID = UUIDValue(r.data.DefaultOrganizationID) + if newState.OrganizationID.IsUnknown() { + newState.OrganizationID = UUIDValue(r.data.DefaultOrganizationID) } - if planState.DisplayName.IsUnknown() { - planState.DisplayName = planState.Name + if newState.DisplayName.IsUnknown() { + newState.DisplayName = newState.Name } - orgID := planState.OrganizationID.ValueUUID() + orgID := newState.OrganizationID.ValueUUID() - templateID := planState.ID.ValueUUID() + templateID := newState.ID.ValueUUID() client := r.data.Client - templateMetadataChanged := !planState.EqualTemplateMetadata(curState) + templateMetadataChanged := !newState.EqualTemplateMetadata(curState) // This is required, as the API will reject no-diff updates. if templateMetadataChanged { tflog.Trace(ctx, "change in template metadata detected, updating.") - updateReq := planState.toUpdateRequest(ctx, resp) + updateReq := newState.toUpdateRequest(ctx, resp) if resp.Diagnostics.HasError() { return } @@ -618,9 +618,9 @@ func (r *TemplateResource) Update(ctx context.Context, req resource.UpdateReques // Since the everyone group always gets deleted by `DisableEveryoneGroupAccess`, we need to run this even if there // were no ACL changes but the template metadata was updated. - if !planState.ACL.IsNull() && (!curState.ACL.Equal(planState.ACL) || templateMetadataChanged) { + if !newState.ACL.IsNull() && (!curState.ACL.Equal(newState.ACL) || templateMetadataChanged) { var acl ACL - resp.Diagnostics.Append(planState.ACL.As(ctx, &acl, basetypes.ObjectAsOptions{})...) + resp.Diagnostics.Append(newState.ACL.As(ctx, &acl, basetypes.ObjectAsOptions{})...) if resp.Diagnostics.HasError() { return } @@ -632,15 +632,11 @@ func (r *TemplateResource) Update(ctx context.Context, req resource.UpdateReques tflog.Trace(ctx, "successfully updated template ACL") } - for idx, plannedVersion := range planState.Versions { - var curVersionID uuid.UUID - // All versions in the state are guaranteed to have known IDs - foundVersion := curState.Versions.ByID(plannedVersion.ID) - // If the version is new, or if the directory hash has changed, create a new version - if foundVersion == nil || foundVersion.DirectoryHash != plannedVersion.DirectoryHash { + for idx := range newState.Versions { + if newState.Versions[idx].ID.IsUnknown() { tflog.Trace(ctx, "discovered a new or modified template version") - versionResp, err := newVersion(ctx, client, newVersionRequest{ - Version: &plannedVersion, + uploadResp, err := newVersion(ctx, client, newVersionRequest{ + Version: &newState.Versions[idx], OrganizationID: orgID, TemplateID: &templateID, }) @@ -648,35 +644,56 @@ func (r *TemplateResource) Update(ctx context.Context, req resource.UpdateReques resp.Diagnostics.AddError("Client Error", err.Error()) return } - curVersionID = versionResp.ID - } else { - // Or if it's an existing version, get the ID - curVersionID = plannedVersion.ID.ValueUUID() - } - versionResp, err := client.TemplateVersion(ctx, curVersionID) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to get template version: %s", err)) - return - } - if plannedVersion.Active.ValueBool() { - tflog.Trace(ctx, "marking template version as active", map[string]any{ - "version_id": versionResp.ID, - "template_id": templateID, - }) - err := client.UpdateActiveTemplateVersion(ctx, templateID, codersdk.UpdateActiveTemplateVersion{ - ID: versionResp.ID, - }) + versionResp, err := client.TemplateVersion(ctx, uploadResp.ID) if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to update active template version: %s", err)) + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to get template version: %s", err)) + return + } + newState.Versions[idx].ID = UUIDValue(versionResp.ID) + newState.Versions[idx].Name = types.StringValue(versionResp.Name) + if newState.Versions[idx].Active.ValueBool() { + err := markActive(ctx, client, templateID, newState.Versions[idx].ID.ValueUUID()) + if err != nil { + resp.Diagnostics.AddError("Client Error", err.Error()) + return + } + } + } else { + // Since the ID was not unknown, it must be in the current state, + // having been retrieved from the private state, + // but the list might be a different size. + curVersion := curState.Versions.ByID(newState.Versions[idx].ID) + if curVersion == nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Public/Private State Mismatch: failed to find template version with ID %s", newState.Versions[idx].ID)) return } - tflog.Trace(ctx, "marked template version as active") + if !curVersion.Name.Equal(newState.Versions[idx].Name) { + _, err := client.UpdateTemplateVersion(ctx, newState.Versions[idx].ID.ValueUUID(), codersdk.PatchTemplateVersionRequest{ + Name: newState.Versions[idx].Name.ValueString(), + Message: newState.Versions[idx].Message.ValueStringPointer(), + }) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to update template version metadata: %s", err)) + return + } + } + if newState.Versions[idx].Active.ValueBool() && !curVersion.Active.ValueBool() { + err := markActive(ctx, client, templateID, newState.Versions[idx].ID.ValueUUID()) + if err != nil { + resp.Diagnostics.AddError("Client Error", err.Error()) + return + } + } } - planState.Versions[idx].ID = UUIDValue(versionResp.ID) + } + + resp.Diagnostics.Append(newState.Versions.setPrivateState(ctx, resp.Private)...) + if resp.Diagnostics.HasError() { + return } // Save updated data into Terraform state - resp.Diagnostics.Append(resp.State.Set(ctx, &planState)...) + resp.Diagnostics.Append(resp.State.Set(ctx, &newState)...) } func (r *TemplateResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { @@ -748,50 +765,79 @@ func (a *activeVersionValidator) ValidateList(ctx context.Context, req validator if !active { resp.Diagnostics.AddError("Client Error", "At least one template version must be active.") } + + // Check all versions have unique names + uniqueNames := make(map[string]struct{}) + for _, version := range data { + if version.Name.IsNull() { + continue + } + if _, ok := uniqueNames[version.Name.ValueString()]; ok { + resp.Diagnostics.AddError("Client Error", "Template version names must be unique.") + return + } + uniqueNames[version.Name.ValueString()] = struct{}{} + } } var _ validator.List = &activeVersionValidator{} -type directoryHashPlanModifier struct{} +type versionsPlanModifier struct{} // Description implements planmodifier.Object. -func (d *directoryHashPlanModifier) Description(ctx context.Context) string { +func (d *versionsPlanModifier) Description(ctx context.Context) string { return d.MarkdownDescription(ctx) } // MarkdownDescription implements planmodifier.Object. -func (d *directoryHashPlanModifier) MarkdownDescription(context.Context) string { +func (d *versionsPlanModifier) MarkdownDescription(context.Context) string { return "Compute the hash of a directory." } -// PlanModifyObject implements planmodifier.Object. -func (d *directoryHashPlanModifier) PlanModifyObject(ctx context.Context, req planmodifier.ObjectRequest, resp *planmodifier.ObjectResponse) { - attributes := req.PlanValue.Attributes() - directory, ok := attributes["directory"].(types.String) - if !ok { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("unexpected type for directory, got: %T", directory)) +// PlanModifyObject implements planmodifier.List. +func (d *versionsPlanModifier) PlanModifyList(ctx context.Context, req planmodifier.ListRequest, resp *planmodifier.ListResponse) { + var data Versions + resp.Diagnostics.Append(req.PlanValue.ElementsAs(ctx, &data, false)...) + if resp.Diagnostics.HasError() { return } - hash, err := computeDirectoryHash(directory.ValueString()) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to compute directory hash: %s", err)) - return + for i := range data { + hash, err := computeDirectoryHash(data[i].Directory.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to compute directory hash: %s", err)) + return + } + data[i].DirectoryHash = types.StringValue(hash) } - attributes["directory_hash"] = types.StringValue(hash) - out, diag := types.ObjectValue(req.PlanValue.AttributeTypes(ctx), attributes) + + var lv LastVersionsByHash + lvBytes, diag := req.Private.GetKey(ctx, LastVersionsKey) if diag.HasError() { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to create plan object: %s", diag)) + resp.Diagnostics.Append(diag...) return } - resp.PlanValue = out + // If this is the first read, init the private state value + if lvBytes == nil { + lv = make(LastVersionsByHash) + } else { + err := json.Unmarshal(lvBytes, &lv) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to unmarshal private state when reading: %s", err)) + return + } + } + + data.reconcileVersionIDs(lv) + + resp.PlanValue, resp.Diagnostics = types.ListValueFrom(ctx, req.PlanValue.ElementType(ctx), data) } -func NewDirectoryHashPlanModifier() planmodifier.Object { - return &directoryHashPlanModifier{} +func NewVersionsPlanModifier() planmodifier.List { + return &versionsPlanModifier{} } -var _ planmodifier.Object = &directoryHashPlanModifier{} +var _ planmodifier.List = &versionsPlanModifier{} var weekValidator = setvalidator.ValueStringsAre( stringvalidator.OneOf("monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"), @@ -908,6 +954,21 @@ func newVersion(ctx context.Context, client *codersdk.Client, req newVersionRequ return &versionResp, nil } +func markActive(ctx context.Context, client *codersdk.Client, templateID uuid.UUID, versionID uuid.UUID) error { + tflog.Trace(ctx, "marking template version as active", map[string]any{ + "version_id": versionID.String(), + "template_id": templateID.String(), + }) + err := client.UpdateActiveTemplateVersion(ctx, templateID, codersdk.UpdateActiveTemplateVersion{ + ID: versionID, + }) + if err != nil { + return fmt.Errorf("Failed to update active template version: %s", err) + } + tflog.Trace(ctx, "marked template version as active") + return nil +} + func convertACLToRequest(permissions ACL) codersdk.UpdateTemplateACL { var userPerms = make(map[string]codersdk.TemplateRole) for _, perm := range permissions.UserPermissions { @@ -1062,3 +1123,81 @@ func (r *TemplateResourceModel) toCreateRequest(ctx context.Context, resp *resou DisableEveryoneGroupAccess: !r.ACL.IsNull(), } } + +type LastVersionsByHash = map[string][]PreviousTemplateVersion + +var LastVersionsKey = "last_versions" + +type PreviousTemplateVersion struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` +} + +type privateState interface { + GetKey(ctx context.Context, key string) ([]byte, diag.Diagnostics) + SetKey(ctx context.Context, key string, value []byte) diag.Diagnostics +} + +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()] + // 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{ + ID: version.ID.ValueUUID(), + Name: version.Name.ValueString(), + }) + } else { + lv[version.DirectoryHash.ValueString()] = []PreviousTemplateVersion{ + { + ID: version.ID.ValueUUID(), + Name: version.Name.ValueString(), + }, + } + } + } + lvBytes, err := json.Marshal(lv) + if err != nil { + diags.AddError("Client Error", fmt.Sprintf("Failed to marshal private state: %s", err)) + return diags + } + return ps.SetKey(ctx, LastVersionsKey, lvBytes) +} + +func (v Versions) reconcileVersionIDs(lv LastVersionsByHash) { + for i := range v { + prevList, ok := lv[v[i].DirectoryHash.ValueString()] + // 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. + if !ok { + v[i].ID = NewUUIDUnknown() + } else { + // More than one candidate, try to match by name + for j, prev := range prevList { + // If the name is the same, use the existing ID, and remove + // it from the previous version candidates + if v[i].Name.ValueString() == prev.Name { + v[i].ID = UUIDValue(prev.ID) + lv[v[i].DirectoryHash.ValueString()] = append(prevList[:j], prevList[j+1:]...) + break + } + } + } + } + + // 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 v { + prevList := lv[v[i].DirectoryHash.ValueString()] + if len(prevList) > 0 && v[i].ID.IsUnknown() { + v[i].ID = UUIDValue(prevList[0].ID) + if v[i].Name.IsUnknown() { + v[i].Name = types.StringValue(prevList[0].Name) + } + lv[v[i].DirectoryHash.ValueString()] = prevList[1:] + } + } +} diff --git a/internal/provider/template_resource_test.go b/internal/provider/template_resource_test.go index 13367b4..7273092 100644 --- a/internal/provider/template_resource_test.go +++ b/internal/provider/template_resource_test.go @@ -2,6 +2,7 @@ package provider import ( "context" + "fmt" "os" "regexp" "slices" @@ -9,10 +10,15 @@ import ( "testing" "text/template" - "github.com/coder/coder/v2/codersdk" - "github.com/coder/terraform-provider-coderd/integration" + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + cp "github.com/otiai10/copy" "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/codersdk" + "github.com/coder/terraform-provider-coderd/integration" ) func TestAccTemplateResource(t *testing.T) { @@ -23,198 +29,348 @@ func TestAccTemplateResource(t *testing.T) { client := integration.StartCoder(ctx, t, "template_acc", true) firstUser, err := client.User(ctx, codersdk.Me) require.NoError(t, err) - cfg1 := testAccTemplateResourceConfig{ - URL: client.URL.String(), - Token: client.SessionToken(), - Name: PtrTo("example-template"), - Versions: []testAccTemplateVersionConfig{ - { - Name: PtrTo("main"), - Directory: PtrTo("../../integration/template-test/example-template/"), - Active: PtrTo(true), - // TODO(ethanndickson): Remove this when we add in `*.tfvars` parsing - TerraformVariables: []testAccTemplateKeyValueConfig{ - { - Key: PtrTo("name"), - Value: PtrTo("world"), + + exTemplateOne := t.TempDir() + err = cp.Copy("../../integration/template-test/example-template", exTemplateOne) + require.NoError(t, err) + + exTemplateTwo := t.TempDir() + err = cp.Copy("../../integration/template-test/example-template-2", exTemplateTwo) + require.NoError(t, err) + + t.Run("BasicUsage", func(t *testing.T) { + cfg1 := testAccTemplateResourceConfig{ + URL: client.URL.String(), + Token: client.SessionToken(), + Name: PtrTo("example-template"), + Versions: []testAccTemplateVersionConfig{ + { + // Auto-generated version name + Directory: &exTemplateOne, + Active: PtrTo(true), + // TODO(ethanndickson): Remove this when we add in `*.tfvars` parsing + TerraformVariables: []testAccTemplateKeyValueConfig{ + { + Key: PtrTo("name"), + Value: PtrTo("world"), + }, }, }, }, - }, - ACL: testAccTemplateACLConfig{ - GroupACL: []testAccTemplateKeyValueConfig{ - { - Key: PtrTo(firstUser.OrganizationIDs[0].String()), - Value: PtrTo("use"), + ACL: testAccTemplateACLConfig{ + GroupACL: []testAccTemplateKeyValueConfig{ + { + Key: PtrTo(firstUser.OrganizationIDs[0].String()), + Value: PtrTo("use"), + }, }, }, - }, - } - - cfg2 := cfg1 - cfg2.Versions = slices.Clone(cfg2.Versions) - cfg2.Name = PtrTo("example-template-new") - cfg2.AllowUserAutostart = PtrTo(false) - cfg2.Versions[0].Directory = PtrTo("../../integration/template-test/example-template-2/") - cfg2.Versions[0].Name = PtrTo("new") - cfg2.ACL.UserACL = []testAccTemplateKeyValueConfig{ - { - Key: PtrTo(firstUser.ID.String()), - Value: PtrTo("admin"), - }, - } - cfg2.AutostopRequirement = testAccAutostopRequirementConfig{ - DaysOfWeek: PtrTo([]string{"monday", "tuesday"}), - Weeks: PtrTo(int64(2)), - } - - cfg3 := cfg2 - cfg3.Versions = slices.Clone(cfg3.Versions) - cfg3.Versions = append(cfg3.Versions, testAccTemplateVersionConfig{ - Name: PtrTo("legacy-template"), - Directory: PtrTo("../../integration/template-test/example-template/"), - Active: PtrTo(false), - TerraformVariables: []testAccTemplateKeyValueConfig{ + } + + cfg2 := cfg1 + cfg2.Versions = slices.Clone(cfg2.Versions) + cfg2.Name = PtrTo("example-template-new") + cfg2.AllowUserAutostart = PtrTo(false) + cfg2.Versions[0].Directory = &exTemplateTwo + cfg2.Versions[0].Name = PtrTo("new") + cfg2.ACL.UserACL = []testAccTemplateKeyValueConfig{ { - Key: PtrTo("name"), - Value: PtrTo("world"), + Key: PtrTo(firstUser.ID.String()), + Value: PtrTo("admin"), }, - }, - }) + } + cfg2.AutostopRequirement = testAccAutostopRequirementConfig{ + DaysOfWeek: PtrTo([]string{"monday", "tuesday"}), + Weeks: PtrTo(int64(2)), + } + + cfg3 := cfg2 + cfg3.Versions = slices.Clone(cfg3.Versions) + cfg3.Versions = append(cfg3.Versions, testAccTemplateVersionConfig{ + Name: PtrTo("legacy-template"), + Directory: &exTemplateOne, + Active: PtrTo(false), + TerraformVariables: []testAccTemplateKeyValueConfig{ + { + Key: PtrTo("name"), + Value: PtrTo("world"), + }, + }, + }) - cfg4 := cfg3 - cfg4.Versions = slices.Clone(cfg4.Versions) - cfg4.Versions[0].Active = PtrTo(false) - cfg4.Versions[1].Active = PtrTo(true) + cfg4 := cfg3 + cfg4.Versions = slices.Clone(cfg4.Versions) + cfg4.Versions[0].Active = PtrTo(false) + cfg4.Versions[1].Active = PtrTo(true) - cfg5 := cfg4 - cfg5.Versions = slices.Clone(cfg5.Versions) - cfg5.Versions[0], cfg5.Versions[1] = cfg5.Versions[1], cfg5.Versions[0] + cfg5 := cfg4 + cfg5.Versions = slices.Clone(cfg5.Versions) + cfg5.Versions[0], cfg5.Versions[1] = cfg5.Versions[1], cfg5.Versions[0] - cfg6 := cfg4 - cfg6.Versions = slices.Clone(cfg6.Versions[1:]) + cfg6 := cfg4 + cfg6.Versions = slices.Clone(cfg6.Versions[1:]) - cfg7 := cfg6 - cfg7.ACL.null = true + cfg7 := cfg6 + cfg7.ACL.null = true - resource.Test(t, resource.TestCase{ - PreCheck: func() { testAccPreCheck(t) }, - IsUnitTest: true, - ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, - Steps: []resource.TestStep{ - { - Config: cfg1.String(t), - Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttrSet("coderd_template.test", "id"), - resource.TestCheckResourceAttr("coderd_template.test", "display_name", "example-template"), - resource.TestCheckResourceAttr("coderd_template.test", "description", ""), - resource.TestCheckResourceAttr("coderd_template.test", "organization_id", firstUser.OrganizationIDs[0].String()), - resource.TestCheckResourceAttr("coderd_template.test", "icon", ""), - resource.TestCheckResourceAttr("coderd_template.test", "default_ttl_ms", "0"), - resource.TestCheckResourceAttr("coderd_template.test", "activity_bump_ms", "3600000"), - resource.TestCheckResourceAttr("coderd_template.test", "auto_stop_requirement.days_of_week.#", "0"), - resource.TestCheckResourceAttr("coderd_template.test", "auto_stop_requirement.weeks", "1"), - resource.TestCheckResourceAttr("coderd_template.test", "auto_start_permitted_days_of_week.#", "7"), - resource.TestCheckResourceAttr("coderd_template.test", "allow_user_cancel_workspace_jobs", "true"), - resource.TestCheckResourceAttr("coderd_template.test", "allow_user_auto_start", "true"), - resource.TestCheckResourceAttr("coderd_template.test", "allow_user_auto_stop", "true"), - resource.TestCheckResourceAttr("coderd_template.test", "failure_ttl_ms", "0"), - resource.TestCheckResourceAttr("coderd_template.test", "time_til_dormant_ms", "0"), - resource.TestCheckResourceAttr("coderd_template.test", "time_til_dormant_autodelete_ms", "0"), - resource.TestCheckResourceAttr("coderd_template.test", "require_active_version", "false"), - resource.TestMatchTypeSetElemNestedAttrs("coderd_template.test", "versions.*", map[string]*regexp.Regexp{ - "name": regexp.MustCompile("main"), - "id": regexp.MustCompile(".*"), - "directory_hash": regexp.MustCompile(".+"), - "message": regexp.MustCompile(""), - }), - ), - }, - // Import - { - Config: cfg1.String(t), - ResourceName: "coderd_template.test", - ImportState: true, - ImportStateVerify: true, - // In the real world, `versions` needs to be added to the configuration after importing - ImportStateVerifyIgnore: []string{"versions", "acl"}, - }, - // Update existing version & metadata - { - Config: cfg2.String(t), - Check: resource.ComposeAggregateTestCheckFunc( - resource.TestCheckResourceAttrSet("coderd_template.test", "id"), - resource.TestCheckResourceAttr("coderd_template.test", "name", "example-template-new"), - resource.TestCheckResourceAttr("coderd_template.test", "allow_user_auto_start", "false"), - resource.TestCheckResourceAttr("coderd_template.test", "auto_stop_requirement.days_of_week.#", "2"), - resource.TestCheckResourceAttr("coderd_template.test", "auto_stop_requirement.weeks", "2"), - resource.TestMatchTypeSetElemNestedAttrs("coderd_template.test", "versions.*", map[string]*regexp.Regexp{ - "name": regexp.MustCompile("new"), - }), - ), - }, - // Append version - { - Config: cfg3.String(t), - Check: resource.ComposeAggregateTestCheckFunc( - resource.TestCheckResourceAttr("coderd_template.test", "versions.#", "2"), - resource.TestMatchTypeSetElemNestedAttrs("coderd_template.test", "versions.*", map[string]*regexp.Regexp{ - "name": regexp.MustCompile("legacy-template"), - }), - resource.TestMatchTypeSetElemNestedAttrs("coderd_template.test", "versions.*", map[string]*regexp.Regexp{ - "name": regexp.MustCompile("new"), - }), - ), - }, - // Change active version - { - Config: cfg4.String(t), - Check: resource.ComposeAggregateTestCheckFunc( - resource.TestCheckResourceAttr("coderd_template.test", "versions.#", "2"), - resource.TestMatchTypeSetElemNestedAttrs("coderd_template.test", "versions.*", map[string]*regexp.Regexp{ - "active": regexp.MustCompile("true"), - "name": regexp.MustCompile("legacy-template"), - }), - resource.TestMatchTypeSetElemNestedAttrs("coderd_template.test", "versions.*", map[string]*regexp.Regexp{ - "active": regexp.MustCompile("false"), - "name": regexp.MustCompile("new"), - }), - ), - }, - // Swap versions - { - Config: cfg5.String(t), - Check: resource.ComposeAggregateTestCheckFunc( - resource.TestCheckResourceAttr("coderd_template.test", "versions.#", "2"), - resource.TestMatchTypeSetElemNestedAttrs("coderd_template.test", "versions.*", map[string]*regexp.Regexp{ - "active": regexp.MustCompile("true"), - "name": regexp.MustCompile("legacy-template"), - }), - resource.TestMatchTypeSetElemNestedAttrs("coderd_template.test", "versions.*", map[string]*regexp.Regexp{ - "active": regexp.MustCompile("false"), - "name": regexp.MustCompile("new"), - }), - ), + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + IsUnitTest: true, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // Init, creates the first version + { + Config: cfg1.String(t), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("coderd_template.test", "id"), + resource.TestCheckResourceAttr("coderd_template.test", "display_name", "example-template"), + resource.TestCheckResourceAttr("coderd_template.test", "description", ""), + resource.TestCheckResourceAttr("coderd_template.test", "organization_id", firstUser.OrganizationIDs[0].String()), + resource.TestCheckResourceAttr("coderd_template.test", "icon", ""), + resource.TestCheckResourceAttr("coderd_template.test", "default_ttl_ms", "0"), + resource.TestCheckResourceAttr("coderd_template.test", "activity_bump_ms", "3600000"), + resource.TestCheckResourceAttr("coderd_template.test", "auto_stop_requirement.days_of_week.#", "0"), + resource.TestCheckResourceAttr("coderd_template.test", "auto_stop_requirement.weeks", "1"), + resource.TestCheckResourceAttr("coderd_template.test", "auto_start_permitted_days_of_week.#", "7"), + resource.TestCheckResourceAttr("coderd_template.test", "allow_user_cancel_workspace_jobs", "true"), + resource.TestCheckResourceAttr("coderd_template.test", "allow_user_auto_start", "true"), + resource.TestCheckResourceAttr("coderd_template.test", "allow_user_auto_stop", "true"), + resource.TestCheckResourceAttr("coderd_template.test", "failure_ttl_ms", "0"), + resource.TestCheckResourceAttr("coderd_template.test", "time_til_dormant_ms", "0"), + resource.TestCheckResourceAttr("coderd_template.test", "time_til_dormant_autodelete_ms", "0"), + resource.TestCheckResourceAttr("coderd_template.test", "require_active_version", "false"), + resource.TestMatchTypeSetElemNestedAttrs("coderd_template.test", "versions.*", map[string]*regexp.Regexp{ + "name": regexp.MustCompile(".+"), + "id": regexp.MustCompile(".+"), + "directory_hash": regexp.MustCompile(".+"), + "message": regexp.MustCompile(""), + }), + testAccCheckNumTemplateVersions(ctx, client, 1), + ), + }, + // Modify template contents. Creates a second version. + { + Config: cfg1.String(t), + PreConfig: func() { + file := fmt.Sprintf("%s/terraform.tfvars", *cfg1.Versions[0].Directory) + newFile := []byte("name = \"world2\"") + err := os.WriteFile(file, newFile, 0644) + require.NoError(t, err) + }, + Check: testAccCheckNumTemplateVersions(ctx, client, 2), + // Version should be updated, checked at the end + }, + // Undo modification. Creates a third version since it differs from the last apply + { + Config: cfg1.String(t), + PreConfig: func() { + file := fmt.Sprintf("%s/terraform.tfvars", *cfg1.Versions[0].Directory) + newFile := []byte("name = \"world\"") + err := os.WriteFile(file, newFile, 0644) + require.NoError(t, err) + }, + Check: testAccCheckNumTemplateVersions(ctx, client, 3), + }, + // Import + { + Config: cfg1.String(t), + ResourceName: "coderd_template.test", + ImportState: true, + ImportStateVerify: true, + // In the real world, `versions` needs to be added to the configuration after importing + // We can't import ACL as we can't currently differentiate between managed and unmanaged ACL + ImportStateVerifyIgnore: []string{"versions", "acl"}, + }, + // Change existing version directory & name, update template metadata. Creates a fourth version. + { + Config: cfg2.String(t), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("coderd_template.test", "id"), + resource.TestCheckResourceAttr("coderd_template.test", "name", "example-template-new"), + resource.TestCheckResourceAttr("coderd_template.test", "allow_user_auto_start", "false"), + resource.TestCheckResourceAttr("coderd_template.test", "auto_stop_requirement.days_of_week.#", "2"), + resource.TestCheckResourceAttr("coderd_template.test", "auto_stop_requirement.weeks", "2"), + resource.TestMatchTypeSetElemNestedAttrs("coderd_template.test", "versions.*", map[string]*regexp.Regexp{ + "name": regexp.MustCompile("new"), + }), + testAccCheckNumTemplateVersions(ctx, client, 4), + ), + }, + // Append version. Creates a fifth version + { + Config: cfg3.String(t), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("coderd_template.test", "versions.#", "2"), + resource.TestMatchTypeSetElemNestedAttrs("coderd_template.test", "versions.*", map[string]*regexp.Regexp{ + "name": regexp.MustCompile("legacy-template"), + }), + resource.TestMatchTypeSetElemNestedAttrs("coderd_template.test", "versions.*", map[string]*regexp.Regexp{ + "name": regexp.MustCompile("new"), + }), + testAccCheckNumTemplateVersions(ctx, client, 5), + ), + }, + // Change active version + { + Config: cfg4.String(t), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("coderd_template.test", "versions.#", "2"), + resource.TestMatchTypeSetElemNestedAttrs("coderd_template.test", "versions.*", map[string]*regexp.Regexp{ + "active": regexp.MustCompile("true"), + "name": regexp.MustCompile("legacy-template"), + }), + resource.TestMatchTypeSetElemNestedAttrs("coderd_template.test", "versions.*", map[string]*regexp.Regexp{ + "active": regexp.MustCompile("false"), + "name": regexp.MustCompile("new"), + }), + ), + }, + // Swap versions in-place + { + Config: cfg5.String(t), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("coderd_template.test", "versions.#", "2"), + resource.TestMatchTypeSetElemNestedAttrs("coderd_template.test", "versions.*", map[string]*regexp.Regexp{ + "active": regexp.MustCompile("true"), + "name": regexp.MustCompile("legacy-template"), + }), + resource.TestMatchTypeSetElemNestedAttrs("coderd_template.test", "versions.*", map[string]*regexp.Regexp{ + "active": regexp.MustCompile("false"), + "name": regexp.MustCompile("new"), + }), + ), + }, + // Delete version at index 0 + { + Config: cfg6.String(t), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("coderd_template.test", "versions.#", "1"), + resource.TestMatchTypeSetElemNestedAttrs("coderd_template.test", "versions.*", map[string]*regexp.Regexp{ + "active": regexp.MustCompile("true"), + "name": regexp.MustCompile("legacy-template"), + }), + ), + }, + // Unmanaged ACL + { + Config: cfg7.String(t), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckNoResourceAttr("coderd_template.test", "acl"), + testAccCheckNumTemplateVersions(ctx, client, 5), + ), + }, + // Resource deleted }, - // Delete version at index 0 - { - Config: cfg6.String(t), - Check: resource.ComposeAggregateTestCheckFunc( - resource.TestCheckResourceAttr("coderd_template.test", "versions.#", "1"), - resource.TestMatchTypeSetElemNestedAttrs("coderd_template.test", "versions.*", map[string]*regexp.Regexp{ - "active": regexp.MustCompile("true"), - "name": regexp.MustCompile("legacy-template"), - }), - ), + }) + }) + + t.Run("IdenticalVersions", func(t *testing.T) { + cfg1 := testAccTemplateResourceConfig{ + URL: client.URL.String(), + Token: client.SessionToken(), + Name: PtrTo("example-template2"), + Versions: []testAccTemplateVersionConfig{ + { + // Auto-generated version name + Directory: PtrTo("../../integration/template-test/example-template-2/"), + TerraformVariables: []testAccTemplateKeyValueConfig{ + { + Key: PtrTo("name"), + Value: PtrTo("world"), + }, + }, + Active: PtrTo(true), + }, + { + // Auto-generated version name + Directory: PtrTo("../../integration/template-test/example-template-2/"), + TerraformVariables: []testAccTemplateKeyValueConfig{ + { + Key: PtrTo("name"), + Value: PtrTo("world"), + }, + }, + }, }, - // Unmanaged ACL - { - Config: cfg7.String(t), - Check: resource.ComposeAggregateTestCheckFunc( - resource.TestCheckNoResourceAttr("coderd_template.test", "acl"), - ), + } + + cfg2 := cfg1 + cfg2.Versions = slices.Clone(cfg2.Versions) + cfg2.Versions[1].Name = PtrTo("new-name") + + cfg3 := cfg2 + cfg3.Versions = slices.Clone(cfg3.Versions) + cfg3.Versions[0].Name = PtrTo("new-name-one") + cfg3.Versions[1].Name = PtrTo("new-name-two") + cfg3.Versions[0], cfg3.Versions[1] = cfg3.Versions[1], cfg3.Versions[0] + + cfg4 := cfg1 + cfg4.Versions = slices.Clone(cfg4.Versions) + cfg4.Versions[0].Directory = PtrTo("../../integration/template-test/example-template/") + + cfg5 := cfg4 + cfg5.Versions = slices.Clone(cfg5.Versions) + cfg5.Versions[1].Directory = PtrTo("../../integration/template-test/example-template/") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + IsUnitTest: true, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // Create two identical versions + { + Config: cfg1.String(t), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckNumTemplateVersions(ctx, client, 2), + ), + }, + // Change the name of the second version + { + Config: cfg2.String(t), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("coderd_template.test", "versions.#", "2"), + resource.TestMatchTypeSetElemNestedAttrs("coderd_template.test", "versions.*", map[string]*regexp.Regexp{ + "active": regexp.MustCompile("true"), + "name": regexp.MustCompile(".+"), + }), + resource.TestMatchTypeSetElemNestedAttrs("coderd_template.test", "versions.*", map[string]*regexp.Regexp{ + "active": regexp.MustCompile("false"), + "name": regexp.MustCompile("^new-name$"), + }), + ), + }, + // Swap the two versions, give them both new names + { + Config: cfg3.String(t), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("coderd_template.test", "versions.#", "2"), + resource.TestMatchTypeSetElemNestedAttrs("coderd_template.test", "versions.*", map[string]*regexp.Regexp{ + "active": regexp.MustCompile("true"), + "name": regexp.MustCompile("^new-name-one$"), + }), + resource.TestMatchTypeSetElemNestedAttrs("coderd_template.test", "versions.*", map[string]*regexp.Regexp{ + "active": regexp.MustCompile("false"), + "name": regexp.MustCompile("^new-name-two$"), + }), + testAccCheckNumTemplateVersions(ctx, client, 2), + ), + }, + // Change the first version's contents + { + Config: cfg4.String(t), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckNumTemplateVersions(ctx, client, 3), + ), + }, + // Change the second version's contents to match the first + { + Config: cfg5.String(t), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckNumTemplateVersions(ctx, client, 4), + ), + }, }, - }, + }) }) } @@ -395,3 +551,194 @@ type testAccTemplateKeyValueConfig struct { Key *string Value *string } + +func testAccCheckNumTemplateVersions(ctx context.Context, client *codersdk.Client, expected int) resource.TestCheckFunc { + return func(*terraform.State) error { + templates, err := client.Templates(ctx) + if err != nil { + return err + } + if len(templates) != 1 { + return fmt.Errorf("expected 1 template, got %d", len(templates)) + } + versions, err := client.TemplateVersionsByTemplate(ctx, codersdk.TemplateVersionsByTemplateRequest{ + TemplateID: templates[0].ID, + }) + if err != nil { + return err + } + if len(versions) != expected { + return fmt.Errorf("expected %d versions, got %d", expected, len(versions)) + } + return nil + } +} + +func TestReconcileVersionIDs(t *testing.T) { + aUUID := uuid.New() + bUUID := uuid.New() + cases := []struct { + Name string + inputVersions Versions + inputState LastVersionsByHash + expectedVersions Versions + }{ + { + Name: "IdenticalDontRename", + inputVersions: []TemplateVersion{ + { + Name: types.StringValue("foo"), + DirectoryHash: types.StringValue("aaa"), + ID: NewUUIDUnknown(), + }, + { + Name: types.StringValue("bar"), + DirectoryHash: types.StringValue("aaa"), + ID: NewUUIDUnknown(), + }, + }, + inputState: map[string][]PreviousTemplateVersion{ + "aaa": { + { + ID: aUUID, + Name: "bar", + }, + }, + }, + expectedVersions: []TemplateVersion{ + { + Name: types.StringValue("foo"), + DirectoryHash: types.StringValue("aaa"), + ID: NewUUIDUnknown(), + }, + { + Name: types.StringValue("bar"), + DirectoryHash: types.StringValue("aaa"), + ID: UUIDValue(aUUID), + }, + }, + }, + { + Name: "IdenticalRenameFirst", + inputVersions: []TemplateVersion{ + { + Name: types.StringValue("foo"), + DirectoryHash: types.StringValue("aaa"), + ID: NewUUIDUnknown(), + }, + { + Name: types.StringValue("bar"), + DirectoryHash: types.StringValue("aaa"), + ID: NewUUIDUnknown(), + }, + }, + inputState: map[string][]PreviousTemplateVersion{ + "aaa": { + { + ID: aUUID, + Name: "baz", + }, + }, + }, + expectedVersions: []TemplateVersion{ + { + Name: types.StringValue("foo"), + DirectoryHash: types.StringValue("aaa"), + ID: UUIDValue(aUUID), + }, + { + Name: types.StringValue("bar"), + DirectoryHash: types.StringValue("aaa"), + ID: NewUUIDUnknown(), + }, + }, + }, + { + Name: "IdenticalHashesInState", + inputVersions: []TemplateVersion{ + { + Name: types.StringValue("foo"), + DirectoryHash: types.StringValue("aaa"), + ID: NewUUIDUnknown(), + }, + { + Name: types.StringValue("bar"), + DirectoryHash: types.StringValue("aaa"), + ID: NewUUIDUnknown(), + }, + }, + inputState: map[string][]PreviousTemplateVersion{ + "aaa": { + { + ID: aUUID, + Name: "qux", + }, + { + ID: bUUID, + Name: "baz", + }, + }, + }, + expectedVersions: []TemplateVersion{ + { + Name: types.StringValue("foo"), + DirectoryHash: types.StringValue("aaa"), + ID: UUIDValue(aUUID), + }, + { + Name: types.StringValue("bar"), + DirectoryHash: types.StringValue("aaa"), + ID: UUIDValue(bUUID), + }, + }, + }, + { + Name: "UnknownUsesStateInOrder", + inputVersions: []TemplateVersion{ + { + Name: types.StringValue("foo"), + DirectoryHash: types.StringValue("aaa"), + ID: NewUUIDUnknown(), + }, + { + Name: types.StringUnknown(), + DirectoryHash: types.StringValue("aaa"), + ID: NewUUIDUnknown(), + }, + }, + inputState: map[string][]PreviousTemplateVersion{ + "aaa": { + { + ID: aUUID, + Name: "qux", + }, + { + ID: bUUID, + Name: "baz", + }, + }, + }, + expectedVersions: []TemplateVersion{ + { + Name: types.StringValue("foo"), + DirectoryHash: types.StringValue("aaa"), + ID: UUIDValue(aUUID), + }, + { + Name: types.StringValue("baz"), + DirectoryHash: types.StringValue("aaa"), + ID: UUIDValue(bUUID), + }, + }, + }, + } + + for _, c := range cases { + c := c + t.Run(c.Name, func(t *testing.T) { + c.inputVersions.reconcileVersionIDs(c.inputState) + require.Equal(t, c.expectedVersions, c.inputVersions) + }) + + } +}