@@ -13,6 +13,8 @@ import (
1313
1414 "github.com/hashicorp/terraform-plugin-framework-validators/int64validator"
1515 "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
16+ "github.com/hashicorp/terraform-plugin-framework/attr"
17+ "github.com/hashicorp/terraform-plugin-framework/diag"
1618 "github.com/hashicorp/terraform-plugin-framework/path"
1719 "github.com/hashicorp/terraform-plugin-framework/resource"
1820 "github.com/hashicorp/terraform-plugin-framework/resource/schema"
@@ -21,32 +23,95 @@ import (
2123 "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
2224 "github.com/hashicorp/terraform-plugin-framework/schema/validator"
2325 "github.com/hashicorp/terraform-plugin-framework/types"
26+ "github.com/hashicorp/terraform-plugin-framework/types/basetypes"
27+ "github.com/hashicorp/terraform-plugin-go/tftypes"
2428 "github.com/hashicorp/terraform-plugin-log/tflog"
2529 "gopkg.in/yaml.v3"
2630)
2731
28- // spicepodNormalizePlanModifier normalizes spicepod YAML/JSON to a consistent JSON format.
29- type spicepodNormalizePlanModifier struct {}
32+ // SpicepodStringType is a custom string type that implements semantic equality
33+ // for spicepod configurations, treating equivalent YAML and JSON as equal.
34+ type SpicepodStringType struct {
35+ basetypes.StringType
36+ }
3037
31- var _ planmodifier. String = spicepodNormalizePlanModifier {}
38+ var _ basetypes. StringTypable = SpicepodStringType {}
3239
33- func (m spicepodNormalizePlanModifier ) Description (ctx context.Context ) string {
34- return "Normalizes spicepod configuration (YAML or JSON) to a consistent JSON format."
40+ func (t SpicepodStringType ) Equal (o attr.Type ) bool {
41+ other , ok := o .(SpicepodStringType )
42+ if ! ok {
43+ return false
44+ }
45+ return t .StringType .Equal (other .StringType )
3546}
3647
37- func (m spicepodNormalizePlanModifier ) MarkdownDescription ( ctx context. Context ) string {
38- return "Normalizes spicepod configuration (YAML or JSON) to a consistent JSON format. "
48+ func (t SpicepodStringType ) String ( ) string {
49+ return "SpicepodStringType "
3950}
4051
41- func (m spicepodNormalizePlanModifier ) PlanModifyString (ctx context.Context , req planmodifier.StringRequest , resp * planmodifier.StringResponse ) {
42- // If the value is null or unknown, don't modify
43- if req .PlanValue .IsNull () || req .PlanValue .IsUnknown () {
44- return
52+ func (t SpicepodStringType ) ValueFromString (ctx context.Context , in basetypes.StringValue ) (basetypes.StringValuable , diag.Diagnostics ) {
53+ return SpicepodStringValue {StringValue : in }, nil
54+ }
55+
56+ func (t SpicepodStringType ) ValueFromTerraform (ctx context.Context , in tftypes.Value ) (attr.Value , error ) {
57+ attrValue , err := t .StringType .ValueFromTerraform (ctx , in )
58+ if err != nil {
59+ return nil , err
60+ }
61+ stringValue , ok := attrValue .(basetypes.StringValue )
62+ if ! ok {
63+ return nil , fmt .Errorf ("unexpected value type of %T" , attrValue )
64+ }
65+ stringValuable , diags := t .ValueFromString (ctx , stringValue )
66+ if diags .HasError () {
67+ return nil , fmt .Errorf ("error converting string value: %v" , diags )
68+ }
69+ return stringValuable , nil
70+ }
71+
72+ func (t SpicepodStringType ) ValueType (ctx context.Context ) attr.Value {
73+ return SpicepodStringValue {}
74+ }
75+
76+ // SpicepodStringValue is a custom string value that implements semantic equality
77+ // for spicepod configurations.
78+ type SpicepodStringValue struct {
79+ basetypes.StringValue
80+ }
81+
82+ var _ basetypes.StringValuable = SpicepodStringValue {}
83+ var _ basetypes.StringValuableWithSemanticEquals = SpicepodStringValue {}
84+
85+ func (v SpicepodStringValue ) Equal (o attr.Value ) bool {
86+ other , ok := o .(SpicepodStringValue )
87+ if ! ok {
88+ return false
4589 }
90+ return v .StringValue .Equal (other .StringValue )
91+ }
4692
47- // Normalize the planned value to JSON
48- normalized := normalizeSpicepodToJSON (req .PlanValue .ValueString ())
49- resp .PlanValue = types .StringValue (normalized )
93+ func (v SpicepodStringValue ) Type (ctx context.Context ) attr.Type {
94+ return SpicepodStringType {}
95+ }
96+
97+ // StringSemanticEquals implements semantic equality for spicepod strings.
98+ // It normalizes both YAML and JSON to a common format for comparison.
99+ func (v SpicepodStringValue ) StringSemanticEquals (ctx context.Context , newValuable basetypes.StringValuable ) (bool , diag.Diagnostics ) {
100+ newValue , ok := newValuable .(SpicepodStringValue )
101+ if ! ok {
102+ return false , nil
103+ }
104+
105+ // If either is null or unknown, use standard equality
106+ if v .IsNull () || v .IsUnknown () || newValue .IsNull () || newValue .IsUnknown () {
107+ return v .Equal (newValue ), nil
108+ }
109+
110+ // Normalize both values to JSON for comparison
111+ oldNormalized := normalizeSpicepodToJSON (v .ValueString ())
112+ newNormalized := normalizeSpicepodToJSON (newValue .ValueString ())
113+
114+ return oldNormalized == newNormalized , nil
50115}
51116
52117// normalizeSpicepodToJSON converts a spicepod string (YAML or JSON) to a normalized JSON string.
@@ -101,7 +166,7 @@ type AppResourceModel struct {
101166 ProductionBranch types.String `tfsdk:"production_branch"`
102167
103168 // Spicepod configuration
104- Spicepod types. String `tfsdk:"spicepod"`
169+ Spicepod SpicepodStringValue `tfsdk:"spicepod"`
105170
106171 // Runtime configuration
107172 ImageTag types.String `tfsdk:"image_tag"`
@@ -198,9 +263,7 @@ resource "spiceai_app" "example" {
198263 MarkdownDescription : "The spicepod configuration as a YAML or JSON string. This defines the datasets, models, and other spicepod settings for the app." ,
199264 Optional : true ,
200265 Computed : true ,
201- PlanModifiers : []planmodifier.String {
202- spicepodNormalizePlanModifier {},
203- },
266+ CustomType : SpicepodStringType {},
204267 },
205268
206269 // Runtime configuration attributes
@@ -567,20 +630,32 @@ func (r *AppResource) mapAppToModel(data *AppResourceModel, app *client.App) {
567630
568631 if app .Config .Spicepod != nil {
569632 if spicepodBytes , err := json .Marshal (app .Config .Spicepod ); err == nil {
570- // Normalize the API response to consistent JSON format
571- data .Spicepod = types .StringValue (normalizeSpicepodToJSON (string (spicepodBytes )))
633+ apiSpicepodJSON := string (spicepodBytes )
634+ // If user's value is semantically equal to API response, preserve user's format
635+ if ! data .Spicepod .IsNull () && ! data .Spicepod .IsUnknown () {
636+ userNormalized := normalizeSpicepodToJSON (data .Spicepod .ValueString ())
637+ apiNormalized := normalizeSpicepodToJSON (apiSpicepodJSON )
638+ if userNormalized == apiNormalized {
639+ // Keep the user's original value (YAML or JSON) - don't overwrite
640+ } else {
641+ // Values differ semantically, use API response
642+ data .Spicepod = SpicepodStringValue {StringValue : types .StringValue (apiSpicepodJSON )}
643+ }
644+ } else {
645+ data .Spicepod = SpicepodStringValue {StringValue : types .StringValue (apiSpicepodJSON )}
646+ }
572647 } else {
573- data .Spicepod = types .StringNull ()
648+ data .Spicepod = SpicepodStringValue { StringValue : types .StringNull ()}
574649 }
575650 } else {
576- data .Spicepod = types .StringNull ()
651+ data .Spicepod = SpicepodStringValue { StringValue : types .StringNull ()}
577652 }
578653 } else {
579654 // No config returned, set all config fields to null
580655 data .ImageTag = types .StringNull ()
581656 data .Replicas = types .Int64Null ()
582657 data .NodeGroup = types .StringNull ()
583658 data .StorageClaimSizeGB = types .Float64Null ()
584- data .Spicepod = types .StringNull ()
659+ data .Spicepod = SpicepodStringValue { StringValue : types .StringNull ()}
585660 }
586661}
0 commit comments