Skip to content

Commit 8860cdc

Browse files
authored
oxide_instance: allow in-place external_ips modification (#381)
Closes #378. This patch updates the `oxide_instance` resource to support in-place modifications to its `external_ips` attribute. That is, the instance will no longer be destroyed and recreated when `external_ips` is modified. Instead, the instance will be stopped, external IPs will be detached and/or attached, and the instance will be started. This patch also updates the `Read` method to refresh the external IPs attached to the instance. Previously, since the external IPs required a resource replacement there was no need to refresh them. There are 2 types of external IPs, ephemeral and floating. There can be at most 1 ephemeral external IP attached to an instance and any number of floating external IPs. That means in order to modify ephemeral external IPs the external IPs must be detached first and then attached. This patch needs the following work before it can be merged. - [x] Add tests for the new `external_ips` modification logic. - [x] Add validation logic to `external_ips` to enforce at most 1 ephemeral external IP. This was previously "validated" during instance creation but is not updated during instance update. Here's the error that's returned when an instance is created with more than 1 ephemeral external IP. ``` oxide_instance.foo: Creating... ╷ │ Error: Error creating instance │ │ with oxide_instance.foo, │ on main.tf line 24, in resource "oxide_instance" "foo": │ 24: resource "oxide_instance" "foo" { │ │ API error: POST https://oxide.example.com/v1/instances?project=5476ccc9-464d-4dc4-bfc0-5154de1c986f │ ----------- RESPONSE ----------- │ Status: 400 InvalidRequest │ Message: An instance may not have more than 1 ephemeral IP address │ RequestID: fc6144e5-fa76-4583-a024-2e49ce17140e │ ------- RESPONSE HEADERS ------- │ Content-Type: [application/json] │ X-Request-Id: [fc6144e5-fa76-4583-a024-2e49ce17140e] │ Date: [Thu, 09 Jan 2025 03:28:48 GMT] │ Content-Length: [166] │ ╵ ```
1 parent ac7cc52 commit 8860cdc

File tree

2 files changed

+309
-7
lines changed

2 files changed

+309
-7
lines changed

internal/provider/resource_instance.go

Lines changed: 251 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier"
2222
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
2323
"github.com/hashicorp/terraform-plugin-framework/resource/schema/setplanmodifier"
24+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault"
2425
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
2526
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
2627
"github.com/hashicorp/terraform-plugin-framework/types"
@@ -256,11 +257,20 @@ func (r *instanceResource) Schema(ctx context.Context, _ resource.SchemaRequest,
256257
"external_ips": schema.SetNestedAttribute{
257258
Optional: true,
258259
Description: "External IP addresses provided to this instance.",
260+
Validators: []validator.Set{
261+
instanceExternalIPValidator{},
262+
},
259263
NestedObject: schema.NestedAttributeObject{
260264
Attributes: map[string]schema.Attribute{
265+
// The id attribute is optional, computed, and has a default to account for the
266+
// case where an instance created with an external IP using the default IP pool
267+
// (i.e., id = null) would drift when read (e.g., id = "") and require updating
268+
// in place.
261269
"id": schema.StringAttribute{
262270
Description: "If type is ephemeral, ID of the IP pool to retrieve addresses from, or all available pools if not specified. If type is floating, ID of the floating IP",
263271
Optional: true,
272+
Computed: true,
273+
Default: stringdefault.StaticString(""),
264274
},
265275
"type": schema.StringAttribute{
266276
Description: "Type of external IP.",
@@ -274,9 +284,6 @@ func (r *instanceResource) Schema(ctx context.Context, _ resource.SchemaRequest,
274284
},
275285
},
276286
},
277-
PlanModifiers: []planmodifier.Set{
278-
setplanmodifier.RequiresReplace(),
279-
},
280287
},
281288
"user_data": schema.StringAttribute{
282289
Optional: true,
@@ -510,6 +517,16 @@ func (r *instanceResource) Read(ctx context.Context, req resource.ReadRequest, r
510517
state.TimeCreated = types.StringValue(instance.TimeCreated.String())
511518
state.TimeModified = types.StringValue(instance.TimeModified.String())
512519

520+
externalIPs, diags := newAttachedExternalIPModel(ctx, r.client, state.ID.ValueString())
521+
resp.Diagnostics.Append(diags...)
522+
if resp.Diagnostics.HasError() {
523+
return
524+
}
525+
// Only set the external IPs if there are any to avoid drift.
526+
if len(externalIPs) > 0 {
527+
state.ExternalIPs = externalIPs
528+
}
529+
513530
keySet, diags := newAssociatedSSHKeysOnCreateSet(ctx, r.client, state.ID.ValueString())
514531
resp.Diagnostics.Append(diags...)
515532
if resp.Diagnostics.HasError() {
@@ -605,6 +622,25 @@ func (r *instanceResource) Update(ctx context.Context, req resource.UpdateReques
605622
}
606623
tflog.Trace(ctx, fmt.Sprintf("stopped instance with ID: %v", state.ID.ValueString()), map[string]any{"success": true})
607624

625+
// Update external IPs.
626+
// We detach external IPs first to account for the case where an ephemeral
627+
// external IP's IP Pool is modified. This is because there can only be one
628+
// ephemeral external IP attached to an instance at a given time and the
629+
// last detachment/attachment wins.
630+
{
631+
externalIPsToDetach := sliceDiff(state.ExternalIPs, plan.ExternalIPs)
632+
resp.Diagnostics.Append(detachExternalIPs(ctx, r.client, externalIPsToDetach, state.ID.ValueString())...)
633+
if resp.Diagnostics.HasError() {
634+
return
635+
}
636+
637+
externalIPsToAttach := sliceDiff(plan.ExternalIPs, state.ExternalIPs)
638+
resp.Diagnostics.Append(attachExternalIPs(ctx, r.client, externalIPsToAttach, state.ID.ValueString())...)
639+
if resp.Diagnostics.HasError() {
640+
return
641+
}
642+
}
643+
608644
// Update disk attachments
609645
//
610646
// We attach new disks first in case the new boot disk is one of the newly added
@@ -728,6 +764,15 @@ func (r *instanceResource) Update(ctx context.Context, req resource.UpdateReques
728764
plan.TimeCreated = types.StringValue(instance.TimeCreated.String())
729765
plan.TimeModified = types.StringValue(instance.TimeModified.String())
730766

767+
externalIPs, diags := newAttachedExternalIPModel(ctx, r.client, state.ID.ValueString())
768+
resp.Diagnostics.Append(diags...)
769+
if resp.Diagnostics.HasError() {
770+
return
771+
}
772+
if len(externalIPs) > 0 {
773+
plan.ExternalIPs = externalIPs
774+
}
775+
731776
// TODO: should I do this or read from the newly created ones?
732777
diskSet, diags := newAttachedDisksSet(ctx, r.client, state.ID.ValueString())
733778
resp.Diagnostics.Append(diags...)
@@ -1020,6 +1065,32 @@ func newAttachedNetworkInterfacesModel(ctx context.Context, client *oxide.Client
10201065
return nicSet, nil
10211066
}
10221067

1068+
func newAttachedExternalIPModel(ctx context.Context, client *oxide.Client, instanceID string) (
1069+
[]instanceResourceExternalIPModel, diag.Diagnostics) {
1070+
var diags diag.Diagnostics
1071+
externalIPs := make([]instanceResourceExternalIPModel, 0)
1072+
1073+
externalIPResponse, err := client.InstanceExternalIpList(ctx, oxide.InstanceExternalIpListParams{
1074+
Instance: oxide.NameOrId(instanceID),
1075+
})
1076+
if err != nil {
1077+
diags.AddError(
1078+
"Unable to list instance external ips:",
1079+
"API error: "+err.Error(),
1080+
)
1081+
return nil, diags
1082+
}
1083+
1084+
for _, externalIP := range externalIPResponse.Items {
1085+
externalIPs = append(externalIPs, instanceResourceExternalIPModel{
1086+
ID: types.StringValue(externalIP.Id),
1087+
Type: types.StringValue(string(externalIP.Kind)),
1088+
})
1089+
}
1090+
1091+
return externalIPs, nil
1092+
}
1093+
10231094
type vpcAndSubnetNames struct {
10241095
vpc string
10251096
subnet string
@@ -1183,6 +1254,115 @@ func deleteNICs(ctx context.Context, client *oxide.Client, models []instanceReso
11831254
return nil
11841255
}
11851256

1257+
// attachExternalIPs attaches the external IPs specified by externalIPs to the
1258+
// instance specified by instanceID.
1259+
func attachExternalIPs(ctx context.Context, client *oxide.Client, externalIPs []instanceResourceExternalIPModel, instanceID string) diag.Diagnostics {
1260+
var diags diag.Diagnostics
1261+
1262+
for _, v := range externalIPs {
1263+
externalIPID := v.ID
1264+
externalIPType := v.Type
1265+
1266+
switch oxide.ExternalIpKind(externalIPType.ValueString()) {
1267+
case oxide.ExternalIpKindEphemeral:
1268+
params := oxide.InstanceEphemeralIpAttachParams{
1269+
Instance: oxide.NameOrId(instanceID),
1270+
Body: &oxide.EphemeralIpCreate{
1271+
Pool: oxide.NameOrId(externalIPID.ValueString()),
1272+
},
1273+
}
1274+
1275+
if _, err := client.InstanceEphemeralIpAttach(ctx, params); err != nil {
1276+
diags.AddError(
1277+
fmt.Sprintf("Error attaching ephemeral external IP with ID %s", externalIPID.ValueString()),
1278+
"API error: "+err.Error(),
1279+
)
1280+
1281+
return diags
1282+
}
1283+
1284+
case oxide.ExternalIpKindFloating:
1285+
params := oxide.FloatingIpAttachParams{
1286+
FloatingIp: oxide.NameOrId(externalIPID.ValueString()),
1287+
Body: &oxide.FloatingIpAttach{
1288+
Kind: oxide.FloatingIpParentKindInstance,
1289+
Parent: oxide.NameOrId(instanceID),
1290+
},
1291+
}
1292+
1293+
if _, err := client.FloatingIpAttach(ctx, params); err != nil {
1294+
diags.AddError(
1295+
fmt.Sprintf("Error attaching floating external IP with ID %s", externalIPID.ValueString()),
1296+
"API error: "+err.Error(),
1297+
)
1298+
1299+
return diags
1300+
}
1301+
default:
1302+
diags.AddError(
1303+
fmt.Sprintf("Cannot attach invalid external IP type %q", externalIPType.ValueString()),
1304+
fmt.Sprintf("The external IP type must be one of: %q, %q", oxide.ExternalIpCreateTypeEphemeral, oxide.ExternalIpCreateTypeFloating),
1305+
)
1306+
return diags
1307+
}
1308+
1309+
tflog.Trace(ctx, fmt.Sprintf("successfully attached %s external IP with ID %s", externalIPType.ValueString(), externalIPID.ValueString()), map[string]any{"success": true})
1310+
}
1311+
1312+
return nil
1313+
}
1314+
1315+
// detachExternalIPs detaches the external IPs specified by externalIPs from the
1316+
// instance specified by instanceID.
1317+
func detachExternalIPs(ctx context.Context, client *oxide.Client, externalIPs []instanceResourceExternalIPModel, instanceID string) diag.Diagnostics {
1318+
var diags diag.Diagnostics
1319+
1320+
for _, v := range externalIPs {
1321+
externalIPID := v.ID
1322+
externalIPType := v.Type
1323+
1324+
switch oxide.ExternalIpKind(externalIPType.ValueString()) {
1325+
case oxide.ExternalIpKindEphemeral:
1326+
params := oxide.InstanceEphemeralIpDetachParams{
1327+
Instance: oxide.NameOrId(instanceID),
1328+
}
1329+
1330+
if err := client.InstanceEphemeralIpDetach(ctx, params); err != nil {
1331+
diags.AddError(
1332+
fmt.Sprintf("Error detaching ephemeral external IP with ID %s", externalIPID.ValueString()),
1333+
"API error: "+err.Error(),
1334+
)
1335+
1336+
return diags
1337+
}
1338+
1339+
case oxide.ExternalIpKindFloating:
1340+
params := oxide.FloatingIpDetachParams{
1341+
FloatingIp: oxide.NameOrId(externalIPID.ValueString()),
1342+
}
1343+
1344+
if _, err := client.FloatingIpDetach(ctx, params); err != nil {
1345+
diags.AddError(
1346+
fmt.Sprintf("Error detaching floating external IP with ID %s", externalIPID.ValueString()),
1347+
"API error: "+err.Error(),
1348+
)
1349+
1350+
return diags
1351+
}
1352+
default:
1353+
diags.AddError(
1354+
fmt.Sprintf("Cannot detach invalid external IP type %q", externalIPType.ValueString()),
1355+
fmt.Sprintf("The external IP type must be one of: %q, %q", oxide.ExternalIpCreateTypeEphemeral, oxide.ExternalIpCreateTypeFloating),
1356+
)
1357+
return diags
1358+
}
1359+
1360+
tflog.Trace(ctx, fmt.Sprintf("successfully detached %s external IP with ID %s", externalIPType.ValueString(), externalIPID.ValueString()), map[string]any{"success": true})
1361+
}
1362+
1363+
return nil
1364+
}
1365+
11861366
func attachDisks(ctx context.Context, client *oxide.Client, disks []attr.Value, instanceID string) diag.Diagnostics {
11871367
var diags diag.Diagnostics
11881368

@@ -1327,3 +1507,71 @@ func attrValueSliceContains(s []attr.Value, str string) (bool, error) {
13271507
}
13281508
return false, nil
13291509
}
1510+
1511+
// Ensure the concrete validator satisfies the [validator.Set] interface.
1512+
var _ validator.Set = instanceExternalIPValidator{}
1513+
1514+
// instanceExternalIPValidator is a custom validator that validates the
1515+
// external_ips attribute on an oxide_instance resource.
1516+
type instanceExternalIPValidator struct{}
1517+
1518+
// Description describes the validation in plain text formatting.
1519+
func (f instanceExternalIPValidator) Description(context.Context) string {
1520+
return "cannot have more than one ephemeral external ip"
1521+
}
1522+
1523+
// MarkdownDescription describes the validation in Markdown formatting.
1524+
func (f instanceExternalIPValidator) MarkdownDescription(context.Context) string {
1525+
return "cannot have more than one ephemeral external ip"
1526+
}
1527+
1528+
// ValidateSet validates whether a set of [instanceResourceExternalIPModel]
1529+
// objects has at most one object with type = "ephemeral". The Terraform SDK
1530+
// already deduplicates sets within configuration. For example, the following
1531+
// configuration in Terraform results in a single ephemeral external IP.
1532+
//
1533+
// resource "oxide_instance" "example" {
1534+
// external_ips = [
1535+
// { type = "ephemeral"},
1536+
// { type = "ephemeral"},
1537+
// ]
1538+
// }
1539+
//
1540+
// However, that deduplication does not extend to sets that contain different
1541+
// attributes, like so.
1542+
//
1543+
// resource "oxide_instance" "example" {
1544+
// external_ips = [
1545+
// { type = "ephemeral", id = "a58dc21d-896d-4e5a-bb77-b0922a04e553"},
1546+
// { type = "ephemeral"},
1547+
// ]
1548+
// }
1549+
//
1550+
// That's where this validator comes in. This validator errors with the above
1551+
// configuration, preventing a user from using multiple ephemeral external IPs.
1552+
func (f instanceExternalIPValidator) ValidateSet(ctx context.Context, req validator.SetRequest, resp *validator.SetResponse) {
1553+
if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() {
1554+
return
1555+
}
1556+
1557+
var externalIPs []instanceResourceExternalIPModel
1558+
diags := req.ConfigValue.ElementsAs(ctx, &externalIPs, false)
1559+
resp.Diagnostics.Append(diags...)
1560+
if resp.Diagnostics.HasError() {
1561+
return
1562+
}
1563+
1564+
var ephemeralExternalIPs int
1565+
for _, externalIP := range externalIPs {
1566+
if externalIP.Type.ValueString() == string(oxide.ExternalIpCreateTypeEphemeral) {
1567+
ephemeralExternalIPs++
1568+
}
1569+
}
1570+
if ephemeralExternalIPs > 1 {
1571+
resp.Diagnostics.AddError(
1572+
fmt.Sprintf("Too many external IPs with type = %q", oxide.ExternalIpCreateTypeEphemeral),
1573+
fmt.Sprintf("Only 1 external IP with type = %q is allowed, but found %d.", oxide.ExternalIpCreateTypeEphemeral, ephemeralExternalIPs),
1574+
)
1575+
return
1576+
}
1577+
}

0 commit comments

Comments
 (0)