@@ -3,6 +3,7 @@ package provider
33import (
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
346347Computed :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. " ,
350351Optional :true ,
351352Computed :true ,
352353},
@@ -502,6 +503,17 @@ func (r *TemplateResource) Create(ctx context.Context, req resource.CreateReques
502503data .ID = UUIDValue (templateResp .ID )
503504data .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
506518resp .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
571583func (r * TemplateResource )Update (ctx context.Context ,req resource.UpdateRequest ,resp * resource.UpdateResponse ) {
572- var planState TemplateResourceModel
584+ var newState TemplateResourceModel
573585var 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
578590if resp .Diagnostics .HasError () {
579591return
@@ -585,25 +597,25 @@ func (r *TemplateResource) Update(ctx context.Context, req resource.UpdateReques
585597return
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
600612client := 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.
604616if templateMetadataChanged {
605617tflog .Trace (ctx ,"change in template metadata detected, updating." )
606- updateReq := planState .toUpdateRequest (ctx ,resp )
618+ updateReq := newState .toUpdateRequest (ctx ,resp )
607619if resp .Diagnostics .HasError () {
608620return
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 ) {
622634var acl ACL
623- resp .Diagnostics .Append (planState .ACL .As (ctx ,& acl , basetypes.ObjectAsOptions {})... )
635+ resp .Diagnostics .Append (newState .ACL .As (ctx ,& acl , basetypes.ObjectAsOptions {})... )
624636if resp .Diagnostics .HasError () {
625637return
626638}
@@ -632,51 +644,64 @@ func (r *TemplateResource) Update(ctx context.Context, req resource.UpdateReques
632644tflog .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 () {
641649tflog .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 ] ,
644652OrganizationID :orgID ,
645653TemplateID :& templateID ,
646654})
647655if err != nil {
648656resp .Diagnostics .AddError ("Client Error" ,err .Error ())
649657return
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 () {
662680tflog .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})
666684err := client .UpdateActiveTemplateVersion (ctx ,templateID , codersdk.UpdateActiveTemplateVersion {
667- ID :versionResp . ID ,
685+ ID :newState . Versions [ idx ]. ID . ValueUUID () ,
668686})
669687if err != nil {
670688resp .Diagnostics .AddError ("Client Error" ,fmt .Sprintf ("Failed to update active template version: %s" ,err ))
671689return
672690}
673691tflog .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
682707func (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.
768793func (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 () {
773797return
774798}
775799
776- hash ,err := computeDirectoryHash (directory .ValueString ())
800+ hash ,err := computeDirectoryHash (data . Directory .ValueString ())
777801if err != nil {
778802resp .Diagnostics .AddError ("Client Error" ,fmt .Sprintf ("Failed to compute directory hash: %s" ,err ))
779803return
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 () {
785810return
786811}
787- resp .PlanValue = out
812+
813+ resp .PlanValue ,resp .Diagnostics = types .ObjectValueFrom (ctx ,req .PlanValue .AttributeTypes (ctx ),data )
788814}
789815
790816func NewDirectoryHashPlanModifier () planmodifier.Object {
@@ -1062,3 +1088,95 @@ func (r *TemplateResourceModel) toCreateRequest(ctx context.Context, resp *resou
10621088DisableEveryoneGroupAccess :! r .ACL .IsNull (),
10631089}
10641090}
1091+
1092+ type LastVersionsByHash map [string ]PreviousTemplateVersion
1093+
1094+ func (lv LastVersionsByHash )MarshalJSON () ([]byte ,error ) {
1095+ return json .Marshal (map [string ]PreviousTemplateVersion (lv ))
1096+ }
1097+
1098+ func (lv * LastVersionsByHash )UnmarshalJSON (data []byte )error {
1099+ var m map [string ]PreviousTemplateVersion
1100+ err := json .Unmarshal (data ,& m )
1101+ if err != nil {
1102+ return err
1103+ }
1104+ * lv = LastVersionsByHash (m )
1105+ return nil
1106+ }
1107+
1108+ var LastVersionsKey = "last_versions"
1109+
1110+ type PreviousTemplateVersion struct {
1111+ ID uuid.UUID `json:"id"`
1112+ Name string `json:"name"`
1113+ }
1114+
1115+ type privateState interface {
1116+ GetKey (ctx context.Context ,key string ) ([]byte , diag.Diagnostics )
1117+ SetKey (ctx context.Context ,key string ,value []byte ) diag.Diagnostics
1118+ }
1119+
1120+ func (v Versions )writePrivateState (ctx context.Context ,ps privateState ) (diags diag.Diagnostics ) {
1121+ var lv LastVersionsByHash
1122+ lvBytes ,diag := ps .GetKey (ctx ,LastVersionsKey )
1123+ if diag .HasError () {
1124+ return diag
1125+ }
1126+ err := lv .UnmarshalJSON (lvBytes )
1127+ if err != nil {
1128+ diags .AddError ("Client Error" ,fmt .Sprintf ("Failed to unmarshal private state when writing: %s" ,err ))
1129+ return diags
1130+ }
1131+ for _ ,version := range v {
1132+ lv [version .DirectoryHash .ValueString ()]= PreviousTemplateVersion {
1133+ ID :version .ID .ValueUUID (),
1134+ Name :version .ID .ValueString (),
1135+ }
1136+ lvBytes ,err = lv .MarshalJSON ()
1137+ if err != nil {
1138+ diags .AddError ("Client Error" ,fmt .Sprintf ("Failed to marshal private state: %s" ,err ))
1139+ return diags
1140+ }
1141+ }
1142+ return ps .SetKey (ctx ,LastVersionsKey ,lvBytes )
1143+ }
1144+
1145+ func (v * TemplateVersion )readFromPrivateState (ctx context.Context ,ps privateState ) (diags diag.Diagnostics ) {
1146+ var lv LastVersionsByHash
1147+ lvBytes ,diag := ps .GetKey (ctx ,LastVersionsKey )
1148+ if diag .HasError () {
1149+ diags .Append (diag ... )
1150+ return
1151+ }
1152+ // If this is the first read, init the private state value
1153+ if lvBytes == nil {
1154+ setEmptyPrivateState (ctx ,ps )
1155+ return
1156+ }
1157+ err := lv .UnmarshalJSON (lvBytes )
1158+ if err != nil {
1159+ diags .AddError ("Client Error" ,fmt .Sprintf ("Failed to unmarshal private state when reading: %s" ,err ))
1160+ return
1161+ }
1162+
1163+ prev ,ok := lv [v .DirectoryHash .ValueString ()]
1164+ // If not in state, mark as known after apply since we'll create a new version.
1165+ // Versions who's Terraform configuration has not changed will have known
1166+ // IDs at this point, so we need to set this manually.
1167+ if ! ok {
1168+ v .ID = NewUUIDUnknown ()
1169+ return
1170+ }
1171+ // Otherwise, use the existing ID for this hash
1172+ v .ID = UUIDValue (prev .ID )
1173+ return
1174+ }
1175+
1176+ func setEmptyPrivateState (ctx context.Context ,ps privateState ) (diags diag.Diagnostics ) {
1177+ pvBytes ,err := make (LastVersionsByHash ).MarshalJSON ()
1178+ if err != nil {
1179+ panic ("failed to marshal empty private state" )
1180+ }
1181+ return ps .SetKey (ctx ,LastVersionsKey ,pvBytes )
1182+ }