diff --git a/docs/resources/template.md b/docs/resources/template.md index 51f72e8..d070206 100644 --- a/docs/resources/template.md +++ b/docs/resources/template.md @@ -4,7 +4,7 @@ page_title: "coderd_template Resource - terraform-provider-coderd" subcategory: "" description: |- A Coder template. - Logs from building template versions are streamed from the provisioner when the TF_LOG environment variable is INFO or higher. + Logs from building template versions can be optionally streamed from the provisioner by setting the TF_LOG environment variable to INFO or higher. When importing, the ID supplied can be either a template UUID retrieved via the API or /. --- @@ -12,7 +12,7 @@ description: |- A Coder template. -Logs from building template versions are streamed from the provisioner when the `TF_LOG` environment variable is `INFO` or higher. +Logs from building template versions can be optionally streamed from the provisioner by setting the `TF_LOG` environment variable to `INFO` or higher. When importing, the ID supplied can be either a template UUID retrieved via the API or `/`. @@ -101,7 +101,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. If provided, the name *must* change each time the directory contents are updated. +- `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)) - `tf_vars` (Attributes Set) Terraform variables for the template version. (see [below for nested schema](#nestedatt--versions--tf_vars)) diff --git a/internal/provider/template_resource.go b/internal/provider/template_resource.go index 807b026..533a087 100644 --- a/internal/provider/template_resource.go +++ b/internal/provider/template_resource.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "io" + "slices" "strings" "cdr.dev/slog" @@ -230,8 +231,8 @@ func (r *TemplateResource) Metadata(ctx context.Context, req resource.MetadataRe func (r *TemplateResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { resp.Schema = schema.Schema{ - MarkdownDescription: "A Coder template.\n\nLogs from building template versions are streamed from the provisioner " + - "when the `TF_LOG` environment variable is `INFO` or higher.\n\n" + + MarkdownDescription: "A Coder template.\n\nLogs from building template versions can be optionally streamed from the provisioner " + + "by setting the `TF_LOG` environment variable to `INFO` or higher.\n\n" + "When importing, the ID supplied can be either a template UUID retrieved via the API or `/`.", Attributes: map[string]schema.Attribute{ @@ -395,7 +396,7 @@ 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. If provided, the name *must* change each time the directory contents are updated.", + MarkdownDescription: "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.", Optional: true, Computed: true, Validators: []validator.String{ @@ -1053,7 +1054,7 @@ func markActive(ctx context.Context, client *codersdk.Client, templateID uuid.UU ID: versionID, }) if err != nil { - return fmt.Errorf("Failed to update active template version: %s", err) + return fmt.Errorf("failed to update active template version: %s", err) } tflog.Info(ctx, "marked template version as active") return nil @@ -1231,8 +1232,9 @@ type LastVersionsByHash = map[string][]PreviousTemplateVersion var LastVersionsKey = "last_versions" type PreviousTemplateVersion struct { - ID uuid.UUID `json:"id"` - Name string `json:"name"` + ID uuid.UUID `json:"id"` + Name string `json:"name"` + TFVars map[string]string `json:"tf_vars"` } type privateState interface { @@ -1244,18 +1246,24 @@ func (v Versions) setPrivateState(ctx context.Context, ps privateState) (diags d lv := make(LastVersionsByHash) for _, version := range v { vbh, ok := lv[version.DirectoryHash.ValueString()] + tfVars := make(map[string]string, len(version.TerraformVariables)) + for _, tfVar := range version.TerraformVariables { + tfVars[tfVar.Name.ValueString()] = tfVar.Value.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(), + ID: version.ID.ValueUUID(), + Name: version.Name.ValueString(), + TFVars: tfVars, }) } else { lv[version.DirectoryHash.ValueString()] = []PreviousTemplateVersion{ { - ID: version.ID.ValueUUID(), - Name: version.Name.ValueString(), + ID: version.ID.ValueUUID(), + Name: version.Name.ValueString(), + TFVars: tfVars, }, } } @@ -1269,6 +1277,13 @@ func (v Versions) setPrivateState(ctx context.Context, ps privateState) (diags d } func (planVersions Versions) reconcileVersionIDs(lv LastVersionsByHash, configVersions Versions) { + // We remove versions that we've matched from `lv`, so make a copy for + // resolving tfvar changes at the end. + fullLv := make(LastVersionsByHash) + for k, v := range lv { + fullLv[k] = slices.Clone(v) + } + for i := range planVersions { prevList, ok := lv[planVersions[i].DirectoryHash.ValueString()] // If not in state, mark as known after apply since we'll create a new version. @@ -1308,4 +1323,47 @@ func (planVersions Versions) reconcileVersionIDs(lv LastVersionsByHash, configVe lv[planVersions[i].DirectoryHash.ValueString()] = prevList[1:] } } + + // If only the Terraform variables have changed, + // 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()] + if !ok { + continue + } + if tfVariablesChanged(prevs, &planVersions[i]) { + planVersions[i].ID = NewUUIDUnknown() + // We could always set the name to unknown here, to generate a + // random one (this is what the Web UI currently does when + // only updating tfvars). + // However, I think it'd be weird if the provider just started + // ignoring the name you set in the config, we'll instead + // require that users update the name if they update the tfvars. + if configVersions[i].Name.IsNull() { + planVersions[i].Name = types.StringUnknown() + } + } + } + } +} + +func tfVariablesChanged(prevs []PreviousTemplateVersion, planned *TemplateVersion) bool { + for _, prev := range prevs { + if prev.ID == planned.ID.ValueUUID() { + // If the previous version has no TFVars, then it was created using + // an older provider version. + if prev.TFVars == nil { + return true + } + for _, tfVar := range planned.TerraformVariables { + if prev.TFVars[tfVar.Name.ValueString()] != tfVar.Value.ValueString() { + return true + } + } + return false + } + } + return true + } diff --git a/internal/provider/template_resource_test.go b/internal/provider/template_resource_test.go index de258f3..9c54edd 100644 --- a/internal/provider/template_resource_test.go +++ b/internal/provider/template_resource_test.go @@ -286,6 +286,15 @@ func TestAccTemplateResource(t *testing.T) { cfg5.Versions = slices.Clone(cfg5.Versions) cfg5.Versions[1].Directory = PtrTo("../../integration/template-test/example-template/") + cfg6 := cfg5 + cfg6.Versions = slices.Clone(cfg6.Versions) + cfg6.Versions[0].TerraformVariables = []testAccTemplateKeyValueConfig{ + { + Key: PtrTo("name"), + Value: PtrTo("world2"), + }, + } + resource.Test(t, resource.TestCase{ PreCheck: func() { testAccPreCheck(t) }, IsUnitTest: true, @@ -343,6 +352,66 @@ func TestAccTemplateResource(t *testing.T) { testAccCheckNumTemplateVersions(ctx, client, 4), ), }, + // Update the Terraform variables of the first version + { + Config: cfg6.String(t), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckNumTemplateVersions(ctx, client, 5), + ), + }, + }, + }) + }) + + t.Run("AutoGenNameUpdateTFVars", func(t *testing.T) { + cfg1 := testAccTemplateResourceConfig{ + URL: client.URL.String(), + Token: client.SessionToken(), + Name: PtrTo("example-template3"), + 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), + }, + }, + ACL: testAccTemplateACLConfig{ + null: true, + }, + } + + cfg2 := cfg1 + cfg2.Versions = slices.Clone(cfg2.Versions) + cfg2.Versions[0].TerraformVariables = []testAccTemplateKeyValueConfig{ + { + Key: PtrTo("name"), + Value: PtrTo("world2"), + }, + } + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + IsUnitTest: true, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: cfg1.String(t), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckNumTemplateVersions(ctx, client, 1), + ), + }, + { + Config: cfg2.String(t), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckNumTemplateVersions(ctx, client, 2), + ), + }, }, }) }) @@ -779,14 +848,16 @@ func TestReconcileVersionIDs(t *testing.T) { Name: "IdenticalDontRename", planVersions: []TemplateVersion{ { - Name: types.StringValue("foo"), - DirectoryHash: types.StringValue("aaa"), - ID: NewUUIDUnknown(), + Name: types.StringValue("foo"), + DirectoryHash: types.StringValue("aaa"), + ID: NewUUIDUnknown(), + TerraformVariables: []Variable{}, }, { - Name: types.StringValue("bar"), - DirectoryHash: types.StringValue("aaa"), - ID: NewUUIDUnknown(), + Name: types.StringValue("bar"), + DirectoryHash: types.StringValue("aaa"), + ID: NewUUIDUnknown(), + TerraformVariables: []Variable{}, }, }, configVersions: []TemplateVersion{ @@ -800,21 +871,24 @@ func TestReconcileVersionIDs(t *testing.T) { inputState: map[string][]PreviousTemplateVersion{ "aaa": { { - ID: aUUID, - Name: "bar", + ID: aUUID, + Name: "bar", + TFVars: map[string]string{}, }, }, }, expectedVersions: []TemplateVersion{ { - Name: types.StringValue("foo"), - DirectoryHash: types.StringValue("aaa"), - ID: NewUUIDUnknown(), + Name: types.StringValue("foo"), + DirectoryHash: types.StringValue("aaa"), + ID: NewUUIDUnknown(), + TerraformVariables: []Variable{}, }, { - Name: types.StringValue("bar"), - DirectoryHash: types.StringValue("aaa"), - ID: UUIDValue(aUUID), + Name: types.StringValue("bar"), + DirectoryHash: types.StringValue("aaa"), + ID: UUIDValue(aUUID), + TerraformVariables: []Variable{}, }, }, }, @@ -822,14 +896,16 @@ func TestReconcileVersionIDs(t *testing.T) { Name: "IdenticalRenameFirst", planVersions: []TemplateVersion{ { - Name: types.StringValue("foo"), - DirectoryHash: types.StringValue("aaa"), - ID: NewUUIDUnknown(), + Name: types.StringValue("foo"), + DirectoryHash: types.StringValue("aaa"), + ID: NewUUIDUnknown(), + TerraformVariables: []Variable{}, }, { - Name: types.StringValue("bar"), - DirectoryHash: types.StringValue("aaa"), - ID: NewUUIDUnknown(), + Name: types.StringValue("bar"), + DirectoryHash: types.StringValue("aaa"), + ID: NewUUIDUnknown(), + TerraformVariables: []Variable{}, }, }, configVersions: []TemplateVersion{ @@ -843,21 +919,24 @@ func TestReconcileVersionIDs(t *testing.T) { inputState: map[string][]PreviousTemplateVersion{ "aaa": { { - ID: aUUID, - Name: "baz", + ID: aUUID, + Name: "baz", + TFVars: map[string]string{}, }, }, }, expectedVersions: []TemplateVersion{ { - Name: types.StringValue("foo"), - DirectoryHash: types.StringValue("aaa"), - ID: UUIDValue(aUUID), + Name: types.StringValue("foo"), + DirectoryHash: types.StringValue("aaa"), + ID: UUIDValue(aUUID), + TerraformVariables: []Variable{}, }, { - Name: types.StringValue("bar"), - DirectoryHash: types.StringValue("aaa"), - ID: NewUUIDUnknown(), + Name: types.StringValue("bar"), + DirectoryHash: types.StringValue("aaa"), + ID: NewUUIDUnknown(), + TerraformVariables: []Variable{}, }, }, }, @@ -865,14 +944,16 @@ func TestReconcileVersionIDs(t *testing.T) { Name: "IdenticalHashesInState", planVersions: []TemplateVersion{ { - Name: types.StringValue("foo"), - DirectoryHash: types.StringValue("aaa"), - ID: NewUUIDUnknown(), + Name: types.StringValue("foo"), + DirectoryHash: types.StringValue("aaa"), + ID: NewUUIDUnknown(), + TerraformVariables: []Variable{}, }, { - Name: types.StringValue("bar"), - DirectoryHash: types.StringValue("aaa"), - ID: NewUUIDUnknown(), + Name: types.StringValue("bar"), + DirectoryHash: types.StringValue("aaa"), + ID: NewUUIDUnknown(), + TerraformVariables: []Variable{}, }, }, configVersions: []TemplateVersion{ @@ -886,25 +967,29 @@ func TestReconcileVersionIDs(t *testing.T) { inputState: map[string][]PreviousTemplateVersion{ "aaa": { { - ID: aUUID, - Name: "qux", + ID: aUUID, + Name: "qux", + TFVars: map[string]string{}, }, { - ID: bUUID, - Name: "baz", + ID: bUUID, + Name: "baz", + TFVars: map[string]string{}, }, }, }, expectedVersions: []TemplateVersion{ { - Name: types.StringValue("foo"), - DirectoryHash: types.StringValue("aaa"), - ID: UUIDValue(aUUID), + Name: types.StringValue("foo"), + DirectoryHash: types.StringValue("aaa"), + ID: UUIDValue(aUUID), + TerraformVariables: []Variable{}, }, { - Name: types.StringValue("bar"), - DirectoryHash: types.StringValue("aaa"), - ID: UUIDValue(bUUID), + Name: types.StringValue("bar"), + DirectoryHash: types.StringValue("aaa"), + ID: UUIDValue(bUUID), + TerraformVariables: []Variable{}, }, }, }, @@ -912,14 +997,16 @@ func TestReconcileVersionIDs(t *testing.T) { Name: "UnknownUsesStateInOrder", planVersions: []TemplateVersion{ { - Name: types.StringValue("foo"), - DirectoryHash: types.StringValue("aaa"), - ID: NewUUIDUnknown(), + Name: types.StringValue("foo"), + DirectoryHash: types.StringValue("aaa"), + ID: NewUUIDUnknown(), + TerraformVariables: []Variable{}, }, { - Name: types.StringUnknown(), - DirectoryHash: types.StringValue("aaa"), - ID: NewUUIDUnknown(), + Name: types.StringUnknown(), + DirectoryHash: types.StringValue("aaa"), + ID: NewUUIDUnknown(), + TerraformVariables: []Variable{}, }, }, configVersions: []TemplateVersion{ @@ -933,55 +1020,152 @@ func TestReconcileVersionIDs(t *testing.T) { inputState: map[string][]PreviousTemplateVersion{ "aaa": { { - ID: aUUID, - Name: "qux", + ID: aUUID, + Name: "qux", + TFVars: map[string]string{}, }, { - ID: bUUID, - Name: "baz", + ID: bUUID, + Name: "baz", + TFVars: map[string]string{}, }, }, }, expectedVersions: []TemplateVersion{ + { + Name: types.StringValue("foo"), + DirectoryHash: types.StringValue("aaa"), + ID: UUIDValue(aUUID), + TerraformVariables: []Variable{}, + }, + { + Name: types.StringValue("baz"), + DirectoryHash: types.StringValue("aaa"), + ID: UUIDValue(bUUID), + TerraformVariables: []Variable{}, + }, + }, + }, + { + Name: "NewVersionNewRandomName", + planVersions: []TemplateVersion{ + { + Name: types.StringValue("weird_draught12"), + DirectoryHash: types.StringValue("bbb"), + ID: UUIDValue(aUUID), + TerraformVariables: []Variable{}, + }, + }, + configVersions: []TemplateVersion{ + { + Name: types.StringNull(), + }, + }, + inputState: map[string][]PreviousTemplateVersion{ + "aaa": { + { + ID: aUUID, + Name: "weird_draught12", + TFVars: map[string]string{}, + }, + }, + }, + expectedVersions: []TemplateVersion{ + { + Name: types.StringUnknown(), + DirectoryHash: types.StringValue("bbb"), + ID: NewUUIDUnknown(), + TerraformVariables: []Variable{}, + }, + }, + }, + { + Name: "IdenticalNewVars", + planVersions: []TemplateVersion{ { Name: types.StringValue("foo"), DirectoryHash: types.StringValue("aaa"), ID: UUIDValue(aUUID), + TerraformVariables: []Variable{ + { + Name: types.StringValue("foo"), + Value: types.StringValue("bar"), + }, + }, }, + }, + configVersions: []TemplateVersion{ { - Name: types.StringValue("baz"), + Name: types.StringValue("foo"), + }, + }, + inputState: map[string][]PreviousTemplateVersion{ + "aaa": { + { + ID: aUUID, + Name: "foo", + TFVars: map[string]string{ + "foo": "foo", + }, + }, + }, + }, + expectedVersions: []TemplateVersion{ + { + Name: types.StringValue("foo"), DirectoryHash: types.StringValue("aaa"), - ID: UUIDValue(bUUID), + ID: NewUUIDUnknown(), + TerraformVariables: []Variable{ + { + Name: types.StringValue("foo"), + Value: types.StringValue("bar"), + }, + }, }, }, }, { - Name: "NewVersionNewRandomName", + Name: "IdenticalSameVars", planVersions: []TemplateVersion{ { - Name: types.StringValue("weird_draught12"), - DirectoryHash: types.StringValue("bbb"), + Name: types.StringValue("foo"), + DirectoryHash: types.StringValue("aaa"), ID: UUIDValue(aUUID), + TerraformVariables: []Variable{ + { + Name: types.StringValue("foo"), + Value: types.StringValue("bar"), + }, + }, }, }, configVersions: []TemplateVersion{ { - Name: types.StringNull(), + Name: types.StringValue("foo"), }, }, inputState: map[string][]PreviousTemplateVersion{ "aaa": { { ID: aUUID, - Name: "weird_draught12", + Name: "foo", + TFVars: map[string]string{ + "foo": "bar", + }, }, }, }, expectedVersions: []TemplateVersion{ { - Name: types.StringUnknown(), - DirectoryHash: types.StringValue("bbb"), - ID: NewUUIDUnknown(), + Name: types.StringValue("foo"), + DirectoryHash: types.StringValue("aaa"), + ID: UUIDValue(aUUID), + TerraformVariables: []Variable{ + { + Name: types.StringValue("foo"), + Value: types.StringValue("bar"), + }, + }, }, }, },