Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 1a54748

Browse files
committedAug 2, 2024·
fix: template version replacement & metadata updates
1 parent c0950ec commit 1a54748

File tree

3 files changed

+196
-52
lines changed

3 files changed

+196
-52
lines changed
 

‎docs/resources/template.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ Optional:
5555

5656
- `active` (Boolean) Whether this version is the active version of the template. Only one version can be active at a time.
5757
- `message` (String) A message describing the changes in this version of the template. Messages longer than 72 characters will be truncated.
58-
- `name` (String) The name of the template version. Automatically generated if not provided.
58+
- `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.
5959
- `provisioner_tags` (Attributes Set) Provisioner tags for the template version. (see [below for nested schema](#nestedatt--versions--provisioner_tags))
6060
- `tf_vars` (Attributes Set) Terraform variables for the template version. (see [below for nested schema](#nestedatt--versions--tf_vars))
6161

‎internal/provider/template_resource.go

Lines changed: 149 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package provider
33
import (
44
"bufio"
55
"context"
6+
"encoding/json"
67
"fmt"
78
"io"
89

@@ -346,7 +347,7 @@ func (r *TemplateResource) Schema(ctx context.Context, req resource.SchemaReques
346347
Computed: true,
347348
},
348349
"name": schema.StringAttribute{
349-
MarkdownDescription: "The name of the template version. Automatically generated if not provided.",
350+
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.",
350351
Optional: true,
351352
Computed: true,
352353
},
@@ -502,6 +503,17 @@ func (r *TemplateResource) Create(ctx context.Context, req resource.CreateReques
502503
data.ID = UUIDValue(templateResp.ID)
503504
data.DisplayName = types.StringValue(templateResp.DisplayName)
504505

506+
// We have to init the private state again since the PlanModifyObject private
507+
// state is not accessible in Create
508+
resp.Diagnostics.Append(setEmptyPrivateState(ctx, resp.Private)...)
509+
if resp.Diagnostics.HasError() {
510+
return
511+
}
512+
resp.Diagnostics.Append(data.Versions.writePrivateState(ctx, resp.Private)...)
513+
if resp.Diagnostics.HasError() {
514+
return
515+
}
516+
505517
// Save data into Terraform sutate
506518
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
507519
}
@@ -569,11 +581,11 @@ func (r *TemplateResource) Read(ctx context.Context, req resource.ReadRequest, r
569581
}
570582

571583
func (r *TemplateResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
572-
var planState TemplateResourceModel
584+
var newState TemplateResourceModel
573585
var curState TemplateResourceModel
574586

575587
// Read Terraform plan data into the model
576-
resp.Diagnostics.Append(req.Plan.Get(ctx, &planState)...)
588+
resp.Diagnostics.Append(req.Plan.Get(ctx, &newState)...)
577589

578590
if resp.Diagnostics.HasError() {
579591
return
@@ -585,25 +597,25 @@ func (r *TemplateResource) Update(ctx context.Context, req resource.UpdateReques
585597
return
586598
}
587599

588-
if planState.OrganizationID.IsUnknown() {
589-
planState.OrganizationID = UUIDValue(r.data.DefaultOrganizationID)
600+
if newState.OrganizationID.IsUnknown() {
601+
newState.OrganizationID = UUIDValue(r.data.DefaultOrganizationID)
590602
}
591603

592-
if planState.DisplayName.IsUnknown() {
593-
planState.DisplayName = planState.Name
604+
if newState.DisplayName.IsUnknown() {
605+
newState.DisplayName = newState.Name
594606
}
595607

596-
orgID := planState.OrganizationID.ValueUUID()
608+
orgID := newState.OrganizationID.ValueUUID()
597609

598-
templateID := planState.ID.ValueUUID()
610+
templateID := newState.ID.ValueUUID()
599611

600612
client := r.data.Client
601613

602-
templateMetadataChanged := !planState.EqualTemplateMetadata(curState)
614+
templateMetadataChanged := !newState.EqualTemplateMetadata(curState)
603615
// This is required, as the API will reject no-diff updates.
604616
if templateMetadataChanged {
605617
tflog.Trace(ctx, "change in template metadata detected, updating.")
606-
updateReq := planState.toUpdateRequest(ctx, resp)
618+
updateReq := newState.toUpdateRequest(ctx, resp)
607619
if resp.Diagnostics.HasError() {
608620
return
609621
}
@@ -618,9 +630,9 @@ func (r *TemplateResource) Update(ctx context.Context, req resource.UpdateReques
618630

619631
// Since the everyone group always gets deleted by `DisableEveryoneGroupAccess`, we need to run this even if there
620632
// were no ACL changes but the template metadata was updated.
621-
if !planState.ACL.IsNull() && (!curState.ACL.Equal(planState.ACL) || templateMetadataChanged) {
633+
if !newState.ACL.IsNull() && (!curState.ACL.Equal(newState.ACL) || templateMetadataChanged) {
622634
var acl ACL
623-
resp.Diagnostics.Append(planState.ACL.As(ctx, &acl, basetypes.ObjectAsOptions{})...)
635+
resp.Diagnostics.Append(newState.ACL.As(ctx, &acl, basetypes.ObjectAsOptions{})...)
624636
if resp.Diagnostics.HasError() {
625637
return
626638
}
@@ -632,51 +644,64 @@ func (r *TemplateResource) Update(ctx context.Context, req resource.UpdateReques
632644
tflog.Trace(ctx, "successfully updated template ACL")
633645
}
634646

635-
for idx, plannedVersion := range planState.Versions {
636-
var curVersionID uuid.UUID
637-
// All versions in the state are guaranteed to have known IDs
638-
foundVersion := curState.Versions.ByID(plannedVersion.ID)
639-
// If the version is new, or if the directory hash has changed, create a new version
640-
if foundVersion == nil || foundVersion.DirectoryHash != plannedVersion.DirectoryHash {
647+
for idx := range newState.Versions {
648+
if newState.Versions[idx].ID.IsUnknown() {
641649
tflog.Trace(ctx, "discovered a new or modified template version")
642-
versionResp, err := newVersion(ctx, client, newVersionRequest{
643-
Version: &plannedVersion,
650+
uploadResp, err := newVersion(ctx, client, newVersionRequest{
651+
Version: &newState.Versions[idx],
644652
OrganizationID: orgID,
645653
TemplateID: &templateID,
646654
})
647655
if err != nil {
648656
resp.Diagnostics.AddError("Client Error", err.Error())
649657
return
650658
}
651-
curVersionID = versionResp.ID
659+
versionResp, err := client.TemplateVersion(ctx, uploadResp.ID)
660+
if err != nil {
661+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to get template version: %s", err))
662+
return
663+
}
664+
newState.Versions[idx].ID = UUIDValue(versionResp.ID)
665+
newState.Versions[idx].Name = types.StringValue(versionResp.Name)
652666
} else {
653-
// Or if it's an existing version, get the ID
654-
curVersionID = plannedVersion.ID.ValueUUID()
655-
}
656-
versionResp, err := client.TemplateVersion(ctx, curVersionID)
657-
if err != nil {
658-
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to get template version: %s", err))
659-
return
667+
versionResp, err := client.UpdateTemplateVersion(ctx, newState.Versions[idx].ID.ValueUUID(), codersdk.PatchTemplateVersionRequest{
668+
Name: newState.Versions[idx].Name.ValueString(),
669+
Message: newState.Versions[idx].Message.ValueStringPointer(),
670+
})
671+
if err != nil {
672+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to update template version metadata: %s", err))
673+
return
674+
}
675+
// If the name was not provided on an update we set it to the patch result, which is the previous name.
676+
// There's no way to go back to an auto-generated name unless the template version files itself change.
677+
newState.Versions[idx].Name = types.StringValue(versionResp.Name)
660678
}
661-
if plannedVersion.Active.ValueBool() {
679+
if newState.Versions[idx].Active.ValueBool() {
662680
tflog.Trace(ctx, "marking template version as active", map[string]any{
663-
"version_id": versionResp.ID,
664-
"template_id": templateID,
681+
"version_id": newState.Versions[idx].ID.ValueString(),
682+
"template_id": templateID.String(),
665683
})
666684
err := client.UpdateActiveTemplateVersion(ctx, templateID, codersdk.UpdateActiveTemplateVersion{
667-
ID: versionResp.ID,
685+
ID: newState.Versions[idx].ID.ValueUUID(),
668686
})
669687
if err != nil {
670688
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to update active template version: %s", err))
671689
return
672690
}
673691
tflog.Trace(ctx, "marked template version as active")
674692
}
675-
planState.Versions[idx].ID = UUIDValue(versionResp.ID)
693+
}
694+
695+
// We only want the previous apply in the state at any given time
696+
resp.Diagnostics.Append(setEmptyPrivateState(ctx, resp.Private)...)
697+
698+
resp.Diagnostics.Append(newState.Versions.writePrivateState(ctx, resp.Private)...)
699+
if resp.Diagnostics.HasError() {
700+
return
676701
}
677702

678703
// Save updated data into Terraform state
679-
resp.Diagnostics.Append(resp.State.Set(ctx, &planState)...)
704+
resp.Diagnostics.Append(resp.State.Set(ctx, &newState)...)
680705
}
681706

682707
func (r *TemplateResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
@@ -766,25 +791,26 @@ func (d *directoryHashPlanModifier) MarkdownDescription(context.Context) string
766791

767792
// PlanModifyObject implements planmodifier.Object.
768793
func (d *directoryHashPlanModifier) PlanModifyObject(ctx context.Context, req planmodifier.ObjectRequest, resp *planmodifier.ObjectResponse) {
769-
attributes := req.PlanValue.Attributes()
770-
directory, ok := attributes["directory"].(types.String)
771-
if !ok {
772-
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("unexpected type for directory, got: %T", directory))
794+
var data TemplateVersion
795+
resp.Diagnostics.Append(req.PlanValue.As(ctx, &data, basetypes.ObjectAsOptions{})...)
796+
if resp.Diagnostics.HasError() {
773797
return
774798
}
775799

776-
hash, err := computeDirectoryHash(directory.ValueString())
800+
hash, err := computeDirectoryHash(data.Directory.ValueString())
777801
if err != nil {
778802
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to compute directory hash: %s", err))
779803
return
780804
}
781-
attributes["directory_hash"] = types.StringValue(hash)
782-
out, diag := types.ObjectValue(req.PlanValue.AttributeTypes(ctx), attributes)
783-
if diag.HasError() {
784-
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to create plan object: %s", diag))
805+
806+
data.DirectoryHash = types.StringValue(hash)
807+
// Populate version IDs or mark them as unknown if the hash has changed
808+
resp.Diagnostics.Append(data.readFromPrivateState(ctx, req.Private)...)
809+
if resp.Diagnostics.HasError() {
785810
return
786811
}
787-
resp.PlanValue = out
812+
813+
resp.PlanValue, resp.Diagnostics = types.ObjectValueFrom(ctx, req.PlanValue.AttributeTypes(ctx), data)
788814
}
789815

790816
func NewDirectoryHashPlanModifier() planmodifier.Object {
@@ -1062,3 +1088,81 @@ func (r *TemplateResourceModel) toCreateRequest(ctx context.Context, resp *resou
10621088
DisableEveryoneGroupAccess: !r.ACL.IsNull(),
10631089
}
10641090
}
1091+
1092+
type LastVersionsByHash = map[string]PreviousTemplateVersion
1093+
1094+
var LastVersionsKey = "last_versions"
1095+
1096+
type PreviousTemplateVersion struct {
1097+
ID uuid.UUID `json:"id"`
1098+
Name string `json:"name"`
1099+
}
1100+
1101+
type privateState interface {
1102+
GetKey(ctx context.Context, key string) ([]byte, diag.Diagnostics)
1103+
SetKey(ctx context.Context, key string, value []byte) diag.Diagnostics
1104+
}
1105+
1106+
func (v Versions) writePrivateState(ctx context.Context, ps privateState) (diags diag.Diagnostics) {
1107+
var lv LastVersionsByHash
1108+
lvBytes, diag := ps.GetKey(ctx, LastVersionsKey)
1109+
if diag.HasError() {
1110+
return diag
1111+
}
1112+
err := json.Unmarshal(lvBytes, &lv)
1113+
if err != nil {
1114+
diags.AddError("Client Error", fmt.Sprintf("Failed to unmarshal private state when writing: %s", err))
1115+
return diags
1116+
}
1117+
for _, version := range v {
1118+
lv[version.DirectoryHash.ValueString()] = PreviousTemplateVersion{
1119+
ID: version.ID.ValueUUID(),
1120+
Name: version.ID.ValueString(),
1121+
}
1122+
lvBytes, err = json.Marshal(lv)
1123+
if err != nil {
1124+
diags.AddError("Client Error", fmt.Sprintf("Failed to marshal private state: %s", err))
1125+
return diags
1126+
}
1127+
}
1128+
return ps.SetKey(ctx, LastVersionsKey, lvBytes)
1129+
}
1130+
1131+
func (v *TemplateVersion) readFromPrivateState(ctx context.Context, ps privateState) (diags diag.Diagnostics) {
1132+
var lv LastVersionsByHash
1133+
lvBytes, diag := ps.GetKey(ctx, LastVersionsKey)
1134+
if diag.HasError() {
1135+
diags.Append(diag...)
1136+
return
1137+
}
1138+
// If this is the first read, init the private state value
1139+
if lvBytes == nil {
1140+
setEmptyPrivateState(ctx, ps)
1141+
return
1142+
}
1143+
err := json.Unmarshal(lvBytes, &lv)
1144+
if err != nil {
1145+
diags.AddError("Client Error", fmt.Sprintf("Failed to unmarshal private state when reading: %s", err))
1146+
return
1147+
}
1148+
1149+
prev, ok := lv[v.DirectoryHash.ValueString()]
1150+
// If not in state, mark as known after apply since we'll create a new version.
1151+
// Versions who's Terraform configuration has not changed will have known
1152+
// IDs at this point, so we need to set this manually.
1153+
if !ok {
1154+
v.ID = NewUUIDUnknown()
1155+
return
1156+
}
1157+
// Otherwise, use the existing ID for this hash
1158+
v.ID = UUIDValue(prev.ID)
1159+
return
1160+
}
1161+
1162+
func setEmptyPrivateState(ctx context.Context, ps privateState) (diags diag.Diagnostics) {
1163+
pvBytes, err := json.Marshal(make(LastVersionsByHash))
1164+
if err != nil {
1165+
panic("failed to marshal empty private state")
1166+
}
1167+
return ps.SetKey(ctx, LastVersionsKey, pvBytes)
1168+
}

‎internal/provider/template_resource_test.go

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package provider
22

33
import (
44
"context"
5+
"fmt"
56
"os"
67
"regexp"
78
"slices"
@@ -29,7 +30,7 @@ func TestAccTemplateResource(t *testing.T) {
2930
Name: PtrTo("example-template"),
3031
Versions: []testAccTemplateVersionConfig{
3132
{
32-
Name: PtrTo("main"),
33+
// Auto-generated version name
3334
Directory: PtrTo("../../integration/template-test/example-template/"),
3435
Active: PtrTo(true),
3536
// TODO(ethanndickson): Remove this when we add in `*.tfvars` parsing
@@ -102,6 +103,7 @@ func TestAccTemplateResource(t *testing.T) {
102103
IsUnitTest: true,
103104
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
104105
Steps: []resource.TestStep{
106+
// Init, creates the first version
105107
{
106108
Config: cfg1.String(t),
107109
Check: resource.ComposeTestCheckFunc(
@@ -123,23 +125,46 @@ func TestAccTemplateResource(t *testing.T) {
123125
resource.TestCheckResourceAttr("coderd_template.test", "time_til_dormant_autodelete_ms", "0"),
124126
resource.TestCheckResourceAttr("coderd_template.test", "require_active_version", "false"),
125127
resource.TestMatchTypeSetElemNestedAttrs("coderd_template.test", "versions.*", map[string]*regexp.Regexp{
126-
"name": regexp.MustCompile("main"),
127-
"id": regexp.MustCompile(".*"),
128+
"name": regexp.MustCompile(".+"),
129+
"id": regexp.MustCompile(".+"),
128130
"directory_hash": regexp.MustCompile(".+"),
129131
"message": regexp.MustCompile(""),
130132
}),
131133
),
132134
},
135+
// Modify template contents. Creates a second version.
136+
{
137+
Config: cfg1.String(t),
138+
PreConfig: func() {
139+
file := fmt.Sprintf("%s/terraform.tfvars", *cfg1.Versions[0].Directory)
140+
newFile := []byte("name = \"world2\"")
141+
err := os.WriteFile(file, newFile, 0644)
142+
require.NoError(t, err)
143+
},
144+
// Version should be updated, checked at the end
145+
},
146+
// Undo modification. Creates a third version since it differs from the last apply
147+
{
148+
Config: cfg1.String(t),
149+
PreConfig: func() {
150+
file := fmt.Sprintf("%s/terraform.tfvars", *cfg1.Versions[0].Directory)
151+
newFile := []byte("name = \"world\"")
152+
err := os.WriteFile(file, newFile, 0644)
153+
require.NoError(t, err)
154+
},
155+
// Version should be updated, checked at the end
156+
},
133157
// Import
134158
{
135159
Config: cfg1.String(t),
136160
ResourceName: "coderd_template.test",
137161
ImportState: true,
138162
ImportStateVerify: true,
139163
// In the real world, `versions` needs to be added to the configuration after importing
164+
// We can't import ACL as we can't currently differentiate between managed and unmanaged ACL
140165
ImportStateVerifyIgnore: []string{"versions", "acl"},
141166
},
142-
// Update existing version & metadata
167+
// Change existing version directory & name, update template metadata. Creates a fourth version.
143168
{
144169
Config: cfg2.String(t),
145170
Check: resource.ComposeAggregateTestCheckFunc(
@@ -153,7 +178,7 @@ func TestAccTemplateResource(t *testing.T) {
153178
}),
154179
),
155180
},
156-
// Append version
181+
// Append version. Creates a fifth version
157182
{
158183
Config: cfg3.String(t),
159184
Check: resource.ComposeAggregateTestCheckFunc(
@@ -181,7 +206,7 @@ func TestAccTemplateResource(t *testing.T) {
181206
}),
182207
),
183208
},
184-
// Swap versions
209+
// Swap versions in-place
185210
{
186211
Config: cfg5.String(t),
187212
Check: resource.ComposeAggregateTestCheckFunc(
@@ -214,6 +239,21 @@ func TestAccTemplateResource(t *testing.T) {
214239
resource.TestCheckNoResourceAttr("coderd_template.test", "acl"),
215240
),
216241
},
242+
// Count orphaned versions
243+
{
244+
Config: cfg7.String(t),
245+
PreConfig: func() {
246+
templates, err := client.Templates(ctx)
247+
require.NoError(t, err)
248+
require.Len(t, templates, 1)
249+
versions, err := client.TemplateVersionsByTemplate(ctx, codersdk.TemplateVersionsByTemplateRequest{
250+
TemplateID: templates[0].ID,
251+
})
252+
require.NoError(t, err)
253+
require.Len(t, versions, 5)
254+
},
255+
},
256+
// Resource deleted
217257
},
218258
})
219259
}

0 commit comments

Comments
 (0)
Please sign in to comment.