@@ -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,20 @@ func (r *TemplateResource) Update(ctx context.Context, req resource.UpdateReques
821819 tflog .Info (ctx , "successfully updated template ACL" )
822820 }
823821
822+ // Read config to determine which version names are user-set vs computed.
823+ var configVersions Versions
824+ resp .Diagnostics .Append (req .Config .GetAttribute (ctx , path .Root ("versions" ), & configVersions )... )
825+ if resp .Diagnostics .HasError () {
826+ return
827+ }
828+
824829 for idx := range newState .Versions {
825830 if newState .Versions [idx ].ID .IsUnknown () {
831+ // If the user didn't explicitly set a name in the config,
832+ // clear it so that Coderd generates a fresh random name.
833+ if idx < len (configVersions ) && configVersions [idx ].Name .IsNull () {
834+ newState .Versions [idx ].Name = types .StringValue ("" )
835+ }
826836 tflog .Info (ctx , "discovered a new or modified template version" )
827837 uploadResp , logs , err := newVersion (ctx , client , newVersionRequest {
828838 Version : & newState .Versions [idx ],
@@ -946,6 +956,109 @@ func (r *TemplateResource) ConfigValidators(context.Context) []resource.ConfigVa
946956 return []resource.ConfigValidator {}
947957}
948958
959+ // ModifyPlan implements resource.ResourceWithModifyPlan.
960+ // It computes directory hashes for each version and validates version constraints.
961+ // Unlike the previous attribute-level plan modifier, this method only writes
962+ // directory_hash values via SetAttribute, avoiding reconstruction of the entire
963+ // versions list which would strip cty-level sensitivity marks from tf_vars.
964+ func (r * TemplateResource ) ModifyPlan (ctx context.Context , req resource.ModifyPlanRequest , resp * resource.ModifyPlanResponse ) {
965+ // On destroy, the plan will be null. Nothing to do.
966+ if req .Plan .Raw .IsNull () {
967+ return
968+ }
969+
970+ var planVersions Versions
971+ resp .Diagnostics .Append (req .Plan .GetAttribute (ctx , path .Root ("versions" ), & planVersions )... )
972+ if resp .Diagnostics .HasError () {
973+ return
974+ }
975+
976+ var configVersions Versions
977+ resp .Diagnostics .Append (req .Config .GetAttribute (ctx , path .Root ("versions" ), & configVersions )... )
978+ if resp .Diagnostics .HasError () {
979+ return
980+ }
981+
982+ hasActiveVersion , diag := hasOneActiveVersion (configVersions )
983+ if diag .HasError () {
984+ resp .Diagnostics .Append (diag ... )
985+ return
986+ }
987+
988+ // Read previous versions from private state.
989+ var lv LastVersionsByHash
990+ lvBytes , diag := req .Private .GetKey (ctx , LastVersionsKey )
991+ if diag .HasError () {
992+ resp .Diagnostics .Append (diag ... )
993+ return
994+ }
995+ if lvBytes == nil {
996+ lv = make (LastVersionsByHash )
997+ // If there's no prior private state, this might be resource creation,
998+ // in which case one version must be active.
999+ if ! hasActiveVersion {
1000+ resp .Diagnostics .AddError ("Client Error" , "At least one template version must be active when creating a" +
1001+ " `coderd_template` resource.\n (Subsequent resource updates can be made without an active template in the list)." )
1002+ return
1003+ }
1004+ } else {
1005+ err := json .Unmarshal (lvBytes , & lv )
1006+ if err != nil {
1007+ resp .Diagnostics .AddError ("Client Error" , fmt .Sprintf ("Failed to unmarshal private state when reading: %s" , err ))
1008+ return
1009+ }
1010+ }
1011+
1012+ // Compute directory hashes.
1013+ for i := range planVersions {
1014+ hash , err := computeDirectoryHash (planVersions [i ].Directory .ValueString ())
1015+ if err != nil {
1016+ resp .Diagnostics .AddError ("Client Error" , fmt .Sprintf ("Failed to compute directory hash: %s" , err ))
1017+ return
1018+ }
1019+ planVersions [i ].DirectoryHash = types .StringValue (hash )
1020+ }
1021+
1022+ // Reconcile version IDs using the shared function.
1023+ diag = planVersions .reconcileVersionIDs (ctx , lv , configVersions , hasActiveVersion )
1024+ if diag .HasError () {
1025+ resp .Diagnostics .Append (diag ... )
1026+ return
1027+ }
1028+
1029+ // Write reconciled values back to plan via SetAttribute to preserve sensitivity marks.
1030+ // We only write `name` when reconciliation resolved it to a known value.
1031+ // If name is still unknown, the plan already carries the correct cty value
1032+ // (including any sensitivity marks inherited from the config expression),
1033+ // and overwriting it with a bare types.StringUnknown() would strip those marks.
1034+ for i := range planVersions {
1035+ resp .Diagnostics .Append (resp .Plan .SetAttribute (ctx ,
1036+ path .Root ("versions" ).AtListIndex (i ).AtName ("directory_hash" ),
1037+ planVersions [i ].DirectoryHash ,
1038+ )... )
1039+ if resp .Diagnostics .HasError () {
1040+ return
1041+ }
1042+ resp .Diagnostics .Append (resp .Plan .SetAttribute (ctx ,
1043+ path .Root ("versions" ).AtListIndex (i ).AtName ("id" ),
1044+ planVersions [i ].ID ,
1045+ )... )
1046+ if resp .Diagnostics .HasError () {
1047+ return
1048+ }
1049+ // Only write name when we resolved it to a known value.
1050+ if ! planVersions [i ].Name .IsUnknown () {
1051+ resp .Diagnostics .Append (resp .Plan .SetAttribute (ctx ,
1052+ path .Root ("versions" ).AtListIndex (i ).AtName ("name" ),
1053+ planVersions [i ].Name ,
1054+ )... )
1055+ if resp .Diagnostics .HasError () {
1056+ return
1057+ }
1058+ }
1059+ }
1060+ }
1061+
9491062type versionsValidator struct {}
9501063
9511064func NewVersionsValidator () validator.List {
@@ -1007,82 +1120,6 @@ func (a *versionsValidator) ValidateList(ctx context.Context, req validator.List
10071120
10081121var _ validator.List = & versionsValidator {}
10091122
1010- type versionsPlanModifier struct {}
1011-
1012- // Description implements planmodifier.Object.
1013- func (d * versionsPlanModifier ) Description (ctx context.Context ) string {
1014- return d .MarkdownDescription (ctx )
1015- }
1016-
1017- // MarkdownDescription implements planmodifier.Object.
1018- func (d * versionsPlanModifier ) MarkdownDescription (context.Context ) string {
1019- return "Compute the hash of a directory."
1020- }
1021-
1022- // PlanModifyObject implements planmodifier.List.
1023- func (d * versionsPlanModifier ) PlanModifyList (ctx context.Context , req planmodifier.ListRequest , resp * planmodifier.ListResponse ) {
1024- var planVersions Versions
1025- resp .Diagnostics .Append (req .PlanValue .ElementsAs (ctx , & planVersions , false )... )
1026- if resp .Diagnostics .HasError () {
1027- return
1028- }
1029- var configVersions Versions
1030- resp .Diagnostics .Append (req .ConfigValue .ElementsAs (ctx , & configVersions , false )... )
1031- if resp .Diagnostics .HasError () {
1032- return
1033- }
1034-
1035- hasActiveVersion , diag := hasOneActiveVersion (configVersions )
1036- if diag .HasError () {
1037- resp .Diagnostics .Append (diag ... )
1038- return
1039- }
1040-
1041- for i := range planVersions {
1042- hash , err := computeDirectoryHash (planVersions [i ].Directory .ValueString ())
1043- if err != nil {
1044- resp .Diagnostics .AddError ("Client Error" , fmt .Sprintf ("Failed to compute directory hash: %s" , err ))
1045- return
1046- }
1047- planVersions [i ].DirectoryHash = types .StringValue (hash )
1048- }
1049-
1050- var lv LastVersionsByHash
1051- lvBytes , diag := req .Private .GetKey (ctx , LastVersionsKey )
1052- if diag .HasError () {
1053- resp .Diagnostics .Append (diag ... )
1054- return
1055- }
1056- // If this is the first read, init the private state value
1057- if lvBytes == nil {
1058- lv = make (LastVersionsByHash )
1059- // If there's no prior private state, this might be resource creation,
1060- // in which case one version must be active.
1061- if ! hasActiveVersion {
1062- resp .Diagnostics .AddError ("Client Error" , "At least one template version must be active when creating a" +
1063- " `coderd_template` resource.\n (Subsequent resource updates can be made without an active template in the list)." )
1064- return
1065- }
1066- } else {
1067- err := json .Unmarshal (lvBytes , & lv )
1068- if err != nil {
1069- resp .Diagnostics .AddError ("Client Error" , fmt .Sprintf ("Failed to unmarshal private state when reading: %s" , err ))
1070- return
1071- }
1072- }
1073-
1074- diag = planVersions .reconcileVersionIDs (ctx , lv , configVersions , hasActiveVersion )
1075- if diag .HasError () {
1076- resp .Diagnostics .Append (diag ... )
1077- return
1078- }
1079-
1080- resp .PlanValue , diag = types .ListValueFrom (ctx , req .PlanValue .ElementType (ctx ), planVersions )
1081- if diag .HasError () {
1082- resp .Diagnostics .Append (diag ... )
1083- }
1084- }
1085-
10861123func hasOneActiveVersion (data Versions ) (hasActiveVersion bool , diags diag.Diagnostics ) {
10871124 active := false
10881125 for _ , version := range data {
@@ -1101,12 +1138,6 @@ func hasOneActiveVersion(data Versions) (hasActiveVersion bool, diags diag.Diagn
11011138 return active , diags
11021139}
11031140
1104- func NewVersionsPlanModifier () planmodifier.List {
1105- return & versionsPlanModifier {}
1106- }
1107-
1108- var _ planmodifier.List = & versionsPlanModifier {}
1109-
11101141var weekValidator = setvalidator .ValueStringsAre (
11111142 stringvalidator .OneOf ("monday" , "tuesday" , "wednesday" , "thursday" , "friday" , "saturday" , "sunday" ),
11121143)
@@ -1630,13 +1661,20 @@ func tfVariablesChanged(ctx context.Context, prevs []PreviousTemplateVersion, pl
16301661 if prev .TFVars == nil {
16311662 return true
16321663 }
1664+ // If the set is unknown, we cannot compare and must treat it as changed.
1665+ if planned .TerraformVariables .IsUnknown () {
1666+ return true
1667+ }
1668+ // If the set is null (tf_vars not specified), treat as no variables.
1669+ // Only consider this a change if the previous version had variables.
1670+ if planned .TerraformVariables .IsNull () {
1671+ return len (prev .TFVars ) > 0
1672+ }
16331673 plannedVars , diags := varsFromSet (ctx , planned .TerraformVariables )
16341674 if diags .HasError () {
16351675 return true
16361676 }
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 () {
1677+ if len (plannedVars ) != len (prev .TFVars ) {
16401678 return true
16411679 }
16421680 for _ , tfVar := range plannedVars {
0 commit comments