@@ -43,6 +43,7 @@ import (
4343var _ resource.Resource = & TemplateResource {}
4444var _ resource.ResourceWithImportState = & TemplateResource {}
4545var _ resource.ResourceWithConfigValidators = & TemplateResource {}
46+ var _ resource.ResourceWithModifyPlan = & TemplateResource {}
4647
4748func 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+
9491189type versionsValidator struct {}
9501190
9511191func 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