From c16a4ebf2c6918b448b74c11645d5c343482b808 Mon Sep 17 00:00:00 2001 From: Tomas Karasek Date: Tue, 14 Nov 2023 14:05:12 +0100 Subject: [PATCH 01/26] refactor: Move Config-related code to internal/config --- internal/config/config.go | 3 ++- internal/fabric_utils/correlation_id.go | 24 ++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 internal/fabric_utils/correlation_id.go diff --git a/internal/config/config.go b/internal/config/config.go index d0fdd20c7..c86c9b60e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,6 +19,7 @@ import ( "github.com/equinix/ecx-go/v2" "github.com/equinix/ne-go" "github.com/equinix/oauth2-go" + "github.com/equinix/terraform-provider-equinix/internal/fabric_utils" "github.com/equinix/terraform-provider-equinix/version" "github.com/hashicorp/go-retryablehttp" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/logging" @@ -186,7 +187,7 @@ func (c *Config) NewFabricClient() *v4.APIClient { authClient.Timeout = c.requestTimeout() fabricHeaderMap := map[string]string{ "X-SOURCE": "API", - "X-CORRELATION-ID": CorrelationId(25), + "X-CORRELATION-ID": fabric_utils.CorrelationId(25), } v4Configuration := v4.Configuration{ BasePath: c.BaseURL, diff --git a/internal/fabric_utils/correlation_id.go b/internal/fabric_utils/correlation_id.go new file mode 100644 index 000000000..64621f687 --- /dev/null +++ b/internal/fabric_utils/correlation_id.go @@ -0,0 +1,24 @@ +package fabric_utils + +import ( + "math/rand" + "time" +) + +const allowed_charset = "abcdefghijklmnopqrstuvwxyz" + + "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#$&@" + +var seededRand = rand.New( + rand.NewSource(time.Now().UnixNano())) + +func CorrelationIdWithCharset(length int, charset string) string { + b := make([]byte, length) + for i := range b { + b[i] = charset[seededRand.Intn(len(charset))] + } + return string(b) +} + +func CorrelationId(length int) string { + return CorrelationIdWithCharset(length, allowed_charset) +} From 4737bc075689b0d00f42e12ba6a8cd119267d394 Mon Sep 17 00:00:00 2001 From: ocobleseqx Date: Thu, 16 Nov 2023 16:25:40 +0100 Subject: [PATCH 02/26] merge go.mod Signed-off-by: ocobleseqx --- equinix/framework_provider.go | 92 +++++++++++++ equinix/helper/framework_datasource_base.go | 73 ++++++++++ equinix/helper/framework_provider_model.go | 23 ++++ equinix/helper/framework_resource_base.go | 132 +++++++++++++++++++ equinix/helper/resource_datasource_config.go | 48 +++++++ equinix/metal_ssh_key/datasource.go | 61 +++++++++ equinix/metal_ssh_key/resource.go | 118 +++++++++++++++++ go.mod | 4 +- go.sum | 51 ++----- main.go | 42 ++++-- 10 files changed, 594 insertions(+), 50 deletions(-) create mode 100644 equinix/framework_provider.go create mode 100644 equinix/helper/framework_datasource_base.go create mode 100644 equinix/helper/framework_provider_model.go create mode 100644 equinix/helper/framework_resource_base.go create mode 100644 equinix/helper/resource_datasource_config.go create mode 100644 equinix/metal_ssh_key/datasource.go create mode 100644 equinix/metal_ssh_key/resource.go diff --git a/equinix/framework_provider.go b/equinix/framework_provider.go new file mode 100644 index 000000000..256d95266 --- /dev/null +++ b/equinix/framework_provider.go @@ -0,0 +1,92 @@ +package equinix + +import ( + "context" + "fmt" + + "github.com/equinix/terraform-provider-equinix/equinix/helper" + "github.com/equinix/terraform-provider-equinix/equinix/metal_ssh_key" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/provider/schema" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +type FrameworkProvider struct { + ProviderVersion string + Meta *helper.FrameworkProviderMeta +} + +func CreateFrameworkProvider(version string) provider.Provider { + return &FrameworkProvider{ + ProviderVersion: version, + } +} + +func (p *FrameworkProvider) Metadata( + ctx context.Context, + req provider.MetadataRequest, + resp *provider.MetadataResponse, +) { + resp.TypeName = "equinixcloud" +} + +func (p *FrameworkProvider) Schema( + ctx context.Context, + req provider.SchemaRequest, + resp *provider.SchemaResponse, +) { + resp.Schema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "endpoint": schema.StringAttribute{ + Optional: true, + Description: "The Equinix API base URL to point out desired environment. Defaults to " + DefaultBaseURL, + }, + "client_id": schema.StringAttribute{ + Optional: true, + Description: "API Consumer Key available under My Apps section in developer portal", + }, + "client_secret": schema.StringAttribute{ + Optional: true, + Description: "API Consumer secret available under My Apps section in developer portal", + }, + "token": schema.StringAttribute{ + Optional: true, + Description: "API token from the developer sandbox", + }, + "auth_token": schema.StringAttribute{ + Optional: true, + Description: "The Equinix Metal API auth key for API operations", + }, + "request_timeout": schema.Int64Attribute{ + Optional: true, + Description: "The duration of time, in seconds, that the Equinix Platform API Client should wait before canceling an API request. Defaults to " + fmt.Sprint(DefaultTimeout), + }, + "response_max_page_size": schema.Int64Attribute{ + Optional: true, + Description: "The maximum number of records in a single response for REST queries that produce paginated responses", + }, + "max_retries": schema.Int64Attribute{ + Optional: true, + Description: "Maximum number of retries.", + }, + "max_retry_wait_seconds": schema.Int64Attribute{ + Optional: true, + Description: "Maximum number of seconds to wait before retrying a request.", + }, + }, + } +} + +func (p *FrameworkProvider) Resources(ctx context.Context) []func() resource.Resource { + return []func() resource.Resource{ + metal_ssh_key.NewResource, + } +} + +func (p *FrameworkProvider) DataSources(ctx context.Context) []func() datasource.DataSource { + // return nil + return []func() datasource.DataSource{ + metal_ssh_key.NewDataSource, + } +} diff --git a/equinix/helper/framework_datasource_base.go b/equinix/helper/framework_datasource_base.go new file mode 100644 index 000000000..8d38d66bf --- /dev/null +++ b/equinix/helper/framework_datasource_base.go @@ -0,0 +1,73 @@ +package helper + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" +) + +// NewBaseDataSource returns a new instance of the BaseDataSource +// struct for cleaner initialization. +func NewBaseDataSource(cfg BaseDataSourceConfig) BaseDataSource { + return BaseDataSource{ + Config: cfg, + } +} + +// BaseDataSourceConfig contains all configurable base resource fields. +type BaseDataSourceConfig struct { + Name string + + // Optional + Schema *schema.Schema + IsEarlyAccess bool +} + +// BaseDataSource contains various re-usable fields and methods +// intended for use in data source implementations by composition. +type BaseDataSource struct { + Config BaseDataSourceConfig + Meta *FrameworkProviderMeta +} + +func (r *BaseDataSource) Configure( + ctx context.Context, + req datasource.ConfigureRequest, + resp *datasource.ConfigureResponse, +) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + r.Meta = GetDataSourceMeta(req, resp) + if resp.Diagnostics.HasError() { + return + } +} + +func (r *BaseDataSource) Metadata( + ctx context.Context, + req datasource.MetadataRequest, + resp *datasource.MetadataResponse, +) { + resp.TypeName = r.Config.Name +} + +func (r *BaseDataSource) Schema( + ctx context.Context, + req datasource.SchemaRequest, + resp *datasource.SchemaResponse, +) { + if r.Config.Schema == nil { + resp.Diagnostics.AddError( + "Missing Schema", + "Base data source was not provided a schema. "+ + "Please provide a Schema config attribute or implement, the Schema(...) function.", + ) + return + } + + resp.Schema = *r.Config.Schema +} diff --git a/equinix/helper/framework_provider_model.go b/equinix/helper/framework_provider_model.go new file mode 100644 index 000000000..fb1eaa73e --- /dev/null +++ b/equinix/helper/framework_provider_model.go @@ -0,0 +1,23 @@ +package helper + +import ( + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/packethost/packngo" +) + +type FrameworkProviderModel struct { + Endpoint types.String `tfsdk:"endpoint,omitempty"` + ClientID types.String `tfsdk:"client_id,omitempty"` + ClientSecret types.String `tfsdk:"client_secret,omitempty"` + Token types.String `tfsdk:"token,omitempty"` + AuthToken types.String `tfsdk:"auth_token,omitempty"` + RequestTimeout types.Int64 `tfsdk:"request_timeout,omitempty"` + ResponseMaxPageSize types.Int64 `tfsdk:"response_max_page_size,omitempty"` + MaxRetries types.Int64 `tfsdk:"max_retries,omitempty"` + MaxRetryWaitSeconds types.Int64 `tfsdk:"max_retry_wait_seconds,omitempty"` +} + +type FrameworkProviderMeta struct { + Client *packngo.Client + Config *FrameworkProviderModel +} diff --git a/equinix/helper/framework_resource_base.go b/equinix/helper/framework_resource_base.go new file mode 100644 index 000000000..6dc95148a --- /dev/null +++ b/equinix/helper/framework_resource_base.go @@ -0,0 +1,132 @@ +package helper + +import ( + "context" + "fmt" + "strconv" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// NewBaseResource returns a new instance of the BaseResource +// struct for cleaner initialization. +func NewBaseResource(cfg BaseResourceConfig) BaseResource { + return BaseResource{ + Config: cfg, + } +} + +// BaseResourceConfig contains all configurable base resource fields. +type BaseResourceConfig struct { + Name string + IDAttr string + IDType attr.Type + + // Optional + Schema *schema.Schema + IsEarlyAccess bool +} + +// BaseResource contains various re-usable fields and methods +// intended for use in resource implementations by composition. +type BaseResource struct { + Config BaseResourceConfig + Meta *FrameworkProviderMeta +} + +func (r *BaseResource) Configure( + ctx context.Context, + req resource.ConfigureRequest, + resp *resource.ConfigureResponse, +) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + r.Meta = GetResourceMeta(req, resp) + if resp.Diagnostics.HasError() { + return + } +} + +func (r *BaseResource) Metadata( + ctx context.Context, + req resource.MetadataRequest, + resp *resource.MetadataResponse, +) { + resp.TypeName = r.Config.Name +} + +func (r *BaseResource) Schema( + ctx context.Context, + req resource.SchemaRequest, + resp *resource.SchemaResponse, +) { + if r.Config.Schema == nil { + resp.Diagnostics.AddError( + "Missing Schema", + "Base resource was not provided a schema. "+ + "Please provide a Schema config attribute or implement, the Schema(...) function.", + ) + return + } + + resp.Schema = *r.Config.Schema +} + +// ImportState should be overridden for resources with +// complex read logic (e.g. parent ID). +func (r *BaseResource) ImportState( + ctx context.Context, + req resource.ImportStateRequest, + resp *resource.ImportStateResponse, +) { + // Enforce defaults + idAttr := r.Config.IDAttr + if idAttr == "" { + idAttr = "id" + } + + idType := r.Config.IDType + if idType == nil { + idType = types.Int64Type + } + + attrPath := path.Root(idAttr) + + if attrPath.Equal(path.Empty()) { + resp.Diagnostics.AddError( + "Resource Import Passthrough Missing Attribute Path", + "This is always an error in the provider. Please report the following to the provider developer:\n\n"+ + "Resource ImportState path must be set to a valid attribute path.", + ) + return + } + + // Handle type conversion + var err error + var idValue any + + switch idType { + case types.Int64Type: + idValue, err = strconv.ParseInt(req.ID, 10, 64) + case types.StringType: + idValue = req.ID + default: + err = fmt.Errorf("unsupported id attribute type: %v", idType) + } + if err != nil { + resp.Diagnostics.AddError( + "Failed to convert ID attribute", + err.Error(), + ) + return + } + + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, attrPath, idValue)...) +} diff --git a/equinix/helper/resource_datasource_config.go b/equinix/helper/resource_datasource_config.go new file mode 100644 index 000000000..bf97a0c2d --- /dev/null +++ b/equinix/helper/resource_datasource_config.go @@ -0,0 +1,48 @@ +package helper + +import ( + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +func GetResourceMeta( + req resource.ConfigureRequest, + resp *resource.ConfigureResponse, +) *FrameworkProviderMeta { + meta, ok := req.ProviderData.(*FrameworkProviderMeta) + + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf( + "Expected *http.Client, got: %T. Please report this issue to the provider developers.", + req.ProviderData, + ), + ) + return nil + } + + return meta +} + +func GetDataSourceMeta( + req datasource.ConfigureRequest, + resp *datasource.ConfigureResponse, +) *FrameworkProviderMeta { + meta, ok := req.ProviderData.(*FrameworkProviderMeta) + + if !ok { + resp.Diagnostics.AddError( + "Unexpected DataSource Configure Type", + fmt.Sprintf( + "Expected *http.Client, got: %T. Please report this issue to the provider developers.", + req.ProviderData, + ), + ) + return nil + } + + return meta +} diff --git a/equinix/metal_ssh_key/datasource.go b/equinix/metal_ssh_key/datasource.go new file mode 100644 index 000000000..8514efa5a --- /dev/null +++ b/equinix/metal_ssh_key/datasource.go @@ -0,0 +1,61 @@ +package metal_ssh_key + +import ( + "context" + + "github.com/equinix/terraform-provider-equinix/equinix/helper" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" +) + +func NewDataSource() datasource.DataSource { + return &DataSource{ + BaseDataSource: helper.NewBaseDataSource( + helper.BaseDataSourceConfig{ + Name: "equinix_metal_ssh_key", + // do we have other than str id types? + Schema: &frameworkDataSourceSchema, + }, + ), + } +} + +type DataSource struct { + helper.BaseDataSource +} + +func (r *DataSource) Read( + ctx context.Context, + req datasource.ReadRequest, + resp *datasource.ReadResponse, +) { +} + +var frameworkDataSourceSchema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Description: "The name of the SSH key for identification", + Required: true, + }, + "public_key": schema.StringAttribute{ + Description: "The public key", + Required: true, + }, + "fingerprint": schema.StringAttribute{ + Description: "The fingerprint of the SSH key", + Computed: true, + }, + "owner_id": schema.StringAttribute{ + Description: "The UUID of the Equinix Metal API User who owns this key", + Computed: true, + }, + "created": schema.StringAttribute{ + Description: "The timestamp for when the SSH key was created", + Computed: true, + }, + "updated": schema.StringAttribute{ + Description: "The timestamp for the last time the SSH key was updated", + Computed: true, + }, + }, +} diff --git a/equinix/metal_ssh_key/resource.go b/equinix/metal_ssh_key/resource.go new file mode 100644 index 000000000..0dbcf212d --- /dev/null +++ b/equinix/metal_ssh_key/resource.go @@ -0,0 +1,118 @@ +package metal_ssh_key + +import ( + "path" + + "context" + + "github.com/equinix/terraform-provider-equinix/equinix/helper" + "github.com/hashicorp/terraform-plugin-framework/resource" + "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/types" + "github.com/packethost/packngo" +) + +type ResourceModel struct { + ID types.String `tfsdk:"id,omitempty"` + Name types.String `tfsdk:"name,omitempty"` + PublicKey types.String `tfsdk:"public_key,omitempty"` + ProjectID types.String `tfsdk:"project_id,omitempty"` + Fingerprint types.String `tfsdk:"fingerprint,omitempty"` + Updated types.String `tfsdk:"updated,omitempty"` + OwnerID types.String `tfsdk:"owner_id,omitempty"` +} + +func (rm *ResourceModel) parse(key *packngo.SSHKey) { + rm.ID = types.StringValue(key.ID) + rm.Name = types.StringValue(key.Label) + rm.PublicKey = types.StringValue(key.Key) + rm.ProjectID = types.StringValue(path.Base(key.Owner.Href)) + rm.Fingerprint = types.StringValue(key.FingerPrint) + rm.Updated = types.StringValue(key.Updated) + rm.OwnerID = types.StringValue(path.Base(key.Owner.Href)) +} + +func NewResource() resource.Resource { + return &Resource{ + BaseResource: helper.NewBaseResource( + helper.BaseResourceConfig{ + Name: "equinix_metal_ssh_key", + // do we have other than str id types? + IDType: types.StringType, + Schema: &frameworkResourceSchema, + }, + ), + } +} + +type Resource struct { + helper.BaseResource +} + +func (r *Resource) Create( + ctx context.Context, + req resource.CreateRequest, + resp *resource.CreateResponse, +) { +} + +func (r *Resource) Read( + ctx context.Context, + req resource.ReadRequest, + resp *resource.ReadResponse, +) { +} + +func (r *Resource) Update( + ctx context.Context, + req resource.UpdateRequest, + resp *resource.UpdateResponse, +) { +} + +func (r *Resource) Delete( + ctx context.Context, + req resource.DeleteRequest, + resp *resource.DeleteResponse, +) { +} + +var frameworkResourceSchema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Description: "The name of the SSH key for identification", + Required: true, + }, + "public_key": schema.StringAttribute{ + Description: "The public key", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "fingerprint": schema.StringAttribute{ + Description: "The fingerprint of the SSH key", + Computed: true, + }, + "owner_id": schema.StringAttribute{ + Description: "The UUID of the Equinix Metal API User who owns this key", + Computed: true, + }, + "created": schema.StringAttribute{ + Description: "The timestamp for when the SSH key was created", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "updated": schema.StringAttribute{ + Description: "The timestamp for the last time the SSH key was updated", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + }, +} diff --git a/go.mod b/go.mod index 560754e3d..2c53173a8 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,9 @@ require ( github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-retryablehttp v0.7.5 github.com/hashicorp/terraform-plugin-docs v0.16.0 + github.com/hashicorp/terraform-plugin-framework v1.4.1 + github.com/hashicorp/terraform-plugin-go v0.19.0 + github.com/hashicorp/terraform-plugin-mux v0.12.0 github.com/hashicorp/terraform-plugin-sdk/v2 v2.30.0 github.com/packethost/packngo v0.30.0 github.com/pkg/errors v0.9.1 @@ -64,7 +67,6 @@ require ( github.com/hashicorp/logutils v1.0.0 // indirect github.com/hashicorp/terraform-exec v0.19.0 // indirect github.com/hashicorp/terraform-json v0.17.1 // indirect - github.com/hashicorp/terraform-plugin-go v0.19.0 // indirect github.com/hashicorp/terraform-plugin-log v0.9.0 // indirect github.com/hashicorp/terraform-registry-address v0.2.2 // indirect github.com/hashicorp/terraform-svchost v0.1.1 // indirect diff --git a/go.sum b/go.sum index 317455487..ae7f1216a 100644 --- a/go.sum +++ b/go.sum @@ -197,8 +197,6 @@ github.com/Masterminds/sprig/v3 v3.2.2 h1:17jRggJu518dr3QaafizSXOjKYp94wKfABxUmy github.com/Masterminds/sprig/v3 v3.2.2/go.mod h1:UoaO7Yp8KlPnJIYWTFkMaqPUYKTfGFPhxNuwnnxkKlk= github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= -github.com/ProtonMail/go-crypto v0.0.0-20230717121422-5aa5874ade95 h1:KLq8BE0KwCL+mmXnjLWEAOYO+2l2AE4YMmqG1ZpZHBs= -github.com/ProtonMail/go-crypto v0.0.0-20230717121422-5aa5874ade95/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 h1:kkhsdkhsCvIsutKu5zLMgWtgh9YxGCNAw8Ad8hjwfYg= github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= github.com/acomagu/bufpipe v1.0.4 h1:e3H4WUzM3npvo5uv95QuJM3cQspFNtFBzvJ2oNjKIDQ= @@ -244,6 +242,7 @@ github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWH github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -260,14 +259,8 @@ github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.m github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0= github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/equinix-labs/fabric-go v0.7.0 h1:AiiVPD4aE/aeiuCK7Fhsq4bvjmJ5RzmZ3boKnp0dl4g= -github.com/equinix-labs/fabric-go v0.7.0/go.mod h1:oqgGS3GOI8hHGPJKsAwDOEX0qRHl52sJGvwA/zMSd90= github.com/equinix-labs/fabric-go v0.7.1 h1:4yk0IKXMcc72rkRVbcYHokAEc1uUB06t6NXK+DtSsbs= github.com/equinix-labs/fabric-go v0.7.1/go.mod h1:oqgGS3GOI8hHGPJKsAwDOEX0qRHl52sJGvwA/zMSd90= -github.com/equinix-labs/metal-go v0.25.1 h1:uL83lRKyAcOfab+9r2xujAuLD8lTsqv89+SPvVFkcBM= -github.com/equinix-labs/metal-go v0.25.1/go.mod h1:SmxCklxW+KjmBLVMdEXgtFO5gD5/b4N0VxcNgUYbOH4= -github.com/equinix-labs/metal-go v0.26.0 h1:0rBTyjF8j58dg++kMFLRi9Jhs5gng5BFn5Y0bl5NPtM= -github.com/equinix-labs/metal-go v0.26.0/go.mod h1:SmxCklxW+KjmBLVMdEXgtFO5gD5/b4N0VxcNgUYbOH4= github.com/equinix-labs/metal-go v0.27.0 h1:p5Bqus/gSs5oQezHWXWpc0IzkQl06+yZgbXT5jB7AWs= github.com/equinix-labs/metal-go v0.27.0/go.mod h1:SmxCklxW+KjmBLVMdEXgtFO5gD5/b4N0VxcNgUYbOH4= github.com/equinix/ecx-go/v2 v2.3.1 h1:gFcAIeyaEUw7S8ebqApmT7E/S7pC7Ac3wgScp89fkPU= @@ -284,8 +277,8 @@ github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYF github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= -github.com/go-git/go-billy/v5 v5.4.1 h1:Uwp5tDRkPr+l/TnbHOQzp+tmJfLceOlbVucgpTz8ix4= -github.com/go-git/go-git/v5 v5.8.1 h1:Zo79E4p7TRk0xoRgMq0RShiTHGKcKI4+DI6BfJc/Q+A= +github.com/go-git/go-billy/v5 v5.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU= +github.com/go-git/go-git/v5 v5.9.0 h1:cD9SFA7sHVRdJ7AYck1ZaAa/yeuBvGPxwXDL8cxrObY= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= @@ -346,7 +339,6 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= @@ -417,8 +409,6 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-plugin v1.5.1 h1:oGm7cWBaYIp3lJpx1RUEfLWophprE2EV/KUeqBYo+6k= github.com/hashicorp/go-plugin v1.5.1/go.mod h1:w1sAEES3g3PuV/RzUrgow20W2uErMly84hhD3um1WL4= -github.com/hashicorp/go-retryablehttp v0.7.4 h1:ZQgVdpTdAL7WpMIwLzCfbalOcSUdkDZnpUv3/+BxzFA= -github.com/hashicorp/go-retryablehttp v0.7.4/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8= github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT9yvm0e+Nd5M= github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8= github.com/hashicorp/go-safetemp v1.0.0 h1:2HR189eFNrjHQyENnQMMpCiBAsRxzbTMIgBhEyExpmo= @@ -430,13 +420,9 @@ github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mO github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/hc-install v0.6.0 h1:fDHnU7JNFNSQebVKYhHZ0va1bC6SrPQ8fpebsvNr2w4= -github.com/hashicorp/hc-install v0.6.0/go.mod h1:10I912u3nntx9Umo1VAeYPUUuehk0aRQJYpMwbX5wQA= github.com/hashicorp/hc-install v0.6.1 h1:IGxShH7AVhPaSuSJpKtVi/EFORNjO+OYVJJrAtGG2mY= github.com/hashicorp/hc-install v0.6.1/go.mod h1:0fW3jpg+wraYSnFDJ6Rlie3RvLf1bIqVIkzoon4KoVE= github.com/hashicorp/hcl/v2 v2.9.1/go.mod h1:FwWsfWEjyV/CMj8s/gqAuiviY72rJ1/oayI9WftqcKg= -github.com/hashicorp/hcl/v2 v2.18.0 h1:wYnG7Lt31t2zYkcquwgKo6MWXzRUDIeIVU5naZwHLl8= -github.com/hashicorp/hcl/v2 v2.18.0/go.mod h1:ThLC89FV4p9MPW804KVbe/cEXoQ8NZEh+JtMeeGErHE= github.com/hashicorp/hcl/v2 v2.19.1 h1://i05Jqznmb2EXqa39Nsvyan2o5XyMowW5fnCKW5RPI= github.com/hashicorp/hcl/v2 v2.19.1/go.mod h1:ThLC89FV4p9MPW804KVbe/cEXoQ8NZEh+JtMeeGErHE= github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI65Y= @@ -445,15 +431,16 @@ github.com/hashicorp/terraform-exec v0.19.0 h1:FpqZ6n50Tk95mItTSS9BjeOVUb4eg81Sp github.com/hashicorp/terraform-exec v0.19.0/go.mod h1:tbxUpe3JKruE9Cuf65mycSIT8KiNPZ0FkuTE3H4urQg= github.com/hashicorp/terraform-json v0.17.1 h1:eMfvh/uWggKmY7Pmb3T85u86E2EQg6EQHgyRwf3RkyA= github.com/hashicorp/terraform-json v0.17.1/go.mod h1:Huy6zt6euxaY9knPAFKjUITn8QxUFIe9VuSzb4zn/0o= -github.com/hashicorp/terraform-plugin-docs v0.14.1 h1:MikFi59KxrP/ewrZoaowrB9he5Vu4FtvhamZFustiA4= -github.com/hashicorp/terraform-plugin-docs v0.14.1/go.mod h1:k2NW8+t113jAus6bb5tQYQgEAX/KueE/u8X2Z45V1GM= +github.com/hashicorp/terraform-plugin-docs v0.16.0 h1:UmxFr3AScl6Wged84jndJIfFccGyBZn52KtMNsS12dI= github.com/hashicorp/terraform-plugin-docs v0.16.0/go.mod h1:M3ZrlKBJAbPMtNOPwHicGi1c+hZUh7/g0ifT/z7TVfA= +github.com/hashicorp/terraform-plugin-framework v1.4.1 h1:ZC29MoB3Nbov6axHdgPbMz7799pT5H8kIrM8YAsaVrs= +github.com/hashicorp/terraform-plugin-framework v1.4.1/go.mod h1:XC0hPcQbBvlbxwmjxuV/8sn8SbZRg4XwGMs22f+kqV0= github.com/hashicorp/terraform-plugin-go v0.19.0 h1:BuZx/6Cp+lkmiG0cOBk6Zps0Cb2tmqQpDM3iAtnhDQU= github.com/hashicorp/terraform-plugin-go v0.19.0/go.mod h1:EhRSkEPNoylLQntYsk5KrDHTZJh9HQoumZXbOGOXmec= github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0= github.com/hashicorp/terraform-plugin-log v0.9.0/go.mod h1:rKL8egZQ/eXSyDqzLUuwUYLVdlYeamldAHSxjUFADow= -github.com/hashicorp/terraform-plugin-sdk/v2 v2.29.0 h1:wcOKYwPI9IorAJEBLzgclh3xVolO7ZorYd6U1vnok14= -github.com/hashicorp/terraform-plugin-sdk/v2 v2.29.0/go.mod h1:qH/34G25Ugdj5FcM95cSoXzUgIbgfhVLXCcEcYaMwq8= +github.com/hashicorp/terraform-plugin-mux v0.12.0 h1:TJlmeslQ11WlQtIFAfth0vXx+gSNgvMEng2Rn9z3WZY= +github.com/hashicorp/terraform-plugin-mux v0.12.0/go.mod h1:8MR0AgmV+Q03DIjyrAKxXyYlq2EUnYBQP8gxAAA0zeM= github.com/hashicorp/terraform-plugin-sdk/v2 v2.30.0 h1:X7vB6vn5tON2b49ILa4W7mFAsndeqJ7bZFOGbVO+0Cc= github.com/hashicorp/terraform-plugin-sdk/v2 v2.30.0/go.mod h1:ydFcxbdj6klCqYEPkPvdvFKiNGKZLUs+896ODUXCyao= github.com/hashicorp/terraform-registry-address v0.2.2 h1:lPQBg403El8PPicg/qONZJDC6YlgCVbWDtNmmZKtBno= @@ -597,8 +584,6 @@ github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5t github.com/zclconf/go-cty v1.2.0/go.mod h1:hOPWgoHbaTUnI5k4D2ld+GRpFJSCe6bCM7m1q/N4PQ8= github.com/zclconf/go-cty v1.8.0/go.mod h1:vVKLxnk3puL4qRAv72AO+W99LUD4da90g3uUAzyuvAk= github.com/zclconf/go-cty v1.8.1/go.mod h1:vVKLxnk3puL4qRAv72AO+W99LUD4da90g3uUAzyuvAk= -github.com/zclconf/go-cty v1.14.0 h1:/Xrd39K7DXbHzlisFP9c4pHao4yyf+/Ug9LEz+Y/yhc= -github.com/zclconf/go-cty v1.14.0/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= github.com/zclconf/go-cty v1.14.1 h1:t9fyA35fwjjUMcmL5hLER+e/rEPqrbCK1/OSE4SI9KA= github.com/zclconf/go-cty v1.14.1/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= github.com/zclconf/go-cty-debug v0.0.0-20191215020915-b22d67c1ba0b/go.mod h1:ZRKQfBXbGkpdV6QMzT3rU1kSTAnfu1dO8dPKjYprgj8= @@ -625,8 +610,6 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20220314234659-1baeb1ce4c0b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= -golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= -golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/crypto v0.15.0 h1:frVn1TEaCEaZcn3Tmd7Y2b5KKPaZ+I32Q2OA3kYp5TA= golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -639,8 +622,6 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0 golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= -golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 h1:k/i9J1pBpvlfR+9QsetwPyERsqu1GIbi967PQMq3Ivc= -golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df h1:UA2aFVmmsIlefxMk29Dp2juaUSth8Pyn3Tq5Y5mJGME= golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= @@ -670,8 +651,6 @@ golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= -golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.13.0 h1:I/DsJXRlw/8l/0c24sM9yb0T4z9liZTduXvdAWYiysY= golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -727,8 +706,6 @@ golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= -golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= -golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/net v0.18.0 h1:mIYleuAkSbHh0tCv7RvjL3F6ZVbLjq4+R7zbOn3Kokg= golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -756,8 +733,6 @@ golang.org/x/oauth2 v0.0.0-20220822191816-0ebed06d0094/go.mod h1:h4gKUeWbJ4rQPri golang.org/x/oauth2 v0.0.0-20220909003341-f21342109be1/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg= golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg= golang.org/x/oauth2 v0.1.0/go.mod h1:G9FE4dLTsbXUu90h/Pf85g4w1D+SSAgR+q46nJZ8M4A= -golang.org/x/oauth2 v0.13.0 h1:jDDenyj+WgFtmV3zYVoi8aE2BwtXFLWOA67ZfNWftiY= -golang.org/x/oauth2 v0.13.0/go.mod h1:/JMhi4ZRXAf4HG9LiNmxvk+45+96RUlVThiH8FzNBn0= golang.org/x/oauth2 v0.14.0 h1:P0Vrf/2538nmC0H+pEQ3MNFRRnVR7RlqyVw+bvm26z0= golang.org/x/oauth2 v0.14.0/go.mod h1:lAtNWgaWfL4cm7j2OV8TxGi9Qb7ECORx8DktCY74OwM= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -848,8 +823,6 @@ golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= -golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q= golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -858,7 +831,7 @@ golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= -golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek= +golang.org/x/term v0.14.0 h1:LGK9IlZ8T9jvdy6cTdfKUCltatMFOehAQo9SRC46UQ8= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -872,8 +845,6 @@ golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= -golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -931,8 +902,8 @@ golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -1142,8 +1113,6 @@ google.golang.org/grpc v1.48.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACu google.golang.org/grpc v1.49.0/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI= google.golang.org/grpc v1.50.0/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI= google.golang.org/grpc v1.50.1/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI= -google.golang.org/grpc v1.57.0 h1:kfzNeI/klCGD2YPMUlaGNT3pxvYfga7smW3Vth8Zsiw= -google.golang.org/grpc v1.57.0/go.mod h1:Sd+9RMTACXwmub0zcNY2c4arhtrbBYD1AUHI/dt16Mo= google.golang.org/grpc v1.57.1 h1:upNTNqv0ES+2ZOOqACwVtS3Il8M12/+Hz41RCPzAjQg= google.golang.org/grpc v1.57.1/go.mod h1:Sd+9RMTACXwmub0zcNY2c4arhtrbBYD1AUHI/dt16Mo= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= diff --git a/main.go b/main.go index e80a0b96a..9d46c4983 100644 --- a/main.go +++ b/main.go @@ -6,24 +6,50 @@ import ( "log" "github.com/equinix/terraform-provider-equinix/equinix" - "github.com/hashicorp/terraform-plugin-sdk/v2/plugin" + "github.com/equinix/terraform-provider-equinix/version" + + "github.com/hashicorp/terraform-plugin-framework/providerserver" + "github.com/hashicorp/terraform-plugin-go/tfprotov5" + "github.com/hashicorp/terraform-plugin-go/tfprotov5/tf5server" + "github.com/hashicorp/terraform-plugin-mux/tf5muxserver" ) //go:generate go run github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs func main() { + + ctx := context.Background() + var debugMode bool flag.BoolVar(&debugMode, "debug", false, "set to true to run the provider with support for debuggers like delve") flag.Parse() - opts := &plugin.ServeOpts{ProviderFunc: equinix.Provider} + + providers := []func() tfprotov5.ProviderServer{ + providerserver.NewProtocol5( + equinix.CreateFrameworkProvider(version.ProviderVersion)), + equinix.Provider().GRPCProvider, + } + + muxServer, err := tf5muxserver.NewMuxServer(ctx, providers...) + if err != nil { + log.Fatal(err) + } + + var serveOpts []tf5server.ServeOpt if debugMode { - err := plugin.Debug(context.Background(), "registry.terraform.io/equinix/equinix", opts) - if err != nil { - log.Fatal(err.Error()) - } - return + serveOpts = append(serveOpts, tf5server.WithManagedDebug()) + } + + err = tf5server.Serve( + "registry.terraform.io/equnix/equinix", + muxServer.ProviderServer, + serveOpts..., + ) + + if err != nil { + log.Fatal(err) } - plugin.Serve(opts) + //plugin.Serve(opts) } From 65369b664f09720067ff895e7b3608e4cfcc6f15 Mon Sep 17 00:00:00 2001 From: ocobleseqx Date: Wed, 15 Nov 2023 16:00:33 +0100 Subject: [PATCH 03/26] merge Signed-off-by: ocobleseqx --- equinix/helper/framework_provider_model.go | 23 --- equinix/helper/resource_datasource_config.go | 48 ------ go.mod | 1 + go.sum | 2 + internal/config/config.go | 12 +- internal/fabric_utils/correlation_id.go | 24 --- {equinix => internal}/framework_provider.go | 24 ++- internal/framework_provider_config.go | 154 ++++++++++++++++++ .../helper/framework_datasource_base.go | 29 +++- .../helper/framework_resource_base.go | 45 ++--- .../metal_ssh_key/datasource.go | 3 +- .../metal_ssh_key/resource.go | 28 +++- main.go | 3 +- 13 files changed, 261 insertions(+), 135 deletions(-) delete mode 100644 equinix/helper/framework_provider_model.go delete mode 100644 equinix/helper/resource_datasource_config.go delete mode 100644 internal/fabric_utils/correlation_id.go rename {equinix => internal}/framework_provider.go (75%) create mode 100644 internal/framework_provider_config.go rename {equinix => internal}/helper/framework_datasource_base.go (74%) rename {equinix => internal}/helper/framework_resource_base.go (82%) rename {equinix => internal}/metal_ssh_key/datasource.go (95%) rename {equinix => internal}/metal_ssh_key/resource.go (84%) diff --git a/equinix/helper/framework_provider_model.go b/equinix/helper/framework_provider_model.go deleted file mode 100644 index fb1eaa73e..000000000 --- a/equinix/helper/framework_provider_model.go +++ /dev/null @@ -1,23 +0,0 @@ -package helper - -import ( - "github.com/hashicorp/terraform-plugin-framework/types" - "github.com/packethost/packngo" -) - -type FrameworkProviderModel struct { - Endpoint types.String `tfsdk:"endpoint,omitempty"` - ClientID types.String `tfsdk:"client_id,omitempty"` - ClientSecret types.String `tfsdk:"client_secret,omitempty"` - Token types.String `tfsdk:"token,omitempty"` - AuthToken types.String `tfsdk:"auth_token,omitempty"` - RequestTimeout types.Int64 `tfsdk:"request_timeout,omitempty"` - ResponseMaxPageSize types.Int64 `tfsdk:"response_max_page_size,omitempty"` - MaxRetries types.Int64 `tfsdk:"max_retries,omitempty"` - MaxRetryWaitSeconds types.Int64 `tfsdk:"max_retry_wait_seconds,omitempty"` -} - -type FrameworkProviderMeta struct { - Client *packngo.Client - Config *FrameworkProviderModel -} diff --git a/equinix/helper/resource_datasource_config.go b/equinix/helper/resource_datasource_config.go deleted file mode 100644 index bf97a0c2d..000000000 --- a/equinix/helper/resource_datasource_config.go +++ /dev/null @@ -1,48 +0,0 @@ -package helper - -import ( - "fmt" - - "github.com/hashicorp/terraform-plugin-framework/datasource" - "github.com/hashicorp/terraform-plugin-framework/resource" -) - -func GetResourceMeta( - req resource.ConfigureRequest, - resp *resource.ConfigureResponse, -) *FrameworkProviderMeta { - meta, ok := req.ProviderData.(*FrameworkProviderMeta) - - if !ok { - resp.Diagnostics.AddError( - "Unexpected Resource Configure Type", - fmt.Sprintf( - "Expected *http.Client, got: %T. Please report this issue to the provider developers.", - req.ProviderData, - ), - ) - return nil - } - - return meta -} - -func GetDataSourceMeta( - req datasource.ConfigureRequest, - resp *datasource.ConfigureResponse, -) *FrameworkProviderMeta { - meta, ok := req.ProviderData.(*FrameworkProviderMeta) - - if !ok { - resp.Diagnostics.AddError( - "Unexpected DataSource Configure Type", - fmt.Sprintf( - "Expected *http.Client, got: %T. Please report this issue to the provider developers.", - req.ProviderData, - ), - ) - return nil - } - - return meta -} diff --git a/go.mod b/go.mod index 2c53173a8..4bfaa5f2d 100644 --- a/go.mod +++ b/go.mod @@ -67,6 +67,7 @@ require ( github.com/hashicorp/logutils v1.0.0 // indirect github.com/hashicorp/terraform-exec v0.19.0 // indirect github.com/hashicorp/terraform-json v0.17.1 // indirect + github.com/hashicorp/terraform-plugin-framework-validators v0.12.0 github.com/hashicorp/terraform-plugin-log v0.9.0 // indirect github.com/hashicorp/terraform-registry-address v0.2.2 // indirect github.com/hashicorp/terraform-svchost v0.1.1 // indirect diff --git a/go.sum b/go.sum index ae7f1216a..1dbeac364 100644 --- a/go.sum +++ b/go.sum @@ -435,6 +435,8 @@ github.com/hashicorp/terraform-plugin-docs v0.16.0 h1:UmxFr3AScl6Wged84jndJIfFcc github.com/hashicorp/terraform-plugin-docs v0.16.0/go.mod h1:M3ZrlKBJAbPMtNOPwHicGi1c+hZUh7/g0ifT/z7TVfA= github.com/hashicorp/terraform-plugin-framework v1.4.1 h1:ZC29MoB3Nbov6axHdgPbMz7799pT5H8kIrM8YAsaVrs= github.com/hashicorp/terraform-plugin-framework v1.4.1/go.mod h1:XC0hPcQbBvlbxwmjxuV/8sn8SbZRg4XwGMs22f+kqV0= +github.com/hashicorp/terraform-plugin-framework-validators v0.12.0 h1:HOjBuMbOEzl7snOdOoUfE2Jgeto6JOjLVQ39Ls2nksc= +github.com/hashicorp/terraform-plugin-framework-validators v0.12.0/go.mod h1:jfHGE/gzjxYz6XoUwi/aYiiKrJDeutQNUtGQXkaHklg= github.com/hashicorp/terraform-plugin-go v0.19.0 h1:BuZx/6Cp+lkmiG0cOBk6Zps0Cb2tmqQpDM3iAtnhDQU= github.com/hashicorp/terraform-plugin-go v0.19.0/go.mod h1:EhRSkEPNoylLQntYsk5KrDHTZJh9HQoumZXbOGOXmec= github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0= diff --git a/internal/config/config.go b/internal/config/config.go index c86c9b60e..f14cd9a71 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,7 +19,6 @@ import ( "github.com/equinix/ecx-go/v2" "github.com/equinix/ne-go" "github.com/equinix/oauth2-go" - "github.com/equinix/terraform-provider-equinix/internal/fabric_utils" "github.com/equinix/terraform-provider-equinix/version" "github.com/hashicorp/go-retryablehttp" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/logging" @@ -78,6 +77,15 @@ var ( redirectsErrorRe = regexp.MustCompile(`stopped after \d+ redirects\z`) ) +const ( + EndpointEnvVar = "EQUINIX_API_ENDPOINT" + ClientIDEnvVar = "EQUINIX_API_CLIENTID" + ClientSecretEnvVar = "EQUINIX_API_CLIENTSECRET" + ClientTokenEnvVar = "EQUINIX_API_TOKEN" + ClientTimeoutEnvVar = "EQUINIX_API_TIMEOUT" + MetalAuthTokenEnvVar = "METAL_AUTH_TOKEN" +) + // Config is the configuration structure used to instantiate the Equinix // provider. type Config struct { @@ -187,7 +195,7 @@ func (c *Config) NewFabricClient() *v4.APIClient { authClient.Timeout = c.requestTimeout() fabricHeaderMap := map[string]string{ "X-SOURCE": "API", - "X-CORRELATION-ID": fabric_utils.CorrelationId(25), + "X-CORRELATION-ID": CorrelationId(25), } v4Configuration := v4.Configuration{ BasePath: c.BaseURL, diff --git a/internal/fabric_utils/correlation_id.go b/internal/fabric_utils/correlation_id.go deleted file mode 100644 index 64621f687..000000000 --- a/internal/fabric_utils/correlation_id.go +++ /dev/null @@ -1,24 +0,0 @@ -package fabric_utils - -import ( - "math/rand" - "time" -) - -const allowed_charset = "abcdefghijklmnopqrstuvwxyz" + - "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#$&@" - -var seededRand = rand.New( - rand.NewSource(time.Now().UnixNano())) - -func CorrelationIdWithCharset(length int, charset string) string { - b := make([]byte, length) - for i := range b { - b[i] = charset[seededRand.Intn(len(charset))] - } - return string(b) -} - -func CorrelationId(length int) string { - return CorrelationIdWithCharset(length, allowed_charset) -} diff --git a/equinix/framework_provider.go b/internal/framework_provider.go similarity index 75% rename from equinix/framework_provider.go rename to internal/framework_provider.go index 256d95266..f24829e9a 100644 --- a/equinix/framework_provider.go +++ b/internal/framework_provider.go @@ -1,20 +1,22 @@ -package equinix +package internal import ( "context" "fmt" - "github.com/equinix/terraform-provider-equinix/equinix/helper" - "github.com/equinix/terraform-provider-equinix/equinix/metal_ssh_key" + "github.com/equinix/terraform-provider-equinix/internal/config" + "github.com/equinix/terraform-provider-equinix/internal/metal_ssh_key" + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/provider/schema" "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" ) type FrameworkProvider struct { ProviderVersion string - Meta *helper.FrameworkProviderMeta + Meta *config.Config } func CreateFrameworkProvider(version string) provider.Provider { @@ -40,7 +42,11 @@ func (p *FrameworkProvider) Schema( Attributes: map[string]schema.Attribute{ "endpoint": schema.StringAttribute{ Optional: true, - Description: "The Equinix API base URL to point out desired environment. Defaults to " + DefaultBaseURL, + Description: "The Equinix API base URL to point out desired environment. Defaults to " + config.DefaultBaseURL, + // TODO: + // Add Validator for url with scheme. It's hard to find where they moved url + // particualr validator to, if in even exist in the TF golang codebase. + // Select and add validators for other attributes too. }, "client_id": schema.StringAttribute{ Optional: true, @@ -60,11 +66,17 @@ func (p *FrameworkProvider) Schema( }, "request_timeout": schema.Int64Attribute{ Optional: true, - Description: "The duration of time, in seconds, that the Equinix Platform API Client should wait before canceling an API request. Defaults to " + fmt.Sprint(DefaultTimeout), + Description: "The duration of time, in seconds, that the Equinix Platform API Client should wait before canceling an API request. Defaults to " + fmt.Sprint(config.DefaultTimeout), + Validators: []validator.Int64{ + int64validator.AtLeast(1), + }, }, "response_max_page_size": schema.Int64Attribute{ Optional: true, Description: "The maximum number of records in a single response for REST queries that produce paginated responses", + Validators: []validator.Int64{ + int64validator.AtLeast(100), + }, }, "max_retries": schema.Int64Attribute{ Optional: true, diff --git a/internal/framework_provider_config.go b/internal/framework_provider_config.go new file mode 100644 index 000000000..179ff08d5 --- /dev/null +++ b/internal/framework_provider_config.go @@ -0,0 +1,154 @@ +package internal + +import ( + "context" + "fmt" + "os" + "strconv" + "time" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/equinix/terraform-provider-equinix/internal/config" +) + +type FrameworkProviderConfig struct { + BaseURL types.String `tfsdk:"endpoint"` + ClientID types.String `tfsdk:"client_id"` + ClientSecret types.String `tfsdk:"client_secret"` + Token types.String `tfsdk:"token"` + AuthToken types.String `tfsdk:"auth_token"` + RequestTimeout types.Int64 `tfsdk:"request_timeout"` + PageSize types.Int64 `tfsdk:"response_max_page_size"` + MaxRetries types.Int64 `tfsdk:"max_retries"` + MaxRetryWaitSeconds types.Int64 `tfsdk:"max_retry_wait_seconds"` +} + +func (c *FrameworkProviderConfig) toOldStyleConfig() *config.Config { + // this immitates func configureProvider in proivder.go + return &config.Config{ + AuthToken: c.AuthToken.ValueString(), + BaseURL: c.BaseURL.ValueString(), + ClientID: c.ClientID.ValueString(), + ClientSecret: c.ClientSecret.ValueString(), + Token: c.Token.ValueString(), + RequestTimeout: time.Duration(c.RequestTimeout.ValueInt64()) * time.Second, + PageSize: int(c.PageSize.ValueInt64()), + MaxRetries: int(c.MaxRetries.ValueInt64()), + MaxRetryWait: time.Duration(c.MaxRetryWaitSeconds.ValueInt64()) * time.Second, + } +} + +func (fp *FrameworkProvider) Configure( + ctx context.Context, + req provider.ConfigureRequest, + resp *provider.ConfigureResponse, +) { + var fpc FrameworkProviderConfig + + // This call reads the configuration from the provider block in the + // Terraform configuration to the FrameworkProviderConfig struct (config) + resp.Diagnostics.Append(req.Config.Get(ctx, &fpc)...) + if resp.Diagnostics.HasError() { + return + } + + // We need to supply values from envvar and defaults, because framework + // provider does not support loading from envvar and defaults :/. + // (it can validate though) + + // this immitates func Provider() *schema.Provider from provider.go + + fpc.BaseURL = determineStrConfValue( + fpc.BaseURL, config.EndpointEnvVar, config.DefaultBaseURL) + + fpc.ClientID = determineStrConfValue( + fpc.ClientID, config.ClientIDEnvVar, "") + + fpc.ClientSecret = determineStrConfValue( + fpc.ClientSecret, config.ClientSecretEnvVar, "") + + fpc.Token = determineStrConfValue( + fpc.Token, config.ClientTokenEnvVar, "") + + fpc.AuthToken = determineStrConfValue( + fpc.AuthToken, config.MetalAuthTokenEnvVar, "") + + fpc.RequestTimeout = determineIntConfValue( + fpc.RequestTimeout, config.ClientTimeoutEnvVar, int64(config.DefaultTimeout), &resp.Diagnostics) + + fpc.MaxRetries = determineIntConfValue( + fpc.MaxRetries, "", 10, &resp.Diagnostics) + + fpc.MaxRetryWaitSeconds = determineIntConfValue( + fpc.MaxRetryWaitSeconds, "", 30, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + oldStyleConfig := fpc.toOldStyleConfig() + err := oldStyleConfig.Load(ctx) + if err != nil { + resp.Diagnostics.AddError( + "Failed to load provider configuration", + err.Error(), + ) + return + } + resp.ResourceData = oldStyleConfig + resp.DataSourceData = oldStyleConfig + + fp.Meta = oldStyleConfig +} + +func GetIntFromEnv( + key string, + defaultValue int64, + diags *diag.Diagnostics, +) int64 { + if key == "" { + return defaultValue + } + envVarVal := os.Getenv(key) + if envVarVal == "" { + return defaultValue + } + + intVal, err := strconv.ParseInt(envVarVal, 10, 64) + if err != nil { + diags.AddWarning( + fmt.Sprintf( + "Failed to parse the environment variable %v "+ + "to an integer. Will use default value: %d instead", + key, + defaultValue, + ), + err.Error(), + ) + return defaultValue + } + + return intVal +} + +func determineIntConfValue(v basetypes.Int64Value, envVar string, defaultValue int64, diags *diag.Diagnostics) basetypes.Int64Value { + if !v.IsNull() { + return v + } + return types.Int64Value(GetIntFromEnv(envVar, defaultValue, diags)) +} + +func determineStrConfValue(v basetypes.StringValue, envVar, defaultValue string) basetypes.StringValue { + if !v.IsNull() { + return v + } + returnVal := os.Getenv(envVar) + + if returnVal == "" { + returnVal = defaultValue + } + + return types.StringValue(returnVal) +} diff --git a/equinix/helper/framework_datasource_base.go b/internal/helper/framework_datasource_base.go similarity index 74% rename from equinix/helper/framework_datasource_base.go rename to internal/helper/framework_datasource_base.go index 8d38d66bf..909a3ebce 100644 --- a/equinix/helper/framework_datasource_base.go +++ b/internal/helper/framework_datasource_base.go @@ -2,11 +2,33 @@ package helper import ( "context" - + "fmt" + "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/equinix/terraform-provider-equinix/internal/config" ) +func GetDataSourceMeta( + req datasource.ConfigureRequest, + resp *datasource.ConfigureResponse, +) *config.Config { + meta, ok := req.ProviderData.(*config.Config) + + if !ok { + resp.Diagnostics.AddError( + "Unexpected DataSource Configure Type", + fmt.Sprintf( + "Expected *http.Client, got: %T. Please report this issue to the provider developers.", + req.ProviderData, + ), + ) + return nil + } + + return meta +} + // NewBaseDataSource returns a new instance of the BaseDataSource // struct for cleaner initialization. func NewBaseDataSource(cfg BaseDataSourceConfig) BaseDataSource { @@ -20,15 +42,14 @@ type BaseDataSourceConfig struct { Name string // Optional - Schema *schema.Schema - IsEarlyAccess bool + Schema *schema.Schema } // BaseDataSource contains various re-usable fields and methods // intended for use in data source implementations by composition. type BaseDataSource struct { Config BaseDataSourceConfig - Meta *FrameworkProviderMeta + Meta *config.Config } func (r *BaseDataSource) Configure( diff --git a/equinix/helper/framework_resource_base.go b/internal/helper/framework_resource_base.go similarity index 82% rename from equinix/helper/framework_resource_base.go rename to internal/helper/framework_resource_base.go index 6dc95148a..0945f31b7 100644 --- a/equinix/helper/framework_resource_base.go +++ b/internal/helper/framework_resource_base.go @@ -3,15 +3,33 @@ package helper import ( "context" "fmt" - "strconv" - "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" - "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/equinix/terraform-provider-equinix/internal/config" ) +func GetResourceMeta( + req resource.ConfigureRequest, + resp *resource.ConfigureResponse, +) *config.Config { + meta, ok := req.ProviderData.(*config.Config) + + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf( + "Expected *http.Client, got: %T. Please report this issue to the provider developers.", + req.ProviderData, + ), + ) + return nil + } + + return meta +} + // NewBaseResource returns a new instance of the BaseResource // struct for cleaner initialization. func NewBaseResource(cfg BaseResourceConfig) BaseResource { @@ -24,18 +42,16 @@ func NewBaseResource(cfg BaseResourceConfig) BaseResource { type BaseResourceConfig struct { Name string IDAttr string - IDType attr.Type // Optional - Schema *schema.Schema - IsEarlyAccess bool + Schema *schema.Schema } // BaseResource contains various re-usable fields and methods // intended for use in resource implementations by composition. type BaseResource struct { Config BaseResourceConfig - Meta *FrameworkProviderMeta + Meta *config.Config } func (r *BaseResource) Configure( @@ -92,11 +108,6 @@ func (r *BaseResource) ImportState( idAttr = "id" } - idType := r.Config.IDType - if idType == nil { - idType = types.Int64Type - } - attrPath := path.Root(idAttr) if attrPath.Equal(path.Empty()) { @@ -112,14 +123,8 @@ func (r *BaseResource) ImportState( var err error var idValue any - switch idType { - case types.Int64Type: - idValue, err = strconv.ParseInt(req.ID, 10, 64) - case types.StringType: - idValue = req.ID - default: - err = fmt.Errorf("unsupported id attribute type: %v", idType) - } + idValue = req.ID + if err != nil { resp.Diagnostics.AddError( "Failed to convert ID attribute", diff --git a/equinix/metal_ssh_key/datasource.go b/internal/metal_ssh_key/datasource.go similarity index 95% rename from equinix/metal_ssh_key/datasource.go rename to internal/metal_ssh_key/datasource.go index 8514efa5a..7e967368d 100644 --- a/equinix/metal_ssh_key/datasource.go +++ b/internal/metal_ssh_key/datasource.go @@ -3,9 +3,10 @@ package metal_ssh_key import ( "context" - "github.com/equinix/terraform-provider-equinix/equinix/helper" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/equinix/terraform-provider-equinix/internal/helper" + ) func NewDataSource() datasource.DataSource { diff --git a/equinix/metal_ssh_key/resource.go b/internal/metal_ssh_key/resource.go similarity index 84% rename from equinix/metal_ssh_key/resource.go rename to internal/metal_ssh_key/resource.go index 0dbcf212d..0d245025a 100644 --- a/equinix/metal_ssh_key/resource.go +++ b/internal/metal_ssh_key/resource.go @@ -5,17 +5,16 @@ import ( "context" - "github.com/equinix/terraform-provider-equinix/equinix/helper" "github.com/hashicorp/terraform-plugin-framework/resource" "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/types" "github.com/packethost/packngo" + "github.com/equinix/terraform-provider-equinix/internal/helper" ) type ResourceModel struct { - ID types.String `tfsdk:"id,omitempty"` Name types.String `tfsdk:"name,omitempty"` PublicKey types.String `tfsdk:"public_key,omitempty"` ProjectID types.String `tfsdk:"project_id,omitempty"` @@ -25,7 +24,6 @@ type ResourceModel struct { } func (rm *ResourceModel) parse(key *packngo.SSHKey) { - rm.ID = types.StringValue(key.ID) rm.Name = types.StringValue(key.Label) rm.PublicKey = types.StringValue(key.Key) rm.ProjectID = types.StringValue(path.Base(key.Owner.Href)) @@ -38,9 +36,7 @@ func NewResource() resource.Resource { return &Resource{ BaseResource: helper.NewBaseResource( helper.BaseResourceConfig{ - Name: "equinix_metal_ssh_key", - // do we have other than str id types? - IDType: types.StringType, + Name: "equinix_metal_ssh_key", Schema: &frameworkResourceSchema, }, ), @@ -56,6 +52,26 @@ func (r *Resource) Create( req resource.CreateRequest, resp *resource.CreateResponse, ) { + client := meta.(*Config).metal + + createRequest := &packngo.SSHKeyCreateRequest{ + Label: d.Get("name").(string), + Key: d.Get("public_key").(string), + } + + projectID, isProjectKey := d.GetOk("project_id") + if isProjectKey { + createRequest.ProjectID = projectID.(string) + } + + key, _, err := client.SSHKeys.Create(createRequest) + if err != nil { + return friendlyError(err) + } + + d.SetId(key.ID) + + return resourceMetalSSHKeyRead(d, meta) } func (r *Resource) Read( diff --git a/main.go b/main.go index 9d46c4983..3b7a13335 100644 --- a/main.go +++ b/main.go @@ -6,6 +6,7 @@ import ( "log" "github.com/equinix/terraform-provider-equinix/equinix" + "github.com/equinix/terraform-provider-equinix/internal" "github.com/equinix/terraform-provider-equinix/version" "github.com/hashicorp/terraform-plugin-framework/providerserver" @@ -26,7 +27,7 @@ func main() { providers := []func() tfprotov5.ProviderServer{ providerserver.NewProtocol5( - equinix.CreateFrameworkProvider(version.ProviderVersion)), + internal.CreateFrameworkProvider(version.ProviderVersion)), equinix.Provider().GRPCProvider, } From 468657a8aab4454573107f95e8e19a6626109ced Mon Sep 17 00:00:00 2001 From: ocobleseqx Date: Thu, 16 Nov 2023 16:30:43 +0100 Subject: [PATCH 04/26] metal_ssh_key create func Signed-off-by: ocobleseqx --- internal/helper/errors.go | 157 +++++++++++++++++++++++++++++ internal/metal_ssh_key/resource.go | 110 ++++++++++++++++---- 2 files changed, 246 insertions(+), 21 deletions(-) create mode 100644 internal/helper/errors.go diff --git a/internal/helper/errors.go b/internal/helper/errors.go new file mode 100644 index 000000000..5536d1ee4 --- /dev/null +++ b/internal/helper/errors.go @@ -0,0 +1,157 @@ +package helper + +import ( + "fmt" + "net/http" + "strings" + + "github.com/packethost/packngo" +) + +// friendlyError improves error messages when the API error is blank or in an +// alternate format (as is the case with invalid token or loadbalancer errors) +func FriendlyError(err error) error { + if e, ok := err.(*packngo.ErrorResponse); ok { + resp := e.Response + errors := Errors(e.Errors) + + if len(errors) == 0 { + errors = Errors{e.SingleError} + } + + return convertToFriendlyError(errors, resp) + } + return err +} + +func FriendlyErrorForMetalGo(err error, resp *http.Response) error { + errors := Errors([]string{err.Error()}) + return convertToFriendlyError(errors, resp) +} + +func convertToFriendlyError(errors Errors, resp *http.Response) error { + er := &ErrorResponse{ + StatusCode: resp.StatusCode, + Errors: errors, + } + respHead := resp.Header + + // this checks if the error comes from API (and not from cache/LB) + if len(errors) > 0 { + ct := respHead.Get("Content-Type") + xrid := respHead.Get("X-Request-Id") + if strings.Contains(ct, "application/json") && len(xrid) > 0 { + er.IsAPIError = true + } + } + return er +} + +func IsForbidden(err error) bool { + r, ok := err.(*packngo.ErrorResponse) + if ok && r.Response != nil { + return r.Response.StatusCode == http.StatusForbidden + } + if r, ok := err.(*ErrorResponse); ok { + return r.StatusCode == http.StatusForbidden + } + return false +} + +func IsNotFound(err error) bool { + if r, ok := err.(*ErrorResponse); ok { + return r.StatusCode == http.StatusNotFound && r.IsAPIError + } + if r, ok := err.(*packngo.ErrorResponse); ok && r.Response != nil { + return r.Response.StatusCode == http.StatusNotFound + } + return false +} + +type Errors []string + +func (e Errors) Error() string { + return strings.Join(e, "; ") +} + +type ErrorResponse struct { + StatusCode int + Errors + IsAPIError bool +} + +func (er *ErrorResponse) Error() string { + ret := "" + if er.IsAPIError { + ret += "API Error " + } + if er.StatusCode != 0 { + ret += fmt.Sprintf("HTTP %d ", er.StatusCode) + } + ret += er.Errors.Error() + return ret +} + + +// isNotAssigned matches errors reported from unassigned virtual networks +func IsNotAssigned(resp *http.Response, err error) bool { + if resp.StatusCode != http.StatusUnprocessableEntity { + return false + } + if err, ok := err.(*packngo.ErrorResponse); ok { + for _, e := range append(err.Errors, err.SingleError) { + if strings.HasPrefix(e, "Virtual network") && strings.HasSuffix(e, "not assigned") { + return true + } + } + } + return false +} + +func HttpForbidden(resp *http.Response, err error) bool { + if resp != nil && (resp.StatusCode != http.StatusForbidden) { + return false + } + + switch err := err.(type) { + case *ErrorResponse, *packngo.ErrorResponse: + return IsForbidden(err) + } + + return false +} + +func HttpNotFound(resp *http.Response, err error) bool { + if resp != nil && (resp.StatusCode != http.StatusNotFound) { + return false + } + + switch err := err.(type) { + case *ErrorResponse, *packngo.ErrorResponse: + return IsNotFound(err) + } + return false +} + +// IgnoreResponseErrors ignores http response errors when matched by one of the +// provided checks +func IgnoreResponseErrors(ignore ...func(resp *http.Response, err error) bool) func(resp *packngo.Response, err error) error { + return func(resp *packngo.Response, err error) error { + var r *http.Response + if resp != nil && resp.Response != nil { + r = resp.Response + } + mute := false + for _, ignored := range ignore { + if ignored(r, err) { + mute = true + break + } + } + + if mute { + return nil + } + return err + } +} diff --git a/internal/metal_ssh_key/resource.go b/internal/metal_ssh_key/resource.go index 0d245025a..9581d563c 100644 --- a/internal/metal_ssh_key/resource.go +++ b/internal/metal_ssh_key/resource.go @@ -1,9 +1,8 @@ package metal_ssh_key import ( - "path" - "context" + "path" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" @@ -15,6 +14,7 @@ import ( ) type ResourceModel struct { + ID types.String `tfsdk:"id"` Name types.String `tfsdk:"name,omitempty"` PublicKey types.String `tfsdk:"public_key,omitempty"` ProjectID types.String `tfsdk:"project_id,omitempty"` @@ -24,6 +24,7 @@ type ResourceModel struct { } func (rm *ResourceModel) parse(key *packngo.SSHKey) { + rm.ID = types.StringValue(key.ID) rm.Name = types.StringValue(key.Label) rm.PublicKey = types.StringValue(key.Key) rm.ProjectID = types.StringValue(path.Base(key.Owner.Href)) @@ -52,49 +53,116 @@ func (r *Resource) Create( req resource.CreateRequest, resp *resource.CreateResponse, ) { - client := meta.(*Config).metal + // r.Meta.AddModuleToMetalUserAgent(d) + client := r.Meta.Metal + // Retrieve values from plan + var rm ResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &rm)...) + if resp.Diagnostics.HasError() { + return + } + + // Generate API request body from plan createRequest := &packngo.SSHKeyCreateRequest{ - Label: d.Get("name").(string), - Key: d.Get("public_key").(string), + Label: rm.Name.ValueString(), + Key: rm.PublicKey.ValueString(), } - projectID, isProjectKey := d.GetOk("project_id") - if isProjectKey { - createRequest.ProjectID = projectID.(string) + if rm.ProjectID.ValueString() != "" { + createRequest.ProjectID = rm.ProjectID.ValueString() } + // Create API resource key, _, err := client.SSHKeys.Create(createRequest) if err != nil { - return friendlyError(err) + resp.Diagnostics.AddError( + "Failed to create SSH Key", + helper.FriendlyError(err).Error(), + ) + return } - d.SetId(key.ID) - - return resourceMetalSSHKeyRead(d, meta) + // Set state to fully populated data + rm.parse(key) + resp.Diagnostics.Append(resp.State.Set(ctx, &rm)...) } func (r *Resource) Read( - ctx context.Context, - req resource.ReadRequest, - resp *resource.ReadResponse, + ctx context.Context, + req resource.ReadRequest, + resp *resource.ReadResponse, ) { + // client := req.ProviderMeta.(*Config).metal + + // var rm ResourceModel + + // resp.Diagnostics.Append(req.State.Get(ctx, &rm)...) + // if resp.Diagnostics.HasError() { + // return + // } + + // key, _, err := client.SSHKeys.Get(rm.ID.ValueString(), nil) + // if err != nil { + // resp.Error = helper.FriendlyError(err) + // return + // } + + // // Set the resource's state with the populated ResourceModel + // rm.parse(key) + // resp.Diagnostics.Append(resp.State.Set(ctx, &rm)...) } + func (r *Resource) Update( - ctx context.Context, - req resource.UpdateRequest, - resp *resource.UpdateResponse, + ctx context.Context, + req resource.UpdateRequest, + resp *resource.UpdateResponse, ) { + // client := r.Meta.Metal + // id := req.ID + + // // Check if any attributes have changed + // if req.HasChange("name") || req.HasChange("public_key") { + // updateRequest := &packngo.SSHKeyUpdateRequest{} + + // if req.HasChange("name") { + // name := req.New.State.(*ResourceModel).Name + // updateRequest.Label = string(name) + // } + + // if req.HasChange("public_key") { + // publicKey := req.New.State.(*ResourceModel).PublicKey + // updateRequest.Key = string(publicKey) + // } + + // _, _, err := client.SSHKeys.Update(id, updateRequest) + // if err != nil { + // resp.Error = friendlyError(err) + // return + // } + // } } func (r *Resource) Delete( - ctx context.Context, - req resource.DeleteRequest, - resp *resource.DeleteResponse, + ctx context.Context, + req resource.DeleteRequest, + resp *resource.DeleteResponse, ) { + // client := req.Meta.Metal + // id := req.ID + + // _, err := client.SSHKeys.Delete(id) + // if err != nil && !isNotFound(err) { + // resp.Error = friendlyError(err) + // return + // } + + // // Set the resource's ID to an empty string to mark it as deleted + // resp.State = nil } + var frameworkResourceSchema = schema.Schema{ Attributes: map[string]schema.Attribute{ "name": schema.StringAttribute{ From b6c5251bd893dd309b32e3e259a282fbbef16f13 Mon Sep 17 00:00:00 2001 From: ocobleseqx Date: Tue, 21 Nov 2023 13:23:32 +0100 Subject: [PATCH 05/26] ssh key resource Signed-off-by: ocobleseqx --- equinix/provider.go | 2 +- internal/framework_provider.go | 8 +- internal/metal_ssh_key/resource.go | 224 +++++++++++++++++++---------- 3 files changed, 152 insertions(+), 82 deletions(-) diff --git a/equinix/provider.go b/equinix/provider.go index 2ff4a5dcf..17efb3708 100644 --- a/equinix/provider.go +++ b/equinix/provider.go @@ -162,7 +162,7 @@ func Provider() *schema.Provider { "equinix_metal_connection": resourceMetalConnection(), "equinix_metal_device": resourceMetalDevice(), "equinix_metal_device_network_type": resourceMetalDeviceNetworkType(), - "equinix_metal_ssh_key": resourceMetalSSHKey(), + // "equinix_metal_ssh_key": resourceMetalSSHKey(), "equinix_metal_organization_member": resourceMetalOrganizationMember(), "equinix_metal_port": resourceMetalPort(), "equinix_metal_project_ssh_key": resourceMetalProjectSSHKey(), diff --git a/internal/framework_provider.go b/internal/framework_provider.go index f24829e9a..a0b4f0d14 100644 --- a/internal/framework_provider.go +++ b/internal/framework_provider.go @@ -66,7 +66,7 @@ func (p *FrameworkProvider) Schema( }, "request_timeout": schema.Int64Attribute{ Optional: true, - Description: "The duration of time, in seconds, that the Equinix Platform API Client should wait before canceling an API request. Defaults to " + fmt.Sprint(config.DefaultTimeout), + Description: fmt.Sprintf("The duration of time, in seconds, that the Equinix Platform API Client should wait before canceling an API request. Defaults to %d", config.DefaultTimeout), Validators: []validator.Int64{ int64validator.AtLeast(1), }, @@ -80,11 +80,11 @@ func (p *FrameworkProvider) Schema( }, "max_retries": schema.Int64Attribute{ Optional: true, - Description: "Maximum number of retries.", + // Description: "Maximum number of retries.", }, "max_retry_wait_seconds": schema.Int64Attribute{ Optional: true, - Description: "Maximum number of seconds to wait before retrying a request.", + // Description: "Maximum number of seconds to wait before retrying a request.", }, }, } @@ -99,6 +99,6 @@ func (p *FrameworkProvider) Resources(ctx context.Context) []func() resource.Res func (p *FrameworkProvider) DataSources(ctx context.Context) []func() datasource.DataSource { // return nil return []func() datasource.DataSource{ - metal_ssh_key.NewDataSource, + // metal_ssh_key.NewDataSource, } } diff --git a/internal/metal_ssh_key/resource.go b/internal/metal_ssh_key/resource.go index 9581d563c..9b7c141e7 100644 --- a/internal/metal_ssh_key/resource.go +++ b/internal/metal_ssh_key/resource.go @@ -3,7 +3,10 @@ package metal_ssh_key import ( "context" "path" + "fmt" + "log" + "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" @@ -15,22 +18,25 @@ import ( type ResourceModel struct { ID types.String `tfsdk:"id"` - Name types.String `tfsdk:"name,omitempty"` - PublicKey types.String `tfsdk:"public_key,omitempty"` - ProjectID types.String `tfsdk:"project_id,omitempty"` - Fingerprint types.String `tfsdk:"fingerprint,omitempty"` - Updated types.String `tfsdk:"updated,omitempty"` - OwnerID types.String `tfsdk:"owner_id,omitempty"` + Name types.String `tfsdk:"name"` + PublicKey types.String `tfsdk:"public_key"` + // ProjectID types.String `tfsdk:"project_id"` + Fingerprint types.String `tfsdk:"fingerprint"` + Created types.String `tfsdk:"created"` + Updated types.String `tfsdk:"updated"` + OwnerID types.String `tfsdk:"owner_id"` } -func (rm *ResourceModel) parse(key *packngo.SSHKey) { +func (rm *ResourceModel) parse(key *packngo.SSHKey) diag.Diagnostics { rm.ID = types.StringValue(key.ID) rm.Name = types.StringValue(key.Label) rm.PublicKey = types.StringValue(key.Key) - rm.ProjectID = types.StringValue(path.Base(key.Owner.Href)) + // rm.ProjectID = types.StringValue(path.Base(key.Owner.Href)) rm.Fingerprint = types.StringValue(key.FingerPrint) + rm.Created = types.StringValue(key.Created) rm.Updated = types.StringValue(key.Updated) rm.OwnerID = types.StringValue(path.Base(key.Owner.Href)) + return nil } func NewResource() resource.Resource { @@ -57,21 +63,21 @@ func (r *Resource) Create( client := r.Meta.Metal // Retrieve values from plan - var rm ResourceModel - resp.Diagnostics.Append(req.Plan.Get(ctx, &rm)...) - if resp.Diagnostics.HasError() { - return - } + var plan ResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } // Generate API request body from plan createRequest := &packngo.SSHKeyCreateRequest{ - Label: rm.Name.ValueString(), - Key: rm.PublicKey.ValueString(), + Label: plan.Name.ValueString(), + Key: plan.PublicKey.ValueString(), } - if rm.ProjectID.ValueString() != "" { - createRequest.ProjectID = rm.ProjectID.ValueString() - } + // if plan.ProjectID.ValueString() != "" { + // createRequest.ProjectID = plan.ProjectID.ValueString() + // } // Create API resource key, _, err := client.SSHKeys.Create(createRequest) @@ -84,33 +90,61 @@ func (r *Resource) Create( } // Set state to fully populated data - rm.parse(key) - resp.Diagnostics.Append(resp.State.Set(ctx, &rm)...) + resp.Diagnostics.Append(plan.parse(key)...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) } func (r *Resource) Read( - ctx context.Context, - req resource.ReadRequest, - resp *resource.ReadResponse, + ctx context.Context, + req resource.ReadRequest, + resp *resource.ReadResponse, ) { - // client := req.ProviderMeta.(*Config).metal - - // var rm ResourceModel + // r.Meta.AddModuleToMetalUserAgent(d) + client := r.Meta.Metal - // resp.Diagnostics.Append(req.State.Get(ctx, &rm)...) - // if resp.Diagnostics.HasError() { - // return - // } + // Retrieve values from plan + var state ResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + // Extract the ID of the resource from the state + id := state.ID.ValueString() - // key, _, err := client.SSHKeys.Get(rm.ID.ValueString(), nil) - // if err != nil { - // resp.Error = helper.FriendlyError(err) - // return - // } + // Use API client to get the current state of the resource + key, _, err := client.SSHKeys.Get(id, nil) + if err != nil { + err = helper.FriendlyError(err) - // // Set the resource's state with the populated ResourceModel - // rm.parse(key) - // resp.Diagnostics.Append(resp.State.Set(ctx, &rm)...) + // If the key is somehow already destroyed, mark as + // succesfully gone + if helper.IsNotFound(err) { + resp.Diagnostics.AddWarning( + "SSHKey", + fmt.Sprintf("[WARN] SSHKey (%s) not found, removing from state", id), + ) + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError( + fmt.Sprintf("Failed to get SSHKey %s", id), + err.Error(), + ) + } + + // Set state to fully populated data + resp.Diagnostics.Append(state.parse(key)...) + if resp.Diagnostics.HasError() { + return + } + + // Update the Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) } @@ -119,52 +153,88 @@ func (r *Resource) Update( req resource.UpdateRequest, resp *resource.UpdateResponse, ) { - // client := r.Meta.Metal - // id := req.ID - - // // Check if any attributes have changed - // if req.HasChange("name") || req.HasChange("public_key") { - // updateRequest := &packngo.SSHKeyUpdateRequest{} - - // if req.HasChange("name") { - // name := req.New.State.(*ResourceModel).Name - // updateRequest.Label = string(name) - // } - - // if req.HasChange("public_key") { - // publicKey := req.New.State.(*ResourceModel).PublicKey - // updateRequest.Key = string(publicKey) - // } - - // _, _, err := client.SSHKeys.Update(id, updateRequest) - // if err != nil { - // resp.Error = friendlyError(err) - // return - // } - // } + // r.Meta.AddModuleToMetalUserAgent(d) + client := r.Meta.Metal + + // Retrieve values from plan + var state, plan ResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + // Extract the ID of the resource from the state + id := plan.ID.ValueString() + + updateRequest := &packngo.SSHKeyUpdateRequest{} + if !state.Name.Equal(plan.Name) { + updateRequest.Label = plan.Name.ValueStringPointer() + } + if !state.PublicKey.Equal(plan.PublicKey) { + updateRequest.Key = plan.PublicKey.ValueStringPointer() + } + + // Use your API client to update the resource + key, _, err := client.SSHKeys.Update(plan.ID.ValueString(), updateRequest) + if err != nil { + err = helper.FriendlyError(err) + resp.Diagnostics.AddError( + "Error updating resource", + "Could not update resource with ID " + id + ": " + err.Error(), + ) + return + } + + // Set state to fully populated data + resp.Diagnostics.Append(plan.parse(key)...) + if resp.Diagnostics.HasError() { + return + } + + // Read the updated state back into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) } func (r *Resource) Delete( - ctx context.Context, - req resource.DeleteRequest, - resp *resource.DeleteResponse, + ctx context.Context, + req resource.DeleteRequest, + resp *resource.DeleteResponse, ) { - // client := req.Meta.Metal - // id := req.ID + // r.Meta.AddModuleToMetalUserAgent(d) + client := r.Meta.Metal - // _, err := client.SSHKeys.Delete(id) - // if err != nil && !isNotFound(err) { - // resp.Error = friendlyError(err) - // return - // } + // Retrieve values from plan + var state ResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } - // // Set the resource's ID to an empty string to mark it as deleted - // resp.State = nil -} + // Extract the ID of the resource from the state + id := state.ID.ValueString() + // Use your API client to delete the resource + deleteResp, err := client.SSHKeys.Delete(id) + if helper.IgnoreResponseErrors(helper.HttpForbidden, helper.HttpNotFound)(deleteResp, err) != nil { + err = helper.FriendlyError(err) + resp.Diagnostics.AddError( + fmt.Sprintf("Failed to delete SSHKey %s", id), + err.Error(), + ) + } + +} var frameworkResourceSchema = schema.Schema{ Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "The unique identifier for this SSH key.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, "name": schema.StringAttribute{ Description: "The name of the SSH key for identification", Required: true, @@ -183,6 +253,9 @@ var frameworkResourceSchema = schema.Schema{ "owner_id": schema.StringAttribute{ Description: "The UUID of the Equinix Metal API User who owns this key", Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, }, "created": schema.StringAttribute{ Description: "The timestamp for when the SSH key was created", @@ -194,9 +267,6 @@ var frameworkResourceSchema = schema.Schema{ "updated": schema.StringAttribute{ Description: "The timestamp for the last time the SSH key was updated", Computed: true, - PlanModifiers: []planmodifier.String{ - stringplanmodifier.UseStateForUnknown(), - }, }, }, } From 8182fda7ad2454f0761afe943569f86c290e7f28 Mon Sep 17 00:00:00 2001 From: ocobleseqx Date: Tue, 21 Nov 2023 17:44:27 +0100 Subject: [PATCH 06/26] fixup! ssh key resource Signed-off-by: ocobleseqx --- internal/metal_ssh_key/resource.go | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/internal/metal_ssh_key/resource.go b/internal/metal_ssh_key/resource.go index 9b7c141e7..4e42e5caa 100644 --- a/internal/metal_ssh_key/resource.go +++ b/internal/metal_ssh_key/resource.go @@ -4,7 +4,6 @@ import ( "context" "path" "fmt" - "log" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/resource" @@ -20,7 +19,6 @@ type ResourceModel struct { ID types.String `tfsdk:"id"` Name types.String `tfsdk:"name"` PublicKey types.String `tfsdk:"public_key"` - // ProjectID types.String `tfsdk:"project_id"` Fingerprint types.String `tfsdk:"fingerprint"` Created types.String `tfsdk:"created"` Updated types.String `tfsdk:"updated"` @@ -31,7 +29,6 @@ func (rm *ResourceModel) parse(key *packngo.SSHKey) diag.Diagnostics { rm.ID = types.StringValue(key.ID) rm.Name = types.StringValue(key.Label) rm.PublicKey = types.StringValue(key.Key) - // rm.ProjectID = types.StringValue(path.Base(key.Owner.Href)) rm.Fingerprint = types.StringValue(key.FingerPrint) rm.Created = types.StringValue(key.Created) rm.Updated = types.StringValue(key.Updated) @@ -75,10 +72,6 @@ func (r *Resource) Create( Key: plan.PublicKey.ValueString(), } - // if plan.ProjectID.ValueString() != "" { - // createRequest.ProjectID = plan.ProjectID.ValueString() - // } - // Create API resource key, _, err := client.SSHKeys.Create(createRequest) if err != nil { @@ -89,12 +82,13 @@ func (r *Resource) Create( return } - // Set state to fully populated data + // Parse API response into the Terraform state resp.Diagnostics.Append(plan.parse(key)...) if resp.Diagnostics.HasError() { return } + // Set state to fully populated data resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) } @@ -223,7 +217,6 @@ func (r *Resource) Delete( err.Error(), ) } - } var frameworkResourceSchema = schema.Schema{ From a5f70380a287e498850495ac90a912af557706e9 Mon Sep 17 00:00:00 2001 From: ocobleseqx Date: Tue, 21 Nov 2023 17:45:14 +0100 Subject: [PATCH 07/26] equinix_metal_bgp_session Signed-off-by: ocobleseqx --- equinix/provider.go | 2 +- internal/framework_provider.go | 2 + internal/metal_bgp_session/resource.go | 235 +++++++++++++++++++++++++ 3 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 internal/metal_bgp_session/resource.go diff --git a/equinix/provider.go b/equinix/provider.go index 17efb3708..045b61dd2 100644 --- a/equinix/provider.go +++ b/equinix/provider.go @@ -174,7 +174,7 @@ func Provider() *schema.Provider { "equinix_metal_vlan": resourceMetalVlan(), "equinix_metal_virtual_circuit": resourceMetalVirtualCircuit(), "equinix_metal_vrf": resourceMetalVRF(), - "equinix_metal_bgp_session": resourceMetalBGPSession(), + // "equinix_metal_bgp_session": resourceMetalBGPSession(), "equinix_metal_port_vlan_attachment": resourceMetalPortVlanAttachment(), "equinix_metal_gateway": resourceMetalGateway(), }, diff --git a/internal/framework_provider.go b/internal/framework_provider.go index a0b4f0d14..afa673d1c 100644 --- a/internal/framework_provider.go +++ b/internal/framework_provider.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/equinix/terraform-provider-equinix/internal/config" + "github.com/equinix/terraform-provider-equinix/internal/metal_bgp_session" "github.com/equinix/terraform-provider-equinix/internal/metal_ssh_key" "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" "github.com/hashicorp/terraform-plugin-framework/datasource" @@ -92,6 +93,7 @@ func (p *FrameworkProvider) Schema( func (p *FrameworkProvider) Resources(ctx context.Context) []func() resource.Resource { return []func() resource.Resource{ + metal_bgp_session.NewResource, metal_ssh_key.NewResource, } } diff --git a/internal/metal_bgp_session/resource.go b/internal/metal_bgp_session/resource.go new file mode 100644 index 000000000..f676a3866 --- /dev/null +++ b/internal/metal_bgp_session/resource.go @@ -0,0 +1,235 @@ +package metal_bgp_session + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "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/resource/schema/boolplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/packethost/packngo" + "github.com/equinix/terraform-provider-equinix/internal/helper" +) + +type BgpSessionResourceModel struct { + ID types.String `tfsdk:"id"` + DeviceID types.String `tfsdk:"device_id"` + AddressFamily types.String `tfsdk:"address_family"` + DefaultRoute types.Bool `tfsdk:"default_route"` + Status types.String `tfsdk:"status"` +} + +func (rm *BgpSessionResourceModel) parse(bgpSession *packngo.BGPSession) diag.Diagnostics { + var diags diag.Diagnostics + rm.ID = types.StringValue(bgpSession.ID) + rm.DeviceID = types.StringValue(bgpSession.Device.ID) + rm.AddressFamily = types.StringValue(bgpSession.AddressFamily) + + defaultRouteValue := false + if bgpSession.DefaultRoute != nil { + defaultRouteValue = *bgpSession.DefaultRoute + } + rm.DefaultRoute = types.BoolValue(defaultRouteValue) + + rm.Status = types.StringValue(bgpSession.Status) + return diags +} + + +func NewResource() resource.Resource { + return &Resource{ + BaseResource: helper.NewBaseResource( + helper.BaseResourceConfig{ + Name: "equinix_metal_bgp_session", + Schema: &bgpSessionResourceSchema, + }, + ), + } +} + +type Resource struct { + helper.BaseResource +} + +func (r *Resource) Create( + ctx context.Context, + req resource.CreateRequest, + resp *resource.CreateResponse, +){ + // Create an instance of the BgpSessionResourceModel to hold the planned state + var plan BgpSessionResourceModel + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Prepare the data for API request + createRequest := packngo.CreateBGPSessionRequest{ + AddressFamily: plan.AddressFamily.ValueString(), + DefaultRoute: plan.DefaultRoute.ValueBoolPointer(), + } + + // Retrieve the API client from the provider meta + // r.Meta.AddModuleToMetalUserAgent(d) + client := r.Meta.Metal + + // API call to create the BGP session + bgpSession, _, err := client.BGPSessions.Create(plan.DeviceID.ValueString(), createRequest) + if err != nil { + err = helper.FriendlyError(err) + resp.Diagnostics.AddError( + "Error creating BGP session", + "Could not create BGP session: " + err.Error(), + ) + return + } + + // Parse API response into the Terraform state + resp.Diagnostics.Append(plan.parse(bgpSession)...) + if resp.Diagnostics.HasError() { + return + } + + // Set the state + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *Resource) Read( + ctx context.Context, + req resource.ReadRequest, + resp *resource.ReadResponse, +) { + // Retrieve the current state + var state BgpSessionResourceModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Retrieve the API client + // r.Meta.AddModuleToMetalUserAgent(d) + client := r.Meta.Metal + + // Extract the ID of the resource from the state + id := state.ID.ValueString() + + // API call to get the current state of the BGP session + bgpSession, _, err := client.BGPSessions.Get(id, nil) + if err != nil { + err = helper.FriendlyError(err) + + // Check if the BGP session no longer exists + if helper.IsNotFound(err) { + resp.Diagnostics.AddWarning( + "BGP session", + fmt.Sprintf("[WARN] BGP session (%s) not found, removing from state", id), + ) + resp.State.RemoveResource(ctx) + return + } + + resp.Diagnostics.AddError( + "Error reading BGP session", + "Could not read BGP session with ID " + id + ": " + err.Error(), + ) + return + } + + // Parse the API response into the Terraform state + resp.Diagnostics.Append(state.parse(bgpSession)...) + if resp.Diagnostics.HasError() { + return + } + + // Update the Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *Resource) Update( + ctx context.Context, + req resource.UpdateRequest, + resp *resource.UpdateResponse, +) { + // This resource does not support updates +} + +func (r *Resource) Delete( + ctx context.Context, + req resource.DeleteRequest, + resp *resource.DeleteResponse, +) { + // Retrieve the current state + var state BgpSessionResourceModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Retrieve the API client + // r.Meta.AddModuleToMetalUserAgent(d) + client := r.Meta.Metal + + // Extract the ID of the resource from the state + id := state.ID.ValueString() + + // API call to delete the BGP session + deleteResp, err := client.BGPSessions.Delete(id) + if helper.IgnoreResponseErrors(helper.HttpForbidden, helper.HttpNotFound)(deleteResp, err) != nil { + err = helper.FriendlyError(err) + resp.Diagnostics.AddError( + fmt.Sprintf("Failed to delete BGP session %s", id), + err.Error(), + ) + } +} + +var bgpSessionResourceSchema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "The unique identifier for this BGP session", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "device_id": schema.StringAttribute{ + Description: "ID of device", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "address_family": schema.StringAttribute{ + Description: "ipv4 or ipv6", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + stringvalidator.OneOf("ipv4", "ipv6"), + }, + }, + "default_route": schema.BoolAttribute{ + Description: "Boolean flag to set the default route policy. False by default", + Optional: true, + Default: booldefault.StaticBool(false), + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.RequiresReplace(), + }, + }, + "status": schema.StringAttribute{ + Description: "Status of the session - up or down", + Computed: true, + }, + }, +} From 28368ccb8da4e49e9a860584c0330c9e96c797b9 Mon Sep 17 00:00:00 2001 From: ocobleseqx Date: Wed, 22 Nov 2023 12:24:07 +0100 Subject: [PATCH 08/26] provider_meta and url validator Signed-off-by: ocobleseqx --- internal/config/config.go | 22 ++++++++++++++++++++++ internal/framework_provider.go | 11 +++++++---- internal/metal_ssh_key/resource.go | 9 +++++---- 3 files changed, 34 insertions(+), 8 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index f14cd9a71..953c5bf54 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -26,6 +26,7 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/meta" "github.com/packethost/packngo" xoauth2 "golang.org/x/oauth2" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" ) var ( @@ -320,6 +321,27 @@ func (c *Config) AddModuleToNEUserAgent(client *ne.Client, d *schema.ResourceDat // the UserAgent resulting in swapped UserAgent. // This can be fixed by letting the headers be overwritten on the initialized Packngo ServiceOp // clients on a query-by-query basis. +func (c *Config) AddFwModuleToMetalUserAgent(ctx context.Context, meta tfsdk.Config) { + c.Metal.UserAgent = generateFwModuleUserAgentString(ctx, meta, c.metalUserAgent) +} + +func (c *Config) AddFwModuleToMetaGolUserAgent(ctx context.Context, meta tfsdk.Config) { + c.Metalgo.GetConfig().UserAgent = generateFwModuleUserAgentString(ctx, meta, c.metalGoUserAgent) +} + +func generateFwModuleUserAgentString(ctx context.Context, meta tfsdk.Config, baseUserAgent string) string { + var m ProviderMeta + diags := meta.Get(ctx, &m) + if diags.HasError() { + log.Printf("[WARN] error retrieving provider_meta") + return baseUserAgent + } + if m.ModuleName != "" { + return strings.Join([]string{m.ModuleName, baseUserAgent}, " ") + } + return baseUserAgent +} + func (c *Config) AddModuleToMetalUserAgent(d *schema.ResourceData) { c.Metal.UserAgent = generateModuleUserAgentString(d, c.metalUserAgent) } diff --git a/internal/framework_provider.go b/internal/framework_provider.go index afa673d1c..bcdef4941 100644 --- a/internal/framework_provider.go +++ b/internal/framework_provider.go @@ -3,6 +3,7 @@ package internal import ( "context" "fmt" + "regexp" "github.com/equinix/terraform-provider-equinix/internal/config" "github.com/equinix/terraform-provider-equinix/internal/metal_bgp_session" @@ -13,8 +14,11 @@ import ( "github.com/hashicorp/terraform-plugin-framework/provider/schema" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" ) +var urlRE = regexp.MustCompile(`^https?://(?:www\.)?[a-zA-Z0-9./]+$`) + type FrameworkProvider struct { ProviderVersion string Meta *config.Config @@ -44,10 +48,9 @@ func (p *FrameworkProvider) Schema( "endpoint": schema.StringAttribute{ Optional: true, Description: "The Equinix API base URL to point out desired environment. Defaults to " + config.DefaultBaseURL, - // TODO: - // Add Validator for url with scheme. It's hard to find where they moved url - // particualr validator to, if in even exist in the TF golang codebase. - // Select and add validators for other attributes too. + Validators: []validator.String{ + stringvalidator.RegexMatches(urlRE, "must be a valid URL with http or https schema"), + }, }, "client_id": schema.StringAttribute{ Optional: true, diff --git a/internal/metal_ssh_key/resource.go b/internal/metal_ssh_key/resource.go index 4e42e5caa..f8bcee1e3 100644 --- a/internal/metal_ssh_key/resource.go +++ b/internal/metal_ssh_key/resource.go @@ -56,7 +56,8 @@ func (r *Resource) Create( req resource.CreateRequest, resp *resource.CreateResponse, ) { - // r.Meta.AddModuleToMetalUserAgent(d) + + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) client := r.Meta.Metal // Retrieve values from plan @@ -97,7 +98,7 @@ func (r *Resource) Read( req resource.ReadRequest, resp *resource.ReadResponse, ) { - // r.Meta.AddModuleToMetalUserAgent(d) + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) client := r.Meta.Metal // Retrieve values from plan @@ -147,7 +148,7 @@ func (r *Resource) Update( req resource.UpdateRequest, resp *resource.UpdateResponse, ) { - // r.Meta.AddModuleToMetalUserAgent(d) + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) client := r.Meta.Metal // Retrieve values from plan @@ -195,7 +196,7 @@ func (r *Resource) Delete( req resource.DeleteRequest, resp *resource.DeleteResponse, ) { - // r.Meta.AddModuleToMetalUserAgent(d) + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) client := r.Meta.Metal // Retrieve values from plan From 608650678cbcdbc2ffc41d99b425e956d91b0124 Mon Sep 17 00:00:00 2001 From: ocobleseqx Date: Wed, 22 Nov 2023 19:24:32 +0100 Subject: [PATCH 09/26] equinix_metal_project Signed-off-by: ocobleseqx --- internal/metal_project/resource.go | 523 +++++++++++++++++++++++++++++ 1 file changed, 523 insertions(+) create mode 100644 internal/metal_project/resource.go diff --git a/internal/metal_project/resource.go b/internal/metal_project/resource.go new file mode 100644 index 000000000..09b18da59 --- /dev/null +++ b/internal/metal_project/resource.go @@ -0,0 +1,523 @@ +package metal_project + +import ( + "context" + "path" + "regexp" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "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-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/packethost/packngo" + "github.com/equinix/terraform-provider-equinix/internal/helper" +) + +var uuidRE = regexp.MustCompile("^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[8|9|aA|bB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}$") + +type ProjectResourceModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Created types.String `tfsdk:"created"` + Updated types.String `tfsdk:"updated"` + BackendTransfer types.Bool `tfsdk:"backend_transfer"` + PaymentMethodID types.String `tfsdk:"payment_method_id"` + OrganizationID types.String `tfsdk:"organization_id"` + BGPConfig *BGPConfigModel `tfsdk:"bgp_config"` +} + +type BGPConfigModel struct { + DeploymentType types.String `tfsdk:"deployment_type"` + ASN types.Int64 `tfsdk:"asn"` + MD5 types.String `tfsdk:"md5"` + Status types.String `tfsdk:"status"` + MaxPrefix types.Int64 `tfsdk:"max_prefix"` +} + +func (bgp *BGPConfigModel) equal(other *BGPConfigModel) bool { + if bgp == nil && other == nil { + return true + } + if bgp == nil || other == nil { + return false + } + return bgp.DeploymentType == other.DeploymentType && + bgp.ASN == other.ASN && + bgp.MD5 == other.MD5 && + bgp.Status == other.Status && + bgp.MaxPrefix == other.MaxPrefix +} + +func (rm *ProjectResourceModel) parse(project *packngo.Project, bgpConfig *packngo.BGPConfig) diag.Diagnostics { + var diags diag.Diagnostics + + // Assuming 'project' is the API response object + rm.ID = types.StringValue(project.ID) + rm.Name = types.StringValue(project.Name) + rm.Created = types.StringValue(project.Created) + rm.Updated = types.StringValue(project.Updated) + rm.BackendTransfer = types.BoolValue(project.BackendTransfer) + + if len(project.PaymentMethod.URL) != 0 { + rm.PaymentMethodID = types.StringValue(path.Base(project.PaymentMethod.URL)) + } + + rm.OrganizationID = types.StringValue(path.Base(project.Organization.URL)) + + // Handle BGP Config if present + if bgpConfig != nil { + rm.BGPConfig = &BGPConfigModel{ + DeploymentType: types.StringValue(bgpConfig.DeploymentType), + ASN: types.Int64Value(int64(bgpConfig.Asn)), + MD5: types.StringValue(bgpConfig.Md5), + Status: types.StringValue(bgpConfig.Status), + MaxPrefix: types.Int64Value(int64(bgpConfig.MaxPrefix)), + } + } + + return diags +} + +func NewResource() resource.Resource { + return &Resource{ + BaseResource: helper.NewBaseResource( + helper.BaseResourceConfig{ + Name: "equinix_metal_project", + Schema: &projectResourceSchema, + }, + ), + } +} + +type Resource struct { + helper.BaseResource +} + +func (r *Resource) Create( + ctx context.Context, + req resource.CreateRequest, + resp *resource.CreateResponse, +) { + // Create an instance of your resource model to hold the planned state + var plan ProjectResourceModel + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Prepare the data for API request + createRequest := packngo.ProjectCreateRequest{ + Name: plan.Name.ValueString(), + } + + // Include optional fields if they are set + if !plan.OrganizationID.IsNull() { + createRequest.OrganizationID = plan.OrganizationID.ValueString() + } + + // Retrieve the API client + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + client := r.Meta.Metal + + // API call to create the project + project, _, err := client.Projects.Create(&createRequest) + if err != nil { + resp.Diagnostics.AddError( + "Error creating project", + "Could not create project: " + err.Error(), + ) + return + } + + // Handle BGP Config if present + if plan.BGPConfig != nil { + bgpCreateRequest := packngo.CreateBGPConfigRequest{ + DeploymentType: plan.BGPConfig.DeploymentType.ValueString(), + Asn: int(plan.BGPConfig.ASN.ValueInt64()), + } + if !plan.BGPConfig.MD5.IsNull() { + bgpCreateRequest.Md5 = plan.BGPConfig.MD5.ValueString() + } + _, err := client.BGPConfig.Create(project.ID, bgpCreateRequest) + if err != nil { + resp.Diagnostics.AddError( + "Error creating BGP configuration", + "Could not create BGP configuration for project: " + err.Error(), + ) + return + } + } + + // Enable Backend Transfer if True + if plan.BackendTransfer.ValueBool() { + pur := packngo.ProjectUpdateRequest{ + BackendTransfer: plan.BackendTransfer.ValueBoolPointer(), + } + project, _, err = client.Projects.Update(project.ID, &pur) + if err != nil { + err = helper.FriendlyError(err) + resp.Diagnostics.AddError( + "Error enabling Backend Transfer", + "Could not enable Backend Transfer for project with ID " + project.ID + ": " + err.Error(), + ) + return + } + } + + // Fetch BGP Config if needed + var bgpConfig *packngo.BGPConfig + if plan.BGPConfig != nil { + bgpConfig, diags = fetchBGPConfig(client, project.ID) + diags.Append(diags...) + if diags.HasError(){ + return + } + } + + // Parse API response into the Terraform state + stateDiags := (&plan).parse(project, bgpConfig) + resp.Diagnostics.Append(stateDiags...) + if resp.Diagnostics.HasError() { + return + } + + // Set the state + diags = resp.State.Set(ctx, &plan) + resp.Diagnostics.Append(diags...) +} + +func (r *Resource) Read( + ctx context.Context, + req resource.ReadRequest, + resp *resource.ReadResponse, +) { + // Retrieve the current state + var state ProjectResourceModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Retrieve the API client from the provider meta + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + client := r.Meta.Metal + + // Extract the ID of the resource from the state + id := state.ID.ValueString() + + // API call to get the current state of the project + project, diags := fetchProject(client, id) + resp.Diagnostics.Append(diags...) + if diags.HasError() { + return + } + + // Fetch BGP Config if needed + var bgpConfig *packngo.BGPConfig + if state.BGPConfig != nil { + bgpConfig, diags = fetchBGPConfig(client, id) + resp.Diagnostics.Append(diags...) + if diags.HasError() { + return + } + } + + // Parse the API response into the Terraform state + resp.Diagnostics.Append(state.parse(project, bgpConfig)...) + if resp.Diagnostics.HasError() { + return + } + + // Update the Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *Resource) Update( + ctx context.Context, + req resource.UpdateRequest, + resp *resource.UpdateResponse, +) { + // Retrieve the current state and plan + var state, plan ProjectResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + // Retrieve the API client + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + client := r.Meta.Metal + + // Extract the ID of the resource from the state + id := state.ID.ValueString() + + // Prepare update request based on the changes + updateRequest := &packngo.ProjectUpdateRequest{} + if state.Name != plan.Name { + updateRequest.Name = plan.Name.ValueStringPointer() + } + if state.PaymentMethodID != plan.Name { + updateRequest.PaymentMethodID = plan.PaymentMethodID.ValueStringPointer() + } + if state.BackendTransfer != plan.BackendTransfer { + updateRequest.BackendTransfer = plan.BackendTransfer.ValueBoolPointer() + } + + // Handle BGP Config changes + bgpConfig, diags := handleBGPConfigChanges(client, &plan, &state, id) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // NOTE (ocobles): adding this in the condition to replicate old behavior + // but it is not clear to me if it was a mistake. I think the project + // should be updated if has changes regardless of whether there are + // changes to the BGP configuration or not. + // Open discussion: https://github.com/equinix/terraform-provider-equinix/discussions/466 + var project *packngo.Project + var err error + if plan.BGPConfig.equal(state.BGPConfig) { + // API call to update the project + project, _, err = client.Projects.Update(id, updateRequest) + if err != nil { + friendlyErr := helper.FriendlyError(err) + resp.Diagnostics.AddError( + "Error updating project", + "Could not update project with ID " + id + ": " + friendlyErr.Error(), + ) + return + } + } else { + // Fetch the project + project, diags = fetchProject(client, id) + resp.Diagnostics.Append(diags...) + if diags.HasError() { + return + } + } + + // Set state to fully populated data + resp.Diagnostics.Append(plan.parse(project, bgpConfig)...) + if resp.Diagnostics.HasError() { + return + } + + // Read the updated state back into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *Resource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + // Retrieve the current state + var state ProjectResourceModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Retrieve the API client + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + client := r.Meta.Metal + + // Extract the ID of the resource from the state + id := state.ID.ValueString() + + // API call to delete the project + deleteResp, err := client.Projects.Delete(id) + if helper.IgnoreResponseErrors(helper.HttpForbidden, helper.HttpNotFound)(deleteResp, err) != nil { + err = helper.FriendlyError(err) + resp.Diagnostics.AddError( + fmt.Sprintf("Failed to delete Project %s", id), + err.Error(), + ) + } +} + + +var projectResourceSchema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "The unique identifier for the project.", + Computed: true, + }, + "name": schema.StringAttribute{ + Description: "The name of the project. The maximum length is 80 characters.", + Required: true, + }, + "created": schema.StringAttribute{ + Description: "The timestamp for when the project was created", + Computed: true, + }, + "updated": schema.StringAttribute{ + Description: "The timestamp for the last time the project was updated", + Computed: true, + }, + "backend_transfer": schema.BoolAttribute{ + Description: "Enable or disable Backend Transfer, default is false", + Optional: true, + Default: booldefault.StaticBool(false), + }, + "payment_method_id": schema.StringAttribute{ + Description: "The UUID of payment method for this project.", + Optional: true, + Computed: true, + Validators: []validator.String{ + stringvalidator.RegexMatches(uuidRE, "must be a valid UUID"), + }, + }, + "organization_id": schema.StringAttribute{ + Description: "The UUID of organization under which the project is created.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + stringvalidator.RegexMatches(uuidRE, "must be a valid UUID"), + }, + }, + "bgp_config": schema.SingleNestedAttribute{ + Description: "Optional BGP settings.", + Optional: true, + Attributes: bgpConfigSchema, + }, + }, +} + + +var bgpConfigSchema = map[string]schema.Attribute{ + "deployment_type": schema.StringAttribute{ + Description: "The BGP deployment type, either 'local' or 'global'.", + Required: true, + Validators: []validator.String{ + stringvalidator.OneOf("local", "global"), + }, + }, + "asn": schema.Int64Attribute{ + Description: "Autonomous System Number for local BGP deployment", + Required: true, + }, + "md5": schema.StringAttribute{ + Description: "Password for BGP session in plaintext (not a checksum)", + Sensitive: true, + Optional: true, + }, + "status": schema.StringAttribute{ + Description: "Status of BGP configuration in the project", + Computed: true, + }, + "max_prefix": schema.Int64Attribute{ + Description: "The maximum number of route filters allowed per server", + Computed: true, + }, +} + +func fetchProject(client *packngo.Client, projectID string) (*packngo.Project, diag.Diagnostics) { + var diags diag.Diagnostics + + project, _, err := client.Projects.Get(projectID, nil) + if err != nil { + friendlyErr := helper.FriendlyError(err) + + // Check if the Project no longer exists + if helper.IsNotFound(friendlyErr) { + diags.AddWarning( + "Project not found", + fmt.Sprintf("Project (%s) not found, removing from state", projectID), + ) + } else { + diags.AddError( + "Error reading project", + "Could not read project with ID " + projectID + ": " + friendlyErr.Error(), + ) + } + return nil, diags + } + + return project, diags +} + +func fetchBGPConfig(client *packngo.Client, projectID string) (*packngo.BGPConfig, diag.Diagnostics) { + var diags diag.Diagnostics + + bgpConfig, _, err := client.BGPConfig.Get(projectID, nil) + if err != nil { + friendlyErr := helper.FriendlyError(err) + diags.AddError( + "Error reading BGP configuration", + "Could not read BGP configuration for project with ID " + projectID + ": " + friendlyErr.Error(), + ) + return nil, diags + } + + return bgpConfig, diags +} + +func handleBGPConfigChanges(client *packngo.Client, plan *ProjectResourceModel, state *ProjectResourceModel, projectID string) (*packngo.BGPConfig, diag.Diagnostics) { + var diags diag.Diagnostics + var bgpConfig *packngo.BGPConfig + + bgpAdded := plan.BGPConfig != nil && state.BGPConfig == nil + bgpRemoved := plan.BGPConfig == nil && state.BGPConfig != nil + bgpChanged := plan.BGPConfig != nil && state.BGPConfig != nil && !plan.BGPConfig.equal(state.BGPConfig) + + if bgpAdded { + // Create BGP Config + bgpCreateRequest := packngo.CreateBGPConfigRequest{ + DeploymentType: plan.BGPConfig.DeploymentType.ValueString(), + Asn: int(plan.BGPConfig.ASN.ValueInt64()), + } + if !plan.BGPConfig.MD5.IsNull() { + bgpCreateRequest.Md5 = plan.BGPConfig.MD5.ValueString() + } + _, err := client.BGPConfig.Create(projectID, bgpCreateRequest) + if err != nil { + friendlyErr := helper.FriendlyError(err) + diags.AddError( + "Error creating BGP configuration", + "Could not create BGP configuration for project: " + friendlyErr.Error(), + ) + return nil, diags + } + + // Fetch the newly created BGP Config + bgpConfig, diags = fetchBGPConfig(client, projectID) + diags.Append(diags...) + } else if bgpRemoved { + bgpConfStr := fmt.Sprintf( + "bgp_config {\n"+ + " deployment_type = \"%s\"\n"+ + " md5 = \"%s\"\n"+ + " asn = %d\n"+ + "}", + state.BGPConfig.DeploymentType.ValueString(), + state.BGPConfig.MD5.ValueString(), + state.BGPConfig.ASN.ValueInt64(), + ) + diags.AddError( + "Error removing BGP configuration", + fmt.Sprintf("BGP Config cannot be removed from a project, please add back\n%s", bgpConfStr), + ) + } else if bgpChanged { + diags.AddError( + "Error updating BGP configuration", + "BGP configuration fields cannot be updated", + ) + } else { // assuming already exists + // Fetch the existing BGP Config + bgpConfig, diags = fetchBGPConfig(client, projectID) + diags.Append(diags...) + } + + return bgpConfig, diags +} From abb7f7f5a602213186d12f8d2066b7ca0d31d90d Mon Sep 17 00:00:00 2001 From: ocobleseqx Date: Thu, 23 Nov 2023 10:44:39 +0100 Subject: [PATCH 10/26] fixup! equinix_metal_project Signed-off-by: ocobleseqx --- equinix/provider.go | 2 +- internal/framework_provider.go | 2 ++ internal/metal_project/resource.go | 18 ++++++++---------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/equinix/provider.go b/equinix/provider.go index 045b61dd2..be646969c 100644 --- a/equinix/provider.go +++ b/equinix/provider.go @@ -166,7 +166,7 @@ func Provider() *schema.Provider { "equinix_metal_organization_member": resourceMetalOrganizationMember(), "equinix_metal_port": resourceMetalPort(), "equinix_metal_project_ssh_key": resourceMetalProjectSSHKey(), - "equinix_metal_project": resourceMetalProject(), + // "equinix_metal_project": resourceMetalProject(), "equinix_metal_organization": resourceMetalOrganization(), "equinix_metal_reserved_ip_block": resourceMetalReservedIPBlock(), "equinix_metal_ip_attachment": resourceMetalIPAttachment(), diff --git a/internal/framework_provider.go b/internal/framework_provider.go index bcdef4941..de96450db 100644 --- a/internal/framework_provider.go +++ b/internal/framework_provider.go @@ -8,6 +8,7 @@ import ( "github.com/equinix/terraform-provider-equinix/internal/config" "github.com/equinix/terraform-provider-equinix/internal/metal_bgp_session" "github.com/equinix/terraform-provider-equinix/internal/metal_ssh_key" + "github.com/equinix/terraform-provider-equinix/internal/metal_project" "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/provider" @@ -98,6 +99,7 @@ func (p *FrameworkProvider) Resources(ctx context.Context) []func() resource.Res return []func() resource.Resource{ metal_bgp_session.NewResource, metal_ssh_key.NewResource, + metal_project.NewResource, } } diff --git a/internal/metal_project/resource.go b/internal/metal_project/resource.go index 09b18da59..1736474cf 100644 --- a/internal/metal_project/resource.go +++ b/internal/metal_project/resource.go @@ -22,14 +22,14 @@ import ( var uuidRE = regexp.MustCompile("^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[8|9|aA|bB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}$") type ProjectResourceModel struct { - ID types.String `tfsdk:"id"` - Name types.String `tfsdk:"name"` - Created types.String `tfsdk:"created"` - Updated types.String `tfsdk:"updated"` - BackendTransfer types.Bool `tfsdk:"backend_transfer"` - PaymentMethodID types.String `tfsdk:"payment_method_id"` - OrganizationID types.String `tfsdk:"organization_id"` - BGPConfig *BGPConfigModel `tfsdk:"bgp_config"` + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Created types.String `tfsdk:"created"` + Updated types.String `tfsdk:"updated"` + BackendTransfer types.Bool `tfsdk:"backend_transfer"` + PaymentMethodID types.String `tfsdk:"payment_method_id"` + OrganizationID types.String `tfsdk:"organization_id"` + BGPConfig *BGPConfigModel `tfsdk:"bgp_config"` } type BGPConfigModel struct { @@ -56,8 +56,6 @@ func (bgp *BGPConfigModel) equal(other *BGPConfigModel) bool { func (rm *ProjectResourceModel) parse(project *packngo.Project, bgpConfig *packngo.BGPConfig) diag.Diagnostics { var diags diag.Diagnostics - - // Assuming 'project' is the API response object rm.ID = types.StringValue(project.ID) rm.Name = types.StringValue(project.Name) rm.Created = types.StringValue(project.Created) From f84458aa9a30dd4811607b69abf6d440472ba068 Mon Sep 17 00:00:00 2001 From: ocobleseqx Date: Thu, 23 Nov 2023 12:45:22 +0100 Subject: [PATCH 11/26] equinix_metal_organization Signed-off-by: ocobleseqx --- equinix/provider.go | 2 +- internal/framework_provider.go | 2 + internal/metal_organization/resource.go | 380 ++++++++++++++++++++++++ 3 files changed, 383 insertions(+), 1 deletion(-) create mode 100644 internal/metal_organization/resource.go diff --git a/equinix/provider.go b/equinix/provider.go index be646969c..4664a94fa 100644 --- a/equinix/provider.go +++ b/equinix/provider.go @@ -167,7 +167,7 @@ func Provider() *schema.Provider { "equinix_metal_port": resourceMetalPort(), "equinix_metal_project_ssh_key": resourceMetalProjectSSHKey(), // "equinix_metal_project": resourceMetalProject(), - "equinix_metal_organization": resourceMetalOrganization(), + // "equinix_metal_organization": resourceMetalOrganization(), "equinix_metal_reserved_ip_block": resourceMetalReservedIPBlock(), "equinix_metal_ip_attachment": resourceMetalIPAttachment(), "equinix_metal_spot_market_request": resourceMetalSpotMarketRequest(), diff --git a/internal/framework_provider.go b/internal/framework_provider.go index de96450db..d9f0248cd 100644 --- a/internal/framework_provider.go +++ b/internal/framework_provider.go @@ -9,6 +9,7 @@ import ( "github.com/equinix/terraform-provider-equinix/internal/metal_bgp_session" "github.com/equinix/terraform-provider-equinix/internal/metal_ssh_key" "github.com/equinix/terraform-provider-equinix/internal/metal_project" + "github.com/equinix/terraform-provider-equinix/internal/metal_organization" "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/provider" @@ -100,6 +101,7 @@ func (p *FrameworkProvider) Resources(ctx context.Context) []func() resource.Res metal_bgp_session.NewResource, metal_ssh_key.NewResource, metal_project.NewResource, + metal_organization.NewResource, } } diff --git a/internal/metal_organization/resource.go b/internal/metal_organization/resource.go new file mode 100644 index 000000000..c4efb1326 --- /dev/null +++ b/internal/metal_organization/resource.go @@ -0,0 +1,380 @@ +package metal_organization + +import ( + "context" + "fmt" + "regexp" + "reflect" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/packethost/packngo" + "github.com/equinix/terraform-provider-equinix/internal/helper" +) + +var countryRE = regexp.MustCompile("(?i)^[a-z]{2}$") + +type OrganizationResourceModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Description types.String `tfsdk:"description"` + Website types.String `tfsdk:"website"` + Twitter types.String `tfsdk:"twitter"` + Logo types.String `tfsdk:"logo"` + Created types.String `tfsdk:"created"` + Updated types.String `tfsdk:"updated"` + Address OrganizationAddress `tfsdk:"address"` +} + +type OrganizationAddress struct { + Address types.String `tfsdk:"address"` + City types.String `tfsdk:"city"` + ZipCode types.String `tfsdk:"zip_code"` + Country types.String `tfsdk:"country"` + State types.String `tfsdk:"state"` +} + +func (rm *OrganizationResourceModel) parse(org *packngo.Organization) diag.Diagnostics { + var diags diag.Diagnostics + + rm.ID = types.StringValue(org.ID) + rm.Name = types.StringValue(org.Name) + rm.Description = types.StringValue(org.Description) + rm.Website = types.StringValue(org.Website) + rm.Twitter = types.StringValue(org.Twitter) + rm.Logo = types.StringValue(org.Logo) + rm.Created = types.StringValue(org.Created) + rm.Updated = types.StringValue(org.Updated) + + address := OrganizationAddress{} + diags.Append(address.parse(&org.Address)...) + rm.Address = address + + return diags +} + +func (addr *OrganizationAddress) parse(a *packngo.Address) diag.Diagnostics { + var diags diag.Diagnostics + + addr.Address = types.StringValue(a.Address) + if a.City != nil { + addr.City = types.StringValue(*a.City) + } + addr.ZipCode = types.StringValue(a.ZipCode) + addr.Country = types.StringValue(a.Country) + if a.State != nil { + addr.State = types.StringValue(*a.State) + } + + return diags +} + + +func NewResource() resource.Resource { + return &Resource{ + BaseResource: helper.NewBaseResource( + helper.BaseResourceConfig{ + Name: "equinix_organization", + Schema: &organizationResourceSchema, + }, + ), + } +} + +type Resource struct { + helper.BaseResource +} + +func (r *Resource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + // Create an instance of your resource model to hold the planned state + var plan OrganizationResourceModel + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Required data for the API request + createRequest := packngo.OrganizationCreateRequest{ + Name: plan.Name.ValueString(), + // Expand the address + Address: packngo.Address{ + Address: plan.Address.Address.ValueString(), + City: plan.Address.City.ValueStringPointer(), + ZipCode: plan.Address.ZipCode.ValueString(), + Country: plan.Address.Country.ValueString(), + State: plan.Address.State.ValueStringPointer(), + }, + } + + // Optional fields + if !plan.Description.IsNull() { + createRequest.Description = plan.Description.ValueString() + } + if !plan.Website.IsNull() { + createRequest.Website = plan.Website.ValueString() + } + if !plan.Twitter.IsNull() { + createRequest.Twitter = plan.Twitter.ValueString() + } + if !plan.Logo.IsNull() { + createRequest.Logo = plan.Logo.ValueString() + } + + // Retrieve the API client from the provider metadata + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + client := r.Meta.Metal + + // API call to create the organization + org, _, err := client.Organizations.Create(&createRequest) + if err != nil { + resp.Diagnostics.AddError( + "Error creating Organization", + "Could not create Organization: " + err.Error(), + ) + return + } + + // Parse API response into the Terraform state + stateDiags := plan.parse(org) + resp.Diagnostics.Append(stateDiags...) + if resp.Diagnostics.HasError() { + return + } + + // Set the state + diags = resp.State.Set(ctx, &plan) + resp.Diagnostics.Append(diags...) +} + +func (r *Resource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + // Retrieve the current state + var state OrganizationResourceModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Retrieve the API client from the provider metadata + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + client := r.Meta.Metal + + // Extract the ID of the resource from the state + id := state.ID.ValueString() + + // API call to get the current state of the organization + org, _, err := client.Organizations.Get(id, &packngo.GetOptions{Includes: []string{"address"}}) + if err != nil { + err = helper.FriendlyError(err) + + // Check if the organization no longer exists + if helper.IsNotFound(err) { + resp.Diagnostics.AddWarning( + "Organization", + fmt.Sprintf("[WARN] Organization (%s) not found, removing from state", id), + ) + resp.State.RemoveResource(ctx) + return + } + + resp.Diagnostics.AddError( + "Error reading Organization", + "Could not read Organization with ID " + id + ": " + err.Error(), + ) + return + } + + // Parse the API response into the Terraform state + stateDiags := state.parse(org) + resp.Diagnostics.Append(stateDiags...) + if resp.Diagnostics.HasError() { + return + } + + // Update the Terraform state + diags = resp.State.Set(ctx, &state) + resp.Diagnostics.Append(diags...) +} + +func (r *Resource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + // Retrieve the current state and plan + var state, plan OrganizationResourceModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + diags = req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Retrieve the API client from the provider metadata + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + client := r.Meta.Metal + + // Extract the ID of the organization from the state + id := state.ID.ValueString() + + // Prepare update request based on the changes + updateRequest := &packngo.OrganizationUpdateRequest{} + if state.Name != plan.Name { + updateRequest.Name = plan.Name.ValueStringPointer() + } + if state.Description != plan.Description { + updateRequest.Description = plan.Description.ValueStringPointer() + } + if state.Website != plan.Website { + updateRequest.Website = plan.Website.ValueStringPointer() + } + if state.Twitter != plan.Twitter { + updateRequest.Twitter = plan.Twitter.ValueStringPointer() + } + if state.Logo != plan.Logo { + updateRequest.Logo = plan.Logo.ValueStringPointer() + } + + // Handle address updates + if !reflect.DeepEqual(state.Address, plan.Address) { + updateRequest.Address = &packngo.Address{ + Address: plan.Address.Address.ValueString(), + City: plan.Address.City.ValueStringPointer(), + ZipCode: plan.Address.ZipCode.ValueString(), + Country: plan.Address.Country.ValueString(), + State: plan.Address.State.ValueStringPointer(), + } + } + + // API call to update the organization + updatedOrg, _, err := client.Organizations.Update(id, updateRequest) + if err != nil { + err = helper.FriendlyError(err) + resp.Diagnostics.AddError( + "Error updating Organization", + "Could not update Organization with ID " + id + ": " + err.Error(), + ) + return + } + + // Parse the updated API response into the Terraform state + stateDiags := state.parse(updatedOrg) + resp.Diagnostics.Append(stateDiags...) + if stateDiags.HasError() { + return + } + + // Update the Terraform state + diags = resp.State.Set(ctx, &state) + resp.Diagnostics.Append(diags...) +} + +func (r *Resource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + // Retrieve the current state + var state OrganizationResourceModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Retrieve the API client + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + client := r.Meta.Metal + + // Extract the ID of the organization + id := state.ID.ValueString() + + // API call to delete the organization + deleteResp, err := client.Organizations.Delete(id) + if helper.IgnoreResponseErrors(helper.HttpForbidden, helper.HttpNotFound)(deleteResp, err) != nil { + err = helper.FriendlyError(err) + resp.Diagnostics.AddError( + fmt.Sprintf("Failed to delete Organization %s", id), + err.Error(), + ) + } +} + +var organizationResourceSchema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "The unique identifier for the Organization", + Computed: true, + }, + "name": schema.StringAttribute{ + Description: "The name of the Organization", + Required: true, + }, + "description": schema.StringAttribute{ + Description: "Description string", + Optional: true, + }, + "website": schema.StringAttribute{ + Description: "Website link", + Optional: true, + }, + "twitter": schema.StringAttribute{ + Description: "Twitter handle", + Optional: true, + }, + "logo": schema.StringAttribute{ + Description: "Logo URL", + Optional: true, + }, + "created": schema.StringAttribute{ + Description: "The timestamp for when the Organization was created", + Computed: true, + }, + "updated": schema.StringAttribute{ + Description: "The timestamp for the last time the Organization was updated", + Computed: true, + }, + "address": schema.SingleNestedAttribute{ + Description: "Address information block", + Required: true, + Attributes: addressSchema, + }, + }, +} + +var addressSchema = map[string]schema.Attribute{ + "address": schema.StringAttribute{ + Description: "Postal address", + Required: true, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, + }, + "city": schema.StringAttribute{ + Description: "City name", + Required: true, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, + }, + "zip_code": schema.StringAttribute{ + Description: "Zip Code", + Required: true, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, + }, + "country": schema.StringAttribute{ + Description: "Two letter country code (ISO 3166-1 alpha-2), e.g. US", + Required: true, + Validators: []validator.String{ + stringvalidator.RegexMatches(countryRE, "Address country must be a two letter code (ISO 3166-1 alpha-2)"), + }, + }, + "state": schema.StringAttribute{ + Description: "State name", + Optional: true, + }, +} From a0f5904d59db48013f8ed8799757b468d3457ca4 Mon Sep 17 00:00:00 2001 From: ocobleseqx Date: Thu, 23 Nov 2023 18:50:11 +0100 Subject: [PATCH 12/26] equinix_organization_member Signed-off-by: ocobleseqx --- equinix/provider.go | 2 +- internal/framework_provider.go | 2 + internal/metal_organization/resource.go | 1 + .../metal_organization_member/resource.go | 405 ++++++++++++++++++ 4 files changed, 409 insertions(+), 1 deletion(-) create mode 100644 internal/metal_organization_member/resource.go diff --git a/equinix/provider.go b/equinix/provider.go index 4664a94fa..d2c2ef9fe 100644 --- a/equinix/provider.go +++ b/equinix/provider.go @@ -163,7 +163,7 @@ func Provider() *schema.Provider { "equinix_metal_device": resourceMetalDevice(), "equinix_metal_device_network_type": resourceMetalDeviceNetworkType(), // "equinix_metal_ssh_key": resourceMetalSSHKey(), - "equinix_metal_organization_member": resourceMetalOrganizationMember(), + // "equinix_metal_organization_member": resourceMetalOrganizationMember(), "equinix_metal_port": resourceMetalPort(), "equinix_metal_project_ssh_key": resourceMetalProjectSSHKey(), // "equinix_metal_project": resourceMetalProject(), diff --git a/internal/framework_provider.go b/internal/framework_provider.go index d9f0248cd..daa005c78 100644 --- a/internal/framework_provider.go +++ b/internal/framework_provider.go @@ -10,6 +10,7 @@ import ( "github.com/equinix/terraform-provider-equinix/internal/metal_ssh_key" "github.com/equinix/terraform-provider-equinix/internal/metal_project" "github.com/equinix/terraform-provider-equinix/internal/metal_organization" + "github.com/equinix/terraform-provider-equinix/internal/metal_organization_member" "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/provider" @@ -102,6 +103,7 @@ func (p *FrameworkProvider) Resources(ctx context.Context) []func() resource.Res metal_ssh_key.NewResource, metal_project.NewResource, metal_organization.NewResource, + metal_organization_member.NewResource, } } diff --git a/internal/metal_organization/resource.go b/internal/metal_organization/resource.go index c4efb1326..42e46bb7e 100644 --- a/internal/metal_organization/resource.go +++ b/internal/metal_organization/resource.go @@ -132,6 +132,7 @@ func (r *Resource) Create(ctx context.Context, req resource.CreateRequest, resp // API call to create the organization org, _, err := client.Organizations.Create(&createRequest) if err != nil { + err = helper.FriendlyError(err) resp.Diagnostics.AddError( "Error creating Organization", "Could not create Organization: " + err.Error(), diff --git a/internal/metal_organization_member/resource.go b/internal/metal_organization_member/resource.go new file mode 100644 index 000000000..07b13a799 --- /dev/null +++ b/internal/metal_organization_member/resource.go @@ -0,0 +1,405 @@ +package metal_organization_member + +import ( + "context" + "fmt" + "path" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/packethost/packngo" + "github.com/equinix/terraform-provider-equinix/internal/helper" +) + +type OrganizationMemberOrInvite struct { + *packngo.Member + *packngo.Invitation +} + +func (m *OrganizationMemberOrInvite) isMember() bool { + return m.Member != nil +} + +func (m *OrganizationMemberOrInvite) isInvitation() bool { + return m.Invitation != nil +} + +type OrganizationMemberResourceModel struct { + ID types.String `tfsdk:"id"` + Invitee types.String `tfsdk:"invitee"` + InvitedBy types.String `tfsdk:"invited_by"` + OrganizationID types.String `tfsdk:"organization_id"` + ProjectsIDs types.Set `tfsdk:"projects_ids"` + Nonce types.String `tfsdk:"nonce"` + Message types.String `tfsdk:"message"` + Created types.String `tfsdk:"created"` + Updated types.String `tfsdk:"updated"` + Roles types.Set `tfsdk:"roles"` + State types.String `tfsdk:"state"` +} + +func findMember(invitee string, members []packngo.Member, invitations []packngo.Invitation) (*OrganizationMemberOrInvite, error) { + for _, mbr := range members { + if mbr.User.Email == invitee { + return &OrganizationMemberOrInvite{Member: &mbr}, nil + } + } + + for _, inv := range invitations { + if inv.Invitee == invitee { + return &OrganizationMemberOrInvite{Invitation: &inv}, nil + } + } + return nil, fmt.Errorf("member not found") +} + +func (rm *OrganizationMemberResourceModel) parse(ctx context.Context, m *OrganizationMemberOrInvite) diag.Diagnostics { + var diags diag.Diagnostics + + if m.isMember() { + // Parse member data + rm.Invitee = types.StringValue(m.Member.User.Email) + rm.OrganizationID = types.StringValue(path.Base(m.Member.Organization.URL)) + rm.State = types.StringValue("active") + + memberProjects := make([]string, len(m.Member.Projects)) + for i, project := range m.Member.Projects { + memberProjects[i] = path.Base(project.URL) + } + + projectIDs, diags := types.SetValueFrom(ctx, types.StringType, memberProjects) + if diags.HasError() { + return diags + } + rm.ProjectsIDs = projectIDs + + roles, diags := types.SetValueFrom(ctx, types.StringType, m.Member.Roles) + if diags.HasError() { + return diags + } + rm.Roles = roles + } else if m.isInvitation() { + // Parse invitation data + rm.Invitee = types.StringValue(m.Invitation.Invitee) + rm.OrganizationID = types.StringValue(path.Base(m.Invitation.Organization.Href)) + rm.State = types.StringValue("invited") + rm.Created = types.StringValue(m.Invitation.CreatedAt.String()) + rm.Updated = types.StringValue(m.Invitation.UpdatedAt.String()) + rm.Nonce = types.StringValue(m.Invitation.Nonce) + rm.InvitedBy = types.StringValue(path.Base(m.Invitation.InvitedBy.Href)) + + projectIDs, diags := types.SetValueFrom(ctx, types.StringType, m.Invitation.Projects) + if diags.HasError() { + return diags + } + rm.ProjectsIDs = projectIDs + + roles, diags := types.SetValueFrom(ctx, types.StringType, m.Invitation.Roles) + if diags.HasError() { + return diags + } + rm.Roles = roles + } + + // Construct the ID for the resource after rm.Invitee and rm.OrganizationID are updated + id := fmt.Sprintf("%s:%s", rm.Invitee.ValueString(), rm.OrganizationID.ValueString()) + rm.ID = types.StringValue(id) + + return diags +} + + +func NewResource() resource.Resource { + return &Resource{ + BaseResource: helper.NewBaseResource( + helper.BaseResourceConfig{ + Name: "equinix_organization_member", + Schema: &organizationMemberResourceSchema, + }, + ), + } +} + +type Resource struct { + helper.BaseResource +} + +func (r *Resource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + // Create an instance of your resource model to hold the planned state + var plan OrganizationMemberResourceModel + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Prepare the data for the API request + var roles []string + resp.Diagnostics.Append(plan.Roles.ElementsAs(ctx, &roles, false)...) + if resp.Diagnostics.HasError() { + return + } + + var projectIDs []string + resp.Diagnostics.Append(plan.ProjectsIDs.ElementsAs(ctx, &projectIDs, false)...) + if resp.Diagnostics.HasError() { + return + } + + createRequest := &packngo.InvitationCreateRequest{ + Invitee: plan.Invitee.ValueString(), + Message: strings.TrimSpace(plan.Message.ValueString()), + Roles: roles, + ProjectsIDs: projectIDs, + } + + // Retrieve the API client from the provider metadata + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + client := r.Meta.Metal + + // API call to create the organization member or send an invitation + invitation, _, err := client.Invitations.Create(plan.OrganizationID.ValueString(), createRequest, nil) + if err != nil { + err = helper.FriendlyError(err) + resp.Diagnostics.AddError( + "Error creating Organization Member", + "Could not create organization member or send invitation: " + err.Error(), + ) + return + } + + // Invitation object wrapped in a member type required by the parse function + m := &OrganizationMemberOrInvite{Invitation: invitation} + + // Parse API response into the Terraform state + stateDiags := plan.parse(ctx, m) + resp.Diagnostics.Append(stateDiags...) + if resp.Diagnostics.HasError() { + return + } + + // Set the state + diags = resp.State.Set(ctx, &plan) + resp.Diagnostics.Append(diags...) +} + +func (r *Resource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + // Retrieve the current state + var state OrganizationMemberResourceModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Retrieve the API client + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + client := r.Meta.Metal + + // Extract the invitee email and organization ID from the state + parts := strings.Split(state.ID.ValueString(), ":") + if len(parts) != 2 { + resp.Diagnostics.AddError( + "Invalid ID format", + "Expected ID format is 'invitee:organizationID'. Got: " + state.ID.ValueString(), + ) + return + } + invitee := parts[0] + orgID := parts[1] + + // API calls to get the current state of the organization member + invitations, _, err := client.Invitations.List(orgID, &packngo.ListOptions{Includes: []string{"user"}}) + if err != nil { + err = helper.FriendlyError(err) + // If the org was destroyed, mark as gone + if helper.IsNotFound(err) { + resp.Diagnostics.AddWarning( + "Organization Member", + fmt.Sprintf("[WARN] Organization (%s) not found, removing Organization Member from state", orgID), + ) + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError( + "Error reading Organization Invitations", + "Could not read invitations for organization: " + err.Error(), + ) + return + } + + members, _, err := client.Members.List(orgID, &packngo.GetOptions{Includes: []string{"user"}}) + if err != nil { + err = helper.FriendlyError(err) + // If the org was destroyed, mark as gone + if helper.IsNotFound(err) { + resp.Diagnostics.AddWarning( + "Organization Member", + fmt.Sprintf("[WARN] Organization (%s) not found, removing Organization Member from state", orgID), + ) + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError( + "Error reading Organization Members", + "Could not read members for organization: " + err.Error(), + ) + return + } + + member, err := findMember(invitee, members, invitations) + // TODO (ocobles) we used to check here with legacy SDKv2 + // if !d.IsNewResource() && err != nil + // to find out if the Read function was called during import + // Now we can use Private state but not sure how to + // https://github.com/hashicorp/terraform-plugin-sdk/issues/1005#issuecomment-1623695760 + if err != nil { + resp.Diagnostics.AddError( + "Error finding organization member", + "Could not find member or invitation: " + err.Error(), + ) + return + } + + // Parse the API response into the Terraform state + parseDiags := state.parse(ctx, member) + resp.Diagnostics.Append(parseDiags...) + if parseDiags.HasError() { + return + } + + // Update the Terraform state + diags = resp.State.Set(ctx, &state) + resp.Diagnostics.Append(diags...) +} + +func (r *Resource) Update( + ctx context.Context, + req resource.UpdateRequest, + resp *resource.UpdateResponse, +) { + // This resource does not support updates +} + +func (r *Resource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + // Retrieve the current state + var state OrganizationMemberResourceModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Retrieve the API client + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + client := r.Meta.Metal + + // Fetch current members and invitations + invitations, _, err := client.Invitations.List(state.OrganizationID.String(), &packngo.ListOptions{Includes: []string{"user"}}) + if err != nil { + resp.Diagnostics.AddError( + "Error reading organization invitations", + "Could not read invitations for organization: " + err.Error(), + ) + return + } + + members, _, err := client.Members.List(state.OrganizationID.String(), &packngo.GetOptions{Includes: []string{"user"}}) + if err != nil { + resp.Diagnostics.AddError( + "Error reading organization members", + "Could not read members for organization: " + err.Error(), + ) + return + } + + // Find the member or invitation to delete + member, err := findMember(state.Invitee.String(), members, invitations) + if err != nil { + // If member or invitation is not found, it's already gone + return + } + + // Delete the member or invitation + if member.isMember() { + _, err = client.Members.Delete(state.OrganizationID.String(), member.Member.ID) + } else if member.isInvitation() { + _, err = client.Invitations.Delete(member.Invitation.ID) + } + + if err != nil { + err = helper.FriendlyError(err) + // If the member/invitation was deleted, mark as gone. + if helper.IsNotFound(err) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError( + "Error deleting organization member", + "Could not delete member or invitation: " + err.Error(), + ) + return + } +} + +var organizationMemberResourceSchema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "The unique identifier for the organization member.", + Computed: true, + }, + "invitee": schema.StringAttribute{ + Description: "The email address of the user to invite", + Required: true, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, + }, + "invited_by": schema.StringAttribute{ + Description: "The user id of the user that sent the invitation (only known in the invitation stage)", + Computed: true, + }, + "organization_id": schema.StringAttribute{ + Description: "The organization to invite the user to", + Required: true, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, + }, + "projects_ids": schema.SetAttribute{ + Description: "Project IDs the member has access to within the organization. If the member is an 'owner', the projects list should be empty.", + Required: true, + ElementType: types.StringType, + }, + "nonce": schema.StringAttribute{ + Description: "The nonce for the invitation (only known in the invitation stage)", + Computed: true, + }, + "message": schema.StringAttribute{ + Description: "A message to the invitee (only used during the invitation stage)", + Optional: true, + }, + "created": schema.StringAttribute{ + Description: "When the invitation was created (only known in the invitation stage)", + Computed: true, + }, + "updated": schema.StringAttribute{ + Description: "When the invitation was updated (only known in the invitation stage)", + Computed: true, + }, + "roles": schema.SetAttribute{ + Description: "Organization roles (owner, collaborator, limited_collaborator, billing)", + Required: true, + ElementType: types.StringType, + }, + "state": schema.StringAttribute{ + Description: "The state of the membership ('invited' when an invitation is open, 'active' when the user is an organization member)", + Computed: true, + }, + }, +} From 88a46ee3983264e379a60ee869a7eeb52bbaa763 Mon Sep 17 00:00:00 2001 From: ocobleseqx Date: Thu, 23 Nov 2023 18:54:31 +0100 Subject: [PATCH 13/26] fixup! equinix_metal_organization Signed-off-by: ocobleseqx --- internal/metal_organization/resource.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/metal_organization/resource.go b/internal/metal_organization/resource.go index 42e46bb7e..ad7c3b83c 100644 --- a/internal/metal_organization/resource.go +++ b/internal/metal_organization/resource.go @@ -78,7 +78,7 @@ func NewResource() resource.Resource { return &Resource{ BaseResource: helper.NewBaseResource( helper.BaseResourceConfig{ - Name: "equinix_organization", + Name: "equinix_metal_organization", Schema: &organizationResourceSchema, }, ), From bc787912d88f6525d2163ac43ef4e46580a7ae62 Mon Sep 17 00:00:00 2001 From: ocobleseqx Date: Thu, 23 Nov 2023 18:54:48 +0100 Subject: [PATCH 14/26] fixup! equinix_organization_member Signed-off-by: ocobleseqx --- internal/metal_organization_member/resource.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/metal_organization_member/resource.go b/internal/metal_organization_member/resource.go index 07b13a799..11589cd38 100644 --- a/internal/metal_organization_member/resource.go +++ b/internal/metal_organization_member/resource.go @@ -118,7 +118,7 @@ func NewResource() resource.Resource { return &Resource{ BaseResource: helper.NewBaseResource( helper.BaseResourceConfig{ - Name: "equinix_organization_member", + Name: "equinix_metal_organization_member", Schema: &organizationMemberResourceSchema, }, ), From 25547f3af64a8713d79600e4cb4d0c23a3b49002 Mon Sep 17 00:00:00 2001 From: ocobleseqx Date: Fri, 24 Nov 2023 12:51:46 +0100 Subject: [PATCH 15/26] migrated port helpers Signed-off-by: ocobleseqx --- internal/helper/utils.go | 75 ++++++ internal/metal_port/port_helpers.go | 362 ++++++++++++++++++++++++++++ 2 files changed, 437 insertions(+) create mode 100644 internal/helper/utils.go create mode 100644 internal/metal_port/port_helpers.go diff --git a/internal/helper/utils.go b/internal/helper/utils.go new file mode 100644 index 000000000..9d53fa258 --- /dev/null +++ b/internal/helper/utils.go @@ -0,0 +1,75 @@ +package helper + +import ( + "strconv" + "strings" +) + +func Contains(s []string, e string) bool { + for _, a := range s { + if a == e { + return true + } + } + return false +} + +func StringArrToIfArr(sli []string) []interface{} { + var arr []interface{} + for _, v := range sli { + arr = append(arr, v) + } + return arr +} + +func ConvertStringArr(ifaceArr []interface{}) []string { + var arr []string + for _, v := range ifaceArr { + if v == nil { + continue + } + arr = append(arr, v.(string)) + } + return arr +} + +func ConvertIntArr(ifaceArr []interface{}) []string { + var arr []string + for _, v := range ifaceArr { + if v == nil { + continue + } + arr = append(arr, strconv.Itoa(v.(int))) + } + return arr +} + +func ConvertIntArr2(ifaceArr []interface{}) []int { + var arr []int + for _, v := range ifaceArr { + if v == nil { + continue + } + arr = append(arr, v.(int)) + } + return arr +} + +func ToLower(v interface{}) string { + return strings.ToLower(v.(string)) +} + +// from https://stackoverflow.com/a/45428032 +func Difference(a, b []string) []string { + mb := make(map[string]struct{}, len(b)) + for _, x := range b { + mb[x] = struct{}{} + } + var diff []string + for _, x := range a { + if _, found := mb[x]; !found { + diff = append(diff, x) + } + } + return diff +} diff --git a/internal/metal_port/port_helpers.go b/internal/metal_port/port_helpers.go new file mode 100644 index 000000000..482ea95ad --- /dev/null +++ b/internal/metal_port/port_helpers.go @@ -0,0 +1,362 @@ +package metal_port + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" + "github.com/packethost/packngo" + "github.com/pkg/errors" + "github.com/equinix/terraform-provider-equinix/internal/helper" +) + +type ClientPortData struct { + Client *packngo.Client + Port *packngo.Port + Data MetalPortResourceModel +} + +func getPortData(client *packngo.Client, data MetalPortResourceModel) (*ClientPortData, *packngo.Response, error) { + getOpts := &packngo.GetOptions{Includes: []string{ + "native_virtual_network", + "virtual_networks", + }} + port, resp, err := client.Ports.Get(data.PortID.ValueString(), getOpts) + if err != nil { + return nil, resp, helper.FriendlyError(err) + } + + cpd := &ClientPortData{ + Client: client, + Port: port, + Data: data, + } + return cpd, resp, nil +} + +func getPortByResourceData(d MetalPortResourceModel, client *packngo.Client) (*packngo.Port, error) { + portId := d.PortID + resourceId := d.ID + + // rely on d.Id in imported resources + if portId.IsNull() { + if !resourceId.IsNull() { + portId = resourceId + } + } + deviceId := d.DeviceID + portName := d.Name + + // check parameter sanity only for a new (not-yet-created) resource + if resourceId.IsNull() { + if !portId.IsNull() && (!deviceId.IsNull() || !portName.IsNull()) { + return nil, fmt.Errorf("you must specify either id or (device_id and name)") + } + } + + var port *packngo.Port + + getOpts := &packngo.GetOptions{Includes: []string{ + "native_virtual_network", + "virtual_networks", + }} + if !portId.IsNull() { + var err error + port, _, err = client.Ports.Get(portId.ValueString(), getOpts) + if err != nil { + return nil, err + } + } else { + if deviceId.IsNull() && portName.IsNull() { + return nil, fmt.Errorf("If you don't use port_id, you must supply both device_id and name") + } + device, _, err := client.Devices.Get(deviceId.ValueString(), getOpts) + if err != nil { + return nil, err + } + return device.GetPortByName(portName.ValueString()) + } + + return port, nil +} + +func getSpecifiedNative(d MetalPortResourceModel) string { + specifiedNative := "" + if !d.NativeVLANID.IsNull() { + specifiedNative = d.NativeVLANID.ValueString() + } + return specifiedNative +} + +func getCurrentNative(p *packngo.Port) string { + currentNative := "" + if p.NativeVirtualNetwork != nil { + currentNative = p.NativeVirtualNetwork.ID + } + return currentNative +} + +func attachedVlanIds(p *packngo.Port) []string { + attached := []string{} + for _, v := range p.AttachedVirtualNetworks { + attached = append(attached, v.ID) + } + return attached +} + +func specifiedVlanIds(ctx context.Context, d MetalPortResourceModel) ([]string, error) { + if !d.VlanIDs.IsNull() { + var ids []string + diags := d.VlanIDs.ElementsAs(ctx, &ids, false) + if diags.HasError(){ + return nil, fmt.Errorf("%w", diags.Errors()) + } + } + + if !d.VxlanIDs.IsNull() { + var ids []string + diags := d.VxlanIDs.ElementsAs(ctx, &ids, false) + if diags.HasError(){ + return nil, fmt.Errorf("%w", diags.Errors()) + } + } + + return []string{}, nil +} + +func batchVlans(ctx context.Context, start time.Time, removeOnly bool) func(*ClientPortData) error { + return func(cpd *ClientPortData) error { + var vlansToAssign []string + var currentNative string + specifiedVlanIds, err := specifiedVlanIds(ctx, cpd.Data) + if err != nil { + return err + } + vlansToRemove := helper.Difference( + attachedVlanIds(cpd.Port), + specifiedVlanIds, + ) + if !removeOnly { + currentNative = getCurrentNative(cpd.Port) + + vlansToAssign = helper.Difference( + specifiedVlanIds, + attachedVlanIds(cpd.Port), + ) + } + vacr := &packngo.VLANAssignmentBatchCreateRequest{} + for _, v := range vlansToRemove { + vacr.VLANAssignments = append(vacr.VLANAssignments, packngo.VLANAssignmentCreateRequest{ + VLAN: v, + State: packngo.VLANAssignmentUnassigned, + }) + } + + for _, v := range vlansToAssign { + native := currentNative == v + vacr.VLANAssignments = append(vacr.VLANAssignments, packngo.VLANAssignmentCreateRequest{ + VLAN: v, + State: packngo.VLANAssignmentAssigned, + Native: &native, + }) + } + return createAndWaitForBatch(ctx, start, cpd, vacr) + } +} + +func createAndWaitForBatch(ctx context.Context, start time.Time, cpd *ClientPortData, vacr *packngo.VLANAssignmentBatchCreateRequest) error { + if len(vacr.VLANAssignments) == 0 { + return nil + } + + portID := cpd.Port.ID + c := cpd.Client + + b, _, err := c.VLANAssignments.CreateBatch(portID, vacr, nil) + if err != nil { + return fmt.Errorf("vlan assignment batch could not be created: %w", err) + } + + deadline, _ := ctx.Deadline() + // originally set timeout in ctx by TF + ctxTimeout := deadline.Sub(start) + + stateChangeConf := &retry.StateChangeConf{ + Delay: 5 * time.Second, + Pending: []string{string(packngo.VLANAssignmentBatchQueued), string(packngo.VLANAssignmentBatchInProgress)}, + Target: []string{string(packngo.VLANAssignmentBatchCompleted)}, + MinTimeout: 5 * time.Second, + Timeout: ctxTimeout - time.Since(start) - 30*time.Second, + Refresh: func() (result interface{}, state string, err error) { + b, _, err := c.VLANAssignments.GetBatch(portID, b.ID, nil) + switch b.State { + case packngo.VLANAssignmentBatchFailed: + return b, string(packngo.VLANAssignmentBatchFailed), + fmt.Errorf("vlan assignment batch %s provisioning failed: %s", b.ID, strings.Join(b.ErrorMessages, "; ")) + case packngo.VLANAssignmentBatchCompleted: + return b, string(packngo.VLANAssignmentBatchCompleted), nil + default: + if err != nil { + return b, "", fmt.Errorf("vlan assignment batch %s could not be polled: %w", b.ID, err) + } + return b, string(b.State), err + } + }, + } + if _, err = stateChangeConf.WaitForStateContext(ctx); err != nil { + return errors.Wrapf(err, "vlan assignment batch %s is not complete after timeout", b.ID) + } + return nil +} + +func updateNativeVlan(cpd *ClientPortData) error { + currentNative := getCurrentNative(cpd.Port) + specifiedNative := getSpecifiedNative(cpd.Data) + + if currentNative != specifiedNative { + var port *packngo.Port + var err error + if specifiedNative == "" && currentNative != "" { + port, _, err = cpd.Client.Ports.UnassignNative(cpd.Port.ID) + } else { + port, _, err = cpd.Client.Ports.AssignNative(cpd.Port.ID, specifiedNative) + } + if err != nil { + return err + } + *(cpd.Port) = *port + } + return nil +} + +func processBondAction(cpd *ClientPortData, actionIsBond bool) error { + wantsBonded := cpd.Data.Bonded.ValueBool() + // only act if the necessary action is the one specified in doBond + if wantsBonded == actionIsBond { + // act if the current Bond state of the port is different than the spcified + if wantsBonded != cpd.Port.Data.Bonded { + action := cpd.Client.Ports.Disbond + if wantsBonded { + action = cpd.Client.Ports.Bond + } + + port, _, err := action(cpd.Port.ID, false) + if err != nil { + return err + } + getOpts := &packngo.GetOptions{Includes: []string{ + "native_virtual_network", + "virtual_networks", + }} + port, _, err = cpd.Client.Ports.Get(port.ID, getOpts) + if err != nil { + return err + } + + *(cpd.Port) = *port + } + } + return nil +} + +func makeBond(cpd *ClientPortData) error { + return processBondAction(cpd, true) +} + +func makeDisbond(cpd *ClientPortData) error { + return processBondAction(cpd, false) +} + +func convertToL2(cpd *ClientPortData) error { + l2 := cpd.Data.Layer2 + isLayer2 := helper.Contains(l2Types, cpd.Port.NetworkType) + + if l2.ValueBool() && !isLayer2 { + port, _, err := cpd.Client.Ports.ConvertToLayerTwo(cpd.Port.ID) + if err != nil { + return err + } + *(cpd.Port) = *port + } + return nil +} + +func convertToL3(cpd *ClientPortData) error { + l2 := cpd.Data.Layer2 + isLayer2 := helper.Contains(l2Types, cpd.Port.NetworkType) + + if !l2.ValueBool() && isLayer2 { + ips := []packngo.AddressRequest{ + {AddressFamily: 4, Public: true}, + {AddressFamily: 4, Public: false}, + {AddressFamily: 6, Public: true}, + } + port, _, err := cpd.Client.Ports.ConvertToLayerThree(cpd.Port.ID, ips) + if err != nil { + return err + } + *(cpd.Port) = *port + } + return nil +} + +func portSanityChecks(ctx context.Context) func(*ClientPortData) error { + return func(cpd *ClientPortData) error { + isBondPort := cpd.Port.Type == "NetworkBondPort" + + // Constraint: Only bond ports have layer2 mode + l2 := cpd.Data.Layer2.ValueBool() + if !isBondPort && l2 { + return fmt.Errorf("layer2 flag can be set only for bond ports") + } + + bonded := cpd.Data.Bonded.ValueBool() + + // Constraint: L3 unbonded is not really allowed for Bond port + if isBondPort && !l2 && !bonded { + return fmt.Errorf("bond port in Layer3 can't be unbonded") + } + + // Constraint: native vlan .. + // - must be one of assigned vlans + // - there must be more than one vlan assigned to the port + nativeVlan := cpd.Data.NativeVLANID + if !nativeVlan.IsNull() { + vlans, err := specifiedVlanIds(ctx, cpd.Data) + if err != nil { + return err + } + if !helper.Contains(vlans, nativeVlan.ValueString()) { + return fmt.Errorf("the native VLAN to be set is not (being) assigned to the port") + } + if len(vlans) < 2 { + return fmt.Errorf("native VLAN can only be set if more than one VLAN are assigned to the port ") + } + } + + return nil + } +} + +func portProperlyDestroyed(port *packngo.Port) error { + var errs []string + if !port.Data.Bonded { + errs = append(errs, fmt.Sprintf("port %s wasn't bonded after equinix_metal_port destroy;", port.ID)) + } + if port.Type == "NetworkBondPort" && port.NetworkType != "layer3" { + errs = append(errs, "bond port should be in layer3 type after destroy;") + } + if port.NativeVirtualNetwork != nil { + errs = append(errs, "port should not have native VLAN assigned after destroy;") + } + if len(port.AttachedVirtualNetworks) != 0 { + errs = append(errs, "port should not have VLANs attached after destroy") + } + if len(errs) > 0 { + return fmt.Errorf("%s", errs) + } + + return nil +} From 74ce2df269904b79d659231a06f5bcaa5f80a5b2 Mon Sep 17 00:00:00 2001 From: ocobleseqx Date: Fri, 24 Nov 2023 17:43:56 +0100 Subject: [PATCH 16/26] equinix_metal_port Signed-off-by: ocobleseqx --- equinix/provider.go | 2 +- internal/framework_provider.go | 2 + ...t_helpers.go => framework_port_helpers.go} | 34 +- internal/metal_port/resource.go | 434 ++++++++++++++++++ 4 files changed, 467 insertions(+), 5 deletions(-) rename internal/metal_port/{port_helpers.go => framework_port_helpers.go} (91%) create mode 100644 internal/metal_port/resource.go diff --git a/equinix/provider.go b/equinix/provider.go index d2c2ef9fe..f9409fef2 100644 --- a/equinix/provider.go +++ b/equinix/provider.go @@ -164,7 +164,7 @@ func Provider() *schema.Provider { "equinix_metal_device_network_type": resourceMetalDeviceNetworkType(), // "equinix_metal_ssh_key": resourceMetalSSHKey(), // "equinix_metal_organization_member": resourceMetalOrganizationMember(), - "equinix_metal_port": resourceMetalPort(), + // "equinix_metal_port": resourceMetalPort(), "equinix_metal_project_ssh_key": resourceMetalProjectSSHKey(), // "equinix_metal_project": resourceMetalProject(), // "equinix_metal_organization": resourceMetalOrganization(), diff --git a/internal/framework_provider.go b/internal/framework_provider.go index daa005c78..6c6fef60a 100644 --- a/internal/framework_provider.go +++ b/internal/framework_provider.go @@ -11,6 +11,7 @@ import ( "github.com/equinix/terraform-provider-equinix/internal/metal_project" "github.com/equinix/terraform-provider-equinix/internal/metal_organization" "github.com/equinix/terraform-provider-equinix/internal/metal_organization_member" + "github.com/equinix/terraform-provider-equinix/internal/metal_port" "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/provider" @@ -104,6 +105,7 @@ func (p *FrameworkProvider) Resources(ctx context.Context) []func() resource.Res metal_project.NewResource, metal_organization.NewResource, metal_organization_member.NewResource, + metal_port.NewResource, } } diff --git a/internal/metal_port/port_helpers.go b/internal/metal_port/framework_port_helpers.go similarity index 91% rename from internal/metal_port/port_helpers.go rename to internal/metal_port/framework_port_helpers.go index 482ea95ad..ee58c5997 100644 --- a/internal/metal_port/port_helpers.go +++ b/internal/metal_port/framework_port_helpers.go @@ -18,6 +18,31 @@ type ClientPortData struct { Data MetalPortResourceModel } +func updatePort(ctx context.Context, client *packngo.Client, plan MetalPortResourceModel) error { + start := time.Now() + cpd, _, err := getPortData(client, plan) + if err != nil { + return helper.FriendlyError(err) + } + + for _, f := range [](func(*ClientPortData) error){ + portSanityChecks(ctx), + batchVlans(ctx, start, true), + makeDisbond, + convertToL2, + makeBond, + convertToL3, + batchVlans(ctx, start, false), + updateNativeVlan, + } { + if err := f(cpd); err != nil { + return helper.FriendlyError(err) + } + } + + return nil +} + func getPortData(client *packngo.Client, data MetalPortResourceModel) (*ClientPortData, *packngo.Response, error) { getOpts := &packngo.GetOptions{Includes: []string{ "native_virtual_network", @@ -52,7 +77,7 @@ func getPortByResourceData(d MetalPortResourceModel, client *packngo.Client) (*p // check parameter sanity only for a new (not-yet-created) resource if resourceId.IsNull() { if !portId.IsNull() && (!deviceId.IsNull() || !portName.IsNull()) { - return nil, fmt.Errorf("you must specify either id or (device_id and name)") + return nil, fmt.Errorf("you must specify either port_id or (device_id and name)") } } @@ -70,7 +95,7 @@ func getPortByResourceData(d MetalPortResourceModel, client *packngo.Client) (*p } } else { if deviceId.IsNull() && portName.IsNull() { - return nil, fmt.Errorf("If you don't use port_id, you must supply both device_id and name") + return nil, fmt.Errorf("if you don't use port_id, you must supply both device_id and name") } device, _, err := client.Devices.Get(deviceId.ValueString(), getOpts) if err != nil { @@ -111,7 +136,8 @@ func specifiedVlanIds(ctx context.Context, d MetalPortResourceModel) ([]string, var ids []string diags := d.VlanIDs.ElementsAs(ctx, &ids, false) if diags.HasError(){ - return nil, fmt.Errorf("%w", diags.Errors()) + return nil, fmt.Errorf("failed to validate vlan IDs: %s", diags.Errors()) + } } @@ -119,7 +145,7 @@ func specifiedVlanIds(ctx context.Context, d MetalPortResourceModel) ([]string, var ids []string diags := d.VxlanIDs.ElementsAs(ctx, &ids, false) if diags.HasError(){ - return nil, fmt.Errorf("%w", diags.Errors()) + return nil, fmt.Errorf("failed to validate vxlan IDs: %s", diags.Errors()) } } diff --git a/internal/metal_port/resource.go b/internal/metal_port/resource.go new file mode 100644 index 000000000..87989c618 --- /dev/null +++ b/internal/metal_port/resource.go @@ -0,0 +1,434 @@ +package metal_port + +import ( + "context" + "fmt" + "time" + + "github.com/equinix/terraform-provider-equinix/internal/helper" + "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "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/packethost/packngo" +) + +var ( + l2Types = []string{"layer2-individual", "layer2-bonded"} + l3Types = []string{"layer3", "hybrid", "hybrid-bonded"} +) + +type MetalPortResourceModel struct { + ID types.String `tfsdk:"id"` + PortID types.String `tfsdk:"port_id"` + DeviceID types.String `tfsdk:"device_id"` + Bonded types.Bool `tfsdk:"bonded"` + Layer2 types.Bool `tfsdk:"layer2"` + NativeVLANID types.String `tfsdk:"native_vlan_id"` + VxlanIDs types.Set `tfsdk:"vxlan_ids"` + VlanIDs types.Set `tfsdk:"vlan_ids"` + ResetOnDelete types.Bool `tfsdk:"reset_on_delete"` + Name types.String `tfsdk:"name"` + NetworkType types.String `tfsdk:"network_type"` + DisbondSupported types.Bool `tfsdk:"disbond_supported"` + BondName types.String `tfsdk:"bond_name"` + BondID types.String `tfsdk:"bond_id"` + Type types.String `tfsdk:"type"` + Mac types.String `tfsdk:"mac"` +} + +func (rm *MetalPortResourceModel) parse(ctx context.Context, port *packngo.Port) diag.Diagnostics { + var diags diag.Diagnostics + + // Assuming 'port' is the API response object for a MetalPort resource + rm.PortID = types.StringValue(port.ID) + rm.Bonded = types.BoolValue(port.Data.Bonded) + // Layer2 is only true if the network type is not in l3Types and is in l2Types + rm.Layer2 = types.BoolValue( + !helper.Contains(l3Types, port.NetworkType) && helper.Contains(l2Types, port.NetworkType), + ) + rm.NativeVLANID = types.StringValue("") + if port.NativeVirtualNetwork != nil { + rm.NativeVLANID = types.StringValue(port.NativeVirtualNetwork.ID) + } + + // Convert VXLAN IDs and VLAN IDs to types.Set + portVxlanIDs := make([]int64, len(port.AttachedVirtualNetworks)) + portVlanIDs := make([]string, len(port.AttachedVirtualNetworks)) + for i, v := range port.AttachedVirtualNetworks { + portVxlanIDs[i] = int64(v.VXLAN) + portVlanIDs[i] = v.ID + } + vxlanIDs, diags := types.SetValueFrom(ctx, types.Int64Type, portVxlanIDs) + if diags.HasError() { + return diags + } + rm.VxlanIDs = vxlanIDs + vlanIDs, diags := types.SetValueFrom(ctx, types.StringType, portVlanIDs) + if diags.HasError() { + return diags + } + rm.VlanIDs = vlanIDs + + rm.Name = types.StringValue(port.Name) + rm.NetworkType = types.StringValue(port.NetworkType) + rm.DisbondSupported = types.BoolValue(port.DisbondOperationSupported) + + if port.Bond != nil { + rm.BondName = types.StringValue(port.Bond.Name) + rm.BondID = types.StringValue(port.Bond.ID) + } + + rm.Type = types.StringValue(port.Type) + rm.Mac = types.StringValue(port.Data.MAC) + + return diags +} + +func NewResource() resource.Resource { + return &Resource{ + BaseResource: helper.NewBaseResource( + helper.BaseResourceConfig{ + Name: "equinix_metal_port", + Schema: &metalPortResourceSchema, + }, + ), + } +} + +type Resource struct { + helper.BaseResource +} + +func (r *Resource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan MetalPortResourceModel + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Retrieve the API client from the provider metadata + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + client := r.Meta.Metal + + // API call to create/update the Metal Port resource + err := updatePort(ctx, client, plan) + if err != nil { + resp.Diagnostics.AddError( + "Error creating Metal Port resource", + "Could not create Metal Port resource: " + err.Error(), + ) + return + } + + // Retrieve updated Metal Port from API + port, err := getPortByResourceData(plan, client) + if err != nil { + err = helper.FriendlyError(err) + // If the org was destroyed, mark as gone + if helper.IsNotFound(err) || helper.IsForbidden(err) { + resp.Diagnostics.AddWarning( + "Metal Port", + fmt.Sprintf("[WARN] Port (%s) not accessible, removing from state", port.ID), + ) + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError( + "Error reading Metal Port", + "Could not read port: " + err.Error(), + ) + return + } + + // Parse API response into Terraform state + stateDiags := plan.parse(ctx, port) + resp.Diagnostics.Append(stateDiags...) + if stateDiags.HasError() { + return + } + + // Set the resource ID + resp.State.Set(ctx, &plan) +} + +func (r *Resource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + // Retrieve the current state + var state MetalPortResourceModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Retrieve the API client from the provider meta + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + client := r.Meta.Metal + + // Retrieve updated Metal Port from API + port, err := getPortByResourceData(state, client) + if err != nil { + err = helper.FriendlyError(err) + if helper.IsNotFound(err) || helper.IsForbidden(err) { + resp.Diagnostics.AddWarning( + "Metal Port", + fmt.Sprintf("[WARN] Port (%s) not accessible, removing from state", state.PortID.ValueString()), + ) + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError( + "Error reading Metal Port", + "Could not read port: " + err.Error(), + ) + return + } + + // Parse the API response into the Terraform state + parseDiags := state.parse(ctx, port) + resp.Diagnostics.Append(parseDiags...) + if parseDiags.HasError() { + return + } + + // Update the Terraform state + diags = resp.State.Set(ctx, &state) + resp.Diagnostics.Append(diags...) +} + + +func (r *Resource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan MetalPortResourceModel + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Retrieve the API client from the provider metadata + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + client := r.Meta.Metal + + // API call to create/update the Metal Port resource + err := updatePort(ctx, client, plan) + if err != nil { + resp.Diagnostics.AddError( + "Error creating Metal Port resource", + "Could not create Metal Port resource: " + err.Error(), + ) + return + } + + // Retrieve updated Metal Port from API + port, err := getPortByResourceData(plan, client) + if err != nil { + err = helper.FriendlyError(err) + // If the org was destroyed, mark as gone + if helper.IsNotFound(err) || helper.IsForbidden(err) { + resp.Diagnostics.AddWarning( + "Metal Port", + fmt.Sprintf("[WARN] Port (%s) not accessible, removing from state", port.ID), + ) + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError( + "Error reading Metal Port", + "Could not read port: " + err.Error(), + ) + return + } + + // Parse API response into Terraform state + stateDiags := plan.parse(ctx, port) + resp.Diagnostics.Append(stateDiags...) + if stateDiags.HasError() { + return + } + + // Set the resource ID + resp.State.Set(ctx, &plan) +} + +func (r *Resource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + // Retrieve the current state + var state MetalPortResourceModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Retrieve the API client from the provider meta + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + client := r.Meta.Metal + + // Check if the port should be reset to default settings before deletion + if state.ResetOnDelete.ValueBool() { + + start := time.Now() + // update cpd.Data with state + cpd, cResp, err := getPortData(client, state) + if helper.IgnoreResponseErrors(helper.HttpForbidden, helper.HttpNotFound)(cResp, err) != nil { + err = helper.FriendlyError(err) + resp.Diagnostics.AddError( + "Error retrieving Metal Port", + "Could not retrieve Metal Port: " + err.Error(), + ) + return + } + + // to reset the port to defaults we iterate through helpers (used in + // create/update), some of which rely on resource state. reuse those helpers by + // setting ephemeral state. + cpd.Data.Layer2 = types.BoolValue(false) + cpd.Data.Bonded = types.BoolValue(true) + cpd.Data.NativeVLANID = types.StringNull() + vlanIDs, diags := types.SetValueFrom(ctx, types.StringType, []string{}) + if diags.HasError() { + return + } + cpd.Data.VlanIDs = vlanIDs + cpd.Data.VxlanIDs = types.SetNull(types.Int64Null().Type(ctx)) + + for _, f := range [](func(*ClientPortData) error){ + batchVlans(ctx, start, true), + makeBond, + convertToL3, + } { + if err := f(cpd); err != nil { + resp.Diagnostics.AddError( + "Error resetting Metal Port", + "Could not reset Metal Port to default settings: " + err.Error(), + ) + return + } + } + + // TODO(displague) error or warn? + if warn := portProperlyDestroyed(cpd.Port); warn != nil { + resp.Diagnostics.AddWarning( + "Metal Port", + fmt.Sprintf("[WARN] %s\n", warn), + ) + } + } +} + +var metalPortResourceSchema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "UUID of the port", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "bonded": schema.BoolAttribute{ + Required: true, + Description: "Flag indicating whether the port should be bonded", + }, + "port_id": schema.StringAttribute{ + Optional: true, + Description: "UUID of the port to lookup. You must specify either port_id or (device_id and name)", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + stringvalidator.ExactlyOneOf(path.Expressions{ + path.MatchRoot("device_id"), + }...), + }, + }, + "device_id": schema.StringAttribute{ + Optional: true, + Description: "UUID of the device to lookup", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + stringvalidator.AlsoRequires(path.Expressions{ + path.MatchRoot("name"), + }...), + }, + }, + "name": schema.StringAttribute{ + Optional: true, + Description: "Name of the port to look up, e.g., bond0, eth1", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + stringplanmodifier.UseStateForUnknown(), + }, + }, + "layer2": schema.BoolAttribute{ + Optional: true, + Description: "Flag indicating whether the port is in layer2 (or layer3) mode. The `layer2` flag can be set only for bond ports.", + }, + "native_vlan_id": schema.StringAttribute{ + Optional: true, + Description: "UUID of native VLAN of the port", + }, + "vxlan_ids": schema.SetAttribute{ + Optional: true, + Computed: true, + Description: "VLAN VXLAN ids to attach (example: [1000])", + ElementType: types.Int64Type, + Validators: []validator.Set{ + setvalidator.ConflictsWith(path.Expressions{ + path.MatchRoot("vlan_ids"), + }...), + }, + }, + "vlan_ids": schema.SetAttribute{ + Optional: true, + Computed: true, + Description: "UUIDs VLANs to attach. To avoid jitter, use the UUID and not the VXLAN", + ElementType: types.StringType, + Validators: []validator.Set{ + setvalidator.ConflictsWith(path.Expressions{ + path.MatchRoot("vxlan_ids"), + }...), + }, + }, + "reset_on_delete": schema.BoolAttribute{ + Optional: true, + Description: "Behavioral setting to reset the port to default settings (layer3 bonded mode without any vlan attached) before delete/destroy", + }, + "network_type": schema.StringAttribute{ + Computed: true, + Description: "One of layer2-bonded, layer2-individual, layer3, hybrid, and hybrid-bonded. This attribute is only set on bond ports.", + }, + "disbond_supported": schema.BoolAttribute{ + Computed: true, + Description: "Flag indicating whether the port can be removed from a bond", + }, + "bond_name": schema.StringAttribute{ + Computed: true, + Description: "Name of the bond port", + }, + "bond_id": schema.StringAttribute{ + Computed: true, + Description: "UUID of the bond port", + }, + "type": schema.StringAttribute{ + Computed: true, + Description: "Port type", + }, + "mac": schema.StringAttribute{ + Computed: true, + Description: "MAC address of the port", + }, + }, +} From df9e6b2981bb26c6066e2dc8b6b779b50a902c8a Mon Sep 17 00:00:00 2001 From: ocobleseqx Date: Fri, 24 Nov 2023 18:27:13 +0100 Subject: [PATCH 17/26] equinix_metal_vlan Signed-off-by: ocobleseqx --- equinix/provider.go | 3 +- internal/framework_provider.go | 2 + internal/metal_vlan/resource.go | 267 ++++++++++++++++++++++++++++++++ 3 files changed, 271 insertions(+), 1 deletion(-) create mode 100644 internal/metal_vlan/resource.go diff --git a/equinix/provider.go b/equinix/provider.go index f9409fef2..724083c56 100644 --- a/equinix/provider.go +++ b/equinix/provider.go @@ -157,6 +157,7 @@ func Provider() *schema.Provider { "equinix_network_acl_template": resourceNetworkACLTemplate(), "equinix_network_device_link": resourceNetworkDeviceLink(), "equinix_network_file": resourceNetworkFile(), + "equinix_metal_user_api_key": resourceMetalUserAPIKey(), "equinix_metal_project_api_key": resourceMetalProjectAPIKey(), "equinix_metal_connection": resourceMetalConnection(), @@ -171,7 +172,7 @@ func Provider() *schema.Provider { "equinix_metal_reserved_ip_block": resourceMetalReservedIPBlock(), "equinix_metal_ip_attachment": resourceMetalIPAttachment(), "equinix_metal_spot_market_request": resourceMetalSpotMarketRequest(), - "equinix_metal_vlan": resourceMetalVlan(), + // "equinix_metal_vlan": resourceMetalVlan(), "equinix_metal_virtual_circuit": resourceMetalVirtualCircuit(), "equinix_metal_vrf": resourceMetalVRF(), // "equinix_metal_bgp_session": resourceMetalBGPSession(), diff --git a/internal/framework_provider.go b/internal/framework_provider.go index 6c6fef60a..dac32bf9f 100644 --- a/internal/framework_provider.go +++ b/internal/framework_provider.go @@ -12,6 +12,7 @@ import ( "github.com/equinix/terraform-provider-equinix/internal/metal_organization" "github.com/equinix/terraform-provider-equinix/internal/metal_organization_member" "github.com/equinix/terraform-provider-equinix/internal/metal_port" + "github.com/equinix/terraform-provider-equinix/internal/metal_vlan" "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/provider" @@ -106,6 +107,7 @@ func (p *FrameworkProvider) Resources(ctx context.Context) []func() resource.Res metal_organization.NewResource, metal_organization_member.NewResource, metal_port.NewResource, + metal_vlan.NewResource, } } diff --git a/internal/metal_vlan/resource.go b/internal/metal_vlan/resource.go new file mode 100644 index 000000000..e2cde02bf --- /dev/null +++ b/internal/metal_vlan/resource.go @@ -0,0 +1,267 @@ +package metal_vlan + +import ( + "context" + "fmt" + "path" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/packethost/packngo" + "github.com/equinix/terraform-provider-equinix/internal/helper" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + tpfpath "github.com/hashicorp/terraform-plugin-framework/path" +) + +type MetalVlanResourceModel struct { + ID types.String `tfsdk:"id"` + ProjectID types.String `tfsdk:"project_id"` + Description types.String `tfsdk:"description"` + Facility types.String `tfsdk:"facility"` + Metro types.String `tfsdk:"metro"` + Vxlan types.Int64 `tfsdk:"vxlan"` +} + +func (rm *MetalVlanResourceModel) parse(ctx context.Context, vlan *packngo.VirtualNetwork) diag.Diagnostics { + var diags diag.Diagnostics + + // Assuming 'vlan' is the API response object for a MetalVlan resource + rm.ProjectID = types.StringValue(vlan.Project.ID) + rm.Description = types.StringValue(vlan.Description) + rm.Vxlan = types.Int64Value(int64(vlan.VXLAN)) + rm.Facility = types.StringValue(vlan.FacilityCode) + rm.Metro = types.StringValue(vlan.MetroCode) + + return diags +} + +func NewResource() resource.Resource { + return &Resource{ + BaseResource: helper.NewBaseResource( + helper.BaseResourceConfig{ + Name: "equinix_metal_vlan", + Schema: &metalVlanResourceSchema, + }, + ), + } +} + +type Resource struct { + helper.BaseResource +} + +func (r *Resource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan MetalVlanResourceModel + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Retrieve the API client + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + client := r.Meta.Metal + + // Translate the plan into an API request + createRequest := &packngo.VirtualNetworkCreateRequest{ + ProjectID: plan.ProjectID.ValueString(), + Description: plan.Description.ValueString(), + // Include other fields as necessary + } + if !plan.Metro.IsNull(){ + createRequest.Metro = plan.Metro.ValueString() + } + if !plan.Facility.IsNull(){ + createRequest.Facility = plan.Facility.ValueString() + } + + // API call to create the MetalVlan resource + vlan, _, err := client.ProjectVirtualNetworks.Create(createRequest) + if err != nil { + resp.Diagnostics.AddError( + "Error creating MetalVlan", + "Could not create MetalVlan: " + err.Error(), + ) + return + } + + // Parse API response into Terraform state + diags = plan.parse(ctx, vlan) + resp.Diagnostics.Append(diags...) + if diags.HasError() { + return + } + + // Set the resource ID and update the state + resp.State.Set(ctx, &plan) +} + +func (r *Resource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state MetalVlanResourceModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Retrieve the API client + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + client := r.Meta.Metal + + // Extract the ID of the resource from the state + id := state.ID.ValueString() + + // API call to read the current state of the Metal Vlan + getOpts := &packngo.GetOptions{Includes: []string{"assigned_to"}} + vlan, _, err := client.ProjectVirtualNetworks.Get(id, getOpts) + if err != nil { + err = helper.FriendlyError(err) + // Check if the VLAN no longer exists + if helper.IsNotFound(err) { + resp.Diagnostics.AddWarning( + "Metal VLAN", + fmt.Sprintf("[WARN] VLAN (%s) not found, removing from state", id), + ) + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError( + "Error reading MetalVlan", + "Could not read MetalVlan with ID " + id + ": " + err.Error(), + ) + return + } + + // Parse API response into Terraform state + diags = state.parse(ctx, vlan) + resp.Diagnostics.Append(diags...) + if diags.HasError() { + return + } + + // Update the Terraform state + resp.State.Set(ctx, &state) +} + +func (r *Resource) Update( + ctx context.Context, + req resource.UpdateRequest, + resp *resource.UpdateResponse, +) { + // This resource does not support updates +} + +func (r *Resource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state MetalVlanResourceModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Retrieve the API client + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + client := r.Meta.Metal + + // Extract the ID of the resource from the state + id := state.ID.ValueString() + + // API call to read the current state of the Metal Vlan + getOpts := &packngo.GetOptions{Includes: []string{ + "instances", + "instances.network_ports.virtual_networks", + "internet_gateway", + }} + vlan, getResp, err := client.ProjectVirtualNetworks.Get(id, getOpts) + if helper.IgnoreResponseErrors(helper.HttpForbidden, helper.HttpNotFound)(getResp, err) != nil { + err = helper.FriendlyError(err) + resp.Diagnostics.AddError( + "Error deleting Metal VLAN", + "Could not retrieve Metal VLAN with ID " + id + ": " + err.Error(), + ) + return + } else if err != nil { + // missing vlans are deleted + return + } + + // all device ports must be unassigned before delete + for _, i := range vlan.Instances { + for _, p := range i.NetworkPorts { + for _, a := range p.AttachedVirtualNetworks { + // a.ID is not set despite including instaces.network_ports.virtual_networks + // TODO(displague) packngo should offer GetID() that uses ID or Href + aID := path.Base(a.Href) + + if aID == id { + _, deleteResp, err := client.Ports.Unassign(p.ID, id) + + if helper.IgnoreResponseErrors(helper.HttpForbidden, helper.HttpNotFound)(deleteResp, err) != nil { + err = helper.FriendlyError(err) + resp.Diagnostics.AddError( + "Error deleting Metal VLAN", + "Could not unassign Metal VLAN with ID " + id + ": " + err.Error(), + ) + return + } + } + } + } + } + + // TODO(displague) do we need to unassign gateway connections before delete? + err = helper.IgnoreResponseErrors(helper.HttpForbidden, helper.HttpNotFound)(client.ProjectVirtualNetworks.Delete(id)) + if err != nil { + err = helper.FriendlyError(err) + resp.Diagnostics.AddError( + "Error deleting Metal VLAN", + "Could not delete Metal VLAN with ID " + id + ": " + err.Error(), + ) + return + } +} + + +var metalVlanResourceSchema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "The unique identifier for the VLAN", + Computed: true, + }, + "project_id": schema.StringAttribute{ + Required: true, + Description: "ID of the parent project", + }, + "description": schema.StringAttribute{ + Optional: true, + Description: "Description string", + }, + "facility": schema.StringAttribute{ + Optional: true, + Description: "Facility where to create the VLAN", + DeprecationMessage: "Use metro instead of facility. For more information, read the migration guide.", + Validators: []validator.String{ + stringvalidator.ConflictsWith(tpfpath.Expressions{ + tpfpath.MatchRoot("vxlan"), + }...), + }, + }, + "metro": schema.StringAttribute{ + Optional: true, + Description: "Metro in which to create the VLAN", + Validators: []validator.String{ + stringvalidator.ExactlyOneOf(tpfpath.Expressions{ + tpfpath.MatchRoot("facility"), + }...), + }, + }, + "vxlan": schema.Int64Attribute{ + Optional: true, + Computed: true, + Description: "VLAN ID, must be unique in metro", + }, + }, +} From 7e35f5c4c8b55ffab0603c9fc8e7dec500b9c065 Mon Sep 17 00:00:00 2001 From: ocobleseqx Date: Mon, 27 Nov 2023 22:31:37 +0100 Subject: [PATCH 18/26] equinix_metal_connection Signed-off-by: ocobleseqx --- equinix/provider.go | 2 +- internal/framework_provider.go | 8 +- internal/metal_connection/framework_models.go | 230 +++++++++ .../metal_connection/framework_resource.go | 469 ++++++++++++++++++ .../framework_schema_resource.go | 189 +++++++ 5 files changed, 894 insertions(+), 4 deletions(-) create mode 100644 internal/metal_connection/framework_models.go create mode 100644 internal/metal_connection/framework_resource.go create mode 100644 internal/metal_connection/framework_schema_resource.go diff --git a/equinix/provider.go b/equinix/provider.go index 724083c56..c9a62ed67 100644 --- a/equinix/provider.go +++ b/equinix/provider.go @@ -160,7 +160,7 @@ func Provider() *schema.Provider { "equinix_metal_user_api_key": resourceMetalUserAPIKey(), "equinix_metal_project_api_key": resourceMetalProjectAPIKey(), - "equinix_metal_connection": resourceMetalConnection(), + // "equinix_metal_connection": resourceMetalConnection(), "equinix_metal_device": resourceMetalDevice(), "equinix_metal_device_network_type": resourceMetalDeviceNetworkType(), // "equinix_metal_ssh_key": resourceMetalSSHKey(), diff --git a/internal/framework_provider.go b/internal/framework_provider.go index dac32bf9f..57e812f33 100644 --- a/internal/framework_provider.go +++ b/internal/framework_provider.go @@ -7,19 +7,20 @@ import ( "github.com/equinix/terraform-provider-equinix/internal/config" "github.com/equinix/terraform-provider-equinix/internal/metal_bgp_session" - "github.com/equinix/terraform-provider-equinix/internal/metal_ssh_key" - "github.com/equinix/terraform-provider-equinix/internal/metal_project" + "github.com/equinix/terraform-provider-equinix/internal/metal_connection" "github.com/equinix/terraform-provider-equinix/internal/metal_organization" "github.com/equinix/terraform-provider-equinix/internal/metal_organization_member" "github.com/equinix/terraform-provider-equinix/internal/metal_port" + "github.com/equinix/terraform-provider-equinix/internal/metal_project" + "github.com/equinix/terraform-provider-equinix/internal/metal_ssh_key" "github.com/equinix/terraform-provider-equinix/internal/metal_vlan" "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/provider/schema" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/schema/validator" - "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" ) var urlRE = regexp.MustCompile(`^https?://(?:www\.)?[a-zA-Z0-9./]+$`) @@ -108,6 +109,7 @@ func (p *FrameworkProvider) Resources(ctx context.Context) []func() resource.Res metal_organization_member.NewResource, metal_port.NewResource, metal_vlan.NewResource, + metal_connection.NewResource, } } diff --git a/internal/metal_connection/framework_models.go b/internal/metal_connection/framework_models.go new file mode 100644 index 000000000..ea4e9fc8b --- /dev/null +++ b/internal/metal_connection/framework_models.go @@ -0,0 +1,230 @@ +package metal_connection + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/packethost/packngo" + "github.com/hashicorp/terraform-plugin-framework/diag" +) + +type MetalConnectionResourceModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Facility types.String `tfsdk:"facility"` + Metro types.String `tfsdk:"metro"` + Redundancy types.String `tfsdk:"redundancy"` + ContactEmail types.String `tfsdk:"contact_email"` + Type types.String `tfsdk:"type"` + ProjectID types.String `tfsdk:"project_id"` + Speed types.String `tfsdk:"speed"` + Description types.String `tfsdk:"description"` + Mode types.String `tfsdk:"mode"` + Tags types.List `tfsdk:"tags"` // List of strings + Vlans types.List `tfsdk:"vlans"` // List of ints + ServiceTokenType types.String `tfsdk:"service_token_type"` + OrganizationID types.String `tfsdk:"organization_id"` + Status types.String `tfsdk:"status"` + Token types.String `tfsdk:"token"` + Ports types.List `tfsdk:"ports"` // List of Port + ServiceTokens types.List `tfsdk:"service_tokens"` // List of ServiceToken +} + +type Port struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Role types.String `tfsdk:"role"` + Speed types.Int64 `tfsdk:"speed"` + Status types.String `tfsdk:"status"` + LinkStatus types.String `tfsdk:"link_status"` + VirtualCircuitIDs types.List `tfsdk:"virtual_circuit_ids"` // List of String +} + +type ServiceToken struct { + ID types.String `tfsdk:"id"` + MaxAllowedSpeed types.String `tfsdk:"max_allowed_speed"` + Role types.String `tfsdk:"role"` + State types.String `tfsdk:"state"` + Type types.String `tfsdk:"type"` +} + +func (rm *MetalConnectionResourceModel) parse(ctx context.Context, conn *packngo.Connection) diag.Diagnostics { + var diags diag.Diagnostics + + rm.OrganizationID = types.StringValue(conn.Organization.ID) + rm.Name = types.StringValue(conn.Name) + rm.Facility = types.StringValue(conn.Facility.Code) + rm.Metro = types.StringValue(conn.Metro.Code) + rm.Description = types.StringValue(conn.Description) + rm.ContactEmail = types.StringValue(conn.ContactEmail) + rm.Status = types.StringValue(conn.Status) + rm.Redundancy = types.StringValue(string(conn.Redundancy)) + rm.Token = types.StringValue(conn.Token) + rm.Type = types.StringValue(string(conn.Type)) + rm.Mode = types.StringValue(string(*conn.Mode)) + tags, diags := types.ListValueFrom(ctx, types.StringType, conn.Tags) + if diags.HasError() { + return diags + } + rm.Tags = tags + + // Parse Speed + speed := "0" + var err error + if conn.Speed > 0 { + speed, err = speedUintToStr(conn.Speed) + if err != nil { + diags.AddError( + fmt.Sprintf("Failed to convert Speed (%d) to string", conn.Speed), + err.Error(), + ) + return diags + } + } + rm.Speed = types.StringValue(speed) + + // Parse Project ID + // fix the project id get when it's added straight to the Connection API resource + // https://github.com/packethost/packngo/issues/317 + if conn.Type == packngo.ConnectionShared { + rm.ProjectID = types.StringValue(conn.Ports[0].VirtualCircuits[0].Project.ID) + } + + // Parse Service Token Type + tokenType := "" + if len(conn.Tokens) > 0 { + tokenType = string(conn.Tokens[0].ServiceTokenType) + } + rm.ServiceTokenType = types.StringValue(tokenType) + + // Parse Vlans + diags = rm.parseConnectionVlans(ctx, conn) + if diags.HasError() { + return diags + } + + // Parse Ports + diags = rm.parseConnectionPorts(ctx, conn.Ports) + if diags.HasError() { + return diags + } + + // Parse ServiceTokens + connServiceTokens := make([]ServiceToken, len(conn.Tokens)) + for i, token := range conn.Tokens { + speed, err := speedUintToStr(token.MaxAllowedSpeed) + if err != nil { + var diags diag.Diagnostics + diags.AddError( + fmt.Sprintf("Failed to convert token MaxAllowedSpeed (%d) to string", token.MaxAllowedSpeed), + err.Error(), + ) + return diags + } + connServiceTokens[i] = ServiceToken{ + ID: types.StringValue(token.ID), + MaxAllowedSpeed: types.StringValue(speed), + Role: types.StringValue(string(token.Role)), + State: types.StringValue(token.State), + Type: types.StringValue(string(token.ServiceTokenType)), + } + } + serviceTokens, diags := types.ListValueFrom(ctx, ServiceTokensObjectType, connServiceTokens) + if diags.HasError() { + return diags + } + rm.ServiceTokens = serviceTokens + + return diags +} + +func (rm *MetalConnectionResourceModel) parseConnectionPorts(ctx context.Context, cps []packngo.ConnectionPort) diag.Diagnostics { + ret := make([]Port, len(cps)) + order := map[packngo.ConnectionPortRole]int{ + packngo.ConnectionPortPrimary: 0, + packngo.ConnectionPortSecondary: 1, + } + + for _, p := range cps { + // Parse VirtualCircuits + portVcIDs := make([]string, len(p.VirtualCircuits)) + for i, vc := range p.VirtualCircuits { + portVcIDs[i] = vc.ID + } + vcIDs, diags := types.ListValueFrom(ctx, types.StringType, portVcIDs) + if diags.HasError() { + return diags + } + connPort := Port{ + ID: types.StringValue(p.ID), + Name: types.StringValue(p.Name), + Role: types.StringValue(string(p.Role)), + Speed: types.Int64Value(int64(p.Speed)), + Status: types.StringValue(p.Status), + LinkStatus: types.StringValue(p.LinkStatus), + VirtualCircuitIDs: vcIDs, + } + + // Sort the ports by role, asserting the API always returns primary for len of 1 responses + ret[order[p.Role]] = connPort + } + + ports, diags := types.ListValueFrom(ctx, types.StringType, ret) + if diags.HasError() { + return diags + } + rm.Ports = ports + return nil +} + +func (rm *MetalConnectionResourceModel) parseConnectionVlans(ctx context.Context, conn *packngo.Connection) diag.Diagnostics { + var ret []int + + if conn.Type == packngo.ConnectionShared { + order := map[packngo.ConnectionPortRole]int{ + packngo.ConnectionPortPrimary: 0, + packngo.ConnectionPortSecondary: 1, + } + + rawVlans := make([]int, len(conn.Ports)) + for _, p := range conn.Ports { + rawVlans[order[p.Role]] = p.VirtualCircuits[0].VNID + } + + for _, v := range rawVlans { + if v > 0 { + ret = append(ret, v) + } + } + } + vlans, diags := types.ListValueFrom(ctx, types.StringType, ret) + if diags.HasError() { + return diags + } + rm.Vlans = vlans + return nil +} + +func speedStrToUint(speed string) (uint64, error) { + allowedStrings := []string{} + for _, allowedSpeed := range allowedSpeeds { + if allowedSpeed.Str == speed { + return allowedSpeed.Int, nil + } + allowedStrings = append(allowedStrings, allowedSpeed.Str) + } + return 0, fmt.Errorf("invalid speed string: %s. Allowed strings: %s", speed, strings.Join(allowedStrings, ", ")) +} + +func speedUintToStr(speed uint64) (string, error) { + allowedUints := []uint64{} + for _, allowedSpeed := range allowedSpeeds { + if speed == allowedSpeed.Int { + return allowedSpeed.Str, nil + } + allowedUints = append(allowedUints, allowedSpeed.Int) + } + return "", fmt.Errorf("%d is not allowed speed value. Allowed values: %v", speed, allowedUints) +} \ No newline at end of file diff --git a/internal/metal_connection/framework_resource.go b/internal/metal_connection/framework_resource.go new file mode 100644 index 000000000..72bbce539 --- /dev/null +++ b/internal/metal_connection/framework_resource.go @@ -0,0 +1,469 @@ +package metal_connection + +import ( + "context" + "fmt" + "math" + "reflect" + "strconv" + + "github.com/equinix/terraform-provider-equinix/internal/helper" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/packethost/packngo" + "golang.org/x/exp/slices" +) + +var ( + mega uint64 = 1000 * 1000 + giga uint64 = 1000 * mega + allowedSpeeds = []struct { + Int uint64 + Str string + }{ + {50 * mega, "50Mbps"}, + {200 * mega, "200Mbps"}, + {500 * mega, "500Mbps"}, + {1 * giga, "1Gbps"}, + {2 * giga, "2Gbps"}, + {5 * giga, "5Gbps"}, + {10 * giga, "10Gbps"}, + } +) + +func NewResource() resource.Resource { + return &Resource{ + BaseResource: helper.NewBaseResource( + helper.BaseResourceConfig{ + Name: "equinix_metal_connection", + Schema: &metalConnectionResourceSchema, + }, + ), + } +} + +type Resource struct { + helper.BaseResource +} + +func (r *Resource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan MetalConnectionResourceModel + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Prepare the API request based on the plan + createRequest := &packngo.ConnectionCreateRequest{ + Name: plan.Name.ValueString(), + Redundancy: packngo.ConnectionRedundancy(plan.Redundancy.ValueString()), + Type: packngo.ConnectionType(plan.Type.ValueString()), + } + + // missing email is tolerated for user keys (can't be reasonably detected) + if plan.ContactEmail.ValueString() != "" { + createRequest.ContactEmail = plan.ContactEmail.ValueString() + } + + var tokenType packngo.FabricServiceTokenType + if plan.ServiceTokenType.ValueString() != "" { + tokenType = packngo.FabricServiceTokenType(plan.ServiceTokenType.ValueString()) + } + + // Handle the speed setting + if plan.Type.ValueString() == string(packngo.ConnectionDedicated) || tokenType == "a_side" { + if plan.Speed.ValueStringPointer() == nil { + resp.Diagnostics.AddError( + "Error creating Metal Connection", + "You must set speed, it's optional only for shared connections of type z_side", + ) + return + } + speed, err := speedStrToUint(plan.Speed.ValueString()) + if err != nil { + resp.Diagnostics.AddError( + "Error creating Metal Connection", + "Could not parse connection speed: " + err.Error(), + ) + return + } + createRequest.Speed = speed + } + + // Add tags if they are set + if len(plan.Tags.Elements()) > 0 { + tags := []string{} + if diags := plan.Tags.ElementsAs(ctx, &tags, false); diags != nil { + resp.Diagnostics.Append(diags...) + return + } + createRequest.Tags = tags + } + + if plan.Metro.ValueString() != "" { + createRequest.Metro = plan.Metro.ValueString() + } + + if plan.Facility.ValueString() != "" { + createRequest.Facility = plan.Facility.ValueString() + } + + if plan.Description.ValueString() != "" { + createRequest.Description = plan.Description.ValueStringPointer() + } + + vlans := []int{} + if diags := plan.Vlans.ElementsAs(ctx, &vlans, false); diags != nil { + resp.Diagnostics.Append(diags...) + return + } + + // Retrieve the API client from the provider metadata + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + client := r.Meta.Metal + + // API call to create the dedicated or shared connection + var conn *packngo.Connection + var err error + projectId := plan.ProjectID.ValueString() + if plan.Type.ValueString() == string(packngo.ConnectionShared) { + // Shared connection specific logic + if projectId == "" { + resp.Diagnostics.AddError( + "Missing project_id", + "project_id is required for 'shared' connection type", + ) + return + } + if plan.Mode.ValueString() == string(packngo.ConnectionModeTunnel) { + resp.Diagnostics.AddError( + "Wrong mode", + "tunnel mode is not supported for 'shared' connections", + ) + return + } + if createRequest.Redundancy == packngo.ConnectionPrimary && len(vlans) == 2 { + resp.Diagnostics.AddError( + "Wrong number of vlans", + "when you create a 'shared' connection without redundancy, you must only set max 1 vlan", + ) + return + } + createRequest.VLANs = vlans + createRequest.ServiceTokenType = tokenType + + // Create the shared connection + var err error + conn, _, err = client.Connections.ProjectCreate(projectId, createRequest) + if err != nil { + resp.Diagnostics.AddError( + "Error creating MetalConnection", + "Could not create MetalConnection: " + err.Error(), + ) + return + } + } else { + // Dedicated connection specific logic + organizationId := plan.OrganizationID.ValueString() + if organizationId == "" { + proj, _, err := client.Projects.Get(projectId, &packngo.GetOptions{Includes: []string{"organization"}}) + if err != nil { + resp.Diagnostics.AddError( + fmt.Sprintf("Failed to get Project %s", projectId), + err.Error(), + ) + return + } + organizationId = proj.Organization.ID + } + if plan.ServiceTokenType.ValueString() != "" { + resp.Diagnostics.AddError( + "Failed to create Metal Connection", + "when you create a 'dedicated' connection, you must not set service_token_type", + ) + return + } + if len(vlans) > 0 { + resp.Diagnostics.AddError( + "Failed to create Metal Connection", + "when you create a 'dedicated' connection, you must not set vlans", + ) + return + } + createRequest.Mode = packngo.ConnectionMode(plan.Mode.ValueString()) + + // Create the dedicated connection + conn, _, err = client.Connections.OrganizationCreate(plan.OrganizationID.ValueString(), createRequest) + if err != nil { + resp.Diagnostics.AddError( + "Error creating MetalConnection", + "Could not create MetalConnection: " + err.Error(), + ) + return + } + } + + // Parse API response into the Terraform state + resp.Diagnostics.Append(plan.parse(ctx, conn)...) + if resp.Diagnostics.HasError() { + return + } + + // Set the state + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *Resource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state MetalConnectionResourceModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Retrieve the API client from the provider metadata + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + client := r.Meta.Metal + + // Extract the ID of the resource from the state + id := state.ID.ValueString() + + // Retrieve the Metal Connection from the API + conn, _, err := client.Connections.Get(id, &packngo.GetOptions{Includes: []string{"service_tokens", "organization", "facility", "metro", "project"}}) + if err != nil { + // If the Metal Connection is not found, remove it from the state + if helper.IsNotFound(err) { + resp.Diagnostics.AddWarning( + "Metal Connection", + fmt.Sprintf("[WARN] Connection (%s) not found, removing from state", id), + ) + resp.State.RemoveResource(ctx) + return + } + + resp.Diagnostics.AddError( + "Error reading Metal Connection", + "Could not read Metal Connection with ID " + id + ": " + err.Error(), + ) + return + } + + // Update the state using the API response + diags = state.parse(ctx, conn) + resp.Diagnostics.Append(diags...) + if diags.HasError() { + return + } + + resp.State.Set(ctx, &state) +} + +func (r *Resource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var state, plan MetalConnectionResourceModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + diags = req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Retrieve the API client from the provider metadata + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + client := r.Meta.Metal + + // Extract the ID of the organization from the state + id := state.ID.ValueString() + + // Prepare update request based on the changes + updateRequest := &packngo.ConnectionUpdateRequest{} + // TODO (ocobles) The legacy SDK code includes below code snippet which + // looks incorrect as "locked" is a device field. Delete it when we are sure it is not necessary + // + // if d.HasChange("locked") { + // var action func(string) (*packngo.Response, error) + // if d.Get("locked").(bool) { + // action = client.Devices.Lock + // } else { + // action = client.Devices.Unlock + // } + // if _, err := action(d.Id()); err != nil { + // return friendlyError(err) + // } + // } + if !state.Description.Equal(plan.Description) { + updateRequest.Description = plan.Description.ValueStringPointer() + } + if !state.Mode.Equal(plan.Mode) { + mode := packngo.ConnectionMode(plan.Mode.ValueString()) + updateRequest.Mode = &mode + } + if !state.Redundancy.Equal(plan.Redundancy) { + updateRequest.Redundancy = packngo.ConnectionRedundancy(plan.Redundancy.ValueString()) + } + + // TODO(displague) packngo does not implement ContactEmail for update + // if !state.ContactEmail.Equal(plan.ContactEmail) { ... } + + if !state.Tags.Equal(plan.Tags) { + tags := []string{} + if diags := plan.Tags.ElementsAs(ctx, &tags, false); diags != nil { + resp.Diagnostics.Append(diags...) + return + } + updateRequest.Tags = tags + } + + if !reflect.DeepEqual(updateRequest, packngo.ConnectionUpdateRequest{}) { + if _, _, err := client.Connections.Update(id, updateRequest, nil); err != nil { + err = helper.FriendlyError(err) + resp.Diagnostics.AddError( + "Error updating Metal Connection", + "Could not update Connection with ID " + id + ": " + err.Error(), + ) + } + } + + // Don't update VLANs until _after_ the main ConnectionUpdateRequest has succeeded + if !state.Vlans.Equal(plan.Vlans) { + connType := packngo.ConnectionType(plan.Type.ValueString()) + + if connType == packngo.ConnectionShared { + oldVlans := []int{} + if diags := state.Vlans.ElementsAs(ctx, &oldVlans, false); diags != nil { + resp.Diagnostics.Append(diags...) + return + } + + newVlans := []int{} + if diags := plan.Vlans.ElementsAs(ctx, &newVlans, false); diags != nil { + resp.Diagnostics.Append(diags...) + return + } + + maxVlans := int(math.Max(float64(len(oldVlans)), float64(len(newVlans)))) + + ports := make([]Port, 0, len(plan.Ports.Elements())) + if diags := plan.Ports.ElementsAs(ctx, &ports, false); diags != nil { + resp.Diagnostics.Append(diags...) + return + } + + for i := 0; i < maxVlans; i++ { + if oldVlans[i] != (newVlans[i]) { + if i+1 > len(newVlans) { + // The VNID was removed; unassign the old VNID + if _, _, diags := updateHiddenVirtualCircuitVNID(ctx, client, ports[i], ""); diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + } else { + j := slices.Index(oldVlans, newVlans[i]) + if j > i { + // The VNID was moved to a different list index; unassign the VNID for the old index so that it is available for reassignment + if _, _, diags := updateHiddenVirtualCircuitVNID(ctx, client, ports[j], ""); diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + } + // Assign the VNID (whether it is new or moved) to the correct port + if _, _, diags := updateHiddenVirtualCircuitVNID(ctx, client, ports[i], strconv.Itoa(newVlans[i])); diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + } + } + } + } + } else { + resp.Diagnostics.AddError( + "Error updating Metal Connection", + "Could not update Metal Connection with ID " + id + ": when you update a 'dedicated' connection, you cannot set vlans", + ) + } + + // Retrieve the Metal Connection from the API + conn, _, err := client.Connections.Get(id, &packngo.GetOptions{Includes: []string{"service_tokens", "organization", "facility", "metro", "project"}}) + if err != nil { + // If the Metal Connection is not found, remove it from the state + if helper.IsNotFound(err) { + resp.Diagnostics.AddWarning( + "Metal Connection", + fmt.Sprintf("[WARN] Connection (%s) not found, removing from state", id), + ) + resp.State.RemoveResource(ctx) + return + } + + resp.Diagnostics.AddError( + "Error reading Metal Connection", + "Could not read Metal Connection with ID " + id + ": " + err.Error(), + ) + return + } + + // Update the state using the API response + diags = state.parse(ctx, conn) + resp.Diagnostics.Append(diags...) + if diags.HasError() { + return + } + + resp.State.Set(ctx, &state) +} + + +func updateHiddenVirtualCircuitVNID(ctx context.Context, client *packngo.Client, port Port, newVNID string) (*packngo.VirtualCircuit, *packngo.Response, diag.Diagnostics) { + // This function is used to update the implicit virtual circuits attached to a shared `metal_connection` resource + // Do not use this function for a non-shared `metal_connection` + vcids := make([]types.String, 0, len(port.VirtualCircuitIDs.Elements())) + diags := port.VirtualCircuitIDs.ElementsAs(ctx, &vcids, false) + if diags.HasError() { + return nil, nil, diags + } + vcid := vcids[0].ValueString() + ucr := packngo.VCUpdateRequest{} + ucr.VirtualNetworkID = &newVNID + vc, resp, err := client.VirtualCircuits.Update(vcid, &ucr, nil) + if err != nil { + err = helper.FriendlyError(err) + diags.AddError( + "Error Updating Metal Connection", + "Could not update Metal Connection: " + err.Error(), + ) + return nil, nil, diags + } + return vc, resp, nil +} + +func (r *Resource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state MetalConnectionResourceModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Retrieve the API client from the provider metadata + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + client := r.Meta.Metal + + // Extract the ID of the organization from the state + id := state.ID.ValueString() + + // API call to delete the Metal Connection + deleteResp, err := client.Connections.Delete(id, true) + if helper.IgnoreResponseErrors(helper.HttpForbidden, helper.HttpNotFound)(deleteResp, err) != nil { + err = helper.FriendlyError(err) + resp.Diagnostics.AddError( + fmt.Sprintf("Failed to delete Metal Connection %s", id), + err.Error(), + ) + } +} diff --git a/internal/metal_connection/framework_schema_resource.go b/internal/metal_connection/framework_schema_resource.go new file mode 100644 index 000000000..87e691aec --- /dev/null +++ b/internal/metal_connection/framework_schema_resource.go @@ -0,0 +1,189 @@ +package metal_connection + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/packethost/packngo" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" +) + +var metalConnectionResourceSchema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "The unique identifier for this Metal Connection", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "name": schema.StringAttribute{ + Required: true, + Description: "Name of the connection resource", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "facility": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: "Facility where the connection will be created", + DeprecationMessage: "Use metro instead of facility. For more information, read the migration guide.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + stringvalidator.ExactlyOneOf(path.Expressions{ + path.MatchRoot("metro"), + }...), + }, + }, + "metro": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: "Metro where the connection will be created", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + // TODO (ocobles) + // StateFunc: toLower + // StateFunc doesn't exist in terraform, it requires implementation of bespoke logic before storing state, for instance in resource Create method + }, + "redundancy": schema.StringAttribute{ + Required: true, + Description: "Connection redundancy - redundant or primary", + Validators: []validator.String{ + stringvalidator.OneOf( + string(packngo.ConnectionRedundant), + string(packngo.ConnectionPrimary), + ), + }, + }, + "contact_email": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: "The preferred email used for communication and notifications about the Equinix Fabric interconnection", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "type": schema.StringAttribute{ + Required: true, + Description: "Connection type - dedicated or shared", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + stringvalidator.OneOf( + string(packngo.ConnectionDedicated), + string(packngo.ConnectionShared), + ), + }, + }, + "project_id": schema.StringAttribute{ + Optional: true, + Description: "ID of the project where the connection is scoped to. Required with type \"shared\"", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + stringplanmodifier.UseStateForUnknown(), + }, + }, + "speed": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: "Port speed. Required for a_side connections", + }, + "description": schema.StringAttribute{ + Optional: true, + Description: "Description of the connection resource", + }, + "mode": schema.StringAttribute{ + Optional: true, + Description: "Mode for connections in IBX facilities with the dedicated type - standard or tunnel", + Default: stringdefault.StaticString(string(packngo.ConnectionModeStandard)), + Validators: []validator.String{ + stringvalidator.OneOf( + string(packngo.ConnectionModeStandard), + string(packngo.ConnectionModeTunnel), + ), + }, + }, + "tags": schema.ListAttribute{ + Computed: true, + Description: "Tags attached to the connection", + ElementType: types.StringType, + }, + "vlans": schema.ListAttribute{ + Computed: true, + Description: "Only used with shared connection. VLANs to attach. Pass one vlan for Primary/Single connection and two vlans for Redundant connection", + ElementType: types.Int64Type, + Validators: []validator.List{ + listvalidator.SizeAtMost(2), + }, + }, + "service_token_type": schema.StringAttribute{ + Optional: true, + Description: "Only used with shared connection. Type of service token to use for the connection, a_side or z_side", + Validators: []validator.String{ + stringvalidator.OneOf("a_side", "z_side"), + }, + }, + "organization_id": schema.StringAttribute{ + Optional: true, + Description: "ID of the organization responsible for the connection. Applicable with type \"dedicated\"", + Default: stringdefault.StaticString("standard"), + Validators: []validator.String{ + stringvalidator.AtLeastOneOf(path.Expressions{ + path.MatchRoot("project_id"), + }...), + }, + }, + "status": schema.StringAttribute{ + Computed: true, + Description: "Status of the connection resource", + }, + "token": schema.StringAttribute{ + Computed: true, + Description: "Only used with shared connection. Fabric Token required to continue the setup process with [equinix_ecx_l2_connection](https://registry.terraform.io/providers/equinix/equinix/latest/docs/resources/equinix_ecx_l2_connection) or from the [Equinix Fabric Portal](https://ecxfabric.equinix.com/dashboard)", + DeprecationMessage: "If your organization already has connection service tokens enabled, use `service_tokens` instead", + }, + "service_tokens": schema.ListAttribute{ + Computed: true, + Description: "List of service tokens required to continue the setup process", + ElementType: ServiceTokensObjectType, + }, + "ports": schema.ListAttribute{ + Computed: true, + Description: "List of connection ports", + ElementType: PortsObjectType, + }, + }, +} + +var PortsObjectType = types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "id": types.StringType, + "name": types.StringType, + "role": types.StringType, + "speed": types.StringType, + "status": types.StringType, + "link_status": types.StringType, + "virtual_circuit_ids": types.ListType{ElemType: types.StringType}, + }, +} + +var ServiceTokensObjectType = types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "id": types.StringType, + "max_allowed_speed": types.StringType, + "role": types.StringType, + "state": types.StringType, + "type": types.StringType, + }, +} \ No newline at end of file From e349308ad2cc067a926b8d39be891ca6b91d0d24 Mon Sep 17 00:00:00 2001 From: ocobleseqx Date: Tue, 28 Nov 2023 21:49:58 +0100 Subject: [PATCH 19/26] equinix_metal_gateway Signed-off-by: ocobleseqx --- equinix/provider.go | 2 +- go.mod | 1 + go.sum | 2 + internal/framework_provider.go | 4 + internal/metal_gateway/framework_models.go | 55 ++++++ internal/metal_gateway/framework_resource.go | 183 ++++++++++++++++++ .../framework_schema_resource.go | 96 +++++++++ 7 files changed, 342 insertions(+), 1 deletion(-) create mode 100644 internal/metal_gateway/framework_models.go create mode 100644 internal/metal_gateway/framework_resource.go create mode 100644 internal/metal_gateway/framework_schema_resource.go diff --git a/equinix/provider.go b/equinix/provider.go index c9a62ed67..3f65f90e3 100644 --- a/equinix/provider.go +++ b/equinix/provider.go @@ -177,7 +177,7 @@ func Provider() *schema.Provider { "equinix_metal_vrf": resourceMetalVRF(), // "equinix_metal_bgp_session": resourceMetalBGPSession(), "equinix_metal_port_vlan_attachment": resourceMetalPortVlanAttachment(), - "equinix_metal_gateway": resourceMetalGateway(), + // "equinix_metal_gateway": resourceMetalGateway(), }, ProviderMetaSchema: map[string]*schema.Schema{ "module_name": { diff --git a/go.mod b/go.mod index 4bfaa5f2d..3591c4c14 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/hashicorp/go-retryablehttp v0.7.5 github.com/hashicorp/terraform-plugin-docs v0.16.0 github.com/hashicorp/terraform-plugin-framework v1.4.1 + github.com/hashicorp/terraform-plugin-framework-timeouts v0.4.1 github.com/hashicorp/terraform-plugin-go v0.19.0 github.com/hashicorp/terraform-plugin-mux v0.12.0 github.com/hashicorp/terraform-plugin-sdk/v2 v2.30.0 diff --git a/go.sum b/go.sum index 1dbeac364..15bbff302 100644 --- a/go.sum +++ b/go.sum @@ -435,6 +435,8 @@ github.com/hashicorp/terraform-plugin-docs v0.16.0 h1:UmxFr3AScl6Wged84jndJIfFcc github.com/hashicorp/terraform-plugin-docs v0.16.0/go.mod h1:M3ZrlKBJAbPMtNOPwHicGi1c+hZUh7/g0ifT/z7TVfA= github.com/hashicorp/terraform-plugin-framework v1.4.1 h1:ZC29MoB3Nbov6axHdgPbMz7799pT5H8kIrM8YAsaVrs= github.com/hashicorp/terraform-plugin-framework v1.4.1/go.mod h1:XC0hPcQbBvlbxwmjxuV/8sn8SbZRg4XwGMs22f+kqV0= +github.com/hashicorp/terraform-plugin-framework-timeouts v0.4.1 h1:gm5b1kHgFFhaKFhm4h2TgvMUlNzFAtUqlcOWnWPm+9E= +github.com/hashicorp/terraform-plugin-framework-timeouts v0.4.1/go.mod h1:MsjL1sQ9L7wGwzJ5RjcI6FzEMdyoBnw+XK8ZnOvQOLY= github.com/hashicorp/terraform-plugin-framework-validators v0.12.0 h1:HOjBuMbOEzl7snOdOoUfE2Jgeto6JOjLVQ39Ls2nksc= github.com/hashicorp/terraform-plugin-framework-validators v0.12.0/go.mod h1:jfHGE/gzjxYz6XoUwi/aYiiKrJDeutQNUtGQXkaHklg= github.com/hashicorp/terraform-plugin-go v0.19.0 h1:BuZx/6Cp+lkmiG0cOBk6Zps0Cb2tmqQpDM3iAtnhDQU= diff --git a/internal/framework_provider.go b/internal/framework_provider.go index 57e812f33..2b9ed8c60 100644 --- a/internal/framework_provider.go +++ b/internal/framework_provider.go @@ -14,6 +14,7 @@ import ( "github.com/equinix/terraform-provider-equinix/internal/metal_project" "github.com/equinix/terraform-provider-equinix/internal/metal_ssh_key" "github.com/equinix/terraform-provider-equinix/internal/metal_vlan" + "github.com/equinix/terraform-provider-equinix/internal/metal_gateway" "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/datasource" @@ -110,6 +111,9 @@ func (p *FrameworkProvider) Resources(ctx context.Context) []func() resource.Res metal_port.NewResource, metal_vlan.NewResource, metal_connection.NewResource, + func() resource.Resource { + return metal_gateway.NewResource(ctx) + }, } } diff --git a/internal/metal_gateway/framework_models.go b/internal/metal_gateway/framework_models.go new file mode 100644 index 000000000..935a2854f --- /dev/null +++ b/internal/metal_gateway/framework_models.go @@ -0,0 +1,55 @@ +package metal_gateway + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/packethost/packngo" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework-timeouts/resource/timeouts" +) + +type MetalGatewayResourceModel struct { + ID types.String `tfsdk:"id"` + ProjectID types.String `tfsdk:"project_id"` + VlanID types.String `tfsdk:"vlan_id"` + VrfID types.String `tfsdk:"vrf_id"` + IPReservationID types.String `tfsdk:"ip_reservation_id"` + PrivateIPv4SubnetSize types.Int64 `tfsdk:"private_ipv4_subnet_size"` + State types.String `tfsdk:"state"` + Timeouts timeouts.Value `tfsdk:"timeouts"` +} + +func (rm *MetalGatewayResourceModel) parse(ctx context.Context, mg *packngo.MetalGateway) diag.Diagnostics { + var diags diag.Diagnostics + + // Convert Metal Gateway data to the Terraform state + rm.ID = types.StringValue(mg.ID) + rm.ProjectID = types.StringValue(mg.Project.ID) + rm.VlanID = types.StringValue(mg.VirtualNetwork.ID) + + if mg.VRF != nil { + rm.VrfID = types.StringValue(mg.VRF.ID) + } else { + rm.VrfID = types.StringNull() + } + + if mg.IPReservation != nil { + rm.IPReservationID = types.StringValue(mg.IPReservation.ID) + } else { + rm.IPReservationID = types.StringNull() + } + + // Calculate subnet size if it's a private IPv4 subnet + privateIPv4SubnetSize := uint64(0) + if !mg.IPReservation.Public { + privateIPv4SubnetSize = 1 << (32 - mg.IPReservation.CIDR) + rm.PrivateIPv4SubnetSize = types.Int64Value(int64(privateIPv4SubnetSize)) + } else { + rm.PrivateIPv4SubnetSize = types.Int64Null() + } + + rm.State = types.StringValue(string(mg.State)) + + return diags +} diff --git a/internal/metal_gateway/framework_resource.go b/internal/metal_gateway/framework_resource.go new file mode 100644 index 000000000..35fa525f8 --- /dev/null +++ b/internal/metal_gateway/framework_resource.go @@ -0,0 +1,183 @@ +package metal_gateway +import ( + "context" + "time" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/packethost/packngo" + "github.com/equinix/terraform-provider-equinix/internal/helper" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" +) + + +func NewResource(ctx context.Context) resource.Resource { + return &Resource{ + BaseResource: helper.NewBaseResource( + helper.BaseResourceConfig{ + Name: "equinix_metal_gateway", + Schema: metalGatewayResourceSchema(ctx), + }, + ), + } +} + +type Resource struct { + helper.BaseResource +} + +func (r *Resource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan MetalGatewayResourceModel + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Retrieve the API client from the provider metadata + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + client := r.Meta.Metal + + // Build the create request based on the plan + createRequest := packngo.MetalGatewayCreateRequest{ + VirtualNetworkID: plan.VlanID.ValueString(), + IPReservationID: plan.IPReservationID.ValueString(), + PrivateIPv4SubnetSize: int(plan.PrivateIPv4SubnetSize.ValueInt64()), + } + + // Call the API to create the resource + result, _, err := client.MetalGateways.Create(plan.ProjectID.ValueString(), &createRequest) + if err != nil { + resp.Diagnostics.AddError("Error creating MetalGateway", err.Error()) + return + } + + // Update the Terraform state with the new ID + diags = resp.State.Set(ctx, &MetalGatewayResourceModel{ + ID: types.StringValue(result.ID), + // Set other fields as necessary + }) + resp.Diagnostics.Append(diags...) +} + + +func (r *Resource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state MetalGatewayResourceModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Retrieve the API client from the provider metadata + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + client := r.Meta.Metal + + // Extract the ID of the resource from the state + id := state.ID.ValueString() + + // API call to get the Metal Gateway + includes := &packngo.GetOptions{Includes: []string{"project", "ip_reservation", "virtual_network", "vrf"}} + mg, _, err := client.MetalGateways.Get(id, includes) + if err != nil { + err = helper.FriendlyError(err) + resp.Diagnostics.AddError( + "Error reading Metal Gateway", + "Could not read Metal Gateway with ID " + id + ": " + err.Error(), + ) + return + } + + // Parse the API response into the Terraform state + diags = state.parse(ctx, mg) + resp.Diagnostics.Append(diags...) + if diags.HasError() { + return + } + + // Update the Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + + +func (r *Resource) Update( + ctx context.Context, + req resource.UpdateRequest, + resp *resource.UpdateResponse, +) { + // This resource does not support updates +} + +func (r *Resource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + // Retrieve the API client + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + client := r.Meta.Metal + + // Retrieve the current state + var state MetalGatewayResourceModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Extract the ID of the resource from the state + id := state.ID.ValueString() + + // API call to delete the Metal Gateway + deleteResp, err := client.MetalGateways.Delete(id) + if helper.IgnoreResponseErrors(helper.HttpForbidden, helper.HttpNotFound)(deleteResp, err) != nil { + err = helper.FriendlyError(err) + resp.Diagnostics.AddError( + fmt.Sprintf("Failed to delete Metal Gateway %s", id), + err.Error(), + ) + } + + // Wait for the deletion to be completed + // + // NOTE (ocobles) WaitForStateorRetryContext doesn't exist in terraform framework + // using sdk library https://discuss.hashicorp.com/t/terraform-plugin-framework-what-is-the-replacement-for-waitforstate-or-retrycontext/45538 + deleteTimeout, diags := state.Timeouts.Delete(ctx, 20*time.Minute) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + deleteWaiter := getGatewayStateWaiter( + client, + id, + deleteTimeout, + []string{string(packngo.MetalGatewayDeleting)}, + []string{}, + ) + + _, err = deleteWaiter.WaitForStateContext(ctx) + if err != nil { + err = helper.FriendlyError(err) + resp.Diagnostics.AddError( + "Error waiting for deletion of MetalGateway", + "Failed to delete MetalGateway with ID " + id + ": " + err.Error(), + ) + return + } +} + +func getGatewayStateWaiter(client *packngo.Client, id string, timeout time.Duration, pending, target []string) *retry.StateChangeConf { + return &retry.StateChangeConf{ + Pending: pending, + Target: target, + Refresh: func() (interface{}, string, error) { + getOpts := &packngo.GetOptions{Includes: []string{"project", "ip_reservation", "virtual_network", "vrf"}} + + gw, _, err := client.MetalGateways.Get(id, getOpts) // TODO: we are not using the returned VRF. Remove the includes? + if err != nil { + return 0, "", err + } + return gw, string(gw.State), nil + }, + Timeout: timeout, + Delay: 10 * time.Second, + MinTimeout: 5 * time.Second, + } +} diff --git a/internal/metal_gateway/framework_schema_resource.go b/internal/metal_gateway/framework_schema_resource.go new file mode 100644 index 000000000..36d704f61 --- /dev/null +++ b/internal/metal_gateway/framework_schema_resource.go @@ -0,0 +1,96 @@ +package metal_gateway + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework-timeouts/resource/timeouts" +) + +var subnetSizes = []int64{8, 16, 32, 64, 128} + +func metalGatewayResourceSchema(ctx context.Context) *schema.Schema { + return &schema.Schema{ + Attributes: map[string]schema.Attribute{ + "timeouts": timeouts.Attributes(ctx, timeouts.Opts{ + Delete: true, + }), + "id": schema.StringAttribute{ + Description: "The unique identifier for this Metal Gateway", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "project_id": schema.StringAttribute{ + Description: "UUID of the Project where the Gateway is scoped to", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "vlan_id": schema.StringAttribute{ + Description: "UUID of the VLAN to associate", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "vrf_id": schema.StringAttribute{ + Description: "UUID of the VRF associated with the IP Reservation", + Computed: true, + }, + "ip_reservation_id": schema.StringAttribute{ + Description: "UUID of the Public or VRF IP Reservation to associate", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + stringvalidator.ConflictsWith(path.Expressions{ + path.MatchRoot("private_ipv4_subnet_size"), + }...), + }, + // NOTE (ocobles) + //DiffSuppressFunc does not exist in fw, but I think it would not be necessary anyway and with computed in conflict it should work fine + //DiffSuppressFunc: func(k, old, new string, d *schema.ResourceData) bool { + // Suppress diff of IP reservation ID if private_ipv4_subnet_size has been set. + // When the subnet size is set, the API will create a private subnet and return its ID + // in this field, which generates a diff (ip_reservation_id is unset in HCL, + // but the refreshed state shows there's an UUID of the new IPv4 block). + // if d.Get("private_ipv4_subnet_size").(int) != 0 { + // return true + // } + // return false + // }, + }, + "private_ipv4_subnet_size": schema.Int64Attribute{ + Description: "Size of the private IPv4 subnet to create for this gateway", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.RequiresReplace(), + }, + Validators: []validator.Int64{ + int64validator.OneOf(subnetSizes...), + int64validator.ConflictsWith(path.Expressions{ + path.MatchRoot("ip_reservation_id"), + path.MatchRoot("vrf_id"), + }...), + }, + }, + "state": schema.StringAttribute{ + Description: "Status of the gateway resource", + Computed: true, + }, + }, + } +} From b4893bcad0c43c92a7ce66b6b364def997c1be1c Mon Sep 17 00:00:00 2001 From: ocobleseqx Date: Wed, 29 Nov 2023 16:18:48 +0100 Subject: [PATCH 20/26] equinix_metal_ip_attachment Signed-off-by: ocobleseqx --- equinix/provider.go | 2 +- internal/framework_provider.go | 4 +- .../metal_ip_attachment/framework_models.go | 51 ++++++ .../metal_ip_attachment/framework_resource.go | 148 ++++++++++++++++++ .../framework_schema_resource.go | 82 ++++++++++ 5 files changed, 285 insertions(+), 2 deletions(-) create mode 100644 internal/metal_ip_attachment/framework_models.go create mode 100644 internal/metal_ip_attachment/framework_resource.go create mode 100644 internal/metal_ip_attachment/framework_schema_resource.go diff --git a/equinix/provider.go b/equinix/provider.go index 3f65f90e3..cc777ab10 100644 --- a/equinix/provider.go +++ b/equinix/provider.go @@ -170,7 +170,7 @@ func Provider() *schema.Provider { // "equinix_metal_project": resourceMetalProject(), // "equinix_metal_organization": resourceMetalOrganization(), "equinix_metal_reserved_ip_block": resourceMetalReservedIPBlock(), - "equinix_metal_ip_attachment": resourceMetalIPAttachment(), + // "equinix_metal_ip_attachment": resourceMetalIPAttachment(), "equinix_metal_spot_market_request": resourceMetalSpotMarketRequest(), // "equinix_metal_vlan": resourceMetalVlan(), "equinix_metal_virtual_circuit": resourceMetalVirtualCircuit(), diff --git a/internal/framework_provider.go b/internal/framework_provider.go index 2b9ed8c60..42bdd966e 100644 --- a/internal/framework_provider.go +++ b/internal/framework_provider.go @@ -8,13 +8,14 @@ import ( "github.com/equinix/terraform-provider-equinix/internal/config" "github.com/equinix/terraform-provider-equinix/internal/metal_bgp_session" "github.com/equinix/terraform-provider-equinix/internal/metal_connection" + "github.com/equinix/terraform-provider-equinix/internal/metal_gateway" + "github.com/equinix/terraform-provider-equinix/internal/metal_ip_attachment" "github.com/equinix/terraform-provider-equinix/internal/metal_organization" "github.com/equinix/terraform-provider-equinix/internal/metal_organization_member" "github.com/equinix/terraform-provider-equinix/internal/metal_port" "github.com/equinix/terraform-provider-equinix/internal/metal_project" "github.com/equinix/terraform-provider-equinix/internal/metal_ssh_key" "github.com/equinix/terraform-provider-equinix/internal/metal_vlan" - "github.com/equinix/terraform-provider-equinix/internal/metal_gateway" "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/datasource" @@ -114,6 +115,7 @@ func (p *FrameworkProvider) Resources(ctx context.Context) []func() resource.Res func() resource.Resource { return metal_gateway.NewResource(ctx) }, + metal_ip_attachment.NewResource, } } diff --git a/internal/metal_ip_attachment/framework_models.go b/internal/metal_ip_attachment/framework_models.go new file mode 100644 index 000000000..16f3dd2e0 --- /dev/null +++ b/internal/metal_ip_attachment/framework_models.go @@ -0,0 +1,51 @@ +package metal_ip_attachment + +import ( + "fmt" + "path" + + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/packethost/packngo" + "github.com/hashicorp/terraform-plugin-framework/diag" +) + +type MetalIPAttachmentResourceModel struct { + ID types.String `tfsdk:"id"` + DeviceID types.String `tfsdk:"device_id"` + CIDRNotation types.String `tfsdk:"cidr_notation"` + Address types.String `tfsdk:"address"` + Gateway types.String `tfsdk:"gateway"` + Network types.String `tfsdk:"network"` + Netmask types.String `tfsdk:"netmask"` + AddressFamily types.Int64 `tfsdk:"address_family"` + CIDR types.Int64 `tfsdk:"cidr"` + Public types.Bool `tfsdk:"public"` + Global types.Bool `tfsdk:"global"` + Manageable types.Bool `tfsdk:"manageable"` + Management types.Bool `tfsdk:"management"` + VrfID types.String `tfsdk:"vrf_id"` +} + +func (rm *MetalIPAttachmentResourceModel) parse(assignment *packngo.IPAddressAssignment) diag.Diagnostics { + var diags diag.Diagnostics + + rm.ID = types.StringValue(assignment.ID) + rm.DeviceID = types.StringValue(path.Base(assignment.AssignedTo.Href)) + rm.CIDRNotation = types.StringValue(fmt.Sprintf("%s/%d", assignment.Network, assignment.CIDR)) + rm.Address = types.StringValue(assignment.Address) + rm.Gateway = types.StringValue(assignment.Gateway) + rm.Network = types.StringValue(assignment.Network) + rm.Netmask = types.StringValue(assignment.Netmask) + rm.AddressFamily = types.Int64Value(int64(assignment.AddressFamily)) + rm.CIDR = types.Int64Value(int64(assignment.CIDR)) + rm.Public = types.BoolValue(assignment.Public) + rm.Global = types.BoolValue(assignment.Global) + rm.Manageable = types.BoolValue(assignment.Manageable) + rm.Management = types.BoolValue(assignment.Management) + + if assignment.VRF != nil { + rm.VrfID = types.StringValue(assignment.VRF.ID) + } + + return diags +} diff --git a/internal/metal_ip_attachment/framework_resource.go b/internal/metal_ip_attachment/framework_resource.go new file mode 100644 index 000000000..e7fd77881 --- /dev/null +++ b/internal/metal_ip_attachment/framework_resource.go @@ -0,0 +1,148 @@ +package metal_ip_attachment + +import ( + "context" + "fmt" + + "github.com/equinix/terraform-provider-equinix/internal/helper" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/packethost/packngo" +) + +func NewResource() resource.Resource { + return &Resource{ + BaseResource: helper.NewBaseResource( + helper.BaseResourceConfig{ + Name: "equinix_metal_ip_attachment", + Schema: &metalIPAttachmentResourceSchema, + }, + ), + } +} + +type Resource struct { + helper.BaseResource +} + +func (r *Resource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + // Initialize and get values from the plan + var plan MetalIPAttachmentResourceModel + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Retrieve the API client from the provider metadata + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + client := r.Meta.Metal + + // Prepare the request body + createRequest := packngo.AddressStruct{ + Address: plan.CIDRNotation.ValueString(), + } + + // API call to create the IP Attachment + assignment, _, err := client.DeviceIPs.Assign(plan.DeviceID.ValueString(), &createRequest) + if err != nil { + resp.Diagnostics.AddError( + "Error assigning IP to device", + fmt.Sprintf("Could not assign IP address %s to device %s: %s", plan.CIDRNotation.ValueString(), plan.DeviceID.ValueString(), err), + ) + return + } + + // Parse API response into the Terraform state + stateDiags := plan.parse(assignment) + resp.Diagnostics.Append(stateDiags...) + if resp.Diagnostics.HasError() { + return + } + + // Set the state + diags = resp.State.Set(ctx, &plan) + resp.Diagnostics.Append(diags...) +} + + +func (r *Resource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + // Initialize and get current state + var state MetalIPAttachmentResourceModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Retrieve the API client from the provider metadata + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + client := r.Meta.Metal + + // Extract the ID of the resource from the state + id := state.ID.ValueString() + + // API call to get the current state of the IP Attachment + assignment, _, err := client.DeviceIPs.Get(id, nil) + if err != nil { + err = helper.FriendlyError(err) + + // If the IP Attachment is not found, mark as successfully removed + if helper.IsNotFound(err) { + resp.Diagnostics.AddWarning( + "Metal IP Attachment", + fmt.Sprintf("[WARN] IP Attachment (%s) not found, removing from state", id), + ) + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError( + "Error reading Metal IP Attachment", + "Could not read IP Attachment with ID " + id + ": " + err.Error(), + ) + return + } + + // Update the state using the API response + diags = state.parse(assignment) + resp.Diagnostics.Append(diags...) + if diags.HasError() { + return + } + + resp.State.Set(ctx, &state) +} + +func (r *Resource) Update( + ctx context.Context, + req resource.UpdateRequest, + resp *resource.UpdateResponse, +) { + // This resource does not support updates +} + +func (r *Resource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + // Initialize and get current state + var state MetalIPAttachmentResourceModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Retrieve the API client from the provider metadata + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + client := r.Meta.Metal + + // Extract the ID of the organization from the state + id := state.ID.ValueString() + + // API call to delete the IP Attachment + deleteResp, err := client.DeviceIPs.Unassign(id) + if helper.IgnoreResponseErrors(helper.HttpForbidden, helper.HttpNotFound)(deleteResp, err) != nil { + err = helper.FriendlyError(err) + resp.Diagnostics.AddError( + fmt.Sprintf("Failed to delete IP Attachment %s", id), + err.Error(), + ) + } +} diff --git a/internal/metal_ip_attachment/framework_schema_resource.go b/internal/metal_ip_attachment/framework_schema_resource.go new file mode 100644 index 000000000..11f063369 --- /dev/null +++ b/internal/metal_ip_attachment/framework_schema_resource.go @@ -0,0 +1,82 @@ +package metal_ip_attachment + +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" +) + +var metalIPAttachmentResourceSchema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + Description: "The unique identifier of the IP attachment", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "device_id": schema.StringAttribute{ + Required: true, + Description: "UUID of the device to which the IP is attached", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "cidr_notation": schema.StringAttribute{ + Required: true, + Description: "CIDR notation of the IP address", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + //TODO (ocobles) is not described in the legacy sdk documentation + "address": schema.StringAttribute{ + Computed: true, + Description: "The IP address", + }, + "gateway": schema.StringAttribute{ + Computed: true, + Description: "The gateway IP address", + }, + "network": schema.StringAttribute{ + Computed: true, + Description: "The network IP address portion of the block specification", + }, + "netmask": schema.StringAttribute{ + Computed: true, + Description: "The mask in decimal notation", + }, + "address_family": schema.Int64Attribute{ + Computed: true, + Description: "Address family as integer (4 or 6)", + }, + "cidr": schema.Int64Attribute{ + Computed: true, + Description: "Length of CIDR prefix of the block as integer", + }, + "public": schema.BoolAttribute{ + Computed: true, + Description: "Flag indicating whether IP block is addressable from the Internet", + }, + //TODO (ocobles) is not described in the legacy sdk documentation + "global": schema.BoolAttribute{ + Computed: true, + Description: "Flag indicating whether IP block is global (i.e., assignable in any location)", + }, + //TODO (ocobles) is not described in the legacy sdk documentation + "manageable": schema.BoolAttribute{ + Computed: true, + Description: "Flag indicating whether the IP block is manageable", + }, + //TODO (ocobles) is not described in the legacy sdk documentation + "management": schema.BoolAttribute{ + Computed: true, + Description: "Flag indicating whether the IP block is for management", + }, + //TODO (ocobles) it wasn't returned in the legacy sdk resource + "vrf_id": schema.StringAttribute{ + Computed: true, + Description: "UUID of the VRF associated with the IP Reservation", + }, + }, +} From cab6c58f455d00bea1dadebedd7f0ab2cab7f2c0 Mon Sep 17 00:00:00 2001 From: ocobleseqx Date: Fri, 1 Dec 2023 14:16:27 +0100 Subject: [PATCH 21/26] equinix_metal_reserved_ip_block Signed-off-by: ocobleseqx --- equinix/provider.go | 2 +- internal/framework_provider.go | 4 + .../framework_models.go | 122 +++++++ .../framework_resource.go | 297 ++++++++++++++++++ .../framework_schema_resource.go | 227 +++++++++++++ internal/schema_validators/strings/is_json.go | 47 +++ 6 files changed, 698 insertions(+), 1 deletion(-) create mode 100644 internal/metal_reserved_ip_block/framework_models.go create mode 100644 internal/metal_reserved_ip_block/framework_resource.go create mode 100644 internal/metal_reserved_ip_block/framework_schema_resource.go create mode 100644 internal/schema_validators/strings/is_json.go diff --git a/equinix/provider.go b/equinix/provider.go index cc777ab10..2e297b00d 100644 --- a/equinix/provider.go +++ b/equinix/provider.go @@ -169,7 +169,7 @@ func Provider() *schema.Provider { "equinix_metal_project_ssh_key": resourceMetalProjectSSHKey(), // "equinix_metal_project": resourceMetalProject(), // "equinix_metal_organization": resourceMetalOrganization(), - "equinix_metal_reserved_ip_block": resourceMetalReservedIPBlock(), + // "equinix_metal_reserved_ip_block": resourceMetalReservedIPBlock(), // "equinix_metal_ip_attachment": resourceMetalIPAttachment(), "equinix_metal_spot_market_request": resourceMetalSpotMarketRequest(), // "equinix_metal_vlan": resourceMetalVlan(), diff --git a/internal/framework_provider.go b/internal/framework_provider.go index 42bdd966e..0dcf96314 100644 --- a/internal/framework_provider.go +++ b/internal/framework_provider.go @@ -16,6 +16,7 @@ import ( "github.com/equinix/terraform-provider-equinix/internal/metal_project" "github.com/equinix/terraform-provider-equinix/internal/metal_ssh_key" "github.com/equinix/terraform-provider-equinix/internal/metal_vlan" + "github.com/equinix/terraform-provider-equinix/internal/metal_reserved_ip_block" "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/datasource" @@ -116,6 +117,9 @@ func (p *FrameworkProvider) Resources(ctx context.Context) []func() resource.Res return metal_gateway.NewResource(ctx) }, metal_ip_attachment.NewResource, + func() resource.Resource { + return metal_reserved_ip_block.NewResource(ctx) + }, } } diff --git a/internal/metal_reserved_ip_block/framework_models.go b/internal/metal_reserved_ip_block/framework_models.go new file mode 100644 index 000000000..a7722ab01 --- /dev/null +++ b/internal/metal_reserved_ip_block/framework_models.go @@ -0,0 +1,122 @@ +package metal_reserved_ip_block + +import ( + "fmt" + "path" + "encoding/json" + "context" + + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/packethost/packngo" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework-timeouts/resource/timeouts" +) + +type MetalReservedIPBlockResourceModel struct { + ID types.String `tfsdk:"id"` + ProjectID types.String `tfsdk:"project_id"` + Facility types.String `tfsdk:"facility"` + Metro types.String `tfsdk:"metro"` + Description types.String `tfsdk:"description"` + Quantity types.Int64 `tfsdk:"quantity"` + Type types.String `tfsdk:"type"` + CidrNotation types.String `tfsdk:"cidr_notation"` + Tags types.List `tfsdk:"tags"` + CustomData types.String `tfsdk:"custom_data"` + WaitForState types.String `tfsdk:"wait_for_state"` + VrfID types.String `tfsdk:"vrf_id"` + Network types.String `tfsdk:"network"` + Cidr types.Int64 `tfsdk:"cidr"` + Address types.String `tfsdk:"address"` + AddressFamily types.Int64 `tfsdk:"address_family"` + Gateway types.String `tfsdk:"gateway"` + Netmask types.String `tfsdk:"netmask"` + Manageable types.Bool `tfsdk:"manageable"` + Management types.Bool `tfsdk:"management"` + Global types.Bool `tfsdk:"global"` + Public types.Bool `tfsdk:"public"` + Timeouts timeouts.Value `tfsdk:"timeouts"` +} + +func (rm *MetalReservedIPBlockResourceModel) parse(ctx context.Context, reservedBlock *packngo.IPAddressReservation) diag.Diagnostics { + var diags diag.Diagnostics + + rm.ID = types.StringValue(reservedBlock.ID) + rm.ProjectID = types.StringValue(path.Base(reservedBlock.Project.Href)) + rm.Address = types.StringValue(reservedBlock.Address) + rm.AddressFamily = types.Int64Value(int64(reservedBlock.AddressFamily)) + rm.Cidr = types.Int64Value(int64(reservedBlock.CIDR)) + rm.Gateway = types.StringValue(reservedBlock.Gateway) + rm.Network = types.StringValue(reservedBlock.Network) + rm.Netmask = types.StringValue(reservedBlock.Netmask) + rm.Public = types.BoolValue(reservedBlock.Public) + rm.Management = types.BoolValue(reservedBlock.Management) + rm.Manageable = types.BoolValue(reservedBlock.Manageable) + rm.Type = types.StringValue(string(reservedBlock.Type)) + + // Optional fields + if reservedBlock.Facility != nil { + rm.Facility = types.StringValue(reservedBlock.Facility.Code) + } + if reservedBlock.Metro != nil { + rm.Metro = types.StringValue(reservedBlock.Metro.Code) + } + if reservedBlock.VRF != nil { + rm.VrfID = types.StringValue(reservedBlock.VRF.ID) + } + if reservedBlock.Description != nil && (*(reservedBlock.Description) != "") { + rm.Description = types.StringPointerValue(reservedBlock.Description) + } + + // Handling tags as a list + tags, diags := types.ListValueFrom(ctx, types.StringType, reservedBlock.Tags) + if diags.HasError() { + return diags + } + rm.Tags = tags + + // Custom data (assuming it's a JSON string) + if reservedBlock.CustomData != nil { + customDataJSON, err := json.Marshal(reservedBlock.CustomData) + if err != nil { + diags.AddError( + "Error parsing Reserved IP Block", + fmt.Sprintf("Error marshaling custom data to JSON: %s", err.Error()), + ) + return diags + } else { + rm.CustomData = types.StringValue(string(customDataJSON)) + } + } + + // Description + if reservedBlock.Description != nil { + rm.Description = types.StringPointerValue(reservedBlock.Description) + } + + // CIDR notation + rm.CidrNotation = types.StringValue(fmt.Sprintf("%s/%d", reservedBlock.Network, reservedBlock.CIDR)) + + quantity := 0 + if reservedBlock.AddressFamily == 4 { + quantity = 1 << (32 - reservedBlock.CIDR) + } else { + // In Equinix Metal, a reserved IPv6 block is allocated when a device is + // run in a project. It's always /56, and it can't be created with + // Terraform, only imported. The longest assignable prefix is /64, + // making it max 256 subnets per block. The following logic will hold as + // long as /64 is the smallest assignable subnet size. + bits := 64 - reservedBlock.CIDR + if bits > 30 { + diags.AddError( + "Error parsing Reserved IP Block", + fmt.Sprintf("strange (too small) CIDR prefix: %d", reservedBlock.CIDR), + ) + return diags + } + quantity = 1 << uint(bits) + } + rm.Quantity = types.Int64Value(int64(quantity)) + + return diags +} diff --git a/internal/metal_reserved_ip_block/framework_resource.go b/internal/metal_reserved_ip_block/framework_resource.go new file mode 100644 index 000000000..c6901e466 --- /dev/null +++ b/internal/metal_reserved_ip_block/framework_resource.go @@ -0,0 +1,297 @@ +package metal_reserved_ip_block + +import ( + "context" + "fmt" + "time" + "log" + "encoding/json" + + "github.com/equinix/terraform-provider-equinix/internal/helper" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/packethost/packngo" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" +) + +func NewResource(ctx context.Context) resource.Resource { + return &Resource{ + BaseResource: helper.NewBaseResource( + helper.BaseResourceConfig{ + Name: "equinix_metal_reserved_ip_block", + Schema: metalReservedIpBlockResourceSchema(ctx), + }, + ), + } +} + +type Resource struct { + helper.BaseResource +} + +func (r *Resource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + // Initialize and get values from the plan + var plan MetalReservedIPBlockResourceModel + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Retrieve the API client from the provider metadata + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + client := r.Meta.Metal + + // Prepare the request for your API using data from the plan + createRequest := packngo.IPReservationCreateRequest{ + Type: packngo.IPReservationType(plan.Type.ValueString()), + Quantity: int(plan.Quantity.ValueInt64()), + VRFID: plan.VrfID.ValueString(), + Network: plan.Network.ValueString(), + CIDR: int(plan.Cidr.ValueInt64()), + Description: plan.Description.ValueString(), + CustomData: plan.CustomData.ValueString(), // NOTE (ocobles) in legacy sdk we were checking if d.HasChange("custom_data") { req.CustomData = d.Get("custom_data")} + } + + // Implement the conditional logic as per your requirements + if plan.Type.ValueString() == "global_ipv4" && (plan.Facility.ValueString() != "" || plan.Metro.ValueString() != "") { + resp.Diagnostics.AddError("Invalid Configuration", "Facility and metro can't be set for global IP block reservation") + return + } + + if plan.Type.ValueString() == "public_ipv4" && (plan.Facility.ValueString() == "" && plan.Metro.ValueString() == "") { + resp.Diagnostics.AddError("Invalid Configuration", "You should set either metro or facility for non-global IP block reservation") + return + } + + if plan.Facility.ValueString() != "" { + createRequest.Facility = plan.Facility.ValueStringPointer() + } + + if plan.Metro.ValueString() != "" { + createRequest.Metro = plan.Metro.ValueStringPointer() + } + + // Add tags if they are set + if len(plan.Tags.Elements()) > 0 { + tags := []string{} + if diags := plan.Tags.ElementsAs(ctx, &tags, false); diags != nil { + resp.Diagnostics.Append(diags...) + return + } + createRequest.Tags = tags + } + + + // Create the resource using the API + start := time.Now() + blockAddr, _, err := client.ProjectIPs.Create(plan.ProjectID.ValueString(), &createRequest) + if err != nil { + err = helper.FriendlyError(err) + resp.Diagnostics.AddError( + "Error creating MetalReservedIPBlock", + fmt.Sprintf("Could not create MetalReservedIPBlock: %s", err), + ) + return + } + + // Wait for IP Reservation to reach target state + wfs := plan.WaitForState.ValueString() + log.Printf("[DEBUG] Waiting for IP Reservation (%s) to become %s", blockAddr.ID, wfs) + target := []string{string(packngo.IPReservationStateCreated)} + if wfs != string(packngo.IPReservationStateCreated) { + target = append(target, wfs) + } + createTimeout, diags := plan.Timeouts.Create(ctx, 20*time.Minute) + createTimeout = createTimeout - 30*time.Second - time.Since(start) + createWaiter := getReservedIpBlockStateWaiter( + client, + blockAddr.ID, + createTimeout, + []string{string(packngo.IPReservationStatePending)}, + target, + ) + + reservedIPItf, err := createWaiter.WaitForStateContext(ctx) + if err != nil { + err = helper.FriendlyError(err) + resp.Diagnostics.AddError( + "Error waiting for creationg of IP Reservation", + fmt.Sprintf("error waiting for IP Reservation (%s) to become %s: %s", blockAddr.ID, wfs, err), + ) + return + } + + ip, ok := reservedIPItf.(*packngo.IPAddressReservation) + if !ok { + resp.Diagnostics.AddError( + "Error parsing IP Reservation response", + "Unexpected response type from API", + ) + return + } + + // Map the created resource data back to the Terraform state + var resourceState MetalReservedIPBlockResourceModel + resourceState.parse(ctx, ip) + diags = resp.State.Set(ctx, &resourceState) + resp.Diagnostics.Append(diags...) +} + + +func getReservedIpBlockStateWaiter(client *packngo.Client, id string, timeout time.Duration, pending, target []string) *retry.StateChangeConf { + return &retry.StateChangeConf{ + Pending: pending, + Target: target, + Refresh: reservedIPStateRefreshFunc(client, id), + Timeout: timeout, + MinTimeout: 5 * time.Second, + } +} + +func reservedIPStateRefreshFunc(client *packngo.Client, reservedIPId string) retry.StateRefreshFunc { + return func() (interface{}, string, error) { + reservedIP, _, err := client.ProjectIPs.Get(reservedIPId, nil) + if err != nil { + return nil, "", fmt.Errorf("error retrieving reserved IP block %s: %s", reservedIPId, err) + } + + return reservedIP, string(reservedIP.State), nil + } +} + +func (r *Resource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state MetalReservedIPBlockResourceModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Retrieve the API client from the provider metadata + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + client := r.Meta.Metal + + // Extract the ID of the resource from the state + id := state.ID.ValueString() + + // Retrieve the resource from the API + getOpts := &packngo.GetOptions{Includes: []string{"facility", "metro", "project", "vrf"}} + getOpts = getOpts.Filter("types", "public_ipv4,global_ipv4,private_ipv4,public_ipv6,vrf") + reservedBlock, _, err := client.ProjectIPs.Get(id, getOpts) + if err != nil { + err = helper.FriendlyError(err) + + // Check if no longer exists + if helper.IsNotFound(err) { + resp.Diagnostics.AddWarning( + "Metal Reserved IP Block", + fmt.Sprintf("[WARN] IP Block (%s) not found, removing from state", id), + ) + resp.State.RemoveResource(ctx) + return + } + + resp.Diagnostics.AddError( + "Error reading Metal Reserved IP Block", + fmt.Sprintf("Could not read MetalReservedIPBlock with ID %s: %s", id, err), + ) + return + } + + // Update the state with the current values of the resource + diags = state.parse(ctx, reservedBlock) + resp.Diagnostics.Append(diags...) + diags = resp.State.Set(ctx, &state) + resp.Diagnostics.Append(diags...) +} + +func (r *Resource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan MetalReservedIPBlockResourceModel + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + var state MetalReservedIPBlockResourceModel + diags = req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Retrieve the API client from the provider metadata + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + client := r.Meta.Metal + + // Extract the ID of the organization from the state + id := state.ID.ValueString() + + // Prepare the update request + updateRequest := &packngo.IPAddressUpdateRequest{} + if !state.Description.Equal(plan.Description) { + updateRequest.Description = plan.Description.ValueStringPointer() + } + if !state.Tags.Equal(plan.Tags) { + tags := []string{} + if diags := plan.Tags.ElementsAs(ctx, &tags, false); diags != nil { + resp.Diagnostics.Append(diags...) + return + } + updateRequest.Tags = &tags + } + if !state.CustomData.Equal(plan.CustomData) { + var v interface{} + if err := json.Unmarshal([]byte(plan.CustomData.ValueString()), v); err != nil { + diags.AddError( + "Error updating IP Block", + fmt.Sprintf("Error marshaling custom data to JSON: %s", err.Error()), + ) + return + } + updateRequest.CustomData = v + } + + // Call your API to update the resource + updatedBlock, _, err := client.ProjectIPs.Update(id, updateRequest, nil) + if err != nil { + err = helper.FriendlyError(err) + resp.Diagnostics.AddError( + "Error updating MetalReservedIPBlock", + fmt.Sprintf("Could not update MetalReservedIPBlock with ID %s: %s", id, err), + ) + return + } + + // Update the state with the new values of the resource + diags = state.parse(ctx, updatedBlock) + resp.Diagnostics.Append(diags...) + diags = resp.State.Set(ctx, &state) + resp.Diagnostics.Append(diags...) +} + +func (r *Resource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state MetalReservedIPBlockResourceModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Retrieve the API client from the provider metadata + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + client := r.Meta.Metal + + // Extract the ID of the organization from the state + id := state.ID.ValueString() + + // Call your API to delete the resource + deleteResp, err := client.ProjectIPs.Remove(id) + if helper.IgnoreResponseErrors(helper.HttpForbidden, helper.HttpNotFound)(deleteResp, err) != nil { + err = helper.FriendlyError(err) + resp.Diagnostics.AddError( + fmt.Sprintf("Failed to delete IP Reservation block %s", id), + err.Error(), + ) + } +} diff --git a/internal/metal_reserved_ip_block/framework_schema_resource.go b/internal/metal_reserved_ip_block/framework_schema_resource.go new file mode 100644 index 000000000..e8dbc45a7 --- /dev/null +++ b/internal/metal_reserved_ip_block/framework_schema_resource.go @@ -0,0 +1,227 @@ +package metal_reserved_ip_block + +import ( + "context" + + customstringvalidator "github.com/equinix/terraform-provider-equinix/internal/schema_validators/strings" + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" + "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/packethost/packngo" + "github.com/hashicorp/terraform-plugin-framework-timeouts/resource/timeouts" +) + +func metalReservedIpBlockResourceSchema(ctx context.Context) *schema.Schema { + return &schema.Schema{ + Attributes: map[string]schema.Attribute{ + "timeouts": timeouts.Attributes(ctx, timeouts.Opts{ + Delete: true, + }), + "id": schema.StringAttribute{ + Computed: true, + Description: "The unique identifier of the reserved IP block", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "project_id": schema.StringAttribute{ + Required: true, + Description: "The metal project ID where to allocate the address block", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + stringplanmodifier.UseStateForUnknown(), + }, + }, + "facility": schema.StringAttribute{ + Optional: true, + Description: "Facility where to allocate the public IP address block", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplaceIfConfigured(), + }, + Validators: []validator.String{ + stringvalidator.ConflictsWith(path.Expressions{ + path.MatchRoot("metro"), + }...), + }, + // NOTE (ocobles) + //DiffSuppressFunc does not exist in fw + // Let's try with RequiresReplaceIfConfigured ans see if it works as expected + // otherwise replace it with appropriate logic in the Update function + // + // DiffSuppressFunc: func(k, old, new string, d *schema.ResourceData) bool { + // // suppress diff when unsetting facility + // if len(old) > 0 && new == "" { + // return true + // } + // return old == new + // }, + }, + "metro": schema.StringAttribute{ + Optional: true, + Description: "Metro where to allocate the public IP address block", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + // TODO (ocobles) + // + // DiffSuppressFunc: func(k, fromState, fromHCL string, d *schema.ResourceData) bool { + // _, facOk := d.GetOk("facility") + // // if facility is not in state, treat the diff normally, otherwise do following messy checks: + // if facOk { + // // If metro from HCL is specified, but not present in state, suppress the diff. + // // This is legacy, and I think it's here because of migration, so that old + // // facility reservations are not recreated when metro is specified ???) + // if fromHCL != "" && fromState == "" { + // return true + // } + // // If metro is present in state but not present in HCL, suppress the diff. + // // This is for "facility-specified" reservation blocks created after ~July 2021. + // // These blocks will have metro "computed" to the TF state, and we don't want to + // // emit a diff if the metro field is empty in HCL. + // if fromHCL == "" && fromState != "" { + // return true + // } + // } + // return fromState == fromHCL + // }, + // + // TODO (ocobles) + // + // StateFunc doesn't exist in terraform, it requires implementation of bespoke logic before storing state, for instance in resource Create method + // StateFunc: toLower + }, + "description": schema.StringAttribute{ + Optional: true, + Description: "Arbitrary description for the reserved IP block", + }, + "quantity": schema.Int64Attribute{ + Optional: true, + Computed: true, + Description: "The number of allocated /32 addresses, a power of 2", + Validators: []validator.Int64{ + int64validator.ExactlyOneOf(path.Expressions{ + path.MatchRoot("vrf_id"), + }...), + }, + }, + "type": schema.StringAttribute{ + Optional: true, + Description: "Either global_ipv4, public_ipv4, or vrf. Defaults to public_ipv4.", + Default: stringdefault.StaticString("public_ipv4"), + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + stringvalidator.OneOf( + "public_ipv4", + "global_ipv4", + "vrf", + ), + }, + }, + "tags": schema.ListAttribute{ + Optional: true, + Description: "Tags attached to the reserved block", + ElementType: types.StringType, + }, + "custom_data": schema.StringAttribute{ + Optional: true, + Description: "Custom Data in JSON format assigned to the IP Reservation", + Default: stringdefault.StaticString("{}"), + Validators: []validator.String{ + // NOTE (ocobles) StringIsJSON doesn't exist in framework, + // This is a custom implementation I made and we need to ensure it is working as expected + customstringvalidator.StringIsJSON(), + }, + // TODO (ocobles) https://discuss.hashicorp.com/t/diffsuppressfunc-alternative-in-terraform-framework/52578/4 + // DiffSuppressFunc: structure.SuppressJsonDiff, + }, + "wait_for_state": schema.StringAttribute{ + Optional: true, + Description: "Wait for the IP reservation block to reach a desired state on resource creation", + Default: stringdefault.StaticString(string(packngo.IPReservationStateCreated)), + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + stringvalidator.OneOf( + string(packngo.IPReservationStateCreated), + string(packngo.IPReservationStatePending), + ), + }, + }, + "vrf_id": schema.StringAttribute{ + Optional: true, + Description: "VRF ID for type=vrf reservations", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + stringvalidator.AlsoRequires(path.Expressions{ + path.MatchRoot("network"), + path.MatchRoot("cidr"), + }...), + }, + }, + "network": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: "An unreserved network address from an existing vrf ip_range", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "cidr": schema.Int64Attribute{ + Optional: true, + Computed: true, + Description: "The size of the network to reserve from an existing vrf ip_range", + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.RequiresReplace(), + }, + }, + "cidr_notation": schema.StringAttribute{ + Computed: true, + Description: "CIDR notation of the IP address", + }, + "address": schema.StringAttribute{ + Computed: true, + Description: "The IP address", + }, + "address_family": schema.Int64Attribute{ + Computed: true, + Description: "Address family as integer (4 or 6)", + }, + "gateway": schema.StringAttribute{ + Computed: true, + Description: "The gateway IP address", + }, + "netmask": schema.StringAttribute{ + Computed: true, + Description: "The mask in decimal notation", + }, + "manageable": schema.BoolAttribute{ + Computed: true, + Description: "Flag indicating whether the IP block is manageable", + }, + "management": schema.BoolAttribute{ + Computed: true, + Description: "Flag indicating whether the IP block is for management", + }, + "public": schema.BoolAttribute{ + Computed: true, + Description: "Flag indicating whether IP block is addressable from the Internet", + }, + "global": schema.BoolAttribute{ + Computed: true, + Description: "Flag indicating whether IP block is global (i.e., assignable in any location)", + }, + }, + } +} diff --git a/internal/schema_validators/strings/is_json.go b/internal/schema_validators/strings/is_json.go new file mode 100644 index 000000000..a4c782e21 --- /dev/null +++ b/internal/schema_validators/strings/is_json.go @@ -0,0 +1,47 @@ +package schema_validators + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// Ensure our implementation satisfies the validator.String interface. +var _ validator.String = &StringIsJSONValidator{} + +type StringIsJSONValidator struct{} + +// Description returns a plain text description of the validator's behavior, suitable for a practitioner to understand its impact. +func (v StringIsJSONValidator) Description(ctx context.Context) string { + return "string must be valid JSON" +} + +// MarkdownDescription returns a markdown formatted description of the validator's behavior, suitable for a practitioner to understand its impact. +func (v StringIsJSONValidator) MarkdownDescription(ctx context.Context) string { + return "string must be valid JSON" +} + +// ValidateString runs the main validation logic of the validator, reading configuration data out of `req` and updating `resp` with diagnostics. +func (v StringIsJSONValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { + // If the value is unknown or null, there is nothing to validate. + if req.ConfigValue.IsUnknown() || req.ConfigValue.IsNull() { + return + } + + var js json.RawMessage + err := json.Unmarshal([]byte(req.ConfigValue.ValueString()), &js) + if err != nil { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid JSON Format", + fmt.Sprintf("String must be valid JSON: %s", err), + ) + } +} + +// StringIsJSON returns a new StringIsJSONValidator. +func StringIsJSON() StringIsJSONValidator { + return StringIsJSONValidator{} +} From 3270da1ba717b6659863c08061007f90553796d9 Mon Sep 17 00:00:00 2001 From: ocobleseqx Date: Fri, 1 Dec 2023 16:01:10 +0100 Subject: [PATCH 22/26] fixup! equinix_metal_reserved_ip_block Signed-off-by: ocobleseqx --- internal/metal_reserved_ip_block/framework_resource.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/metal_reserved_ip_block/framework_resource.go b/internal/metal_reserved_ip_block/framework_resource.go index c6901e466..0b58bd1db 100644 --- a/internal/metal_reserved_ip_block/framework_resource.go +++ b/internal/metal_reserved_ip_block/framework_resource.go @@ -88,8 +88,8 @@ func (r *Resource) Create(ctx context.Context, req resource.CreateRequest, resp if err != nil { err = helper.FriendlyError(err) resp.Diagnostics.AddError( - "Error creating MetalReservedIPBlock", - fmt.Sprintf("Could not create MetalReservedIPBlock: %s", err), + "Error creating Metal Reserved IPBlock", + fmt.Sprintf("Could not create Metal Reserved IP Block: %s", err), ) return } @@ -193,7 +193,7 @@ func (r *Resource) Read(ctx context.Context, req resource.ReadRequest, resp *res resp.Diagnostics.AddError( "Error reading Metal Reserved IP Block", - fmt.Sprintf("Could not read MetalReservedIPBlock with ID %s: %s", id, err), + fmt.Sprintf("Could not read Metal Reserved IP Block with ID %s: %s", id, err), ) return } @@ -257,8 +257,8 @@ func (r *Resource) Update(ctx context.Context, req resource.UpdateRequest, resp if err != nil { err = helper.FriendlyError(err) resp.Diagnostics.AddError( - "Error updating MetalReservedIPBlock", - fmt.Sprintf("Could not update MetalReservedIPBlock with ID %s: %s", id, err), + "Error updating Metal Reserved IP Block", + fmt.Sprintf("Could not update Metal Reserved IP Block with ID %s: %s", id, err), ) return } From e4efb6a126685b76636298d02db4365c2b73bb91 Mon Sep 17 00:00:00 2001 From: ocobleseqx Date: Fri, 1 Dec 2023 16:02:10 +0100 Subject: [PATCH 23/26] equinix_metal_vrf Signed-off-by: ocobleseqx --- equinix/provider.go | 2 +- internal/framework_provider.go | 4 +- internal/metal_vrf/framework_models.go | 40 ++++ internal/metal_vrf/framework_resource.go | 202 ++++++++++++++++++ .../metal_vrf/framework_schema_resource.go | 51 +++++ 5 files changed, 297 insertions(+), 2 deletions(-) create mode 100644 internal/metal_vrf/framework_models.go create mode 100644 internal/metal_vrf/framework_resource.go create mode 100644 internal/metal_vrf/framework_schema_resource.go diff --git a/equinix/provider.go b/equinix/provider.go index 2e297b00d..541a934c5 100644 --- a/equinix/provider.go +++ b/equinix/provider.go @@ -174,7 +174,7 @@ func Provider() *schema.Provider { "equinix_metal_spot_market_request": resourceMetalSpotMarketRequest(), // "equinix_metal_vlan": resourceMetalVlan(), "equinix_metal_virtual_circuit": resourceMetalVirtualCircuit(), - "equinix_metal_vrf": resourceMetalVRF(), + // "equinix_metal_vrf": resourceMetalVRF(), // "equinix_metal_bgp_session": resourceMetalBGPSession(), "equinix_metal_port_vlan_attachment": resourceMetalPortVlanAttachment(), // "equinix_metal_gateway": resourceMetalGateway(), diff --git a/internal/framework_provider.go b/internal/framework_provider.go index 0dcf96314..259025d15 100644 --- a/internal/framework_provider.go +++ b/internal/framework_provider.go @@ -14,9 +14,10 @@ import ( "github.com/equinix/terraform-provider-equinix/internal/metal_organization_member" "github.com/equinix/terraform-provider-equinix/internal/metal_port" "github.com/equinix/terraform-provider-equinix/internal/metal_project" + "github.com/equinix/terraform-provider-equinix/internal/metal_reserved_ip_block" "github.com/equinix/terraform-provider-equinix/internal/metal_ssh_key" "github.com/equinix/terraform-provider-equinix/internal/metal_vlan" - "github.com/equinix/terraform-provider-equinix/internal/metal_reserved_ip_block" + "github.com/equinix/terraform-provider-equinix/internal/metal_vrf" "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/datasource" @@ -120,6 +121,7 @@ func (p *FrameworkProvider) Resources(ctx context.Context) []func() resource.Res func() resource.Resource { return metal_reserved_ip_block.NewResource(ctx) }, + metal_vrf.NewResource, } } diff --git a/internal/metal_vrf/framework_models.go b/internal/metal_vrf/framework_models.go new file mode 100644 index 000000000..9e5d74223 --- /dev/null +++ b/internal/metal_vrf/framework_models.go @@ -0,0 +1,40 @@ +package metal_vrf + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/packethost/packngo" + "github.com/hashicorp/terraform-plugin-framework/diag" +) + +type MetalVRFResourceModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Description types.String `tfsdk:"description"` + Metro types.String `tfsdk:"metro"` + LocalASN types.Int64 `tfsdk:"local_asn"` + IPRanges types.List `tfsdk:"ip_ranges"` + ProjectID types.String `tfsdk:"project_id"` +} + +func (rm *MetalVRFResourceModel) parse(ctx context.Context, vrf *packngo.VRF) diag.Diagnostics { + var diags diag.Diagnostics + + rm.ID = types.StringValue(vrf.ID) + rm.Name = types.StringValue(vrf.Name) + rm.Description = types.StringValue(vrf.Description) + rm.Metro = types.StringValue(vrf.Metro.Code) + rm.LocalASN = types.Int64Value(int64(vrf.LocalASN)) + + // Converting the IPRanges slice to a Terraform types.List + ipRanges, diags := types.ListValueFrom(ctx, types.StringType, vrf.IPRanges) + if diags.HasError() { + return diags + } + rm.IPRanges = ipRanges + + rm.ProjectID = types.StringValue(vrf.Project.ID) + + return diags +} diff --git a/internal/metal_vrf/framework_resource.go b/internal/metal_vrf/framework_resource.go new file mode 100644 index 000000000..a248c7e5b --- /dev/null +++ b/internal/metal_vrf/framework_resource.go @@ -0,0 +1,202 @@ +package metal_vrf + +import ( + "context" + "fmt" + + "github.com/equinix/terraform-provider-equinix/internal/helper" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/packethost/packngo" +) + +func NewResource() resource.Resource { + return &Resource{ + BaseResource: helper.NewBaseResource( + helper.BaseResourceConfig{ + Name: "equinix_metal_vrf", + Schema: &metalVrfResourceSchema, + }, + ), + } +} + +type Resource struct { + helper.BaseResource +} + +func (r *Resource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + // Initialize and get values from the plan + var plan MetalVRFResourceModel + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Retrieve the API client from the provider metadata + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + client := r.Meta.Metal + + // Convert the plan to an API request + createRequest := &packngo.VRFCreateRequest{ + Name: plan.Name.ValueString(), + Description: plan.Description.ValueString(), + Metro: plan.Metro.ValueString(), + LocalASN: int(plan.LocalASN.ValueInt64()), + } + + ipRanges := []string{} + if diags := plan.IPRanges.ElementsAs(ctx, &ipRanges, false); diags != nil { + resp.Diagnostics.Append(diags...) + return + } + createRequest.IPRanges = ipRanges + + // API call to create the resource + vrf, _, err := client.VRFs.Create(plan.ProjectID.ValueString(), createRequest) + if err != nil { + err = helper.FriendlyError(err) + resp.Diagnostics.AddError( + "Error creating Metal VRF", + fmt.Sprintf("Could not create Metal VRF: %s", err), + ) + return + } + + // Update the Terraform state with the new resource + var resourceState MetalVRFResourceModel + resourceState.parse(ctx, vrf) + diags = resp.State.Set(ctx, &resourceState) + resp.Diagnostics.Append(diags...) +} + +func (r *Resource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state MetalVRFResourceModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Retrieve the API client from the provider metadata + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + client := r.Meta.Metal + + // Extract the ID of the resource from the state + id := state.ID.ValueString() + + // Retrieve the resource from the API + vrf, _, err := client.VRFs.Get(id, &packngo.GetOptions{}) + if err != nil { + err = helper.FriendlyError(err) + // If the VRF was destroyed, mark as gone + if helper.IsNotFound(err) || helper.IsForbidden(err) { + resp.Diagnostics.AddWarning( + "Metal Metal", + fmt.Sprintf("[WARN] VRF (%s) not accessible, removing from state", id), + ) + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError( + "Error reading Metal VRF", + fmt.Sprintf("Could not read Metal VRF with ID %s: %s", id, err), + ) + return + + } + + // Update the state with the current values of the resource + diags = state.parse(ctx, vrf) + resp.Diagnostics.Append(diags...) + diags = resp.State.Set(ctx, &state) + resp.Diagnostics.Append(diags...) +} + +func (r *Resource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan MetalVRFResourceModel + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + var state MetalVRFResourceModel + diags = req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Retrieve the API client from the provider metadata + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + client := r.Meta.Metal + + // Extract the ID of the organization from the state + id := state.ID.ValueString() + + // Prepare the update request + updateRequest := &packngo.VRFUpdateRequest{} + if !state.Name.Equal(plan.Name) { + updateRequest.Name = plan.Name.ValueStringPointer() + } + if !state.Description.Equal(plan.Description) { + updateRequest.Description = plan.Description.ValueStringPointer() + } + if !state.LocalASN.Equal(plan.LocalASN) { + asn := int(plan.LocalASN.ValueInt64()) + updateRequest.LocalASN = &asn + } + if !state.IPRanges.Equal(plan.IPRanges) { + ranges := []string{} + if diags := plan.IPRanges.ElementsAs(ctx, &ranges, false); diags != nil { + resp.Diagnostics.Append(diags...) + return + } + updateRequest.IPRanges = &ranges + } + + // Call your API to update the resource + updatedVrf, _, err := client.VRFs.Update(id, updateRequest) + if err != nil { + err = helper.FriendlyError(err) + resp.Diagnostics.AddError( + "Error updating Metal VRF", + fmt.Sprintf("Could not update Metal VRF with ID %s: %s", id, err), + ) + return + } + + // Update the state with the new values of the resource + diags = state.parse(ctx, updatedVrf) + resp.Diagnostics.Append(diags...) + diags = resp.State.Set(ctx, &state) + resp.Diagnostics.Append(diags...) +} + +func (r *Resource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state MetalVRFResourceModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Retrieve the API client from the provider metadata + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + client := r.Meta.Metal + + // Extract the ID of the organization from the state + id := state.ID.ValueString() + + // Call your API to delete the resource + deleteResp, err := client.VRFs.Delete(id) + if helper.IgnoreResponseErrors(helper.HttpForbidden, helper.HttpNotFound)(deleteResp, err) != nil { + err = helper.FriendlyError(err) + resp.Diagnostics.AddError( + "Error deleting Metal VRF", + fmt.Sprintf("Could not delete Metal VRF with ID %s: %s", id, err), + ) + return + } +} diff --git a/internal/metal_vrf/framework_schema_resource.go b/internal/metal_vrf/framework_schema_resource.go new file mode 100644 index 000000000..31ea89b35 --- /dev/null +++ b/internal/metal_vrf/framework_schema_resource.go @@ -0,0 +1,51 @@ +package metal_vrf + +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" +) + +var metalVrfResourceSchema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + Description: "The unique identifier of the VRF", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "name": schema.StringAttribute{ + Required: true, + Description: "User-supplied name of the VRF, unique to the project", + }, + "metro": schema.StringAttribute{ + Required: true, + Description: "Metro Code", + PlanModifiers: []planmodifier.String{ // NOTE (ocobles) it wasn't mark as required in legacy sdk schema but it cannot be updated + stringplanmodifier.RequiresReplace(), + }, + }, + "project_id": schema.StringAttribute{ + Required: true, + Description: "ID of the project where the connection is scoped to. Required with type \"shared\"", + PlanModifiers: []planmodifier.String{ // NOTE (ocobles) it wasn't mark as required in legacy sdk schema but it cannot be updated + stringplanmodifier.RequiresReplace(), + }, + }, + "description": schema.StringAttribute{ + Required: true, + Description: "Description of the VRF", + }, + "local_asn": schema.Int64Attribute{ + Optional: true, + Computed: true, + Description: "The 4-byte ASN set on the VRF", + }, + "ip_ranges": schema.ListAttribute{ + Optional: true, + Description: "All IPv4 and IPv6 Ranges that will be available to BGP Peers.", + }, + // TODO: created_by, created_at, updated_at, href + }, +} From 6c28857825956c8b5fd11f213c5c998991c09e5d Mon Sep 17 00:00:00 2001 From: ocobleseqx Date: Fri, 1 Dec 2023 17:33:54 +0100 Subject: [PATCH 24/26] equinix_metal_virtual_circuit Signed-off-by: ocobleseqx --- equinix/provider.go | 2 +- equinix/resource_metal_virtual_circuit.go | 15 +- internal/framework_provider.go | 4 + internal/metal_connection/framework_models.go | 18 +- .../framework_resource.go | 2 +- .../metal_virtual_circuit/framework_models.go | 90 ++++++ .../framework_resource.go | 305 ++++++++++++++++++ .../framework_schema_resource.go | 141 ++++++++ 8 files changed, 552 insertions(+), 25 deletions(-) create mode 100644 internal/metal_virtual_circuit/framework_models.go create mode 100644 internal/metal_virtual_circuit/framework_resource.go create mode 100644 internal/metal_virtual_circuit/framework_schema_resource.go diff --git a/equinix/provider.go b/equinix/provider.go index 541a934c5..a52e2b67a 100644 --- a/equinix/provider.go +++ b/equinix/provider.go @@ -173,7 +173,7 @@ func Provider() *schema.Provider { // "equinix_metal_ip_attachment": resourceMetalIPAttachment(), "equinix_metal_spot_market_request": resourceMetalSpotMarketRequest(), // "equinix_metal_vlan": resourceMetalVlan(), - "equinix_metal_virtual_circuit": resourceMetalVirtualCircuit(), + // "equinix_metal_virtual_circuit": resourceMetalVirtualCircuit(), // "equinix_metal_vrf": resourceMetalVRF(), // "equinix_metal_bgp_session": resourceMetalBGPSession(), "equinix_metal_port_vlan_attachment": resourceMetalPortVlanAttachment(), diff --git a/equinix/resource_metal_virtual_circuit.go b/equinix/resource_metal_virtual_circuit.go index a832bc852..90c7585c3 100644 --- a/equinix/resource_metal_virtual_circuit.go +++ b/equinix/resource_metal_virtual_circuit.go @@ -160,21 +160,8 @@ func resourceMetalVirtualCircuitCreate(ctx context.Context, d *schema.ResourceDa portId := d.Get("port_id").(string) projectId := d.Get("project_id").(string) - tags := d.Get("tags.#").(int) - if tags > 0 { - vncr.Tags = convertStringArr(d.Get("tags").([]interface{})) - } + - if nniVlan, ok := d.GetOk("nni_vlan"); ok { - vncr.NniVLAN = nniVlan.(int) - } - conn, _, err := client.Connections.Get(connId, nil) - if err != nil { - return err - } - if conn.Status == string(packngo.VCStatusPending) { - return fmt.Errorf("Connection request with name %s and ID %s wasn't approved yet", conn.Name, conn.ID) - } vc, _, err := client.VirtualCircuits.Create(projectId, connId, portId, &vncr, nil) if err != nil { diff --git a/internal/framework_provider.go b/internal/framework_provider.go index 259025d15..c4b5203c2 100644 --- a/internal/framework_provider.go +++ b/internal/framework_provider.go @@ -16,6 +16,7 @@ import ( "github.com/equinix/terraform-provider-equinix/internal/metal_project" "github.com/equinix/terraform-provider-equinix/internal/metal_reserved_ip_block" "github.com/equinix/terraform-provider-equinix/internal/metal_ssh_key" + "github.com/equinix/terraform-provider-equinix/internal/metal_virtual_circuit" "github.com/equinix/terraform-provider-equinix/internal/metal_vlan" "github.com/equinix/terraform-provider-equinix/internal/metal_vrf" "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" @@ -122,6 +123,9 @@ func (p *FrameworkProvider) Resources(ctx context.Context) []func() resource.Res return metal_reserved_ip_block.NewResource(ctx) }, metal_vrf.NewResource, + func() resource.Resource { + return metal_virtual_circuit.NewResource(ctx) + }, } } diff --git a/internal/metal_connection/framework_models.go b/internal/metal_connection/framework_models.go index ea4e9fc8b..46760ae17 100644 --- a/internal/metal_connection/framework_models.go +++ b/internal/metal_connection/framework_models.go @@ -148,15 +148,15 @@ func (rm *MetalConnectionResourceModel) parseConnectionPorts(ctx context.Context } for _, p := range cps { - // Parse VirtualCircuits - portVcIDs := make([]string, len(p.VirtualCircuits)) - for i, vc := range p.VirtualCircuits { - portVcIDs[i] = vc.ID - } - vcIDs, diags := types.ListValueFrom(ctx, types.StringType, portVcIDs) - if diags.HasError() { - return diags - } + // Parse VirtualCircuits + portVcIDs := make([]string, len(p.VirtualCircuits)) + for i, vc := range p.VirtualCircuits { + portVcIDs[i] = vc.ID + } + vcIDs, diags := types.ListValueFrom(ctx, types.StringType, portVcIDs) + if diags.HasError() { + return diags + } connPort := Port{ ID: types.StringValue(p.ID), Name: types.StringValue(p.Name), diff --git a/internal/metal_reserved_ip_block/framework_resource.go b/internal/metal_reserved_ip_block/framework_resource.go index 0b58bd1db..193ff92bf 100644 --- a/internal/metal_reserved_ip_block/framework_resource.go +++ b/internal/metal_reserved_ip_block/framework_resource.go @@ -115,7 +115,7 @@ func (r *Resource) Create(ctx context.Context, req resource.CreateRequest, resp if err != nil { err = helper.FriendlyError(err) resp.Diagnostics.AddError( - "Error waiting for creationg of IP Reservation", + "Error waiting for creation of IP Reservation", fmt.Sprintf("error waiting for IP Reservation (%s) to become %s: %s", blockAddr.ID, wfs, err), ) return diff --git a/internal/metal_virtual_circuit/framework_models.go b/internal/metal_virtual_circuit/framework_models.go new file mode 100644 index 000000000..161f64c7a --- /dev/null +++ b/internal/metal_virtual_circuit/framework_models.go @@ -0,0 +1,90 @@ +package metal_virtual_circuit + +import ( + "context" + "regexp" + "strconv" + "log" + + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/packethost/packngo" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework-timeouts/resource/timeouts" +) + +type MetalVirtualCircuitResourceModel struct { + ID types.String `tfsdk:"id"` + ConnectionID types.String `tfsdk:"connection_id"` + ProjectID types.String `tfsdk:"project_id"` + PortID types.String `tfsdk:"port_id"` + Name types.String `tfsdk:"name"` + Description types.String `tfsdk:"description"` + Speed types.String `tfsdk:"speed"` + Tags types.List `tfsdk:"tags"` + NniVLAN types.Int64 `tfsdk:"nni_vlan"` + VlanID types.String `tfsdk:"vlan_id"` + VrfID types.String `tfsdk:"vrf_id"` + PeerASN types.Int64 `tfsdk:"peer_asn"` + Subnet types.String `tfsdk:"subnet"` + MetalIP types.String `tfsdk:"metal_ip"` + CustomerIP types.String `tfsdk:"customer_ip"` + MD5 types.String `tfsdk:"md5"` + Vnid types.Int64 `tfsdk:"vnid"` + NniVnid types.Int64 `tfsdk:"nni_vnid"` + Status types.String `tfsdk:"status"` + Timeouts timeouts.Value `tfsdk:"timeouts"` +} + +func (rm *MetalVirtualCircuitResourceModel) parse(ctx context.Context, vc *packngo.VirtualCircuit) diag.Diagnostics { + var diags diag.Diagnostics + + rm.ID = types.StringValue(vc.ID) + rm.ProjectID = types.StringValue(vc.Project.ID) + rm.Name = types.StringValue(vc.Name) + rm.Description = types.StringValue(vc.Description) + rm.Speed = types.StringValue(strconv.Itoa(vc.Speed)) + rm.Status = types.StringValue(string(vc.Status)) + rm.NniVLAN = types.Int64Value(int64(vc.NniVLAN)) + rm.Vnid = types.Int64Value(int64(vc.VNID)) + rm.NniVnid = types.Int64Value(int64(vc.NniVNID)) + rm.PeerASN = types.Int64Value(int64(vc.PeerASN)) + rm.Subnet = types.StringValue(vc.Subnet) + rm.MetalIP = types.StringValue(vc.MetalIP) + rm.CustomerIP = types.StringValue(vc.CustomerIP) + rm.MD5 = types.StringValue(vc.MD5) + + // TODO: use API field from VC responses when available The regexp is + // optimistic, not guaranteed. This affects resource imports. "port" is not + // in the Includes above to assure the Href needed below. + connectionID := types.StringNull() // vc.Connection.ID is not available yet + portID := "" // vc.Port.ID would be available with ?include=port + connectionRe := regexp.MustCompile("/connections/([0-9a-z-]+)/ports/([0-9a-z-]+)") + matches := connectionRe.FindStringSubmatch(vc.Port.Href.Href) + if len(matches) == 3 { + connectionID = types.StringValue(matches[1]) + portID = matches[2] + } else { + log.Printf("[DEBUG] Could not parse connection and port ID from port href %s", vc.Port.Href.Href) + } + rm.ConnectionID = connectionID + rm.PortID = types.StringValue(portID) + + // VRF ID + if vc.VRF != nil { + rm.VrfID = types.StringValue(vc.VRF.ID) + } + + // VLAN ID + if vc.VirtualNetwork != nil { + rm.VlanID = types.StringValue(vc.VirtualNetwork.ID) + } + + // Handling tags as a list + tags, diags := types.ListValueFrom(ctx, types.StringType, vc.Tags) + if diags.HasError() { + return diags + } + rm.Tags = tags + + return diags +} diff --git a/internal/metal_virtual_circuit/framework_resource.go b/internal/metal_virtual_circuit/framework_resource.go new file mode 100644 index 000000000..e8b2016b2 --- /dev/null +++ b/internal/metal_virtual_circuit/framework_resource.go @@ -0,0 +1,305 @@ +package metal_virtual_circuit + +import ( + "context" + "fmt" + "time" + "reflect" + + "github.com/equinix/terraform-provider-equinix/internal/helper" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/packethost/packngo" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" +) + +func NewResource(ctx context.Context) resource.Resource { + return &Resource{ + BaseResource: helper.NewBaseResource( + helper.BaseResourceConfig{ + Name: "equinix_metal_virtual_circuit", + Schema: metalVirtualCircuitResourceSchema(ctx), + }, + ), + } +} + +type Resource struct { + helper.BaseResource +} + +func (r *Resource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan MetalVirtualCircuitResourceModel + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Retrieve the API client from the provider metadata + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + client := r.Meta.Metal + + // Check connection status + connId := plan.ConnectionID.ValueString() + conn, _, err := client.Connections.Get(connId, nil) + if err != nil { + err = helper.FriendlyError(err) + resp.Diagnostics.AddError( + "Error Creating Metal Virtual Circuit", + fmt.Sprintf("Could not read Connection with ID %s: %s", connId, err), + ) + return + } + if conn.Status == string(packngo.VCStatusPending) { + resp.Diagnostics.AddError( + "Error Creating Metal Virtual Circuit", + fmt.Sprintf("Connection request with name %s and ID %s wasn't approved yet", conn.Name, connId), + ) + return + } + + // Convert the plan to an API request format + createRequest := packngo.VCCreateRequest{ + VirtualNetworkID: plan.VlanID.ValueString(), + Name: plan.Name.ValueString(), + Description: plan.Description.ValueString(), + Speed: plan.Speed.ValueString(), + VRFID: plan.VrfID.ValueString(), + PeerASN: int(plan.PeerASN.ValueInt64()), + Subnet: plan.Subnet.ValueString(), + MetalIP: plan.MetalIP.ValueString(), + CustomerIP: plan.CustomerIP.ValueString(), + MD5: plan.MD5.ValueString(), + } + + // Add tags if they are set + if len(plan.Tags.Elements()) > 0 { + tags := []string{} + if diags := plan.Tags.ElementsAs(ctx, &tags, false); diags != nil { + resp.Diagnostics.Append(diags...) + return + } + createRequest.Tags = tags + } + + if !plan.NniVLAN.IsNull() && !plan.NniVLAN.IsUnknown(){ + createRequest.NniVLAN = int(plan.NniVLAN.ValueInt64()) + } + + // API call to create the resource + vc, _, err := client.VirtualCircuits.Create(plan.ProjectID.ValueString(), connId, plan.PortID.ValueString(), &createRequest, nil) + if err != nil { + resp.Diagnostics.AddError( + "Error creating Metal Virtual Circuit", + fmt.Sprintf("Could not create Metal Virtual Circuit: %s", err), + ) + return + } + + // Wait for VC to reach target state + // TODO: offer to wait while VCStatusPending + targetState := string(packngo.VCStatusActive) + createTimeout, diags := plan.Timeouts.Create(ctx, 20*time.Minute) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + createWaiter := getVCStateWaiter( + client, + vc.ID, + createTimeout, + []string{string(packngo.VCStatusActivating)}, + []string{targetState}, + ) + + vcItf, err := createWaiter.WaitForStateContext(ctx) + if err != nil { + err = helper.FriendlyError(err) + resp.Diagnostics.AddError( + "Error waiting for creationg of Metal Virtual Circuit", + fmt.Sprintf("error waiting for Virtual Circuit (%s) to become %s: %s", vc.ID, targetState, err), + ) + return + } + + vc, ok := vcItf.(*packngo.VirtualCircuit) + if !ok { + resp.Diagnostics.AddError( + "Error parsing Virtual Circuit response", + "Unexpected response type from API", + ) + return + } + + // Update the Terraform state with the new resource + var resourceState MetalVirtualCircuitResourceModel + resourceState.parse(ctx, vc) + diags = resp.State.Set(ctx, &resourceState) + resp.Diagnostics.Append(diags...) +} + +func (r *Resource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state MetalVirtualCircuitResourceModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Retrieve the API client from the provider metadata + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + client := r.Meta.Metal + + // Extract the ID of the resource from the state + id := state.ID.ValueString() + + // Retrieve the resource from the API + getOpts := &packngo.GetOptions{Includes: []string{"project", "virtual_network", "vrf"}} + vc, _, err := client.VirtualCircuits.Get(id, getOpts) + if err != nil { + err = helper.FriendlyError(err) + resp.Diagnostics.AddError( + "Error reading Metal Virtual Circuit", + fmt.Sprintf("Could not read Metal Virtual Circuit with ID %s: %s", id, err), + ) + return + } + + // Update the state with the current values of the resource + resourceState := MetalVirtualCircuitResourceModel{} + resourceState.parse(ctx, vc) + diags = resp.State.Set(ctx, &resourceState) + resp.Diagnostics.Append(diags...) +} + +func (r *Resource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan MetalVirtualCircuitResourceModel + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + var state MetalVirtualCircuitResourceModel + diags = req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Retrieve the API client from the provider metadata + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + client := r.Meta.Metal + + // Extract the ID of the organization from the state + id := state.ID.ValueString() + + // Prepare the update request + updateRequest := &packngo.VCUpdateRequest{} + if !state.VlanID.Equal(plan.VlanID) { + updateRequest.VirtualNetworkID = plan.VlanID.ValueStringPointer() + } + if !state.Name.Equal(plan.Name) { + updateRequest.Name = plan.Name.ValueStringPointer() + } + if !state.Description.Equal(plan.Description) { + updateRequest.Description = plan.Description.ValueStringPointer() + } + if !state.Speed.Equal(plan.Speed) { + updateRequest.Speed = plan.Speed.ValueString() + } + if !state.Tags.Equal(plan.Tags) { + tags := []string{} + if diags := plan.Tags.ElementsAs(ctx, &tags, false); diags != nil { + resp.Diagnostics.Append(diags...) + return + } + updateRequest.Tags = &tags + } + + if !reflect.DeepEqual(updateRequest, packngo.VCUpdateRequest{}) { + var updatedVC *packngo.VirtualCircuit + var err error + if updatedVC, _, err = client.VirtualCircuits.Update(id, updateRequest, nil); err != nil { + err = helper.FriendlyError(err) + resp.Diagnostics.AddError( + "Error updating Metal Virtual Circuit", + fmt.Sprintf("Could not update Virtual Circuit with ID %s: %s", id, err), + ) + return + } + // Update the state with the new values of the resource + diags = state.parse(ctx, updatedVC) + resp.Diagnostics.Append(diags...) + } + + diags = resp.State.Set(ctx, &state) + resp.Diagnostics.Append(diags...) +} + +func (r *Resource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state MetalVirtualCircuitResourceModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Retrieve the API client from the provider metadata + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + client := r.Meta.Metal + + // Extract the ID of the organization from the state + id := state.ID.ValueString() + + // Call your API to delete the resource + deleteResp, err := client.VirtualCircuits.Delete(id) + if helper.IgnoreResponseErrors(helper.HttpForbidden, helper.HttpNotFound)(deleteResp, err) != nil { + err = helper.FriendlyError(err) + resp.Diagnostics.AddError( + fmt.Sprintf("Failed to delete Metal Virtual Circuit %s", id), + err.Error(), + ) + } + + // Wait for VC to be deleted + deleteTimeout, diags := state.Timeouts.Delete(ctx, 20*time.Minute) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + deleteTimeout = deleteTimeout - 30*time.Second + deleteWaiter := getVCStateWaiter( + client, + id, + deleteTimeout, + []string{string(packngo.VCStatusDeleting)}, + []string{}, + ) + + _, err = deleteWaiter.WaitForStateContext(ctx) + if helper.IgnoreResponseErrors(helper.HttpForbidden, helper.HttpNotFound)(deleteResp, err) != nil { + err = helper.FriendlyError(err) + resp.Diagnostics.AddError( + fmt.Sprintf("Failed to delete Metal Virtual Circuit %s", id), + fmt.Sprintf("error waiting for Virtual Circuit (%s) to be deleted: %s", id, err), + ) + } +} + +func getVCStateWaiter(client *packngo.Client, id string, timeout time.Duration, pending, target []string) *retry.StateChangeConf { + return &retry.StateChangeConf{ + Pending: pending, + Target: target, + Refresh: func() (interface{}, string, error) { + vc, _, err := client.VirtualCircuits.Get(id, nil) + if err != nil { + return 0, "", err + } + return vc, string(vc.Status), nil + }, + Timeout: timeout, + Delay: 10 * time.Second, + MinTimeout: 5 * time.Second, + } +} diff --git a/internal/metal_virtual_circuit/framework_schema_resource.go b/internal/metal_virtual_circuit/framework_schema_resource.go new file mode 100644 index 000000000..8c8186e62 --- /dev/null +++ b/internal/metal_virtual_circuit/framework_schema_resource.go @@ -0,0 +1,141 @@ +package metal_virtual_circuit + +import ( + "context" + + "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/resource/schema/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework-timeouts/resource/timeouts" +) + +func metalVirtualCircuitResourceSchema(ctx context.Context) *schema.Schema { + return &schema.Schema{ + Attributes: map[string]schema.Attribute{ + "timeouts": timeouts.Attributes(ctx, timeouts.Opts{ + Delete: true, + }), + "id": schema.StringAttribute{ + Computed: true, + Description: "Unique identifier of the Virtual Circuit", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "connection_id": schema.StringAttribute{ + Required: true, + Description: "UUID of Connection where the VC is scoped to", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "project_id": schema.StringAttribute{ + Required: true, + Description: "UUID of the Project where the VC is scoped to", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "port_id": schema.StringAttribute{ + Required: true, + Description: "UUID of the Connection Port where the VC is scoped to", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "name": schema.StringAttribute{ + Optional: true, + Description: "Name of the Virtual Circuit resource", + }, + "description": schema.StringAttribute{ + Optional: true, + Description: "Description of the Virtual Circuit resource", + }, + "speed": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: "Description of the Virtual Circuit speed", + // TODO: implement logic similar to SuppressDiffFunc for input with units to bps without units + }, + "tags": schema.ListAttribute{ + Optional: true, + Description: "Tags attached to the reserved block", + ElementType: types.StringType, + }, + "nni_vlan": schema.Int64Attribute{ + Optional: true, + Description: "Equinix Metal network-to-network VLAN ID (optional when the connection has mode=tunnel)", + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.RequiresReplace(), + }, + }, + "vlan_id": schema.StringAttribute{ + Optional: true, + Description: "UUID of the VLAN to associate", + Validators: []validator.String{ + stringvalidator.ExactlyOneOf(path.Expressions{ + path.MatchRoot("vrf_id"), + }...), + }, + }, + "vrf_id": schema.StringAttribute{ + Optional: true, + Description: "UUID of the VRF to associate", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + stringvalidator.AlsoRequires(path.Expressions{ + path.MatchRoot("peer_asn"), + path.MatchRoot("subnet"), + path.MatchRoot("metal_ip"), + path.MatchRoot("customer_ip"), + }...), + }, + }, + "peer_asn": schema.Int64Attribute{ + Optional: true, + Description: "The BGP ASN of the peer. The same ASN may be the used across several VCs, but it cannot be the same as the local_asn of the VRF.", + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.RequiresReplace(), + }, + }, + "subnet": schema.StringAttribute{ + Optional: true, + Description: `A subnet from one of the IP blocks associated with the VRF that we will help create an IP reservation for. Can only be either a /30 or /31. + * For a /31 block, it will only have two IP addresses, which will be used for the metal_ip and customer_ip. + * For a /30 block, it will have four IP addresses, but the first and last IP addresses are not usable. We will default to the first usable IP address for the metal_ip.`, + }, + "metal_ip": schema.StringAttribute{ + Optional: true, + Description: "The Metal IP address for the SVI (Switch Virtual Interface) of the VirtualCircuit. Will default to the first usable IP in the subnet.", + }, + "customer_ip": schema.StringAttribute{ + Optional: true, + Description: "The Customer IP address which the CSR switch will peer with. Will default to the other usable IP in the subnet.", + }, + "md5": schema.StringAttribute{ + Optional: true, + Sensitive: true, + Description: "The password that can be set for the VRF BGP peer", + }, + "vnid": schema.Int64Attribute{ + Computed: true, + Description: "VNID VLAN parameter, see https://metal.equinix.com/developers/docs/networking/fabric/", + }, + "nni_vnid": schema.Int64Attribute{ + Computed: true, + Description: "Nni VLAN ID parameter, see https://metal.equinix.com/developers/docs/networking/fabric/", + }, + "status": schema.StringAttribute{ + Computed: true, + Description: "Status of the virtual circuit resource", + }, + }, + } +} From 2122fe849ea591ae44926de47771812a3a314573 Mon Sep 17 00:00:00 2001 From: ocobleseqx Date: Mon, 4 Dec 2023 16:30:29 +0100 Subject: [PATCH 25/26] equinix_metal_device_network_type Signed-off-by: ocobleseqx --- equinix/provider.go | 2 +- internal/config/config.go | 4 + internal/framework_provider.go | 2 + .../framework_models.go | 25 +++ .../framework_resource.go | 179 ++++++++++++++++++ .../framework_schema_resource.go | 28 +++ 6 files changed, 239 insertions(+), 1 deletion(-) create mode 100644 internal/metal_device_network_type/framework_models.go create mode 100644 internal/metal_device_network_type/framework_resource.go create mode 100644 internal/metal_device_network_type/framework_schema_resource.go diff --git a/equinix/provider.go b/equinix/provider.go index a52e2b67a..3e1194ac5 100644 --- a/equinix/provider.go +++ b/equinix/provider.go @@ -162,7 +162,7 @@ func Provider() *schema.Provider { "equinix_metal_project_api_key": resourceMetalProjectAPIKey(), // "equinix_metal_connection": resourceMetalConnection(), "equinix_metal_device": resourceMetalDevice(), - "equinix_metal_device_network_type": resourceMetalDeviceNetworkType(), + // "equinix_metal_device_network_type": resourceMetalDeviceNetworkType(), // "equinix_metal_ssh_key": resourceMetalSSHKey(), // "equinix_metal_organization_member": resourceMetalOrganizationMember(), // "equinix_metal_port": resourceMetalPort(), diff --git a/internal/config/config.go b/internal/config/config.go index 953c5bf54..7c2c0a521 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -32,6 +32,10 @@ import ( var ( UuidRE = regexp.MustCompile("^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[8|9|aA|bB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}$") IpAddressTypes = []string{"public_ipv4", "private_ipv4", "public_ipv6"} + DeviceNetworkTypes = []string{"layer3", "hybrid", "layer2-individual", "layer2-bonded"} + DeviceNetworkTypesHB = []string{"layer3", "hybrid", "hybrid-bonded", "layer2-individual", "layer2-bonded"} + NetworkTypeList = strings.Join(DeviceNetworkTypes, ", ") + NetworkTypeListHB = strings.Join(DeviceNetworkTypesHB, ", ") ) type ProviderMeta struct { diff --git a/internal/framework_provider.go b/internal/framework_provider.go index c4b5203c2..072bc12f6 100644 --- a/internal/framework_provider.go +++ b/internal/framework_provider.go @@ -8,6 +8,7 @@ import ( "github.com/equinix/terraform-provider-equinix/internal/config" "github.com/equinix/terraform-provider-equinix/internal/metal_bgp_session" "github.com/equinix/terraform-provider-equinix/internal/metal_connection" + "github.com/equinix/terraform-provider-equinix/internal/metal_device_network_type" "github.com/equinix/terraform-provider-equinix/internal/metal_gateway" "github.com/equinix/terraform-provider-equinix/internal/metal_ip_attachment" "github.com/equinix/terraform-provider-equinix/internal/metal_organization" @@ -126,6 +127,7 @@ func (p *FrameworkProvider) Resources(ctx context.Context) []func() resource.Res func() resource.Resource { return metal_virtual_circuit.NewResource(ctx) }, + metal_device_network_type.NewResource, } } diff --git a/internal/metal_device_network_type/framework_models.go b/internal/metal_device_network_type/framework_models.go new file mode 100644 index 000000000..fe15584fb --- /dev/null +++ b/internal/metal_device_network_type/framework_models.go @@ -0,0 +1,25 @@ +package metal_device_network_type + +import ( + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/packethost/packngo" +) + +type MetalDeviceNetworkTypeResourceModel struct { + ID types.String `tfsdk:"id"` + DeviceID types.String `tfsdk:"device_id"` + Type types.String `tfsdk:"type"` +} + +func (rm *MetalDeviceNetworkTypeResourceModel) parse(device *packngo.Device, currentType string) { + rm.DeviceID = types.StringValue(device.ID) + rm.ID = rm.DeviceID + + // if "hybrid-bonded" is set as desired state and current state is "layer3", + // keep the value in "hybrid-bonded" + devNType := device.GetNetworkType() + if currentType == "hybrid-bonded" && device.GetNetworkType() == "layer3" { + devNType = "hybrid-bonded" + } + rm.Type = types.StringValue(devNType) +} \ No newline at end of file diff --git a/internal/metal_device_network_type/framework_resource.go b/internal/metal_device_network_type/framework_resource.go new file mode 100644 index 000000000..30475f271 --- /dev/null +++ b/internal/metal_device_network_type/framework_resource.go @@ -0,0 +1,179 @@ +package metal_device_network_type + +import ( + "context" + "fmt" + + "github.com/equinix/terraform-provider-equinix/internal/helper" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/packethost/packngo" +) + +func NewResource() resource.Resource { + return &Resource{ + BaseResource: helper.NewBaseResource( + helper.BaseResourceConfig{ + Name: "equinix_metal_device_network_type", + Schema: &metalDeviceNetworkTypeResourceSchema, + }, + ), + } +} + +type Resource struct { + helper.BaseResource +} + +func (r *Resource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan MetalDeviceNetworkTypeResourceModel + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Retrieve the API client from the provider metadata + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + client := r.Meta.Metal + + // Convert the plan to your API's request format + ntype := plan.Type.ValueString() + + // Making an API call to configure the resource + device, err := getAndPossiblySetNetworkType(plan, client, ntype) + if err != nil { + resp.Diagnostics.AddError( + "Error creating Metal Device Network Type", + fmt.Sprintf("Could not configure Metal Device Network Type for device '%s': %s", plan.DeviceID.ValueString(), err), + ) + return + } + + // Map the created resource data back to the Terraform state + var resourceState MetalDeviceNetworkTypeResourceModel + resourceState.parse(device, device.GetNetworkType()) + diags = resp.State.Set(ctx, &resourceState) + resp.Diagnostics.Append(diags...) +} + +func (r *Resource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state MetalDeviceNetworkTypeResourceModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Retrieve the API client from the provider metadata + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + client := r.Meta.Metal + + // Extract the ID of the resource from the state + id := state.ID.ValueString() + + // Retrieve the resource from the API + device, err := getDevIDandNetworkType(state, client) + if err != nil { + err = helper.FriendlyError(err) + + // Check if the Device no longer exists + if helper.IsNotFound(err) { + resp.Diagnostics.AddWarning( + "Device", + fmt.Sprintf("[WARN] Device (%s) for Network Type request not found, removing from state", id), + ) + resp.State.RemoveResource(ctx) + return + } + + resp.Diagnostics.AddError( + "Error reading Device", + "Could not read Device with ID " + id + ": " + err.Error(), + ) + return + } + + // Map the created resource data back to the Terraform state + var resourceState MetalDeviceNetworkTypeResourceModel + resourceState.parse(device, state.Type.ValueString()) + diags = resp.State.Set(ctx, &resourceState) + resp.Diagnostics.Append(diags...) +} + +func (r *Resource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan MetalDeviceNetworkTypeResourceModel + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + var state MetalDeviceNetworkTypeResourceModel + diags = req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Retrieve the API client from the provider metadata + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + client := r.Meta.Metal + + // Extract the ID of the organization from the state + id := state.ID.ValueString() + + // Call API to update the resource + var device *packngo.Device + var err error + if !state.Type.Equal(plan.Type) { + device, err = getAndPossiblySetNetworkType(state, client, plan.Type.ValueString()) + } else { + device, err = getDevIDandNetworkType(state, client) + } + + if err != nil { + resp.Diagnostics.AddError( + "Error updating Metal Device Network Type", + fmt.Sprintf("Could not configure Metal Device Network Type for device '%s': %s", id, err), + ) + return + } + + // Update the state with the new values of the resource + var resourceState MetalDeviceNetworkTypeResourceModel + resourceState.parse(device, state.Type.ValueString()) + diags = resp.State.Set(ctx, &resourceState) + resp.Diagnostics.Append(diags...) +} + +func (r *Resource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + // This resource does not support delete +} + +func getAndPossiblySetNetworkType(data MetalDeviceNetworkTypeResourceModel, c *packngo.Client, targetType string) (*packngo.Device, error) { + var device *packngo.Device + var err error + + // "hybrid-bonded" is an alias for "layer3" with VLAN(s) connected. We use + // other resource for VLAN attachment, so we treat these two as equivalent + if targetType == "hybrid-bonded" { + targetType = "layer3" + } + + device, err = getDevIDandNetworkType(data, c) + + if err == nil && device.GetNetworkType() != targetType { + device, err = c.DevicePorts.DeviceToNetworkType(device.ID, targetType) + } + return device, err +} + +func getDevIDandNetworkType(data MetalDeviceNetworkTypeResourceModel, c *packngo.Client) (*packngo.Device, error) { + deviceID := data.ID + if deviceID.IsNull() && deviceID.IsUnknown() { + deviceID = data.DeviceID + } + + dev, _, err := c.Devices.Get(deviceID.ValueString(), nil) + return dev, err +} \ No newline at end of file diff --git a/internal/metal_device_network_type/framework_schema_resource.go b/internal/metal_device_network_type/framework_schema_resource.go new file mode 100644 index 000000000..9de65eeb6 --- /dev/null +++ b/internal/metal_device_network_type/framework_schema_resource.go @@ -0,0 +1,28 @@ +package metal_device_network_type + +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/equinix/terraform-provider-equinix/internal/config" +) + +var metalDeviceNetworkTypeResourceSchema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + Description: "The unique identifier of the reserved IP block", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "device_id": schema.StringAttribute{ + Required: true, + Description: "The ID of the device on which the network type should be set", + }, + "type": schema.StringAttribute{ + Required: true, + Description: "Network type to set. Must be one of " + config.NetworkTypeListHB, + }, + }, +} From 140522981551771753c0639fc32c43f745199de8 Mon Sep 17 00:00:00 2001 From: ocobleseqx Date: Mon, 4 Dec 2023 20:25:26 +0100 Subject: [PATCH 26/26] metal_spot_market_request Signed-off-by: ocobleseqx --- equinix/provider.go | 2 +- internal/framework_provider.go | 4 + .../framework_resource.go | 4 +- .../framework_models.go | 109 ++++++ .../framework_resource.go | 344 ++++++++++++++++++ .../framework_schema_resource.go | 222 +++++++++++ .../framework_resource.go | 2 +- internal/metal_vrf/framework_resource.go | 2 +- 8 files changed, 684 insertions(+), 5 deletions(-) create mode 100644 internal/metal_spot_market_request/framework_models.go create mode 100644 internal/metal_spot_market_request/framework_resource.go create mode 100644 internal/metal_spot_market_request/framework_schema_resource.go diff --git a/equinix/provider.go b/equinix/provider.go index 3e1194ac5..b224bb390 100644 --- a/equinix/provider.go +++ b/equinix/provider.go @@ -171,7 +171,7 @@ func Provider() *schema.Provider { // "equinix_metal_organization": resourceMetalOrganization(), // "equinix_metal_reserved_ip_block": resourceMetalReservedIPBlock(), // "equinix_metal_ip_attachment": resourceMetalIPAttachment(), - "equinix_metal_spot_market_request": resourceMetalSpotMarketRequest(), + // "equinix_metal_spot_market_request": resourceMetalSpotMarketRequest(), // "equinix_metal_vlan": resourceMetalVlan(), // "equinix_metal_virtual_circuit": resourceMetalVirtualCircuit(), // "equinix_metal_vrf": resourceMetalVRF(), diff --git a/internal/framework_provider.go b/internal/framework_provider.go index 072bc12f6..e3946d644 100644 --- a/internal/framework_provider.go +++ b/internal/framework_provider.go @@ -16,6 +16,7 @@ import ( "github.com/equinix/terraform-provider-equinix/internal/metal_port" "github.com/equinix/terraform-provider-equinix/internal/metal_project" "github.com/equinix/terraform-provider-equinix/internal/metal_reserved_ip_block" + "github.com/equinix/terraform-provider-equinix/internal/metal_spot_market_request" "github.com/equinix/terraform-provider-equinix/internal/metal_ssh_key" "github.com/equinix/terraform-provider-equinix/internal/metal_virtual_circuit" "github.com/equinix/terraform-provider-equinix/internal/metal_vlan" @@ -128,6 +129,9 @@ func (p *FrameworkProvider) Resources(ctx context.Context) []func() resource.Res return metal_virtual_circuit.NewResource(ctx) }, metal_device_network_type.NewResource, + func() resource.Resource { + return metal_spot_market_request.NewResource(ctx) + }, } } diff --git a/internal/metal_device_network_type/framework_resource.go b/internal/metal_device_network_type/framework_resource.go index 30475f271..83386c1c4 100644 --- a/internal/metal_device_network_type/framework_resource.go +++ b/internal/metal_device_network_type/framework_resource.go @@ -36,7 +36,7 @@ func (r *Resource) Create(ctx context.Context, req resource.CreateRequest, resp r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) client := r.Meta.Metal - // Convert the plan to your API's request format + // Target type ntype := plan.Type.ValueString() // Making an API call to configure the resource @@ -93,7 +93,7 @@ func (r *Resource) Read(ctx context.Context, req resource.ReadRequest, resp *res return } - // Map the created resource data back to the Terraform state + // Parse the API response into the Terraform state var resourceState MetalDeviceNetworkTypeResourceModel resourceState.parse(device, state.Type.ValueString()) diags = resp.State.Set(ctx, &resourceState) diff --git a/internal/metal_spot_market_request/framework_models.go b/internal/metal_spot_market_request/framework_models.go new file mode 100644 index 000000000..883674839 --- /dev/null +++ b/internal/metal_spot_market_request/framework_models.go @@ -0,0 +1,109 @@ +package metal_spot_market_request + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/packethost/packngo" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework-timeouts/resource/timeouts" +) + +type MetalSpotMarketRequestResourceModel struct { + ID types.String `tfsdk:"id"` + DevicesMin types.Int64 `tfsdk:"devices_min"` + DevicesMax types.Int64 `tfsdk:"devices_max"` + MaxBidPrice types.Float64 `tfsdk:"max_bid_price"` + Facilities types.List `tfsdk:"facilities"` + Metro types.String `tfsdk:"metro"` + ProjectID types.String `tfsdk:"project_id"` + WaitForDevices types.Bool `tfsdk:"wait_for_devices"` + InstanceParams *InstanceParameters `tfsdk:"instance_parameters"` + Timeouts timeouts.Value `tfsdk:"timeouts"` +} + +type InstanceParameters struct { + BillingCycle types.String `tfsdk:"billing_cycle"` + Plan types.String `tfsdk:"plan"` + OperatingSystem types.String `tfsdk:"operating_system"` + Hostname types.String `tfsdk:"hostname"` + TermintationTime types.String `tfsdk:"termination_time"` + TerminationTime types.String `tfsdk:"termination_time"` + AlwaysPXE types.Bool `tfsdk:"always_pxe"` + Description types.String `tfsdk:"description"` + Features types.List `tfsdk:"features"` + Locked types.Bool `tfsdk:"locked"` + ProjectSSHKeys types.List `tfsdk:"project_ssh_keys"` + UserSSHKeys types.List `tfsdk:"user_ssh_keys"` + Userdata types.String `tfsdk:"userdata"` + Customdata types.String `tfsdk:"customdata"` + IPXEScriptURL types.String `tfsdk:"ipxe_script_url"` + Tags types.List `tfsdk:"tags"` +} + +func (rm *MetalSpotMarketRequestResourceModel) parse(ctx context.Context, smr *packngo.SpotMarketRequest) diag.Diagnostics { + var diags diag.Diagnostics + + // Map fields from packngo.SpotMarketRequest to MetalSpotMarketRequestResourceModel + rm.DevicesMin = types.Int64Value(int64(smr.DevicesMin)) + rm.DevicesMax = types.Int64Value(int64(smr.DevicesMax)) + rm.MaxBidPrice = types.Float64Value(smr.MaxBidPrice) + + // Assuming smr.Facilities is a slice of string + facilities, diags := types.ListValueFrom(ctx, types.StringType, smr.Facilities) + if diags.HasError() { + return diags + } + rm.Facilities = facilities + + rm.Metro = types.StringValue(smr.Metro.ID) + rm.ProjectID = types.StringValue(smr.Project.ID) + + // Map instance_parameters + params := &InstanceParameters{ + BillingCycle: types.StringValue(smr.Parameters.BillingCycle), + Plan: types.StringValue(smr.Parameters.Plan), + OperatingSystem: types.StringValue(smr.Parameters.OperatingSystem), + Hostname: types.StringValue(smr.Parameters.Hostname), + TermintationTime: types.StringValue(smr.Parameters.TerminationTime.String()), + TerminationTime: types.StringValue(smr.Parameters.TerminationTime.String()), + AlwaysPXE: types.BoolValue(smr.Parameters.AlwaysPXE), + Description: types.StringValue(smr.Parameters.Description), + Locked: types.BoolValue(smr.Parameters.Locked), + Userdata: types.StringValue(smr.Parameters.UserData), + Customdata: types.StringValue(smr.Parameters.CustomData), + IPXEScriptURL: types.StringValue(smr.Parameters.IPXEScriptURL), + } + rm.InstanceParams = params + + // Handling project ssh keys as a list + projectKeys, diags := types.ListValueFrom(ctx, types.StringType, smr.Parameters.ProjectSSHKeys) + if diags.HasError() { + return diags + } + params.ProjectSSHKeys = projectKeys + + // Handling user ssh keys as a list + userKeys, diags := types.ListValueFrom(ctx, types.StringType, smr.Parameters.UserSSHKeys) + if diags.HasError() { + return diags + } + params.UserSSHKeys = userKeys + + // Handling features as a list + features, diags := types.ListValueFrom(ctx, types.StringType, smr.Parameters.Features) + if diags.HasError() { + return diags + } + params.Features = features + + // Handling tags as a list + tags, diags := types.ListValueFrom(ctx, types.StringType, smr.Parameters.Tags) + if diags.HasError() { + return diags + } + params.Tags = tags + + + return diags +} diff --git a/internal/metal_spot_market_request/framework_resource.go b/internal/metal_spot_market_request/framework_resource.go new file mode 100644 index 000000000..828941b01 --- /dev/null +++ b/internal/metal_spot_market_request/framework_resource.go @@ -0,0 +1,344 @@ +package metal_spot_market_request + +import ( + "context" + "fmt" + "regexp" + "time" + + "github.com/equinix/terraform-provider-equinix/internal/helper" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/packethost/packngo" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" +) + +var ( + matchIPXEScript = regexp.MustCompile(`(?i)^#![i]?pxe`) +) + +func NewResource(ctx context.Context) resource.Resource { + return &Resource{ + BaseResource: helper.NewBaseResource( + helper.BaseResourceConfig{ + Name: "equinix_metal_device_network_type", + Schema: metalSpotMarketRequestResourceSchema(ctx), + }, + ), + } +} + +type Resource struct { + helper.BaseResource +} + +func (r *Resource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan MetalSpotMarketRequestResourceModel + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Prepare the data for API request + params := packngo.SpotMarketRequestInstanceParameters{ + Hostname: plan.InstanceParams.Hostname.ValueString(), + BillingCycle: plan.InstanceParams.BillingCycle.ValueString(), + Plan: plan.InstanceParams.Plan.ValueString(), + OperatingSystem: plan.InstanceParams.OperatingSystem.ValueString(), + } + + if !plan.InstanceParams.IPXEScriptURL.IsNull() { + params.IPXEScriptURL = plan.InstanceParams.IPXEScriptURL.ValueString() + } + if !plan.InstanceParams.Userdata.IsNull() { + params.UserData = plan.InstanceParams.Userdata.ValueString() + } + if params.OperatingSystem == "custom_ipxe" { + if params.IPXEScriptURL == "" && params.UserData == "" { + resp.Diagnostics.AddError( + "Error creating Metal Spot Market Request", + "\"ipxe_script_url\" or \"user_data\" must be provided when \"custom_ipxe\" OS is selected.", + ) + return + } + + // ipxe_script_url + user_data is OK, unless user_data is an ipxe script in + // which case it's an error. + if params.IPXEScriptURL != "" { + if matchIPXEScript.MatchString(params.UserData) { + resp.Diagnostics.AddError( + "Error creating Metal Spot Market Request", + "\"user_data\" should not be an iPXE script when \"ipxe_script_url\" is also provided.", + ) + return + } + } + } + if params.OperatingSystem != "custom_ipxe" && params.IPXEScriptURL != "" { + resp.Diagnostics.AddError( + "Error creating Metal Spot Market Request", + "\"ipxe_script_url\" argument provided, but OS is not \"custom_ipxe\". Please verify and fix device arguments.", + ) + return + } + if !plan.InstanceParams.Customdata.IsNull() { + params.CustomData = plan.InstanceParams.Customdata.ValueString() + } + if !plan.InstanceParams.AlwaysPXE.IsNull() { + params.AlwaysPXE = plan.InstanceParams.AlwaysPXE.ValueBool() + } + if !plan.InstanceParams.Description.IsNull() { + params.Description = plan.InstanceParams.Description.ValueString() + } + if !plan.InstanceParams.Locked.IsNull() { + params.Locked = plan.InstanceParams.Locked.ValueBool() + } + if !plan.InstanceParams.Features.IsNull() { + resp.Diagnostics.Append(plan.InstanceParams.Features.ElementsAs(ctx, ¶ms.Features, false)...) + if resp.Diagnostics.HasError() { + return + } + } + if !plan.InstanceParams.Tags.IsNull() { + resp.Diagnostics.Append(plan.InstanceParams.Tags.ElementsAs(ctx, ¶ms.Tags, false)...) + if resp.Diagnostics.HasError() { + return + } + } + if !plan.InstanceParams.ProjectSSHKeys.IsNull() { + resp.Diagnostics.Append(plan.InstanceParams.ProjectSSHKeys.ElementsAs(ctx, ¶ms.ProjectSSHKeys, false)...) + if resp.Diagnostics.HasError() { + return + } + } + if !plan.InstanceParams.UserSSHKeys.IsNull() { + resp.Diagnostics.Append(plan.InstanceParams.UserSSHKeys.ElementsAs(ctx, ¶ms.UserSSHKeys, false)...) + if resp.Diagnostics.HasError() { + return + } + } + + smrc := &packngo.SpotMarketRequestCreateRequest{ + DevicesMax: int(plan.DevicesMax.ValueInt64()), + DevicesMin: int(plan.DevicesMin.ValueInt64()), + MaxBidPrice: plan.MaxBidPrice.ValueFloat64(), + Parameters: params, + } + if !plan.Facilities.IsNull() { + resp.Diagnostics.Append(plan.Facilities.ElementsAs(ctx, &smrc.FacilityIDs, false)...) + if resp.Diagnostics.HasError() { + return + } + } + if !plan.Metro.IsNull() { + smrc.Metro = plan.Metro.ValueString() + } + + // Retrieve the API client from the provider metadata + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + client := r.Meta.Metal + + // Making an API call to configure the resource + waitForDevices := plan.WaitForDevices.ValueBool() + start := time.Now() + smr, _, err := client.SpotMarketRequests.Create(smrc, plan.ProjectID.String()) + if err != nil { + err = helper.FriendlyError(err) + resp.Diagnostics.AddError( + "Error creating Metal Spot Market Request", + "Could not create Spot Market Request: " + err.Error(), ) + return + } + + if waitForDevices { + createTimeout, diags := plan.Timeouts.Create(ctx, 30*time.Minute) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + createTimeout = createTimeout - time.Since(start) - time.Second*10 // reduce 30s to avoid context deadline + stateConf := &retry.StateChangeConf{ + Pending: []string{"not_done"}, + Target: []string{"done"}, + Refresh: resourceStateRefreshFunc(client, smr.ID), + Timeout: createTimeout, + MinTimeout: 5 * time.Second, + Delay: 3 * time.Second, // Wait 10 secs before starting + NotFoundChecks: 600, // Setting high number, to support long timeouts + } + + smrRespItf, err := stateConf.WaitForStateContext(ctx) + if err != nil { + err = helper.FriendlyError(err) + resp.Diagnostics.AddError( + "Error waiting for creation of Metal Spot Market Request", + fmt.Sprintf("error waiting for Spot Market Request (%s) to become 'done': %s", smr.ID, err), + ) + return + } + + var ok bool + smr, ok = smrRespItf.(*packngo.SpotMarketRequest) + if !ok { + resp.Diagnostics.AddError( + "Error parsing IP Reservation response", + "Unexpected response type from API", + ) + return + } + } + + // Map the created resource data back to the Terraform state + stateDiags := plan.parse(ctx, smr) + resp.Diagnostics.Append(stateDiags...) + if stateDiags.HasError() { + return + } +} + +func (r *Resource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state MetalSpotMarketRequestResourceModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Retrieve the API client from the provider metadata + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + client := r.Meta.Metal + + // Extract the ID of the resource from the state + id := state.ID.ValueString() + + // Retrieve the resource from the API + smr, _, err := client.SpotMarketRequests.Get(id, &packngo.GetOptions{Includes: []string{"project", "devices", "facilities", "metro"}}) + if err != nil { + err = helper.FriendlyError(err) + + // Check if the Device no longer exists + if helper.IsNotFound(err) { + resp.Diagnostics.AddWarning( + "Metal Spot Market Request", + fmt.Sprintf("[WARN] Spot Market Request (%s) not found, removing from state", id), + ) + resp.State.RemoveResource(ctx) + return + } + + resp.Diagnostics.AddError( + "Error reading Device", + "Could not read Device with ID " + id + ": " + err.Error(), + ) + return + } + + // Parse the API response into the Terraform state + parseDiags := state.parse(ctx, smr) + resp.Diagnostics.Append(parseDiags...) + if parseDiags.HasError() { + return + } + + // Update the Terraform state + diags = resp.State.Set(ctx, &state) + resp.Diagnostics.Append(diags...) +} + +func (r *Resource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + // This resource does not support update +} + +func (r *Resource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state MetalSpotMarketRequestResourceModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Retrieve the API client from the provider metadata + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + client := r.Meta.Metal + + // Extract the ID of the organization from the state + id := state.ID.ValueString() + + waitForDevices := state.WaitForDevices.ValueBool() + if waitForDevices { + smr, _, err := client.SpotMarketRequests.Get(id, &packngo.GetOptions{Includes: []string{"project", "devices", "facilities", "metro"}}) + if err != nil { + resp.Diagnostics.AddWarning( + "Metal Spot Market Request", + fmt.Sprintf("[WARN] Spot Market Request (%s) not accessible for deletion, removing from state", id), + ) + return + } + + deleteTimeout, diags := state.Timeouts.Delete(ctx, 30*time.Minute) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + deleteTimeout = deleteTimeout - time.Second*30 // reduce 30s to avoid context deadline + stateConf := &retry.StateChangeConf{ + Pending: []string{"not_done"}, + Target: []string{"done"}, + Refresh: resourceStateRefreshFunc(client, id), + Timeout: deleteTimeout, + MinTimeout: 5 * time.Second, + Delay: 3 * time.Second, // Wait 10 secs before starting + NotFoundChecks: 600, // Setting high number, to support long timeouts + } + + _, err = stateConf.WaitForStateContext(ctx) + if err != nil { + err = helper.FriendlyError(err) + resp.Diagnostics.AddError( + "Failed to delete Metal Spot Market Request", + fmt.Sprintf("error waiting for Spot Market Request (%s) to become 'done' before proceed with deletion: %s", id, err), + ) + return + } + + for _, d := range smr.Devices { + deleteResp, err := client.Devices.Delete(d.ID, true) + if helper.IgnoreResponseErrors(helper.HttpForbidden, helper.HttpNotFound)(deleteResp, err) != nil { + err = helper.FriendlyError(err) + resp.Diagnostics.AddError( + fmt.Sprintf("Failed to delete Metal Spot Market Request %s", id), + fmt.Sprintf("error waiting for Spot Market Request (%s) to be deleted: %s", id, err), + ) + return + } + } + } + +} + +func resourceStateRefreshFunc(client *packngo.Client, requestId string) retry.StateRefreshFunc { + return func() (interface{}, string, error) { + smr, _, err := client.SpotMarketRequests.Get(requestId, &packngo.GetOptions{Includes: []string{"project", "devices", "facilities", "metro"}}) + if err != nil { + return nil, "", fmt.Errorf("failed to fetch Spot market request with following error: %s", err.Error()) + } + var finished bool + + for _, d := range smr.Devices { + + dev, _, err := client.Devices.Get(d.ID, nil) + if err != nil { + return nil, "", fmt.Errorf("failed to fetch Device with following error: %s", err.Error()) + } + if dev.State != "active" { + break + } else { + finished = true + } + } + if finished { + return smr, "done", nil + } + return nil, "not_done", nil + } +} \ No newline at end of file diff --git a/internal/metal_spot_market_request/framework_schema_resource.go b/internal/metal_spot_market_request/framework_schema_resource.go new file mode 100644 index 000000000..a38fb75d0 --- /dev/null +++ b/internal/metal_spot_market_request/framework_schema_resource.go @@ -0,0 +1,222 @@ +package metal_spot_market_request + +import ( + "context" + + "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/resource/schema/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/float64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/listplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/objectplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework-timeouts/resource/timeouts" +) + +func metalSpotMarketRequestResourceSchema(ctx context.Context) *schema.Schema { + return &schema.Schema{ + Attributes: map[string]schema.Attribute{ + "timeouts": timeouts.Attributes(ctx, timeouts.Opts{ + Delete: true, + Create: true, + }), + "id": schema.StringAttribute{ + Computed: true, + Description: "The unique identifier of the reserved IP block", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "devices_min": schema.Int64Attribute{ + Required: true, + Description: "Minimum number devices to be created", + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.RequiresReplace(), + }, + }, + "devices_max": schema.Int64Attribute{ + Required: true, + Description: "Maximum number devices to be created", + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.RequiresReplace(), + }, + }, + "max_bid_price": schema.Float64Attribute{ + Required: true, + Description: "Maximum price user is willing to pay per hour per device", + PlanModifiers: []planmodifier.Float64{ + float64planmodifier.RequiresReplace(), + }, + //TODO (ocobles) + // DiffSuppressFunc: func(k, old, new string, d *schema.ResourceData) bool { + // oldF, err := strconv.ParseFloat(old, 64) + // if err != nil { + // return false + // } + // newF, err := strconv.ParseFloat(new, 64) + // if err != nil { + // return false + // } + // // suppress diff if the difference between existing and new bid price + // // is less than 2% + // diffThreshold := .02 + // priceDiff := oldF / newF + + // if diffThreshold < priceDiff { + // return true + // } + // return false + // }, + }, + "facilities": schema.ListAttribute{ + Optional: true, + Computed: true, + ElementType: types.StringType, + Description: "Facility IDs where devices should be created", + DeprecationMessage: "Use metro instead of facility. For more information, read the migration guide: https://registry.terraform.io/providers/equinix/equinix/latest/docs/guides/migration_guide_facilities_to_metros_devices", + PlanModifiers: []planmodifier.List{ + listplanmodifier.RequiresReplace(), + }, + Validators: []validator.List{ + listvalidator.ExactlyOneOf(path.Expressions{ + path.MatchRoot("metro"), + }...), + }, + //TODO (ocobles) + // DiffSuppressFunc: func(k, old, new string, d *schema.ResourceData) bool { + // oldData, newData := d.GetChange("facilities") + + // // If this function is called and oldData or newData is nil, + // // then the attribute changed + // if oldData == nil || newData == nil { + // return false + // } + + // oldArray := oldData.([]interface{}) + // newArray := newData.([]interface{}) + + // // If the number of items in the list is different, + // // then the attribute changed + // if len(oldArray) != len(newArray) { + // return false + // } + + // // Convert data to string arrays + // oldFacilities := make([]string, len(oldArray)) + // newFacilities := make([]string, len(newArray)) + // for i, oldFacility := range oldArray { + // oldFacilities[i] = fmt.Sprint(oldFacility) + // } + // for j, newFacility := range newArray { + // newFacilities[j] = fmt.Sprint(newFacility) + // } + // // Sort the old and new arrays so that we don't show a diff + // // if the facilities are the same but the order is different + // sort.Strings(oldFacilities) + // sort.Strings(newFacilities) + // return reflect.DeepEqual(oldFacilities, newFacilities) + }, + "metro": schema.StringAttribute{ + Optional: true, + Description: "Metro where devices should be created", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + //TODO (ocobles) + // StateFunc: toLower, + }, + "project_id": schema.StringAttribute{ + Required: true, + Description: "Project ID", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "wait_for_devices": schema.BoolAttribute{ + Optional: true, + Description: "On resource creation - wait until all desired devices are active, on resource destruction - wait until devices are removed", + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.RequiresReplace(), + }, + }, + "instance_parameters": schema.SingleNestedAttribute{ + Required: true, + Description: "Parameters for devices provisioned from this request. You can find the parameter description from the [equinix_metal_device doc](device.md)", + Attributes: instanceParametersSchema, // Referencing the defined schema + PlanModifiers: []planmodifier.Object{ + objectplanmodifier.RequiresReplace(), + }, + }, + }, + } +} + +var instanceParametersSchema = map[string]schema.Attribute{ + "billing_cycle": schema.StringAttribute{ + Required: true, + Description: "Billing cycle for the instance", + }, + "plan": schema.StringAttribute{ + Required: true, + Description: "The plan or size of the instance", + }, + "operating_system": schema.StringAttribute{ + Required: true, + Description: "The operating system of the instance", + }, + "hostname": schema.StringAttribute{ + Required: true, + Description: "The hostname of the instance", + }, + "termintation_time": schema.StringAttribute{ + Computed: true, + Description: "The termination time of the instance", + DeprecationMessage: "Use instance_parameters.termination_time instead", + }, + "termination_time": schema.StringAttribute{ + Computed: true, + Description: "The termination time of the instance", + }, + "always_pxe": schema.BoolAttribute{ + Optional: true, + Default: booldefault.StaticBool(false), + }, + "description": schema.StringAttribute{ + Optional: true, + }, + "features": schema.ListAttribute{ + Optional: true, + ElementType: types.StringType, + }, + "locked": schema.StringAttribute{ + Optional: true, + }, + "project_ssh_keys": schema.ListAttribute{ + Optional: true, + ElementType: types.StringType, + }, + "user_ssh_keys": schema.ListAttribute{ + Optional: true, + ElementType: types.StringType, + }, + "userdata": schema.StringAttribute{ + Optional: true, + }, + "customdata": schema.StringAttribute{ + Optional: true, + }, + "ipxe_script_url": schema.StringAttribute{ + Optional: true, + }, + "tags": schema.ListAttribute{ + Optional: true, + ElementType: types.StringType, + }, +} + diff --git a/internal/metal_virtual_circuit/framework_resource.go b/internal/metal_virtual_circuit/framework_resource.go index e8b2016b2..6ce2cba99 100644 --- a/internal/metal_virtual_circuit/framework_resource.go +++ b/internal/metal_virtual_circuit/framework_resource.go @@ -116,7 +116,7 @@ func (r *Resource) Create(ctx context.Context, req resource.CreateRequest, resp if err != nil { err = helper.FriendlyError(err) resp.Diagnostics.AddError( - "Error waiting for creationg of Metal Virtual Circuit", + "Error waiting for creation of Metal Virtual Circuit", fmt.Sprintf("error waiting for Virtual Circuit (%s) to become %s: %s", vc.ID, targetState, err), ) return diff --git a/internal/metal_vrf/framework_resource.go b/internal/metal_vrf/framework_resource.go index a248c7e5b..057451775 100644 --- a/internal/metal_vrf/framework_resource.go +++ b/internal/metal_vrf/framework_resource.go @@ -92,7 +92,7 @@ func (r *Resource) Read(ctx context.Context, req resource.ReadRequest, resp *res // If the VRF was destroyed, mark as gone if helper.IsNotFound(err) || helper.IsForbidden(err) { resp.Diagnostics.AddWarning( - "Metal Metal", + "Metal VRF", fmt.Sprintf("[WARN] VRF (%s) not accessible, removing from state", id), ) resp.State.RemoveResource(ctx)