Skip to content

Commit 4922d9a

Browse files
[3.1.4] Service Tags support added (#21)
1 parent 636a66c commit 4922d9a

File tree

10 files changed

+1336
-36
lines changed

10 files changed

+1336
-36
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Changelog
22

3+
## [3.1.4] - 2025-07-17
4+
### Features
5+
- service `tags` support added.
6+
37
## [3.1.3] - 2024-07-25
48
### Package Updates
59
- upgraded go 1.19 => 1.21

docs/resources/skysql_service.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ resource "skysql_service" "default" {
2929
volume_type = "gp3"
3030
volume_iops = 3000
3131
volume_throughput = 125
32+
tags = {
33+
name = "myservice" # API will overwrite this with service name
34+
environment = "production" # Optional additional tags
35+
}
3236
# The service create is an asynchronous operation.
3337
# if you want to wait for the service to be created set wait_for_creation to true
3438
wait_for_creation = true
@@ -65,6 +69,7 @@ resource "skysql_service" "default" {
6569
- `size` (String) The size of the service. Valid values are: sky-2x4, sky-2x8 etc
6670
- `ssl_enabled` (Boolean) Whether to enable SSL. Valid values are: true or false
6771
- `storage` (Number) The storage size in GB. Valid values are: 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 2000, 3000, 4000, 5000, 6000, 7000, 8000, 9000, 10000
72+
- `tags` (Map of String) Tags associated with the service. Note: The API will automatically overwrite 'tags.name' with the service name.
6873
- `timeouts` (Block, Optional) (see [below for nested schema](#nestedblock--timeouts))
6974
- `version` (String) The software version
7075
- `volume_iops` (Number) The volume IOPS. This is only applicable for AWS

examples/main.tf

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ resource "skysql_service" "default" {
2929
volume_type = "gp3"
3030
volume_iops = 3000
3131
volume_throughput = 125
32+
tags = {
33+
environment = "production"
34+
team = "backend"
35+
# Note: tags.name is automatically set to match the service name
36+
}
3237
allow_list = [
3338
{
3439
"ip" : "127.0.0.1/32",

examples/resources/skysql_service.tf

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ resource "skysql_service" "default" {
1515
volume_type = "gp3"
1616
volume_iops = 3000
1717
volume_throughput = 125
18+
tags = {
19+
name = "myservice" # API will overwrite this with service name
20+
environment = "production" # Optional additional tags
21+
}
1822
# The service create is an asynchronous operation.
1923
# if you want to wait for the service to be created set wait_for_creation to true
2024
wait_for_creation = true

internal/provider/service_resource.go

Lines changed: 145 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
10381123
var serviceUpdateWaitStates = []string{"ready", "failed", "stopped"}
10391124

10401125
func (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

13521444
func (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

Comments
 (0)