Skip to content

Commit d930a29

Browse files
committed
fix: template version replacement & metadata updates
1 parent 8af42aa commit d930a29

File tree

3 files changed

+196
-52
lines changed

3 files changed

+196
-52
lines changed

docs/resources/template.md

+1-1
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

+149-45
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+
}

0 commit comments

Comments
 (0)