Skip to content

Commit ad98bf0

Browse files
jatcod3rPepziC0les
andauthored
fix: avoid stripping cty sensitivity marks during plan modification (#343)
Extending Kirby's fix. When coderd_template has sensitive tf_vars and a deferred dependency (like time_static) that gets replaced during apply, Terraform re-plans the resource. The previous plan modifier used types.ListValueFrom() to write the entire versions list back, which reconstructed tftypes values and stripped Terraform core's cty-level sensitivity marks. This caused: 'Provider produced inconsistent final plan: inconsistent values for sensitive attribute' Changes: - Replace attribute-level PlanModifyList with resource-level ModifyPlan that only writes directory_hash via SetAttribute (proven safe for sensitivity preservation) - Move version reconciliation logic (new vs reuse) from plan modifier into Update, re-deriving from private state instead of ID.IsUnknown() - Keep reconcileVersionIDs() for unit tests but no longer call from plan modifier Fixes #305 Test Template: ```.tf provider "coderd" { url = "https://******" token = "******" } variable "secret_one" { type = string sensitive = true default = "no" } variable "normal_info_one" { type = string default = "normal-info-2" } locals { my_secrets = { "normal-info-1" = var.normal_info_one "secret-1" = var.secret_one "random" = "Hi!" } my_tags = { # "scope" = var.normal_info_one # "env" = "prod" } } data "coderd_organization" "org" { name = "experiment" } resource "random_uuid" "uuid" { keepers = local.my_secrets } resource "coderd_template" "test_vars_only" { name = "test-vars-only" display_name = "Test Vars Only" description = "Test tf_vars + provisioner_tags" organization_id = data.coderd_organization.org.id require_active_version = true acl = null versions = [{ name = random_uuid.uuid.result message = "My newest stable version" directory = "${path.module}/templates/my-coder-template" active = true tf_vars = [for k, v in local.my_secrets : { name = k, value = tostring(v) }] provisioner_tags = [for k, v in local.my_tags : { name = k, value = tostring(v) }] }] } ``` Co-authored-by: ju-pe <jullianpepito@gmail.com>
1 parent d047065 commit ad98bf0

1 file changed

Lines changed: 265 additions & 18 deletions

File tree

internal/provider/template_resource.go

Lines changed: 265 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import (
4343
var _ resource.Resource = &TemplateResource{}
4444
var _ resource.ResourceWithImportState = &TemplateResource{}
4545
var _ resource.ResourceWithConfigValidators = &TemplateResource{}
46+
var _ resource.ResourceWithModifyPlan = &TemplateResource{}
4647

4748
func NewTemplateResource() resource.Resource {
4849
return &TemplateResource{}
@@ -508,9 +509,6 @@ func (r *TemplateResource) Schema(ctx context.Context, req resource.SchemaReques
508509
},
509510
},
510511
},
511-
PlanModifiers: []planmodifier.List{
512-
NewVersionsPlanModifier(),
513-
},
514512
},
515513
},
516514
}
@@ -821,8 +819,89 @@ func (r *TemplateResource) Update(ctx context.Context, req resource.UpdateReques
821819
tflog.Info(ctx, "successfully updated template ACL")
822820
}
823821

