diff --git a/.changelog/0.21.0.toml b/.changelog/0.21.0.toml index e916ad0b..69a5736c 100644 --- a/.changelog/0.21.0.toml +++ b/.changelog/0.21.0.toml @@ -5,5 +5,13 @@ title = "`oxide_ip_pool_silo_link`" description = "The `id` attribute for the `oxide_ip_pool_silo_link` resource now uses the format `IP_POOL_ID/SILO_ID`, matching the `oxide_subnet_pool_silo_link` resource. [#794](https://github.com/oxidecomputer/terraform-provider-oxide/pull/794)" [[enhancements]] +title = "Validate UUID Attributes" +description = "Attributes that expect UUIDs now validate whether their values are, in fact, valid UUIDs. This catches cases where names passed to attributes expecting UUIDs accidentally worked. [#795](https://github.com/oxidecomputer/terraform-provider-oxide/pull/795)." [[bugs]] +title = "`oxide_ip_pool_silo_link`" +description = "The `silo_id` attribute now resolves to the silo's UUID, even if the attribute was previously configured by name. [#795](https://github.com/oxidecomputer/terraform-provider-oxide/pull/795)." + +[[bugs]] +title = "`oxide_subnet_pool_silo_link`" +description = "The `silo_id` attribute now resolves to the silo's UUID, even if the attribute was previously configured by name. [#795](https://github.com/oxidecomputer/terraform-provider-oxide/pull/795)." diff --git a/docs/guides/upgrade.md b/docs/guides/upgrade.md index 0e901571..1714252f 100644 --- a/docs/guides/upgrade.md +++ b/docs/guides/upgrade.md @@ -17,6 +17,46 @@ Refer to the [changelog](https://github.com/oxidecomputer/terraform-provider-oxide/blob/main/CHANGELOG.md) for a full list of changes. +## Upgrading to `0.21.0` + +Release `0.21.0` contains breaking changes that require updates to Terraform +configuration files. + +### UUID Validation + +Attributes that expect UUIDs now validate whether their values are, in fact, +valid UUIDs. This catches cases where names passed to attributes expecting UUIDs +accidentally worked. + +Terraform plans will fail with an `Invalid UUID` error on affected attributes. + +``` +╷ +│ Error: Invalid UUID +│ +│ with oxide_ip_pool_silo_link.example, +│ on main.tf line 15, in resource "oxide_ip_pool_silo_link" "example": +│ 15: silo_id = "oxide" +│ +│ Attribute silo_id value must be a valid UUID, got: oxide +╵ +``` + +Update the attribute to its UUID to comply with the validation. + +```diff +--- main.tf ++++ main.tf + } + + resource "oxide_ip_pool_silo_link" "example" { +- silo_id = "oxide" ++ silo_id = "338bfaf2-75d3-4a1c-8850-32173960f658" + ip_pool_id = "c6f8c195-8cee-40b5-bc95-7eb86edec285" + is_default = false + } +``` + ## Upgrading to `0.20.0` Release `0.20.0` contains breaking changes that require updates to Terraform diff --git a/internal/provider/anti_affinity_group/resource.go b/internal/provider/anti_affinity_group/resource.go index 04d0c374..8aa725ae 100644 --- a/internal/provider/anti_affinity_group/resource.go +++ b/internal/provider/anti_affinity_group/resource.go @@ -21,6 +21,7 @@ import ( "github.com/oxidecomputer/oxide.go/oxide" "github.com/oxidecomputer/terraform-provider-oxide/internal/provider/shared" + oxidevalidator "github.com/oxidecomputer/terraform-provider-oxide/internal/provider/validator" ) // Ensure the implementation satisfies the expected interfaces. @@ -96,6 +97,9 @@ This resource manages anti-affinity groups. "project_id": schema.StringAttribute{ Required: true, Description: "ID of the project that will contain the anti-affinity group.", + Validators: []validator.String{ + oxidevalidator.IsUUID(), + }, PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), }, diff --git a/internal/provider/disk/resource.go b/internal/provider/disk/resource.go index 6f49c93b..bd208434 100644 --- a/internal/provider/disk/resource.go +++ b/internal/provider/disk/resource.go @@ -26,6 +26,7 @@ import ( "github.com/oxidecomputer/oxide.go/oxide" "github.com/oxidecomputer/terraform-provider-oxide/internal/provider/shared" + oxidevalidator "github.com/oxidecomputer/terraform-provider-oxide/internal/provider/validator" ) // Ensure the implementation satisfies the expected interfaces. @@ -121,6 +122,9 @@ To create a blank disk it's necessary to set ''block_size''. Otherwise, one of ' "project_id": schema.StringAttribute{ Required: true, Description: "ID of the project that will contain the disk.", + Validators: []validator.String{ + oxidevalidator.IsUUID(), + }, PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), }, @@ -150,6 +154,7 @@ To create a blank disk it's necessary to set ''block_size''. Otherwise, one of ' Optional: true, Description: "Image ID of the disk source if applicable.", Validators: []validator.String{ + oxidevalidator.IsUUID(), stringvalidator.ConflictsWith(path.Expressions{ path.MatchRoot("block_size"), }...), @@ -165,6 +170,7 @@ To create a blank disk it's necessary to set ''block_size''. Otherwise, one of ' Optional: true, Description: "Snapshot ID of the disk source if applicable.", Validators: []validator.String{ + oxidevalidator.IsUUID(), stringvalidator.ConflictsWith(path.Expressions{ path.MatchRoot("block_size"), }...), diff --git a/internal/provider/external_subnet/resource.go b/internal/provider/external_subnet/resource.go index 08e8639c..a2d4885b 100644 --- a/internal/provider/external_subnet/resource.go +++ b/internal/provider/external_subnet/resource.go @@ -25,6 +25,7 @@ import ( "github.com/oxidecomputer/oxide.go/oxide" "github.com/oxidecomputer/terraform-provider-oxide/internal/provider/shared" + oxidevalidator "github.com/oxidecomputer/terraform-provider-oxide/internal/provider/validator" ) // Ensure the implementation satisfies the expected interfaces. @@ -129,6 +130,9 @@ func (r *Resource) Schema( "project_id": schema.StringAttribute{ Required: true, Description: "Project ID where this external subnet is located.", + Validators: []validator.String{ + oxidevalidator.IsUUID(), + }, PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), }, @@ -168,6 +172,7 @@ func (r *Resource) Schema( stringplanmodifier.UseStateForUnknown(), }, Validators: []validator.String{ + oxidevalidator.IsUUID(), stringvalidator.ConflictsWith(path.MatchRoot("subnet")), }, }, diff --git a/internal/provider/external_subnet_attachment/resource.go b/internal/provider/external_subnet_attachment/resource.go index 8ab60d73..a4200442 100644 --- a/internal/provider/external_subnet_attachment/resource.go +++ b/internal/provider/external_subnet_attachment/resource.go @@ -14,11 +14,13 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/oxidecomputer/oxide.go/oxide" "github.com/oxidecomputer/terraform-provider-oxide/internal/provider/shared" + oxidevalidator "github.com/oxidecomputer/terraform-provider-oxide/internal/provider/validator" ) // Ensure the implementation satisfies the expected interfaces. @@ -98,6 +100,9 @@ func (r *Resource) Schema( "external_subnet_id": schema.StringAttribute{ Required: true, Description: "ID of the external subnet to attach.", + Validators: []validator.String{ + oxidevalidator.IsUUID(), + }, PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), }, @@ -105,6 +110,9 @@ func (r *Resource) Schema( "instance_id": schema.StringAttribute{ Required: true, Description: "ID of the instance to attach the external subnet to.", + Validators: []validator.String{ + oxidevalidator.IsUUID(), + }, PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), }, diff --git a/internal/provider/floating_ip/resource.go b/internal/provider/floating_ip/resource.go index eac54668..ba47def1 100644 --- a/internal/provider/floating_ip/resource.go +++ b/internal/provider/floating_ip/resource.go @@ -23,6 +23,7 @@ import ( "github.com/oxidecomputer/oxide.go/oxide" "github.com/oxidecomputer/terraform-provider-oxide/internal/provider/shared" + oxidevalidator "github.com/oxidecomputer/terraform-provider-oxide/internal/provider/validator" ) // ResourceModel represents the Terraform configuration and state for @@ -144,6 +145,7 @@ This resource manages Oxide floating IPs. stringplanmodifier.RequiresReplaceIfConfigured(), }, Validators: []validator.String{ + oxidevalidator.IsUUID(), stringvalidator.ConflictsWith(path.MatchRoot("ip")), stringvalidator.ConflictsWith(path.MatchRoot("ip_version")), }, @@ -168,6 +170,9 @@ This resource manages Oxide floating IPs. "project_id": schema.StringAttribute{ Required: true, Description: "Project ID where this floating IP is located.", + Validators: []validator.String{ + oxidevalidator.IsUUID(), + }, PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), }, diff --git a/internal/provider/image/resource.go b/internal/provider/image/resource.go index c9bd1bd5..793979e3 100644 --- a/internal/provider/image/resource.go +++ b/internal/provider/image/resource.go @@ -15,11 +15,13 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/oxidecomputer/oxide.go/oxide" "github.com/oxidecomputer/terraform-provider-oxide/internal/provider/shared" + oxidevalidator "github.com/oxidecomputer/terraform-provider-oxide/internal/provider/validator" ) // Ensure the implementation satisfies the expected interfaces. @@ -111,6 +113,9 @@ This resource manages images. "project_id": schema.StringAttribute{ Required: true, Description: "ID of the project that will contain the image.", + Validators: []validator.String{ + oxidevalidator.IsUUID(), + }, PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), }, @@ -143,6 +148,9 @@ This resource manages images. "source_snapshot_id": schema.StringAttribute{ Required: true, Description: "Snapshot ID of the image source if applicable.", + Validators: []validator.String{ + oxidevalidator.IsUUID(), + }, PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), }, diff --git a/internal/provider/images/datasource.go b/internal/provider/images/datasource.go index e2c46694..be9ae507 100644 --- a/internal/provider/images/datasource.go +++ b/internal/provider/images/datasource.go @@ -12,11 +12,13 @@ import ( "github.com/hashicorp/terraform-plugin-framework-timeouts/datasource/timeouts" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/oxidecomputer/oxide.go/oxide" "github.com/oxidecomputer/terraform-provider-oxide/internal/provider/shared" + oxidevalidator "github.com/oxidecomputer/terraform-provider-oxide/internal/provider/validator" ) var ( @@ -92,6 +94,9 @@ Retrieve a list of all images belonging to a silo or project. "project_id": schema.StringAttribute{ Optional: true, Description: "ID of the project which contains the images.", + Validators: []validator.String{ + oxidevalidator.IsUUID(), + }, }, "id": schema.StringAttribute{ Computed: true, diff --git a/internal/provider/instance/resource.go b/internal/provider/instance/resource.go index 9de263c3..b59cc52d 100644 --- a/internal/provider/instance/resource.go +++ b/internal/provider/instance/resource.go @@ -35,6 +35,7 @@ import ( "github.com/oxidecomputer/oxide.go/oxide" "github.com/oxidecomputer/terraform-provider-oxide/internal/provider/shared" + oxidevalidator "github.com/oxidecomputer/terraform-provider-oxide/internal/provider/validator" ) // Ensure the implementation satisfies the expected interfaces. @@ -242,6 +243,9 @@ This resource manages instances. "project_id": schema.StringAttribute{ Required: true, Description: "ID for the project containing this instance.", + Validators: []validator.String{ + oxidevalidator.IsUUID(), + }, PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), }, @@ -314,6 +318,7 @@ This resource manages instances. Optional: true, MarkdownDescription: "ID of the disk the instance should be booted from. Specifying a boot disk is optional but recommended to ensure predictable boot behavior. When provided, this ID must also be present in `disk_attachments`.", Validators: []validator.String{ + oxidevalidator.IsUUID(), stringvalidator.AlsoRequires( path.MatchRoot("disk_attachments"), ), @@ -379,6 +384,9 @@ This resource manages instances. "subnet_id": schema.StringAttribute{ Required: true, Description: "ID of the VPC subnet in which to create the instance network interface.", + Validators: []validator.String{ + oxidevalidator.IsUUID(), + }, PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplaceIf( shared.RequiresReplaceUnlessEmptyStringOrNull(), "", "", @@ -388,6 +396,9 @@ This resource manages instances. "vpc_id": schema.StringAttribute{ Required: true, Description: "ID of the VPC in which to create the instance network interface.", + Validators: []validator.String{ + oxidevalidator.IsUUID(), + }, PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplaceIf( shared.RequiresReplaceUnlessEmptyStringOrNull(), "", "", @@ -527,6 +538,7 @@ This resource manages instances. Computed: true, MarkdownDescription: "ID of the IP pool to allocate from. Conflicts with `ip_version`.", Validators: []validator.String{ + oxidevalidator.IsUUID(), stringvalidator.ConflictsWith( path.MatchRelative().AtParent().AtName("ip_version"), ), diff --git a/internal/provider/instance_external_ips/datasource.go b/internal/provider/instance_external_ips/datasource.go index 6c818eb2..7708331b 100644 --- a/internal/provider/instance_external_ips/datasource.go +++ b/internal/provider/instance_external_ips/datasource.go @@ -12,11 +12,13 @@ import ( "github.com/hashicorp/terraform-plugin-framework-timeouts/datasource/timeouts" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/oxidecomputer/oxide.go/oxide" "github.com/oxidecomputer/terraform-provider-oxide/internal/provider/shared" + oxidevalidator "github.com/oxidecomputer/terraform-provider-oxide/internal/provider/validator" ) var ( @@ -79,6 +81,9 @@ Retrieve information of all external IPs associated to an instance. "instance_id": schema.StringAttribute{ Required: true, Description: "ID of the instance to which the external IPs belong to.", + Validators: []validator.String{ + oxidevalidator.IsUUID(), + }, }, "id": schema.StringAttribute{ Computed: true, diff --git a/internal/provider/ip_pool_silo_link/resource.go b/internal/provider/ip_pool_silo_link/resource.go index abca1b75..f324f25d 100644 --- a/internal/provider/ip_pool_silo_link/resource.go +++ b/internal/provider/ip_pool_silo_link/resource.go @@ -16,11 +16,13 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/oxidecomputer/oxide.go/oxide" "github.com/oxidecomputer/terraform-provider-oxide/internal/provider/shared" + oxidevalidator "github.com/oxidecomputer/terraform-provider-oxide/internal/provider/validator" ) // Ensure the implementation satisfies the expected interfaces. @@ -108,6 +110,9 @@ This resource manages IP pool to silo links. PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), }, + Validators: []validator.String{ + oxidevalidator.IsUUID(), + }, }, "ip_pool_id": schema.StringAttribute{ Required: true, @@ -115,6 +120,9 @@ This resource manages IP pool to silo links. PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), }, + Validators: []validator.String{ + oxidevalidator.IsUUID(), + }, }, "is_default": schema.BoolAttribute{ Required: true, @@ -129,9 +137,6 @@ This resource manages IP pool to silo links. "id": schema.StringAttribute{ Computed: true, Description: "Unique, immutable, system-controlled identifier of the IP pool silo link.", - PlanModifiers: []planmodifier.String{ - stringplanmodifier.UseStateForUnknown(), - }, }, }, } @@ -249,11 +254,29 @@ func (r *Resource) Read( return } + // Resolve the silo to its UUID so the composite ID is always IP_POOL_ID/SILO_ID + // in UUID form, even when silo_id was previously configured by name. + silo, err := r.client.SiloView(ctx, oxide.SiloViewParams{ + Silo: oxide.NameOrId(state.SiloID.ValueString()), + }) + if err != nil { + if shared.Is404(err) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError( + "Unable to read silo:", + "API error: "+err.Error(), + ) + return + } + // Set a deterministic ID based on composite attributes. state.ID = types.StringValue( - fmt.Sprintf("%s/%s", pools[idx].Id, state.SiloID.ValueString()), + fmt.Sprintf("%s/%s", pools[idx].Id, silo.Id), ) + state.SiloID = types.StringValue(silo.Id) state.IPPoolID = types.StringValue(pools[idx].Id) state.IsDefault = types.BoolPointerValue(pools[idx].IsDefault) diff --git a/internal/provider/snapshot/resource.go b/internal/provider/snapshot/resource.go index 7fd59c01..1c492d45 100644 --- a/internal/provider/snapshot/resource.go +++ b/internal/provider/snapshot/resource.go @@ -14,11 +14,13 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/oxidecomputer/oxide.go/oxide" "github.com/oxidecomputer/terraform-provider-oxide/internal/provider/shared" + oxidevalidator "github.com/oxidecomputer/terraform-provider-oxide/internal/provider/validator" ) // Ensure the implementation satisfies the expected interfaces. @@ -95,6 +97,9 @@ This resource manages snapshots. "project_id": schema.StringAttribute{ Required: true, Description: "ID of the project that will contain the snapshot.", + Validators: []validator.String{ + oxidevalidator.IsUUID(), + }, PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), }, @@ -116,6 +121,9 @@ This resource manages snapshots. "disk_id": schema.StringAttribute{ Required: true, Description: "ID of the disk to create the snapshot from.", + Validators: []validator.String{ + oxidevalidator.IsUUID(), + }, PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), }, diff --git a/internal/provider/subnet_pool_silo_link/resource.go b/internal/provider/subnet_pool_silo_link/resource.go index 0b8f854f..c373b2c0 100644 --- a/internal/provider/subnet_pool_silo_link/resource.go +++ b/internal/provider/subnet_pool_silo_link/resource.go @@ -16,11 +16,13 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/oxidecomputer/oxide.go/oxide" "github.com/oxidecomputer/terraform-provider-oxide/internal/provider/shared" + oxidevalidator "github.com/oxidecomputer/terraform-provider-oxide/internal/provider/validator" ) // Ensure the implementation satisfies the expected interfaces. @@ -106,6 +108,9 @@ func (r *Resource) Schema( PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), }, + Validators: []validator.String{ + oxidevalidator.IsUUID(), + }, }, "subnet_pool_id": schema.StringAttribute{ Required: true, @@ -113,6 +118,9 @@ func (r *Resource) Schema( PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), }, + Validators: []validator.String{ + oxidevalidator.IsUUID(), + }, }, "is_default": schema.BoolAttribute{ Required: true, @@ -227,18 +235,42 @@ func (r *Resource) Read( map[string]any{"success": true}, ) + subnetPoolID := state.SubnetPoolID.ValueString() idx := slices.IndexFunc( pools, - func(p oxide.SiloSubnetPool) bool { return p.Id == state.SubnetPoolID.ValueString() }, + func(p oxide.SiloSubnetPool) bool { + // We check for both ID and name equality to ensure resources that + // mistakenly used the subnet pool name aren't removed from state. + return p.Id == subnetPoolID || p.Name == oxide.Name(subnetPoolID) + }, ) if idx < 0 { resp.State.RemoveResource(ctx) return } + // Resolve the silo to its UUID so the composite ID is always + // SUBNET_POOL_ID/SILO_ID in UUID form, even when silo_id was previously + // configured by name. + silo, err := r.client.SiloView(ctx, oxide.SiloViewParams{ + Silo: oxide.NameOrId(state.SiloID.ValueString()), + }) + if err != nil { + if shared.Is404(err) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError( + "Unable to read silo:", + "API error: "+err.Error(), + ) + return + } + // Set a deterministic ID based on composite attributes. - state.ID = types.StringValue(fmt.Sprintf("%s/%s", pools[idx].Id, state.SiloID.ValueString())) + state.ID = types.StringValue(fmt.Sprintf("%s/%s", pools[idx].Id, silo.Id)) + state.SiloID = types.StringValue(silo.Id) state.SubnetPoolID = types.StringValue(pools[idx].Id) state.IsDefault = types.BoolPointerValue(pools[idx].IsDefault) diff --git a/internal/provider/switch_port_settings/resource.go b/internal/provider/switch_port_settings/resource.go index 87d620a3..8e144ef3 100644 --- a/internal/provider/switch_port_settings/resource.go +++ b/internal/provider/switch_port_settings/resource.go @@ -23,6 +23,7 @@ import ( "github.com/oxidecomputer/oxide.go/oxide" "github.com/oxidecomputer/terraform-provider-oxide/internal/provider/shared" + oxidevalidator "github.com/oxidecomputer/terraform-provider-oxide/internal/provider/validator" ) var ( @@ -240,6 +241,9 @@ This resource manages switch port settings. "address_lot_id": schema.StringAttribute{ Required: true, Description: "Address lot the address is allocated from.", + Validators: []validator.String{ + oxidevalidator.IsUUID(), + }, }, "vlan_id": schema.Int32Attribute{ Optional: true, diff --git a/internal/provider/validator/uuid.go b/internal/provider/validator/uuid.go new file mode 100644 index 00000000..fd8d1943 --- /dev/null +++ b/internal/provider/validator/uuid.go @@ -0,0 +1,62 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package validator + +import ( + "context" + "fmt" + + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// Compile-time interface assertion. +var _ validator.String = isUUID{} + +// isUUID validates that a configured string is a valid UUID. +type isUUID struct{} + +// Description returns a plain text description of the validator's behavior. +func (v isUUID) Description(_ context.Context) string { + return "Value must be a valid UUID" +} + +// MarkdownDescription returns a markdown description of the validator's +// behavior. +func (v isUUID) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +// ValidateString validates that a configured string is a valid UUID. Null +// and unknown values are skipped so that this validator can be composed with +// others. +func (v isUUID) ValidateString( + _ context.Context, + req validator.StringRequest, + resp *validator.StringResponse, +) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + value := req.ConfigValue.ValueString() + if _, err := uuid.Parse(value); err != nil { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid UUID", + fmt.Sprintf( + "Attribute %s value must be a valid UUID, got: %s", + req.Path, + value, + ), + ) + } +} + +// IsUUID returns a string validator which ensures that a configured value is a +// valid UUID. +func IsUUID() validator.String { + return isUUID{} +} diff --git a/internal/provider/validator/uuid_test.go b/internal/provider/validator/uuid_test.go new file mode 100644 index 00000000..fe7fc3ab --- /dev/null +++ b/internal/provider/validator/uuid_test.go @@ -0,0 +1,63 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package validator + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stretchr/testify/assert" +) + +func Test_IsUUID(t *testing.T) { + tests := []struct { + name string + value types.String + wantError bool + }{ + { + name: "valid UUID", + value: types.StringValue("8f1ce7f8-0c5b-4e4e-9b1a-6f6d8c1f2a3b"), + wantError: false, + }, + { + name: "invalid UUID (name)", + value: types.StringValue("my-silo"), + wantError: true, + }, + { + name: "empty string", + value: types.StringValue(""), + wantError: true, + }, + { + name: "null is skipped", + value: types.StringNull(), + wantError: false, + }, + { + name: "unknown is skipped", + value: types.StringUnknown(), + wantError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := validator.StringRequest{ + Path: path.Root("test"), + ConfigValue: tt.value, + } + resp := &validator.StringResponse{} + + IsUUID().ValidateString(context.Background(), req, resp) + + assert.Equal(t, tt.wantError, resp.Diagnostics.HasError()) + }) + } +} diff --git a/internal/provider/vpc/resource.go b/internal/provider/vpc/resource.go index ffa1b902..2ae8022b 100644 --- a/internal/provider/vpc/resource.go +++ b/internal/provider/vpc/resource.go @@ -14,11 +14,13 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/oxidecomputer/oxide.go/oxide" "github.com/oxidecomputer/terraform-provider-oxide/internal/provider/shared" + oxidevalidator "github.com/oxidecomputer/terraform-provider-oxide/internal/provider/validator" ) // Ensure the implementation satisfies the expected interfaces. @@ -95,6 +97,9 @@ This resource manages VPCs. "project_id": schema.StringAttribute{ Required: true, Description: "ID of the project that will contain the VPC.", + Validators: []validator.String{ + oxidevalidator.IsUUID(), + }, PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), }, diff --git a/internal/provider/vpc_firewall_rules/resource.go b/internal/provider/vpc_firewall_rules/resource.go index 750cbae1..55ae5af1 100644 --- a/internal/provider/vpc_firewall_rules/resource.go +++ b/internal/provider/vpc_firewall_rules/resource.go @@ -29,6 +29,7 @@ import ( "github.com/oxidecomputer/oxide.go/oxide" "github.com/oxidecomputer/terraform-provider-oxide/internal/provider/shared" + oxidevalidator "github.com/oxidecomputer/terraform-provider-oxide/internal/provider/validator" ) // Ensure the implementation satisfies the expected interfaces. @@ -173,6 +174,9 @@ rules when updating this resource. "vpc_id": schema.StringAttribute{ Required: true, Description: "ID of the VPC that will have the firewall rules applied to.", + Validators: []validator.String{ + oxidevalidator.IsUUID(), + }, PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), }, diff --git a/internal/provider/vpc_internet_gateway/resource.go b/internal/provider/vpc_internet_gateway/resource.go index 60477100..5c45d8a7 100644 --- a/internal/provider/vpc_internet_gateway/resource.go +++ b/internal/provider/vpc_internet_gateway/resource.go @@ -15,11 +15,13 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/oxidecomputer/oxide.go/oxide" "github.com/oxidecomputer/terraform-provider-oxide/internal/provider/shared" + oxidevalidator "github.com/oxidecomputer/terraform-provider-oxide/internal/provider/validator" ) // Ensure the implementation satisfies the expected interfaces. @@ -108,6 +110,9 @@ This resource manages VPC internet gateways. "vpc_id": schema.StringAttribute{ Required: true, Description: "ID of the VPC that will contain the VPC internet gateway.", + Validators: []validator.String{ + oxidevalidator.IsUUID(), + }, PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), }, diff --git a/internal/provider/vpc_router/resource.go b/internal/provider/vpc_router/resource.go index a1290d0b..8be27635 100644 --- a/internal/provider/vpc_router/resource.go +++ b/internal/provider/vpc_router/resource.go @@ -14,11 +14,13 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/oxidecomputer/oxide.go/oxide" "github.com/oxidecomputer/terraform-provider-oxide/internal/provider/shared" + oxidevalidator "github.com/oxidecomputer/terraform-provider-oxide/internal/provider/validator" ) // Ensure the implementation satisfies the expected interfaces. @@ -100,6 +102,9 @@ This resource manages VPC routers. "vpc_id": schema.StringAttribute{ Required: true, Description: "ID of the VPC that will contain the VPC router.", + Validators: []validator.String{ + oxidevalidator.IsUUID(), + }, PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), }, diff --git a/internal/provider/vpc_router_route/resource.go b/internal/provider/vpc_router_route/resource.go index 4e9b0105..74d43850 100644 --- a/internal/provider/vpc_router_route/resource.go +++ b/internal/provider/vpc_router_route/resource.go @@ -21,6 +21,7 @@ import ( "github.com/oxidecomputer/oxide.go/oxide" "github.com/oxidecomputer/terraform-provider-oxide/internal/provider/shared" + oxidevalidator "github.com/oxidecomputer/terraform-provider-oxide/internal/provider/validator" ) // Ensure the implementation satisfies the expected interfaces. @@ -174,6 +175,9 @@ Depending on the type, it will be one of the following: "vpc_router_id": schema.StringAttribute{ Required: true, Description: "ID of the VPC router route that will contain the route.", + Validators: []validator.String{ + oxidevalidator.IsUUID(), + }, PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), }, diff --git a/internal/provider/vpc_subnet/resource.go b/internal/provider/vpc_subnet/resource.go index eeb661b4..2107b88d 100644 --- a/internal/provider/vpc_subnet/resource.go +++ b/internal/provider/vpc_subnet/resource.go @@ -15,11 +15,13 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/oxidecomputer/oxide.go/oxide" "github.com/oxidecomputer/terraform-provider-oxide/internal/provider/shared" + oxidevalidator "github.com/oxidecomputer/terraform-provider-oxide/internal/provider/validator" ) // Ensure the implementation satisfies the expected interfaces. @@ -95,6 +97,9 @@ This resource manages VPC subnets. "vpc_id": schema.StringAttribute{ Required: true, Description: "ID of the VPC that will contain the subnet.", + Validators: []validator.String{ + oxidevalidator.IsUUID(), + }, PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), }, diff --git a/templates/guides/upgrade.md.tmpl b/templates/guides/upgrade.md.tmpl index 0e901571..1714252f 100644 --- a/templates/guides/upgrade.md.tmpl +++ b/templates/guides/upgrade.md.tmpl @@ -17,6 +17,46 @@ Refer to the [changelog](https://github.com/oxidecomputer/terraform-provider-oxide/blob/main/CHANGELOG.md) for a full list of changes. +## Upgrading to `0.21.0` + +Release `0.21.0` contains breaking changes that require updates to Terraform +configuration files. + +### UUID Validation + +Attributes that expect UUIDs now validate whether their values are, in fact, +valid UUIDs. This catches cases where names passed to attributes expecting UUIDs +accidentally worked. + +Terraform plans will fail with an `Invalid UUID` error on affected attributes. + +``` +╷ +│ Error: Invalid UUID +│ +│ with oxide_ip_pool_silo_link.example, +│ on main.tf line 15, in resource "oxide_ip_pool_silo_link" "example": +│ 15: silo_id = "oxide" +│ +│ Attribute silo_id value must be a valid UUID, got: oxide +╵ +``` + +Update the attribute to its UUID to comply with the validation. + +```diff +--- main.tf ++++ main.tf + } + + resource "oxide_ip_pool_silo_link" "example" { +- silo_id = "oxide" ++ silo_id = "338bfaf2-75d3-4a1c-8850-32173960f658" + ip_pool_id = "c6f8c195-8cee-40b5-bc95-7eb86edec285" + is_default = false + } +``` + ## Upgrading to `0.20.0` Release `0.20.0` contains breaking changes that require updates to Terraform