Skip to content

Commit

Permalink
oxide_instance: allow in-place external_ips modification
Browse files Browse the repository at this point in the history
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
detachd 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.
- [ ] Add tests for the new `external_ips` modification logic.
- [ ] 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]
│
╵
```
  • Loading branch information
sudomateo committed Jan 9, 2025
1 parent 90e035d commit 1b14f69
Showing 1 changed file with 149 additions and 3 deletions.
152 changes: 149 additions & 3 deletions internal/provider/resource_instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ func (r *instanceResource) Schema(ctx context.Context, _ resource.SchemaRequest,
},
},
},
// TODO: Add validator to ensure at most one of the elements is of type `ephemeral`.
"external_ips": schema.SetNestedAttribute{
Optional: true,
Description: "External IP addresses provided to this instance.",
Expand All @@ -268,9 +269,6 @@ func (r *instanceResource) Schema(ctx context.Context, _ resource.SchemaRequest,
},
},
},
PlanModifiers: []planmodifier.Set{
setplanmodifier.RequiresReplace(),
},
},
"user_data": schema.StringAttribute{
Optional: true,
Expand Down Expand Up @@ -483,6 +481,26 @@ func (r *instanceResource) Read(ctx context.Context, req resource.ReadRequest, r
return
}

// TODO: Support pagination when Go SDK supports it.
externalIPResponse, err := r.client.InstanceExternalIpList(ctx, oxide.InstanceExternalIpListParams{
Instance: oxide.NameOrId(state.ID.ValueString()),
})
if err != nil {
resp.Diagnostics.AddError(
"Unable to read instance external ips:",
"API error: "+err.Error(),
)
return
}

externalIPs := make([]instanceResourceExternalIPModel, 0, len(externalIPResponse.Items))
for _, externalIP := range externalIPResponse.Items {
externalIPs = append(externalIPs, instanceResourceExternalIPModel{
ID: types.StringValue(externalIP.Id),
Type: types.StringValue(string(externalIP.Kind)),
})
}

tflog.Trace(ctx, fmt.Sprintf("read instance with ID: %v", instance.Id), map[string]any{"success": true})

if instance.BootDiskId != "" {
Expand All @@ -497,6 +515,7 @@ func (r *instanceResource) Read(ctx context.Context, req resource.ReadRequest, r
state.ProjectID = types.StringValue(instance.ProjectId)
state.TimeCreated = types.StringValue(instance.TimeCreated.String())
state.TimeModified = types.StringValue(instance.TimeModified.String())
state.ExternalIPs = externalIPs

keySet, diags := newAssociatedSSHKeysOnCreateSet(ctx, r.client, state.ID.ValueString())
resp.Diagnostics.Append(diags...)
Expand Down Expand Up @@ -583,6 +602,23 @@ func (r *instanceResource) Update(ctx context.Context, req resource.UpdateReques
}
tflog.Trace(ctx, fmt.Sprintf("stopped instance with ID: %v", state.ID.ValueString()), map[string]any{"success": true})

// Update external IPs.
// We detach external IPs first to account for the case where an ephemeral
// external IP's IP Pool is modified. This is because there can only be one
// ephemeral external IP attached to an instance at a given time and the
// last detachment/attachment wins.
externalIPsToDetach := sliceDiff(state.ExternalIPs, plan.ExternalIPs)
resp.Diagnostics.Append(detachExternalIPs(ctx, r.client, externalIPsToDetach, state.ID.ValueString())...)
if resp.Diagnostics.HasError() {
return
}

externalIPsToAttach := sliceDiff(plan.ExternalIPs, state.ExternalIPs)
resp.Diagnostics.Append(attachExternalIPs(ctx, r.client, externalIPsToAttach, state.ID.ValueString())...)
if resp.Diagnostics.HasError() {
return
}

// Update disk attachments
//
// We attach new disks first in case the new boot disk is one of the newly added
Expand Down Expand Up @@ -1133,6 +1169,116 @@ func deleteNICs(ctx context.Context, client *oxide.Client, models []instanceReso
return nil
}

// attachExternalIPs attaches the ephemeral and floating IPs specified by
// `externalIPs` to the instance specified by `instanceID`.
func attachExternalIPs(ctx context.Context, client *oxide.Client, externalIPs []instanceResourceExternalIPModel, instanceID string) diag.Diagnostics {
var diags diag.Diagnostics

for _, v := range externalIPs {
externalIPID := v.ID
externalIPType := v.Type

switch oxide.ExternalIpKind(externalIPType.ValueString()) {
case oxide.ExternalIpKindEphemeral:
params := oxide.InstanceEphemeralIpAttachParams{
Instance: oxide.NameOrId(instanceID),
Body: &oxide.EphemeralIpCreate{
Pool: oxide.NameOrId(externalIPID.ValueString()),
},
}

if _, err := client.InstanceEphemeralIpAttach(ctx, params); err != nil {
diags.AddError(
fmt.Sprintf("Error attaching ephemeral external IP with ID %s", externalIPID.String()),
"API error: "+err.Error(),
)

return diags
}

case oxide.ExternalIpKindFloating:
params := oxide.FloatingIpAttachParams{
FloatingIp: oxide.NameOrId(externalIPID.ValueString()),
Body: &oxide.FloatingIpAttach{
// TODO: Support alternative FloatingIpParentKind values once available upstream.
Kind: oxide.FloatingIpParentKindInstance,
Parent: oxide.NameOrId(instanceID),
},
}

if _, err := client.FloatingIpAttach(ctx, params); err != nil {
diags.AddError(
fmt.Sprintf("Error attaching floating external IP with ID %s", externalIPID.String()),
"API error: "+err.Error(),
)

return diags
}
default:
diags.AddError(
"Error attaching external IP",
fmt.Sprintf("Unknown external IP type %s", externalIPType.String()),
)
return diags
}

tflog.Trace(ctx, fmt.Sprintf("successfully attached %s external IP with ID %s", externalIPType.String(), externalIPID.String()), map[string]any{"success": true})
}

return nil
}

// detachExternalIPs detaches the ephemeral and floating IPs specified by
// `externalIPs` from the instance specified by `instanceID`.
func detachExternalIPs(ctx context.Context, client *oxide.Client, externalIPs []instanceResourceExternalIPModel, instanceID string) diag.Diagnostics {
var diags diag.Diagnostics

for _, v := range externalIPs {
externalIPID := v.ID
externalIPType := v.Type

switch oxide.ExternalIpKind(externalIPType.ValueString()) {
case oxide.ExternalIpKindEphemeral:
params := oxide.InstanceEphemeralIpDetachParams{
Instance: oxide.NameOrId(instanceID),
}

if err := client.InstanceEphemeralIpDetach(ctx, params); err != nil {
diags.AddError(
fmt.Sprintf("Error detaching ephemeral external IP with ID %s", externalIPID.String()),
"API error: "+err.Error(),
)

return diags
}

case oxide.ExternalIpKindFloating:
params := oxide.FloatingIpDetachParams{
FloatingIp: oxide.NameOrId(externalIPID.ValueString()),
}

if _, err := client.FloatingIpDetach(ctx, params); err != nil {
diags.AddError(
fmt.Sprintf("Error detaching floating external IP with ID %s", externalIPID.String()),
"API error: "+err.Error(),
)

return diags
}
default:
diags.AddError(
"Error detaching external IP",
fmt.Sprintf("Unknown external IP type %s", externalIPType.String()),
)
return diags
}

tflog.Trace(ctx, fmt.Sprintf("successfully detached %s external IP with ID %s", externalIPType.String(), externalIPID.String()), map[string]any{"success": true})
}

return nil
}

func attachDisks(ctx context.Context, client *oxide.Client, disks []attr.Value, instanceID string) diag.Diagnostics {
var diags diag.Diagnostics

Expand Down

0 comments on commit 1b14f69

Please sign in to comment.