@@ -98,6 +98,7 @@ type ServiceResourceModel struct {
9898 MaxscaleSize types.String `tfsdk:"maxscale_size"`
9999 FQDN types.String `tfsdk:"fqdn"`
100100 AvailabilityZone types.String `tfsdk:"availability_zone"`
101+ Tags types.Map `tfsdk:"tags"`
101102}
102103
103104// ServiceResourceNamedPortModel is an endpoint port
@@ -133,9 +134,6 @@ var serviceResourceSchemaV0 = schema.Schema{
133134 "must start from a lowercase letter and contain only lowercase letters, numbers and hyphens" ,
134135 ),
135136 },
136- PlanModifiers : []planmodifier.String {
137- stringplanmodifier .RequiresReplace (),
138- },
139137 },
140138 "project_id" : schema.StringAttribute {
141139 Required : false ,
@@ -403,6 +401,15 @@ var serviceResourceSchemaV0 = schema.Schema{
403401 stringplanmodifier .RequiresReplace (),
404402 },
405403 },
404+ "tags" : schema.MapAttribute {
405+ Optional : true ,
406+ Computed : true ,
407+ ElementType : types .StringType ,
408+ Description : "Tags associated with the service. Note: The API will automatically overwrite 'tags.name' with the service name." ,
409+ PlanModifiers : []planmodifier.Map {
410+ & tagsNamePlanModifier {},
411+ },
412+ },
406413 },
407414 Blocks : map [string ]schema.Block {
408415 "timeouts" : timeouts .Block (context .Background (), timeouts.Opts {
@@ -471,6 +478,17 @@ func (r *ServiceResource) Create(ctx context.Context, req resource.CreateRequest
471478 AvailabilityZone : state .AvailabilityZone .ValueString (),
472479 }
473480
481+ // Convert Tags from Terraform to map[string]string
482+ if ! state .Tags .IsNull () && ! state .Tags .IsUnknown () {
483+ var tags map [string ]string
484+ diags := state .Tags .ElementsAs (ctx , & tags , false )
485+ if diags .HasError () {
486+ resp .Diagnostics .Append (diags ... )
487+ return
488+ }
489+ createServiceRequest .Tags = tags
490+ }
491+
474492 if ! Contains [string ]([]string {"gcp" , "aws" , "azure" }, createServiceRequest .Provider ) {
475493 resp .Diagnostics .AddAttributeError (path .Root ("provider" ),
476494 "Invalid provider value" ,
@@ -721,6 +739,11 @@ func (r *ServiceResource) readServiceState(ctx context.Context, data *ServiceRes
721739 if ! (data .MaxscaleNodes .IsUnknown () || data .MaxscaleSize .IsNull ()) {
722740 data .MaxscaleNodes = types .Int64Value (int64 (service .MaxscaleNodes ))
723741 }
742+ if service .Tags != nil {
743+ data .Tags , _ = types .MapValueFrom (ctx , types .StringType , service .Tags )
744+ } else {
745+ data .Tags = types .MapNull (types .StringType )
746+ }
724747 return nil
725748}
726749
@@ -783,6 +806,11 @@ func (r *ServiceResource) Update(ctx context.Context, req resource.UpdateRequest
783806 return
784807 }
785808
809+ r .updateServiceTags (ctx , plan , state , resp )
810+ if resp .Diagnostics .HasError () {
811+ return
812+ }
813+
786814 err := r .readServiceState (ctx , state )
787815 if err != nil {
788816 if errors .Is (err , skysql .ErrorServiceNotFound ) {
@@ -1035,6 +1063,63 @@ func (r *ServiceResource) updateServicePowerState(ctx context.Context, plan *Ser
10351063 }
10361064}
10371065
1066+ func (r * ServiceResource ) updateServiceTags (ctx context.Context , plan * ServiceResourceModel , state * ServiceResourceModel , resp * resource.UpdateResponse ) {
1067+ if ! plan .Tags .IsUnknown () {
1068+ var planTags map [string ]string
1069+ diags := plan .Tags .ElementsAs (ctx , & planTags , false )
1070+ if diags .HasError () {
1071+ // Log warning but don't fail the update to protect against state incompatibility
1072+ tflog .Warn (ctx , "Failed to parse plan tags, skipping tag update" , map [string ]interface {}{
1073+ "id" : state .ID .ValueString (),
1074+ "error" : diags .Errors (),
1075+ })
1076+ return
1077+ }
1078+
1079+ var stateTags map [string ]string
1080+ diags = state .Tags .ElementsAs (ctx , & stateTags , false )
1081+ if diags .HasError () {
1082+ // For backward compatibility, if state tags can't be parsed (e.g., from older provider version),
1083+ // treat as empty map and continue with the update
1084+ tflog .Warn (ctx , "Failed to parse state tags, treating as empty" , map [string ]interface {}{
1085+ "id" : state .ID .ValueString (),
1086+ "error" : diags .Errors (),
1087+ })
1088+ stateTags = make (map [string ]string )
1089+ }
1090+
1091+ if ! reflect .DeepEqual (planTags , stateTags ) {
1092+ tflog .Info (ctx , "Updating service tags" , map [string ]interface {}{
1093+ "id" : state .ID .ValueString (),
1094+ })
1095+
1096+ err := r .client .UpdateServiceTags (ctx , state .ID .ValueString (), planTags )
1097+ if err != nil {
1098+ if errors .Is (err , skysql .ErrorServiceNotFound ) {
1099+ tflog .Warn (ctx , "SkySQL service not found, removing from state" , map [string ]interface {}{
1100+ "id" : state .ID .ValueString (),
1101+ })
1102+ resp .State .RemoveResource (ctx )
1103+ return
1104+ }
1105+ // Log warning but don't fail the update to protect against tag API errors
1106+ tflog .Warn (ctx , "Failed to update service tags, continuing with other updates" , map [string ]interface {}{
1107+ "id" : state .ID .ValueString (),
1108+ "error" : err .Error (),
1109+ })
1110+ return
1111+ }
1112+
1113+ state .Tags = plan .Tags
1114+ resp .Diagnostics .Append (resp .State .Set (ctx , & state )... )
1115+ if resp .Diagnostics .HasError () {
1116+ return
1117+ }
1118+ r .waitForUpdate (ctx , state , resp )
1119+ }
1120+ }
1121+ }
1122+
10381123var serviceUpdateWaitStates = []string {"ready" , "failed" , "stopped" }
10391124
10401125func (r * ServiceResource ) waitForUpdate (ctx context.Context , state * ServiceResourceModel , resp * resource.UpdateResponse ) {
@@ -1344,9 +1429,16 @@ func (r *ServiceResource) ModifyPlan(ctx context.Context, req resource.ModifyPla
13441429 resp .Plan .SetAttribute (ctx , path .Root ("endpoint_allowed_accounts" ), types .ListNull (types .StringType ))
13451430 }
13461431
1347- if state != nil && ! state .AllowList .IsUnknown () && plan .AllowList .IsNull () {
1432+ // Preserve allow_list from state when it's computed or null in plan
1433+ if state != nil && ! state .AllowList .IsUnknown () && (plan .AllowList .IsNull () || plan .AllowList .IsUnknown ()) {
13481434 resp .Plan .SetAttribute (ctx , path .Root ("allow_list" ), state .AllowList )
13491435 }
1436+
1437+ // Preserve endpoint_service from state when it's computed, but only if mechanism isn't changing
1438+ if state != nil && ! state .EndpointService .IsUnknown () && plan .EndpointService .IsUnknown () &&
1439+ plan .Mechanism .ValueString () == state .Mechanism .ValueString () {
1440+ resp .Plan .SetAttribute (ctx , path .Root ("endpoint_service" ), state .EndpointService )
1441+ }
13501442}
13511443
13521444func (r * ServiceResource ) UpgradeState (ctx context.Context ) map [int64 ]resource.StateUpgrader {
@@ -1369,3 +1461,52 @@ func (r *ServiceResource) UpgradeState(ctx context.Context) map[int64]resource.S
13691461 },
13701462 }
13711463}
1464+
1465+ // tagsNamePlanModifier ensures tags.name always matches the service name
1466+ type tagsNamePlanModifier struct {}
1467+
1468+ func (m * tagsNamePlanModifier ) Description (_ context.Context ) string {
1469+ return "Ensures tags.name matches the service name since the API overwrites it"
1470+ }
1471+
1472+ func (m * tagsNamePlanModifier ) MarkdownDescription (ctx context.Context ) string {
1473+ return m .Description (ctx )
1474+ }
1475+
1476+ func (m * tagsNamePlanModifier ) PlanModifyMap (ctx context.Context , req planmodifier.MapRequest , resp * planmodifier.MapResponse ) {
1477+ // Don't modify plan during destroy
1478+ if req .Plan .Raw .IsNull () {
1479+ return
1480+ }
1481+
1482+ // Get the planned service name
1483+ var plannedName types.String
1484+ resp .Diagnostics .Append (req .Plan .GetAttribute (ctx , path .Root ("name" ), & plannedName )... )
1485+ if resp .Diagnostics .HasError () || plannedName .IsNull () || plannedName .IsUnknown () {
1486+ return
1487+ }
1488+
1489+ // If tags are planned, ensure tags.name matches service name
1490+ if ! req .PlanValue .IsNull () && ! req .PlanValue .IsUnknown () {
1491+ var plannedTags map [string ]string
1492+ resp .Diagnostics .Append (req .PlanValue .ElementsAs (ctx , & plannedTags , false )... )
1493+ if resp .Diagnostics .HasError () {
1494+ return
1495+ }
1496+
1497+ // Set tags.name to match service name (API behavior)
1498+ if plannedTags == nil {
1499+ plannedTags = make (map [string ]string )
1500+ }
1501+ plannedTags ["name" ] = plannedName .ValueString ()
1502+
1503+ // Update the planned value
1504+ correctedTags , diags := types .MapValueFrom (ctx , types .StringType , plannedTags )
1505+ resp .Diagnostics .Append (diags ... )
1506+ if resp .Diagnostics .HasError () {
1507+ return
1508+ }
1509+
1510+ resp .PlanValue = correctedTags
1511+ }
1512+ }
0 commit comments