822+
// Read prior versions from private state to determine which versions
823+
// need to be created vs. reused. This replaces the previous approach of
824+
// relying on ID.IsUnknown() set by the plan modifier, which required
825+
// reconstructing the entire versions list and stripped cty sensitivity marks.
826+
var lv LastVersionsByHash
827+
lvBytes, pvDiag := req.Private.GetKey(ctx, LastVersionsKey)
828+
if pvDiag.HasError() {
829+
resp.Diagnostics.Append(pvDiag...)
830+
return
831+
}
832+
if lvBytes != nil {
833+
if err := json.Unmarshal(lvBytes, &lv); err != nil {
834+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to unmarshal private state in Update: %s", err))
835+
return
836+
}
837+
} else {
838+
lv = make(LastVersionsByHash)
839+
}
840+
// Keep an unmodified copy for tf_vars comparison.
841+
fullLv := make(LastVersionsByHash)
842+
for k, v := range lv {
843+
fullLv[k] = slices.Clone(v)
844+
}
845+
846+
// Read config to determine which version names are user-set vs computed.
847+
var configVersions Versions
848+
resp.Diagnostics.Append(req.Config.GetAttribute(ctx, path.Root("versions"), &configVersions)...)
849+
if resp.Diagnostics.HasError() {
850+
return
851+
}
852+
824853
for idx := range newState.Versions {
825-
if newState.Versions[idx].ID.IsUnknown() {
854+
needsNewVersion := false
855+
var matchedPrev *PreviousTemplateVersion
856+
857+
hash := newState.Versions[idx].DirectoryHash.ValueString()
858+
prevList, hashFound := lv[hash]
859+
860+
if !hashFound {
861+
// Directory hash not in private state — new version needed.
862+
needsNewVersion = true
863+
} else {
864+
// Try to find a matching previous version.
865+
// First, try to match by name.
866+
matched := false
867+
for j, prev := range prevList {
868+
if newState.Versions[idx].Name.ValueString() == prev.Name {
869+
matchedPrev = &prevList[j]
870+
// Remove from candidates
871+
lv[hash] = append(prevList[:j], prevList[j+1:]...)
872+
matched = true
873+
break
874+
}
875+
}
876+
// If no name match, use first available candidate.
877+
if !matched && len(prevList) > 0 {
878+
matchedPrev = &prevList[0]
879+
lv[hash] = prevList[1:]
880+
}
881+
}
882+
883+
// If we found a matching previous version, check if tf_vars changed.
884+
if matchedPrev != nil {
885+
// Check tf_vars change using the full (unmodified) private state.
886+
if fullPrevs, ok := fullLv[hash]; ok {
887+
// Build a temporary version with the matched ID to use tfVariablesChanged.
888+
tmpVersion := newState.Versions[idx]
889+
tmpVersion.ID = UUIDValue(matchedPrev.ID)
890+
if tfVariablesChanged(ctx, fullPrevs, &tmpVersion) {
891+
needsNewVersion = true
892+
}
893+
}
894+
} else {
895+
needsNewVersion = true
896+
}
897+
898+
if needsNewVersion {
899+
// If the user didn't explicitly set a name in the config,
900+
// clear it so that Coderd generates a fresh random name.
901+
// Otherwise, the stale name from state would conflict.
902+
if idx < len(configVersions) && configVersions[idx].Name.IsNull() {
903+
newState.Versions[idx].Name = types.StringValue("")
904+
}
826905
tflog.Info(ctx, "discovered a new or modified template version")
827906
uploadResp, logs, err := newVersion(ctx, client, newVersionRequest{
828907
Version: &newState.Versions[idx],
@@ -848,16 +927,14 @@ func (r *TemplateResource) Update(ctx context.Context, req resource.UpdateReques
848927
}
849928
}
850929
} else {
851-
// Since the ID was not unknown, it must be in the current state,
852-
// having been retrieved from the private state,
853-
// but the list might be a different size.
854-
curVersion := curState.Versions.ByID(newState.Versions[idx].ID)
855-
if curVersion == nil {
856-
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Public/Private State Mismatch: failed to find template version with ID %s", newState.Versions[idx].ID))
857-
return
930+
// Reuse existing version — apply the known ID and name.
931+
newState.Versions[idx].ID = UUIDValue(matchedPrev.ID)
932+
if newState.Versions[idx].Name.IsUnknown() {
933+
newState.Versions[idx].Name = types.StringValue(matchedPrev.Name)
858934
}
859-
if !curVersion.Name.Equal(newState.Versions[idx].Name) {
860-
_, err := client.UpdateTemplateVersion(ctx, newState.Versions[idx].ID.ValueUUID(), codersdk.PatchTemplateVersionRequest{
935+
// Check if name changed — rename via API.
936+
if matchedPrev.Name != newState.Versions[idx].Name.ValueString() {
937+
_, err := client.UpdateTemplateVersion(ctx, matchedPrev.ID, codersdk.PatchTemplateVersionRequest{
861938
Name: newState.Versions[idx].Name.ValueString(),
862939
Message: newState.Versions[idx].Message.ValueStringPointer(),
863940
})
@@ -866,8 +943,9 @@ func (r *TemplateResource) Update(ctx context.Context, req resource.UpdateReques
866943
return
867944
}
868945
}
869-
if newState.Versions[idx].Active.ValueBool() && !curVersion.Active.ValueBool() {
870-
err := markActive(ctx, client, templateID, newState.Versions[idx].ID.ValueUUID())
946+
// Check if active status changed.
947+
if newState.Versions[idx].Active.ValueBool() && !matchedPrev.Active {
948+
err := markActive(ctx, client, templateID, matchedPrev.ID)
871949
if err != nil {
872950
resp.Diagnostics.AddError("Client Error", err.Error())
873951
return
@@ -946,6 +1024,168 @@ func (r *TemplateResource) ConfigValidators(context.Context) []resource.ConfigVa
9461024
return []resource.ConfigValidator{}
9471025
}
9481026

1027+
// ModifyPlan implements resource.ResourceWithModifyPlan.
1028+
// It computes directory hashes for each version and validates version constraints.
1029+
// Unlike the previous attribute-level plan modifier, this method only writes
1030+
// directory_hash values via SetAttribute, avoiding reconstruction of the entire
1031+
// versions list which would strip cty-level sensitivity marks from tf_vars.
1032+
func (r *TemplateResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) {
1033+
// On destroy, the plan will be null. Nothing to do.
1034+
if req.Plan.Raw.IsNull() {
1035+
return
1036+
}
1037+
1038+
var planVersions Versions
1039+
resp.Diagnostics.Append(req.Plan.GetAttribute(ctx, path.Root("versions"), &planVersions)...)
1040+
if resp.Diagnostics.HasError() {
1041+
return
1042+
}
1043+
1044+
var configVersions Versions
1045+
resp.Diagnostics.Append(req.Config.GetAttribute(ctx, path.Root("versions"), &configVersions)...)
1046+
if resp.Diagnostics.HasError() {
1047+
return
1048+
}
1049+
1050+
hasActiveVersion, diag := hasOneActiveVersion(configVersions)
1051+
if diag.HasError() {
1052+
resp.Diagnostics.Append(diag...)
1053+
return
1054+
}
1055+
1056+
// Read previous versions from private state.
1057+
var lv LastVersionsByHash
1058+
lvBytes, diag := req.Private.GetKey(ctx, LastVersionsKey)
1059+
if diag.HasError() {
1060+
resp.Diagnostics.Append(diag...)
1061+
return
1062+
}
1063+
if lvBytes == nil {
1064+
lv = make(LastVersionsByHash)
1065+
// If there's no prior private state, this might be resource creation,
1066+
// in which case one version must be active.
1067+
if !hasActiveVersion {
1068+
resp.Diagnostics.AddError("Client Error", "At least one template version must be active when creating a"+
1069+
" `coderd_template` resource.\n(Subsequent resource updates can be made without an active template in the list).")
1070+
return
1071+
}
1072+
} else {
1073+
err := json.Unmarshal(lvBytes, &lv)
1074+
if err != nil {
1075+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to unmarshal private state when reading: %s", err))
1076+
return
1077+
}
1078+
}
1079+
1080+
// Keep an unmodified copy for deactivation checks.
1081+
fullLv := make(LastVersionsByHash)
1082+
for k, v := range lv {
1083+
fullLv[k] = slices.Clone(v)
1084+
}
1085+
1086+
// Phase 1: Compute directory hashes and reconcile IDs via SetAttribute.
1087+
for i := range planVersions {
1088+
hash, err := computeDirectoryHash(planVersions[i].Directory.ValueString())
1089+
if err != nil {
1090+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to compute directory hash: %s", err))
1091+
return
1092+
}
1093+
resp.Diagnostics.Append(resp.Plan.SetAttribute(ctx,
1094+
path.Root("versions").AtListIndex(i).AtName("directory_hash"),
1095+
types.StringValue(hash),
1096+
)...)
1097+
if resp.Diagnostics.HasError() {
1098+
return
1099+
}
1100+
planVersions[i].DirectoryHash = types.StringValue(hash)
1101+
1102+
// Reconcile version ID: determine if this version needs to be newly created.
1103+
needsNew := false
1104+
prevList, hashFound := lv[hash]
1105+
if !hashFound {
1106+
needsNew = true
1107+
} else {
1108+
// Try to match by name.
1109+
matched := false
1110+
for j, prev := range prevList {
1111+
if planVersions[i].Name.ValueString() == prev.Name {
1112+
planVersions[i].ID = UUIDValue(prev.ID)
1113+
lv[hash] = append(prevList[:j], prevList[j+1:]...)
1114+
matched = true
1115+
break
1116+
}
1117+
}
1118+
if !matched && len(prevList) > 0 {
1119+
// Use first available candidate.
1120+
planVersions[i].ID = UUIDValue(prevList[0].ID)
1121+
if planVersions[i].Name.IsUnknown() {
1122+
planVersions[i].Name = types.StringValue(prevList[0].Name)
1123+
}
1124+
lv[hash] = prevList[1:]
1125+
} else if !matched {
1126+
needsNew = true
1127+
}
1128+
}
1129+
1130+
// Check tf_vars change.
1131+
if !needsNew && !planVersions[i].ID.IsUnknown() {
1132+
if prevs, ok := fullLv[hash]; ok {
1133+
if tfVariablesChanged(ctx, prevs, &planVersions[i]) {
1134+
needsNew = true
1135+
}
1136+
}
1137+
}
1138+
1139+
if needsNew {
1140+
planVersions[i].ID = NewUUIDUnknown()
1141+
resp.Diagnostics.Append(resp.Plan.SetAttribute(ctx,
1142+
path.Root("versions").AtListIndex(i).AtName("id"),
1143+
NewUUIDUnknown(),
1144+
)...)
1145+
if resp.Diagnostics.HasError() {
1146+
return
1147+
}
1148+
if configVersions[i].Name.IsNull() {
1149+
planVersions[i].Name = types.StringUnknown()
1150+
resp.Diagnostics.Append(resp.Plan.SetAttribute(ctx,
1151+
path.Root("versions").AtListIndex(i).AtName("name"),
1152+
types.StringUnknown(),
1153+
)...)
1154+
if resp.Diagnostics.HasError() {
1155+
return
1156+
}
1157+
}
1158+
} else {
1159+
// ID stays as-is (matched from state). Write it to ensure plan is consistent.
1160+
resp.Diagnostics.Append(resp.Plan.SetAttribute(ctx,
1161+
path.Root("versions").AtListIndex(i).AtName("id"),
1162+
planVersions[i].ID,
1163+
)...)
1164+
if resp.Diagnostics.HasError() {
1165+
return
1166+
}
1167+
}
1168+
}
1169+
1170+
// Deactivation check.
1171+
if !hasActiveVersion {
1172+
for i := range planVersions {
1173+
if planVersions[i].ID.IsUnknown() {
1174+
continue
1175+
}
1176+
prevs, ok := fullLv[planVersions[i].DirectoryHash.ValueString()]
1177+
if !ok {
1178+
continue
1179+
}
1180+
if versionDeactivated(prevs, &planVersions[i]) {
1181+
resp.Diagnostics.AddError("Client Error", "Plan could not determine which version should be active.\n"+
1182+
"Either specify an active version or modify the contents of the previously active version before marking it as inactive.")
1183+
return
1184+
}
1185+
}
1186+
}
1187+
}
1188+
9491189
type versionsValidator struct{}
9501190

9511191
func NewVersionsValidator() validator.List {
@@ -1630,13 +1870,20 @@ func tfVariablesChanged(ctx context.Context, prevs []PreviousTemplateVersion, pl
16301870
if prev.TFVars == nil {
16311871
return true
16321872
}
1873+
// If the set is unknown, we cannot compare and must treat it as changed.
1874+
if planned.TerraformVariables.IsUnknown() {
1875+
return true
1876+
}
1877+
// If the set is null (tf_vars not specified), treat as no variables.
1878+
// Only consider this a change if the previous version had variables.
1879+
if planned.TerraformVariables.IsNull() {
1880+
return len(prev.TFVars) > 0
1881+
}
16331882
plannedVars, diags := varsFromSet(ctx, planned.TerraformVariables)
16341883
if diags.HasError() {
16351884
return true
16361885
}
1637-
// If the set is unknown or null, we cannot compare and
1638-
// must treat it as changed.
1639-
if planned.TerraformVariables.IsUnknown() || planned.TerraformVariables.IsNull() {
1886+
if len(plannedVars) != len(prev.TFVars) {
16401887
return true
16411888
}
16421889
for _, tfVar := range plannedVars {

0 commit comments

Comments
 (0)