Skip to content

Commit 85deb92

Browse files
committed
Implement semantic equality for spicepod config strings
- Add SpicepodStringType and SpicepodStringValue to support semantic equality for spicepod YAML/JSON strings in Terraform state. - Normalize spicepod config for comparison, treating equivalent YAML and JSON as equal. - Update AppResourceModel and resource schema to use the new type. - Update mapAppToModel to preserve user format if semantically equal to API response. - Refactor and expand spicepod tests for semantic equality.
1 parent 9f1afc4 commit 85deb92

3 files changed

Lines changed: 215 additions & 96 deletions

File tree

examples/local-spice-api/spicepod.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ kind: Spicepod
33
name: terraform-test-app-local-1
44

55
datasets:
6-
- name: test_dataset
6+
- name: test_dataset_2
77
from: s3://spiceai-demo-datasets/taxi_trips/2024/
88
params:
99
file_format: parquet

internal/provider/resource_app.go

Lines changed: 98 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)