diff --git a/docs/resources/equinix_metal_port.md b/docs/resources/equinix_metal_port.md index 656a5fe12..e7fb6681a 100644 --- a/docs/resources/equinix_metal_port.md +++ b/docs/resources/equinix_metal_port.md @@ -4,7 +4,7 @@ subcategory: "Metal" # equinix_metal_port (Resource) -Use this resource to configure network ports on an Equnix Metal device. This resource can control both +Use this resource to configure network ports on an Equinix Metal device. This resource can control both physical and bond ports. This Terraform resource doesn't create an API resource in Equinix Metal, but rather provides finer diff --git a/equinix/provider.go b/equinix/provider.go index 9547c0d50..366c7099f 100644 --- a/equinix/provider.go +++ b/equinix/provider.go @@ -7,8 +7,6 @@ import ( "time" "github.com/equinix/terraform-provider-equinix/internal/resources/metal/metal_connection" - "github.com/equinix/terraform-provider-equinix/internal/resources/metal/metal_project_ssh_key" - "github.com/equinix/terraform-provider-equinix/internal/resources/metal/metal_ssh_key" "github.com/equinix/ecx-go/v2" "github.com/equinix/terraform-provider-equinix/internal/config" @@ -66,14 +64,16 @@ func Provider() *schema.Provider { Description: "The maximum number of records in a single response for REST queries that produce paginated responses", }, "max_retries": { - Type: schema.TypeInt, - Optional: true, - Default: 10, + Type: schema.TypeInt, + Optional: true, + Default: 10, + Description: "Maximum number of retries.", }, "max_retry_wait_seconds": { - Type: schema.TypeInt, - Optional: true, - Default: 30, + Type: schema.TypeInt, + Optional: true, + Default: 30, + Description: "Maximum number of seconds to wait before retrying a request.", }, }, DataSourcesMap: map[string]*schema.Resource{ @@ -108,7 +108,6 @@ func Provider() *schema.Provider { "equinix_metal_plans": dataSourceMetalPlans(), "equinix_metal_port": dataSourceMetalPort(), "equinix_metal_project": dataSourceMetalProject(), - "equinix_metal_project_ssh_key": metal_project_ssh_key.DataSource(), "equinix_metal_reserved_ip_block": dataSourceMetalReservedIPBlock(), "equinix_metal_spot_market_request": dataSourceMetalSpotMarketRequest(), "equinix_metal_virtual_circuit": dataSourceMetalVirtualCircuit(), @@ -135,10 +134,8 @@ func Provider() *schema.Provider { "equinix_metal_connection": metal_connection.Resource(), "equinix_metal_device": resourceMetalDevice(), "equinix_metal_device_network_type": resourceMetalDeviceNetworkType(), - "equinix_metal_ssh_key": metal_ssh_key.Resource(), "equinix_metal_organization_member": resourceMetalOrganizationMember(), "equinix_metal_port": resourceMetalPort(), - "equinix_metal_project_ssh_key": metal_project_ssh_key.Resource(), "equinix_metal_project": resourceMetalProject(), "equinix_metal_organization": resourceMetalOrganization(), "equinix_metal_reserved_ip_block": resourceMetalReservedIPBlock(), diff --git a/equinix/provider_test.go b/equinix/provider_test.go index 7809e9cd7..90e4f8cd9 100644 --- a/equinix/provider_test.go +++ b/equinix/provider_test.go @@ -1,17 +1,25 @@ package equinix import ( + "context" "fmt" "os" "regexp" "strings" "testing" - "github.com/equinix/ecx-go/v2" "github.com/equinix/terraform-provider-equinix/internal/config" + "github.com/equinix/terraform-provider-equinix/internal/provider" + "github.com/equinix/terraform-provider-equinix/version" + + "github.com/equinix/ecx-go/v2" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/stretchr/testify/assert" + + "github.com/hashicorp/terraform-plugin-framework/providerserver" + "github.com/hashicorp/terraform-plugin-go/tfprotov5" + "github.com/hashicorp/terraform-plugin-mux/tf5muxserver" ) var ( @@ -19,6 +27,24 @@ var ( testAccProviderFactories map[string]func() (*schema.Provider, error) testAccProvider *schema.Provider testExternalProviders map[string]resource.ExternalProvider + testAccFrameworkProvider *provider.FrameworkProvider + + testAccProtoV5ProviderFactories = map[string]func() (tfprotov5.ProviderServer, error){ + "equinix": func() (tfprotov5.ProviderServer, error) { + ctx := context.Background() + providers := []func() tfprotov5.ProviderServer{ + testAccProviders["equinix"].GRPCProvider, + providerserver.NewProtocol5( + testAccFrameworkProvider, + ), + } + muxServer, err := tf5muxserver.NewMuxServer(ctx, providers...) + if err != nil { + return nil, err + } + return muxServer.ProviderServer(), nil + }, + } ) type mockECXClient struct { @@ -113,6 +139,9 @@ func init() { Source: "hashicorp/random", }, } + // during framework migration, it is required to duplicate this (TestAccFrameworkProvider declared in internal package) + // for e2e tests that need already migrated resources. Importing from internal produces and import cycle error + testAccFrameworkProvider = provider.CreateFrameworkProvider(version.ProviderVersion).(*provider.FrameworkProvider) } func TestProvider(t *testing.T) { diff --git a/equinix/resource_metal_device_acc_test.go b/equinix/resource_metal_device_acc_test.go index f403dc8be..b26fe6762 100644 --- a/equinix/resource_metal_device_acc_test.go +++ b/equinix/resource_metal_device_acc_test.go @@ -223,10 +223,10 @@ func TestAccMetalDevice_sshConfig(t *testing.T) { t.Fatalf("Cannot generate test SSH key pair: %s", err) } resource.ParallelTest(t, resource.TestCase{ - PreCheck: func() { testAccPreCheck(t) }, - ExternalProviders: testExternalProviders, - Providers: testAccProviders, - CheckDestroy: testAccMetalDeviceCheckDestroyed, + PreCheck: func() { testAccPreCheck(t) }, + ProtoV5ProviderFactories: testAccProtoV5ProviderFactories, + ExternalProviders: testExternalProviders, + CheckDestroy: testAccMetalDeviceCheckDestroyed, Steps: []resource.TestStep{ { Config: testAccMetalDeviceConfig_ssh_key(rs, userSSHKey, projSSHKey), diff --git a/go.mod b/go.mod index 89f4cffd1..800c42016 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,10 @@ 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.5.0 + github.com/hashicorp/terraform-plugin-framework-validators v0.12.0 + github.com/hashicorp/terraform-plugin-go v0.20.0 + github.com/hashicorp/terraform-plugin-mux v0.13.0 github.com/hashicorp/terraform-plugin-sdk/v2 v2.31.0 github.com/hashicorp/terraform-plugin-testing v1.6.0 github.com/packethost/packngo v0.31.0 @@ -65,7 +69,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.18.0 // indirect - github.com/hashicorp/terraform-plugin-go v0.20.0 // indirect github.com/hashicorp/terraform-plugin-log v0.9.0 // indirect github.com/hashicorp/terraform-registry-address v0.2.3 // indirect github.com/hashicorp/terraform-svchost v0.1.1 // indirect diff --git a/go.sum b/go.sum index df6db169a..e7753b757 100644 --- a/go.sum +++ b/go.sum @@ -443,10 +443,16 @@ github.com/hashicorp/terraform-json v0.18.0 h1:pCjgJEqqDESv4y0Tzdqfxr/edOIGkjs8k github.com/hashicorp/terraform-json v0.18.0/go.mod h1:qdeBs11ovMzo5puhrRibdD6d2Dq6TyE/28JiU4tIQxk= 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.5.0 h1:8kcvqJs/x6QyOFSdeAyEgsenVOUeC/IyKpi2ul4fjTg= +github.com/hashicorp/terraform-plugin-framework v1.5.0/go.mod h1:6waavirukIlFpVpthbGd2PUNYaFedB0RwW3MDzJ/rtc= +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.20.0 h1:oqvoUlL+2EUbKNsJbIt3zqqZ7wi6lzn4ufkn/UA51xQ= github.com/hashicorp/terraform-plugin-go v0.20.0/go.mod h1:Rr8LBdMlY53a3Z/HpP+ZU3/xCDqtKNCkeI9qOyT10QE= 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-mux v0.13.0 h1:79U401/3nd8CWwDGtTHc8F3miSCAS9XGtVarxSTDgwA= +github.com/hashicorp/terraform-plugin-mux v0.13.0/go.mod h1:Ndv0FtwDG2ogzH59y64f2NYimFJ6I0smRgFUKfm6dyQ= github.com/hashicorp/terraform-plugin-sdk/v2 v2.31.0 h1:Bl3e2ei2j/Z3Hc2HIS15Gal2KMKyLAZ2om1HCEvK6es= github.com/hashicorp/terraform-plugin-sdk/v2 v2.31.0/go.mod h1:i2C41tszDjiWfziPQDL5R/f3Zp0gahXe5No/MIO9rCE= github.com/hashicorp/terraform-plugin-testing v1.6.0 h1:Wsnfh+7XSVRfwcr2jZYHsnLOnZl7UeaOBvsx6dl/608= @@ -531,8 +537,6 @@ github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8= github.com/oklog/run v1.0.0 h1:Ru7dDtJNOyC66gQ5dQmaCa0qIsAUFY3sFpK1Xk8igrw= github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= -github.com/packethost/packngo v0.30.0 h1:JVeTwbXXETsLTDQncUbYwIFpkOp/xevXrffM2HrFECI= -github.com/packethost/packngo v0.30.0/go.mod h1:BT/XcdwLVmeMtGPbovnxCpnI1s9ylSE1cs/7pq007NE= github.com/packethost/packngo v0.31.0 h1:LLH90ardhULWbagBIc3I3nl2uU75io0a7AwY6hyi0S4= github.com/packethost/packngo v0.31.0/go.mod h1:Io6VJqzkiqmIEQbpOjeIw9v8q9PfcTEq8TEY/tMQsfw= github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= diff --git a/internal/acceptance/acceptance.go b/internal/acceptance/acceptance.go index 19b90f5de..465533285 100644 --- a/internal/acceptance/acceptance.go +++ b/internal/acceptance/acceptance.go @@ -10,7 +10,9 @@ import ( "github.com/equinix/terraform-provider-equinix/equinix" "github.com/equinix/terraform-provider-equinix/internal/config" + "github.com/equinix/terraform-provider-equinix/version" + "github.com/equinix/terraform-provider-equinix/internal/provider" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-testing/helper/resource" ) @@ -26,6 +28,7 @@ var ( TestAccProviders map[string]*schema.Provider TestAccProviderFactories map[string]func() (*schema.Provider, error) TestExternalProviders map[string]resource.ExternalProvider + TestAccFrameworkProvider *provider.FrameworkProvider ) func init() { @@ -43,6 +46,7 @@ func init() { Source: "hashicorp/random", }, } + TestAccFrameworkProvider = provider.CreateFrameworkProvider(version.ProviderVersion).(*provider.FrameworkProvider) } func TestAccPreCheck(t *testing.T) { diff --git a/internal/acceptance/provider_factories.go b/internal/acceptance/provider_factories.go new file mode 100644 index 000000000..eb621c220 --- /dev/null +++ b/internal/acceptance/provider_factories.go @@ -0,0 +1,28 @@ +package acceptance + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/providerserver" + "github.com/hashicorp/terraform-plugin-go/tfprotov5" + "github.com/hashicorp/terraform-plugin-mux/tf5muxserver" +) + +var ProtoV5ProviderFactories = map[string]func() (tfprotov5.ProviderServer, error){ + "equinix": func() (tfprotov5.ProviderServer, error) { + ctx := context.Background() + providers := []func() tfprotov5.ProviderServer{ + TestAccProviders["equinix"].GRPCProvider, + providerserver.NewProtocol5( + TestAccFrameworkProvider, + ), + } + + muxServer, err := tf5muxserver.NewMuxServer(ctx, providers...) + if err != nil { + return nil, err + } + + return muxServer.ProviderServer(), nil + }, +} diff --git a/internal/config/config.go b/internal/config/config.go index 321d25c8b..1de5b7d8a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -21,6 +21,7 @@ import ( "github.com/equinix/oauth2-go" "github.com/equinix/terraform-provider-equinix/version" "github.com/hashicorp/go-retryablehttp" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/logging" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/v2/meta" @@ -315,6 +316,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/attributes_base.go b/internal/framework/attributes_base.go new file mode 100644 index 000000000..ad3b16f2b --- /dev/null +++ b/internal/framework/attributes_base.go @@ -0,0 +1,25 @@ +package framework + +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" +) + +func IDAttributeDefaultDescription() schema.StringAttribute { + return IDAttribute("The unique identifier of the resource") +} + +func IDAttribute(description string) schema.StringAttribute { + att := schema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + Description: "The unique identifier of the resource", + } + if description != "" { + att.Description = description + } + return att +} diff --git a/internal/framework/datasource_base.go b/internal/framework/datasource_base.go new file mode 100644 index 000000000..c383134af --- /dev/null +++ b/internal/framework/datasource_base.go @@ -0,0 +1,94 @@ +package framework + +import ( + "context" + "fmt" + + "github.com/equinix/terraform-provider-equinix/internal/config" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" +) + +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 { + return BaseDataSource{ + Config: cfg, + } +} + +// BaseDataSourceConfig contains all configurable base resource fields. +type BaseDataSourceConfig struct { + Name string + + // Optional + 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 *config.Config +} + +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/internal/framework/resource_base.go b/internal/framework/resource_base.go new file mode 100644 index 000000000..97ce583a9 --- /dev/null +++ b/internal/framework/resource_base.go @@ -0,0 +1,137 @@ +package framework + +import ( + "context" + "fmt" + + "github.com/equinix/terraform-provider-equinix/internal/config" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" +) + +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 { + return BaseResource{ + Config: cfg, + } +} + +// BaseResourceConfig contains all configurable base resource fields. +type BaseResourceConfig struct { + Name string + IDAttr string + + // Optional + 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 *config.Config +} + +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" + } + + 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 + + idValue = req.ID + + 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/internal/provider/provider.go b/internal/provider/provider.go new file mode 100644 index 000000000..9723715f1 --- /dev/null +++ b/internal/provider/provider.go @@ -0,0 +1,123 @@ +package provider + +import ( + "context" + "fmt" + "regexp" + + "github.com/equinix/terraform-provider-equinix/internal/config" + metalprojectsshkey "github.com/equinix/terraform-provider-equinix/internal/resources/metal/project_ssh_key" + metalsshkey "github.com/equinix/terraform-provider-equinix/internal/resources/metal/ssh_key" + "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/metaschema" + "github.com/hashicorp/terraform-plugin-framework/provider/schema" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +var urlRE = regexp.MustCompile(`^https?://(?:www\.)?[a-zA-Z0-9./]+$`) + +type FrameworkProvider struct { + ProviderVersion string + Meta *config.Config +} + +func CreateFrameworkProvider(version string) provider.ProviderWithMetaSchema { + 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 " + config.DefaultBaseURL, + Validators: []validator.String{ + stringvalidator.RegexMatches(urlRE, "must be a valid URL with http or https schema"), + }, + }, + "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: 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), + }, + }, + "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, + 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) MetaSchema( + ctx context.Context, + req provider.MetaSchemaRequest, + resp *provider.MetaSchemaResponse, +) { + resp.Schema = metaschema.Schema{ + Attributes: map[string]metaschema.Attribute{ + "module_name": schema.StringAttribute{ + Optional: true, + }, + }, + } +} + +func (p *FrameworkProvider) Resources(ctx context.Context) []func() resource.Resource { + return []func() resource.Resource{ + metalsshkey.NewResource, + metalprojectsshkey.NewResource, + } +} + +func (p *FrameworkProvider) DataSources(ctx context.Context) []func() datasource.DataSource { + return []func() datasource.DataSource{ + metalprojectsshkey.NewDataSource, + } +} diff --git a/internal/provider/provider_config.go b/internal/provider/provider_config.go new file mode 100644 index 000000000..3f8a39689 --- /dev/null +++ b/internal/provider/provider_config.go @@ -0,0 +1,154 @@ +package provider + +import ( + "context" + "fmt" + "os" + "strconv" + "time" + + "github.com/equinix/terraform-provider-equinix/internal/config" + "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" +) + +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 fwconfig 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, &fwconfig)...) + 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 + + fwconfig.BaseURL = determineStrConfValue( + fwconfig.BaseURL, config.EndpointEnvVar, config.DefaultBaseURL) + + fwconfig.ClientID = determineStrConfValue( + fwconfig.ClientID, config.ClientIDEnvVar, "") + + fwconfig.ClientSecret = determineStrConfValue( + fwconfig.ClientSecret, config.ClientSecretEnvVar, "") + + fwconfig.Token = determineStrConfValue( + fwconfig.Token, config.ClientTokenEnvVar, "") + + fwconfig.AuthToken = determineStrConfValue( + fwconfig.AuthToken, config.MetalAuthTokenEnvVar, "") + + fwconfig.RequestTimeout = determineIntConfValue( + fwconfig.RequestTimeout, config.ClientTimeoutEnvVar, int64(config.DefaultTimeout), &resp.Diagnostics) + + fwconfig.MaxRetries = determineIntConfValue( + fwconfig.MaxRetries, "", 10, &resp.Diagnostics) + + fwconfig.MaxRetryWaitSeconds = determineIntConfValue( + fwconfig.MaxRetryWaitSeconds, "", 30, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + oldStyleConfig := fwconfig.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/internal/resources/metal/metal_project_ssh_key/datasource.go b/internal/resources/metal/metal_project_ssh_key/datasource.go deleted file mode 100644 index 1a6b37da8..000000000 --- a/internal/resources/metal/metal_project_ssh_key/datasource.go +++ /dev/null @@ -1,91 +0,0 @@ -package metal_project_ssh_key - -import ( - "github.com/equinix/terraform-provider-equinix/internal/config" - - equinix_errors "github.com/equinix/terraform-provider-equinix/internal/errors" - - "fmt" - "path" - "strings" - - "github.com/equinix/terraform-provider-equinix/internal/resources/metal/metal_ssh_key" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" - "github.com/packethost/packngo" -) - -func DataSource() *schema.Resource { - dsSchema := metal_ssh_key.CommonFieldsDataSource() - dsSchema["project_id"] = &schema.Schema{ - Type: schema.TypeString, - Description: "The ID of parent project", - ForceNew: true, - Required: true, - } - dataSource := &schema.Resource{ - Read: dataSourceRead, - Schema: dsSchema, - } - return dataSource -} - -func dataSourceRead(d *schema.ResourceData, meta interface{}) error { - client := meta.(*config.Config).Metal - - search := d.Get("search").(string) - id := d.Get("id").(string) - projectID := d.Get("project_id").(string) - - if id == "" && search == "" { - return fmt.Errorf("You must supply either search or id") - } - - var ( - key packngo.SSHKey - searchOpts *packngo.SearchOptions - ) - - if search != "" { - searchOpts = &packngo.SearchOptions{Search: search} - } - keys, _, err := client.Projects.ListSSHKeys(projectID, searchOpts) - if err != nil { - err = fmt.Errorf("Error listing project ssh keys: %s", equinix_errors.FriendlyError(err)) - return err - } - - for i := range keys { - // use the first match for searches - if search != "" { - key = keys[i] - break - } - - // otherwise find the matching ID - if keys[i].ID == id { - key = keys[i] - break - } - } - - if key.ID == "" { - // Not Found - return fmt.Errorf("Project %q SSH Key matching %q was not found", projectID, search) - } - - ownerID := path.Base(key.Owner.Href) - - d.SetId(key.ID) - d.Set("name", key.Label) - d.Set("public_key", key.Key) - d.Set("fingerprint", key.FingerPrint) - d.Set("owner_id", ownerID) - d.Set("created", key.Created) - d.Set("updated", key.Updated) - - if strings.Contains(key.Owner.Href, "/projects/") { - d.Set("project_id", ownerID) - } - - return nil -} diff --git a/internal/resources/metal/metal_project_ssh_key/resource.go b/internal/resources/metal/metal_project_ssh_key/resource.go deleted file mode 100644 index 59a386f46..000000000 --- a/internal/resources/metal/metal_project_ssh_key/resource.go +++ /dev/null @@ -1,19 +0,0 @@ -package metal_project_ssh_key - -import ( - "github.com/equinix/terraform-provider-equinix/internal/resources/metal/metal_ssh_key" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" -) - -func Resource() *schema.Resource { - pkeySchema := metal_ssh_key.CommonFieldsResource() - pkeySchema["project_id"] = &schema.Schema{ - Type: schema.TypeString, - Description: "The ID of parent project", - ForceNew: true, - Required: true, - } - resource := metal_ssh_key.Resource() - resource.Schema = pkeySchema - return resource -} diff --git a/internal/resources/metal/metal_ssh_key/resource.go b/internal/resources/metal/metal_ssh_key/resource.go deleted file mode 100644 index 1fdfc0e51..000000000 --- a/internal/resources/metal/metal_ssh_key/resource.go +++ /dev/null @@ -1,125 +0,0 @@ -package metal_ssh_key - -import ( - "log" - "path" - "strings" - - equinix_errors "github.com/equinix/terraform-provider-equinix/internal/errors" - - "github.com/equinix/terraform-provider-equinix/internal/config" - - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" - "github.com/packethost/packngo" -) - -func Resource() *schema.Resource { - return &schema.Resource{ - Create: create, - Read: read, - Update: update, - Delete: delete, - Importer: &schema.ResourceImporter{ - State: schema.ImportStatePassthrough, - }, - - Schema: CommonFieldsResource(), - } -} - -func create(d *schema.ResourceData, meta interface{}) error { - meta.(*config.Config).AddModuleToMetalUserAgent(d) - client := meta.(*config.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 equinix_errors.FriendlyError(err) - } - - d.SetId(key.ID) - - return read(d, meta) -} - -func read(d *schema.ResourceData, meta interface{}) error { - meta.(*config.Config).AddModuleToMetalUserAgent(d) - client := meta.(*config.Config).Metal - - key, _, err := client.SSHKeys.Get(d.Id(), nil) - if err != nil { - err = equinix_errors.FriendlyError(err) - - // If the key is somehow already destroyed, mark as - // succesfully gone - if equinix_errors.IsNotFound(err) { - log.Printf("[WARN] SSHKey (%s) not found, removing from state", d.Id()) - d.SetId("") - return nil - } - - return err - } - - ownerID := path.Base(key.Owner.Href) - - d.SetId(key.ID) - d.Set("name", key.Label) - d.Set("public_key", key.Key) - d.Set("fingerprint", key.FingerPrint) - d.Set("owner_id", ownerID) - d.Set("created", key.Created) - d.Set("updated", key.Updated) - - if strings.Contains(key.Owner.Href, "/projects/") { - d.Set("project_id", ownerID) - } - - return nil -} - -func update(d *schema.ResourceData, meta interface{}) error { - meta.(*config.Config).AddModuleToMetalUserAgent(d) - client := meta.(*config.Config).Metal - - updateRequest := &packngo.SSHKeyUpdateRequest{} - - if d.HasChange("name") { - kName := d.Get("name").(string) - updateRequest.Label = &kName - } - - if d.HasChange("public_key") { - kKey := d.Get("public_key").(string) - updateRequest.Key = &kKey - } - - _, _, err := client.SSHKeys.Update(d.Id(), updateRequest) - if err != nil { - return equinix_errors.FriendlyError(err) - } - - return read(d, meta) -} - -func delete(d *schema.ResourceData, meta interface{}) error { - meta.(*config.Config).AddModuleToMetalUserAgent(d) - client := meta.(*config.Config).Metal - - resp, err := client.SSHKeys.Delete(d.Id()) - if equinix_errors.IgnoreResponseErrors(equinix_errors.HttpForbidden, equinix_errors.HttpNotFound)(resp, err) != nil { - return equinix_errors.FriendlyError(err) - } - - d.SetId("") - return nil -} diff --git a/internal/resources/metal/metal_ssh_key/schema_common.go b/internal/resources/metal/metal_ssh_key/schema_common.go deleted file mode 100644 index d5cdef374..000000000 --- a/internal/resources/metal/metal_ssh_key/schema_common.go +++ /dev/null @@ -1,77 +0,0 @@ -package metal_ssh_key - -import ( - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" -) - -func commonFields() map[string]*schema.Schema { - return map[string]*schema.Schema{ - "fingerprint": { - Type: schema.TypeString, - Description: "The fingerprint of the SSH key", - Computed: true, - }, - - "created": { - Type: schema.TypeString, - Description: "The timestamp for when the SSH key was created", - Computed: true, - }, - - "updated": { - Type: schema.TypeString, - Description: "The timestamp for the last time the SSH key was updated", - Computed: true, - }, - "owner_id": { - Type: schema.TypeString, - Description: "The UUID of the Equinix Metal API User who owns this key", - Computed: true, - }, - } -} - -func CommonFieldsResource() map[string]*schema.Schema { - resourceSchema := commonFields() - resourceSchema["name"] = &schema.Schema{ - Type: schema.TypeString, - Description: "The name of the SSH key for identification", - Required: true, - } - resourceSchema["public_key"] = &schema.Schema{ - Type: schema.TypeString, - Description: "The public key that will be authorized for SSH access on Equinix Metal devices provisioned with this key", - Required: true, - ForceNew: true, - } - return resourceSchema -} - -func CommonFieldsDataSource() map[string]*schema.Schema { - dsSchema := commonFields() - dsSchema["search"] = &schema.Schema{ - Type: schema.TypeString, - Description: "The name, fingerprint, id, or public_key of the SSH Key to search for in the Equinix Metal project", - Optional: true, - ValidateFunc: validation.NoZeroValues, - } - dsSchema["id"] = &schema.Schema{ - Type: schema.TypeString, - Description: "The id of the SSH Key", - Optional: true, - ValidateFunc: validation.NoZeroValues, - Computed: true, - } - dsSchema["name"] = &schema.Schema{ - Type: schema.TypeString, - Description: "The label of the Equinix Metal SSH Key", - Computed: true, - } - dsSchema["public_key"] = &schema.Schema{ - Type: schema.TypeString, - Description: "The public SSH key that is authorized for SSH access on Equinix Metal devices provisioned with this key", - Computed: true, - } - return dsSchema -} diff --git a/internal/resources/metal/project_ssh_key/datasource.go b/internal/resources/metal/project_ssh_key/datasource.go new file mode 100644 index 000000000..6a846e641 --- /dev/null +++ b/internal/resources/metal/project_ssh_key/datasource.go @@ -0,0 +1,99 @@ +package project_ssh_key + +import ( + equinix_errors "github.com/equinix/terraform-provider-equinix/internal/errors" + "github.com/equinix/terraform-provider-equinix/internal/framework" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/packethost/packngo" + + "context" + "fmt" +) + +func NewDataSource() datasource.DataSource { + return &DataSource{ + BaseDataSource: framework.NewBaseDataSource( + framework.BaseDataSourceConfig{ + Name: "equinix_metal_project_ssh_key", + Schema: &dataSourceSchema, + }, + ), + } +} + +type DataSource struct { + framework.BaseDataSource +} + +func (r *DataSource) Read( + ctx context.Context, + req datasource.ReadRequest, + resp *datasource.ReadResponse, +) { + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + client := r.Meta.Metal + + // Retrieve values from plan + var data DataSourceModel + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + // Extract the ID of the resource from the state + id := data.ID.ValueString() + search := data.Search.ValueString() + projectID := data.ProjectID.ValueString() + + var ( + key packngo.SSHKey + searchOpts *packngo.SearchOptions + ) + + if search != "" { + searchOpts = &packngo.SearchOptions{Search: search} + } + + // Use API client to list SSH keys + keys, _, err := client.Projects.ListSSHKeys(projectID, searchOpts) + if err != nil { + err = equinix_errors.FriendlyError(err) + resp.Diagnostics.AddError( + "Error listing project ssh keys", + err.Error(), + ) + return + } + + for i := range keys { + // use the first match for searches + if search != "" { + key = keys[i] + break + } + + // otherwise find the matching ID + if keys[i].ID == id { + key = keys[i] + break + } + } + + if key.ID == "" { + // Not Found + resp.Diagnostics.AddError( + "Error listing project ssh keys", + fmt.Errorf("project %q SSH Key matching %q was not found", projectID, search).Error(), + ) + return + } + + // Set state to fully populated data + resp.Diagnostics.Append(data.parse(&key)...) + if resp.Diagnostics.HasError() { + return + } + + // Update the Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} diff --git a/internal/resources/metal/project_ssh_key/datasource_schema.go b/internal/resources/metal/project_ssh_key/datasource_schema.go new file mode 100644 index 000000000..3f0c4aa7f --- /dev/null +++ b/internal/resources/metal/project_ssh_key/datasource_schema.go @@ -0,0 +1,59 @@ +package project_ssh_key + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +var dataSourceSchema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "project_id": schema.StringAttribute{ + Description: "The ID of parent project", + Required: true, + }, + "search": schema.StringAttribute{ + Description: "The name, fingerprint, id, or public_key of the SSH Key to search for in the Equinix Metal project", + Optional: true, + Validators: []validator.String{ + stringvalidator.AtLeastOneOf(path.Expressions{ + path.MatchRoot("id"), + }...), + stringvalidator.LengthAtLeast(1), + }, + }, + "id": schema.StringAttribute{ + Description: "The id of the SSH Key", + Optional: true, + Computed: true, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, + }, + "name": schema.StringAttribute{ + Description: "The label of the Equinix Metal SSH Key", + Computed: true, + }, + "public_key": schema.StringAttribute{ + Description: "The public key", + Computed: true, + }, + "fingerprint": schema.StringAttribute{ + Description: "The fingerprint of the SSH 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, + }, + "owner_id": schema.StringAttribute{ + Description: "The UUID of the Equinix Metal API User who owns this key", + Computed: true, + }, + }, +} diff --git a/internal/resources/metal/metal_project_ssh_key/datasource_test.go b/internal/resources/metal/project_ssh_key/datasource_test.go similarity index 70% rename from internal/resources/metal/metal_project_ssh_key/datasource_test.go rename to internal/resources/metal/project_ssh_key/datasource_test.go index 97ff3c3c4..ae0389324 100644 --- a/internal/resources/metal/metal_project_ssh_key/datasource_test.go +++ b/internal/resources/metal/project_ssh_key/datasource_test.go @@ -1,14 +1,14 @@ -package metal_project_ssh_key_test +package project_ssh_key_test import ( "fmt" "regexp" "testing" + "github.com/equinix/terraform-provider-equinix/internal/acceptance" "github.com/hashicorp/terraform-plugin-testing/helper/acctest" "github.com/hashicorp/terraform-plugin-testing/helper/resource" - - "github.com/equinix/terraform-provider-equinix/internal/acceptance" + "github.com/hashicorp/terraform-plugin-testing/plancheck" ) func TestAccDataSourceMetalProjectSSHKey_bySearch(t *testing.T) { @@ -22,7 +22,7 @@ func TestAccDataSourceMetalProjectSSHKey_bySearch(t *testing.T) { resource.ParallelTest(t, resource.TestCase{ PreCheck: func() { acceptance.TestAccPreCheckMetal(t) }, - Providers: acceptance.TestAccProviders, + ProtoV5ProviderFactories: acceptance.ProtoV5ProviderFactories, PreventPostDestroyRefresh: true, CheckDestroy: testAccMetalProjectSSHKeyCheckDestroyed, Steps: []resource.TestStep{ @@ -61,7 +61,7 @@ func TestAccDataSourceMetalProjectSSHKeyDataSource_yID(t *testing.T) { resource.ParallelTest(t, resource.TestCase{ PreCheck: func() { acceptance.TestAccPreCheckMetal(t) }, - Providers: acceptance.TestAccProviders, + ProtoV5ProviderFactories: acceptance.ProtoV5ProviderFactories, PreventPostDestroyRefresh: true, CheckDestroy: testAccMetalProjectSSHKeyCheckDestroyed, Steps: []resource.TestStep{ @@ -145,3 +145,47 @@ resource "equinix_metal_project_ssh_key" "foobar" { return config } + +// Test to verify that switching from SDKv2 to the Framework has not affected provider's behavior +// TODO (ocobles): once migrated, this test may be removed +func TestAccDataSourceMetalProjectSSHKey_upgradeFromVersion(t *testing.T) { + datasourceName := "data.equinix_metal_project_ssh_key.foobar" + keyName := acctest.RandomWithPrefix("tfacc-project-key") + + publicKeyMaterial, _, err := acctest.RandSSHKeyPair("") + if err != nil { + t.Fatalf("Cannot generate test SSH key pair: %s", err) + } + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acceptance.TestAccPreCheckMetal(t) }, + PreventPostDestroyRefresh: true, + CheckDestroy: testAccMetalProjectSSHKeyCheckDestroyed, + Steps: []resource.TestStep{ + { + ExternalProviders: map[string]resource.ExternalProvider{ + "equinix": { + VersionConstraint: "1.24.0", // latest version with resource defined on SDKv2 + Source: "equinix/equinix", + }, + }, + Config: testAccDataSourceMetalProjectSSHKeyConfig_bySearch(keyName, publicKeyMaterial), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + datasourceName, "name", keyName), + resource.TestCheckResourceAttr( + datasourceName, "public_key", publicKeyMaterial), + ), + }, + { + ProtoV5ProviderFactories: acceptance.ProtoV5ProviderFactories, + Config: testAccDataSourceMetalProjectSSHKeyConfig_bySearch(keyName, publicKeyMaterial), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectEmptyPlan(), + }, + }, + }, + }, + }) +} diff --git a/internal/resources/metal/project_ssh_key/models.go b/internal/resources/metal/project_ssh_key/models.go new file mode 100644 index 000000000..eca6c86d7 --- /dev/null +++ b/internal/resources/metal/project_ssh_key/models.go @@ -0,0 +1,59 @@ +package project_ssh_key + +import ( + "path" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/packethost/packngo" +) + +type ResourceModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + PublicKey types.String `tfsdk:"public_key"` + Fingerprint types.String `tfsdk:"fingerprint"` + Created types.String `tfsdk:"created"` + Updated types.String `tfsdk:"updated"` + OwnerID types.String `tfsdk:"owner_id"` + ProjectID types.String `tfsdk:"project_id"` +} + +func (m *ResourceModel) parse(key *packngo.SSHKey) diag.Diagnostics { + m.ID = types.StringValue(key.ID) + m.Name = types.StringValue(key.Label) + m.PublicKey = types.StringValue(key.Key) + m.Fingerprint = types.StringValue(key.FingerPrint) + m.Created = types.StringValue(key.Created) + m.Updated = types.StringValue(key.Updated) + m.OwnerID = types.StringValue(path.Base(key.Owner.Href)) + m.ProjectID = m.OwnerID + return nil +} + +// TODO (ocobles) ideally we would embed ResourceModel instead of +// explicitly define all the ResourceModel fields again in DataSourceModel +// https://github.com/hashicorp/terraform-plugin-framework/issues/242 +type DataSourceModel struct { + Search types.String `tfsdk:"search"` + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + PublicKey types.String `tfsdk:"public_key"` + Fingerprint types.String `tfsdk:"fingerprint"` + Created types.String `tfsdk:"created"` + Updated types.String `tfsdk:"updated"` + OwnerID types.String `tfsdk:"owner_id"` + ProjectID types.String `tfsdk:"project_id"` +} + +func (m *DataSourceModel) parse(key *packngo.SSHKey) diag.Diagnostics { + m.ID = types.StringValue(key.ID) + m.Name = types.StringValue(key.Label) + m.PublicKey = types.StringValue(key.Key) + m.Fingerprint = types.StringValue(key.FingerPrint) + m.Created = types.StringValue(key.Created) + m.Updated = types.StringValue(key.Updated) + m.OwnerID = types.StringValue(path.Base(key.Owner.Href)) + m.ProjectID = m.OwnerID + return nil +} diff --git a/internal/resources/metal/project_ssh_key/resource.go b/internal/resources/metal/project_ssh_key/resource.go new file mode 100644 index 000000000..3a4aff57f --- /dev/null +++ b/internal/resources/metal/project_ssh_key/resource.go @@ -0,0 +1,195 @@ +package project_ssh_key + +import ( + "context" + "fmt" + + equinix_errors "github.com/equinix/terraform-provider-equinix/internal/errors" + "github.com/equinix/terraform-provider-equinix/internal/framework" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/packethost/packngo" +) + +func NewResource() resource.Resource { + return &Resource{ + BaseResource: framework.NewBaseResource( + framework.BaseResourceConfig{ + Name: "equinix_metal_project_ssh_key", + Schema: GetResourceSchema(), + }, + ), + } +} + +type Resource struct { + framework.BaseResource +} + +func (r *Resource) Create( + ctx context.Context, + req resource.CreateRequest, + resp *resource.CreateResponse, +) { + + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + client := r.Meta.Metal + + // Retrieve values from plan + 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: plan.Name.ValueString(), + Key: plan.PublicKey.ValueString(), + ProjectID: plan.ProjectID.ValueString(), + } + + // Create API resource + key, _, err := client.SSHKeys.Create(createRequest) + if err != nil { + resp.Diagnostics.AddError( + "Failed to create Project SSH Key", + equinix_errors.FriendlyError(err).Error(), + ) + return + } + + // 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)...) +} + +func (r *Resource) Read( + ctx context.Context, + req resource.ReadRequest, + resp *resource.ReadResponse, +) { + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + client := r.Meta.Metal + + // Retrieve values from state + 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() + + // Use API client to get the current state of the resource + key, _, err := client.SSHKeys.Get(id, nil) + if err != nil { + err = equinix_errors.FriendlyError(err) + + // If the key is somehow already destroyed, mark as + // succesfully gone + if equinix_errors.IsNotFound(err) { + resp.Diagnostics.AddWarning( + "Equinix Metal Project SSHKey not found during refresh", + fmt.Sprintf("[WARN] SSHKey (%s) not found, removing from state", id), + ) + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError( + fmt.Sprintf("Failed to get Project 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)...) +} + +func (r *Resource) Update( + ctx context.Context, + req resource.UpdateRequest, + resp *resource.UpdateResponse, +) { + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + 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() + } + + // Update the resource + key, _, err := client.SSHKeys.Update(plan.ID.ValueString(), updateRequest) + if err != nil { + err = equinix_errors.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, +) { + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + client := r.Meta.Metal + + // 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() + + // Use API client to delete the resource + deleteResp, err := client.SSHKeys.Delete(id) + if equinix_errors.IgnoreResponseErrors(equinix_errors.HttpForbidden, equinix_errors.HttpNotFound)(deleteResp, err) != nil { + err = equinix_errors.FriendlyError(err) + resp.Diagnostics.AddError( + fmt.Sprintf("Failed to delete Project SSHKey %s", id), + err.Error(), + ) + } +} diff --git a/internal/resources/metal/project_ssh_key/resource_schema.go b/internal/resources/metal/project_ssh_key/resource_schema.go new file mode 100644 index 000000000..208466700 --- /dev/null +++ b/internal/resources/metal/project_ssh_key/resource_schema.go @@ -0,0 +1,20 @@ +package project_ssh_key + +import ( + metal_ssh_key "github.com/equinix/terraform-provider-equinix/internal/resources/metal/ssh_key" + "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" +) + +func GetResourceSchema() *schema.Schema { + sch := metal_ssh_key.GetResourceSchema() + sch.Attributes["project_id"] = schema.StringAttribute{ + Description: "The ID of parent project", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + } + return sch +} diff --git a/internal/resources/metal/metal_project_ssh_key/resource_test.go b/internal/resources/metal/project_ssh_key/resource_test.go similarity index 54% rename from internal/resources/metal/metal_project_ssh_key/resource_test.go rename to internal/resources/metal/project_ssh_key/resource_test.go index 827b99953..127a5b9ed 100644 --- a/internal/resources/metal/metal_project_ssh_key/resource_test.go +++ b/internal/resources/metal/project_ssh_key/resource_test.go @@ -1,4 +1,4 @@ -package metal_project_ssh_key_test +package project_ssh_key_test import ( "fmt" @@ -9,6 +9,7 @@ import ( "github.com/equinix/terraform-provider-equinix/internal/acceptance" "github.com/hashicorp/terraform-plugin-testing/helper/acctest" "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/plancheck" "github.com/hashicorp/terraform-plugin-testing/terraform" "github.com/packethost/packngo" ) @@ -57,10 +58,10 @@ func TestAccMetalProjectSSHKey_basic(t *testing.T) { cfg := testAccMetalProjectSSHKeyConfig_basic(rs, publicKeyMaterial) resource.ParallelTest(t, resource.TestCase{ - PreCheck: func() { acceptance.TestAccPreCheckMetal(t) }, - ExternalProviders: acceptance.TestExternalProviders, - Providers: acceptance.TestAccProviders, - CheckDestroy: testAccMetalProjectSSHKeyCheckDestroyed, + PreCheck: func() { acceptance.TestAccPreCheckMetal(t) }, + ExternalProviders: acceptance.TestExternalProviders, + ProtoV5ProviderFactories: acceptance.ProtoV5ProviderFactories, + CheckDestroy: testAccMetalProjectSSHKeyCheckDestroyed, Steps: []resource.TestStep{ { Config: cfg, @@ -96,3 +97,61 @@ func testAccMetalProjectSSHKeyCheckDestroyed(s *terraform.State) error { return nil } + +// Test to verify that switching from SDKv2 to the Framework has not affected provider's behavior +// TODO (ocobles): once migrated, this test may be removed +func TestAccMetalProjectSSHKey_upgradeFromVersion(t *testing.T) { + rs := acctest.RandString(10) + var key packngo.SSHKey + publicKeyMaterial, _, err := acctest.RandSSHKeyPair("") + if err != nil { + t.Fatalf("Cannot generate test SSH key pair: %s", err) + } + cfg := testAccMetalProjectSSHKeyConfig_basic(rs, publicKeyMaterial) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acceptance.TestAccPreCheckMetal(t) }, + CheckDestroy: testAccMetalProjectSSHKeyCheckDestroyed, + Steps: []resource.TestStep{ + { + ExternalProviders: map[string]resource.ExternalProvider{ + "equinix": { + VersionConstraint: "1.24.0", // latest version with resource defined on SDKv2 + Source: "equinix/equinix", + }, + "random": { + Source: "hashicorp/random", + }, + }, + Config: cfg, + Check: resource.ComposeTestCheckFunc( + acceptance.TestAccCheckMetalSSHKeyExists("equinix_metal_project_ssh_key.test", &key), + resource.TestCheckResourceAttr( + "equinix_metal_project_ssh_key.test", "public_key", publicKeyMaterial), + resource.TestCheckResourceAttrPair( + "equinix_metal_device.test", "ssh_key_ids.0", + "equinix_metal_project_ssh_key.test", "id", + ), + resource.TestCheckResourceAttrPair( + "equinix_metal_project.test", "id", + "equinix_metal_project_ssh_key.test", "project_id", + ), + ), + }, + { + ExternalProviders: map[string]resource.ExternalProvider{ + "random": { + Source: "hashicorp/random", + }, + }, + ProtoV5ProviderFactories: acceptance.ProtoV5ProviderFactories, + Config: cfg, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectEmptyPlan(), + }, + }, + }, + }, + }) +} diff --git a/internal/resources/metal/ssh_key/models.go b/internal/resources/metal/ssh_key/models.go new file mode 100644 index 000000000..1a89d1d2e --- /dev/null +++ b/internal/resources/metal/ssh_key/models.go @@ -0,0 +1,30 @@ +package ssh_key + +import ( + "path" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/packethost/packngo" +) + +type ResourceModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + PublicKey types.String `tfsdk:"public_key"` + Fingerprint types.String `tfsdk:"fingerprint"` + Created types.String `tfsdk:"created"` + Updated types.String `tfsdk:"updated"` + OwnerID types.String `tfsdk:"owner_id"` +} + +func (m *ResourceModel) parse(key *packngo.SSHKey) diag.Diagnostics { + m.ID = types.StringValue(key.ID) + m.Name = types.StringValue(key.Label) + m.PublicKey = types.StringValue(key.Key) + m.Fingerprint = types.StringValue(key.FingerPrint) + m.Created = types.StringValue(key.Created) + m.Updated = types.StringValue(key.Updated) + m.OwnerID = types.StringValue(path.Base(key.Owner.Href)) + return nil +} diff --git a/internal/resources/metal/ssh_key/resource.go b/internal/resources/metal/ssh_key/resource.go new file mode 100644 index 000000000..d3448a97e --- /dev/null +++ b/internal/resources/metal/ssh_key/resource.go @@ -0,0 +1,194 @@ +package ssh_key + +import ( + "context" + "fmt" + + equinix_errors "github.com/equinix/terraform-provider-equinix/internal/errors" + "github.com/equinix/terraform-provider-equinix/internal/framework" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/packethost/packngo" +) + +func NewResource() resource.Resource { + return &Resource{ + BaseResource: framework.NewBaseResource( + framework.BaseResourceConfig{ + Name: "equinix_metal_ssh_key", + Schema: GetResourceSchema(), + }, + ), + } +} + +type Resource struct { + framework.BaseResource +} + +func (r *Resource) Create( + ctx context.Context, + req resource.CreateRequest, + resp *resource.CreateResponse, +) { + + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + client := r.Meta.Metal + + // Retrieve values from plan + 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: plan.Name.ValueString(), + Key: plan.PublicKey.ValueString(), + } + + // Create API resource + key, _, err := client.SSHKeys.Create(createRequest) + if err != nil { + resp.Diagnostics.AddError( + "Failed to create SSH Key", + equinix_errors.FriendlyError(err).Error(), + ) + return + } + + // 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)...) +} + +func (r *Resource) Read( + ctx context.Context, + req resource.ReadRequest, + resp *resource.ReadResponse, +) { + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + client := r.Meta.Metal + + // Retrieve values from state + 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() + + // Use API client to get the current state of the resource + key, _, err := client.SSHKeys.Get(id, nil) + if err != nil { + err = equinix_errors.FriendlyError(err) + + // If the key is somehow already destroyed, mark as + // succesfully gone + if equinix_errors.IsNotFound(err) { + resp.Diagnostics.AddWarning( + "Equinix Metal SSHKey not found during refresh", + 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)...) +} + +func (r *Resource) Update( + ctx context.Context, + req resource.UpdateRequest, + resp *resource.UpdateResponse, +) { + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + 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() + } + + // Update the resource + key, _, err := client.SSHKeys.Update(plan.ID.ValueString(), updateRequest) + if err != nil { + err = equinix_errors.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, +) { + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + client := r.Meta.Metal + + // 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() + + // Use API client to delete the resource + deleteResp, err := client.SSHKeys.Delete(id) + if equinix_errors.IgnoreResponseErrors(equinix_errors.HttpForbidden, equinix_errors.HttpNotFound)(deleteResp, err) != nil { + err = equinix_errors.FriendlyError(err) + resp.Diagnostics.AddError( + fmt.Sprintf("Failed to delete SSHKey %s", id), + err.Error(), + ) + } +} diff --git a/internal/resources/metal/ssh_key/resource_schema.go b/internal/resources/metal/ssh_key/resource_schema.go new file mode 100644 index 000000000..4e8b14afb --- /dev/null +++ b/internal/resources/metal/ssh_key/resource_schema.go @@ -0,0 +1,57 @@ +package ssh_key + +import ( + "github.com/equinix/terraform-provider-equinix/internal/framework" + "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" +) + +func GetResourceSchema() *schema.Schema { + sch := GetCommonFieldsSchema() + sch.Attributes["name"] = schema.StringAttribute{ + Description: "The name of the SSH key for identification", + Required: true, + } + sch.Attributes["public_key"] = schema.StringAttribute{ + Description: "The public key", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + } + return sch +} + +func GetCommonFieldsSchema() *schema.Schema { + return &schema.Schema{ + Attributes: map[string]schema.Attribute{ + "id": framework.IDAttributeDefaultDescription(), + "fingerprint": schema.StringAttribute{ + Description: "The fingerprint of the SSH key", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "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, + }, + "owner_id": schema.StringAttribute{ + Description: "The UUID of the Equinix Metal API User who owns this key", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + }, + } +} diff --git a/internal/resources/metal/metal_ssh_key/resource_test.go b/internal/resources/metal/ssh_key/resource_test.go similarity index 70% rename from internal/resources/metal/metal_ssh_key/resource_test.go rename to internal/resources/metal/ssh_key/resource_test.go index 3860e87d7..3a27e92ad 100644 --- a/internal/resources/metal/metal_ssh_key/resource_test.go +++ b/internal/resources/metal/ssh_key/resource_test.go @@ -1,4 +1,4 @@ -package metal_ssh_key_test +package ssh_key_test import ( "fmt" @@ -11,6 +11,7 @@ import ( "github.com/hashicorp/terraform-plugin-testing/helper/acctest" "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/plancheck" "github.com/hashicorp/terraform-plugin-testing/terraform" "github.com/packethost/packngo" ) @@ -58,9 +59,9 @@ func TestAccMetalSSHKey_basic(t *testing.T) { } resource.ParallelTest(t, resource.TestCase{ - PreCheck: func() { acceptance.TestAccPreCheckMetal(t) }, - Providers: acceptance.TestAccProviders, - CheckDestroy: testAccMetalSSHKeyCheckDestroyed, + PreCheck: func() { acceptance.TestAccPreCheckMetal(t) }, + ProtoV5ProviderFactories: acceptance.ProtoV5ProviderFactories, + CheckDestroy: testAccMetalSSHKeyCheckDestroyed, Steps: []resource.TestStep{ { Config: testAccMetalSSHKeyConfig_basic(rInt, publicKeyMaterial), @@ -86,9 +87,9 @@ func TestAccMetalSSHKey_projectBasic(t *testing.T) { } resource.ParallelTest(t, resource.TestCase{ - PreCheck: func() { acceptance.TestAccPreCheckMetal(t) }, - Providers: acceptance.TestAccProviders, - CheckDestroy: testAccMetalSSHKeyCheckDestroyed, + PreCheck: func() { acceptance.TestAccPreCheckMetal(t) }, + ProtoV5ProviderFactories: acceptance.ProtoV5ProviderFactories, + CheckDestroy: testAccMetalSSHKeyCheckDestroyed, Steps: []resource.TestStep{ { Config: testAccCheckMetalSSHKeyConfig_projectBasic(rInt, publicKeyMaterial), @@ -112,9 +113,9 @@ func TestAccMetalSSHKey_update(t *testing.T) { } resource.ParallelTest(t, resource.TestCase{ - PreCheck: func() { acceptance.TestAccPreCheckMetal(t) }, - Providers: acceptance.TestAccProviders, - CheckDestroy: testAccMetalSSHKeyCheckDestroyed, + PreCheck: func() { acceptance.TestAccPreCheckMetal(t) }, + ProtoV5ProviderFactories: acceptance.ProtoV5ProviderFactories, + CheckDestroy: testAccMetalSSHKeyCheckDestroyed, Steps: []resource.TestStep{ { Config: testAccMetalSSHKeyConfig_basic(rInt, publicKeyMaterial), @@ -146,9 +147,9 @@ func TestAccMetalSSHKey_projectImportBasic(t *testing.T) { t.Fatalf("Cannot generate test SSH key pair: %s", err) } resource.ParallelTest(t, resource.TestCase{ - PreCheck: func() { acceptance.TestAccPreCheckMetal(t) }, - Providers: acceptance.TestAccProviders, - CheckDestroy: testAccMetalSSHKeyCheckDestroyed, + PreCheck: func() { acceptance.TestAccPreCheckMetal(t) }, + ProtoV5ProviderFactories: acceptance.ProtoV5ProviderFactories, + CheckDestroy: testAccMetalSSHKeyCheckDestroyed, Steps: []resource.TestStep{ { Config: testAccCheckMetalSSHKeyConfig_projectBasic(acctest.RandInt(), sshKey), @@ -168,9 +169,9 @@ func TestAccMetalSSHKey_importBasic(t *testing.T) { t.Fatalf("Cannot generate test SSH key pair: %s", err) } resource.ParallelTest(t, resource.TestCase{ - PreCheck: func() { acceptance.TestAccPreCheckMetal(t) }, - Providers: acceptance.TestAccProviders, - CheckDestroy: testAccMetalSSHKeyCheckDestroyed, + PreCheck: func() { acceptance.TestAccPreCheckMetal(t) }, + ProtoV5ProviderFactories: acceptance.ProtoV5ProviderFactories, + CheckDestroy: testAccMetalSSHKeyCheckDestroyed, Steps: []resource.TestStep{ { Config: testAccMetalSSHKeyConfig_basic(acctest.RandInt(), sshKey), @@ -221,3 +222,49 @@ resource "equinix_metal_project_ssh_key" "foobar" { project_id = equinix_metal_project.test.id }`, rInt, rInt, publicSshKey) } + +// Test to verify that switching from SDKv2 to the Framework has not affected provider's behavior +// TODO (ocobles): once migrated, this test may be removed +func TestAccMetalSSHKey_upgradeFromVersion(t *testing.T) { + var key packngo.SSHKey + rInt := acctest.RandInt() + publicKeyMaterial, _, err := acctest.RandSSHKeyPair("") + if err != nil { + t.Fatalf("Cannot generate test SSH key pair: %s", err) + } + cfg := testAccMetalSSHKeyConfig_basic(rInt, publicKeyMaterial) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acceptance.TestAccPreCheckMetal(t) }, + CheckDestroy: testAccMetalSSHKeyCheckDestroyed, + Steps: []resource.TestStep{ + { + ExternalProviders: map[string]resource.ExternalProvider{ + "equinix": { + VersionConstraint: "1.24.0", // latest version with resource defined on SDKv2 + Source: "equinix/equinix", + }, + }, + Config: cfg, + Check: resource.ComposeTestCheckFunc( + acceptance.TestAccCheckMetalSSHKeyExists("equinix_metal_ssh_key.foobar", &key), + resource.TestCheckResourceAttr( + "equinix_metal_ssh_key.foobar", "name", fmt.Sprintf("tfacc-user-key-%d", rInt)), + resource.TestCheckResourceAttr( + "equinix_metal_ssh_key.foobar", "public_key", publicKeyMaterial), + resource.TestCheckResourceAttrSet( + "equinix_metal_ssh_key.foobar", "owner_id"), + ), + }, + { + ProtoV5ProviderFactories: acceptance.ProtoV5ProviderFactories, + Config: cfg, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectEmptyPlan(), + }, + }, + }, + }, + }) +} diff --git a/main.go b/main.go index e80a0b96a..7dcbf01a0 100644 --- a/main.go +++ b/main.go @@ -6,24 +6,49 @@ import ( "log" "github.com/equinix/terraform-provider-equinix/equinix" - "github.com/hashicorp/terraform-plugin-sdk/v2/plugin" + "github.com/equinix/terraform-provider-equinix/internal/provider" + "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( + provider.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()) } - plugin.Serve(opts) + err = tf5server.Serve( + "registry.terraform.io/equinix/equinix", + muxServer.ProviderServer, + serveOpts..., + ) + + if err != nil { + log.Fatal(err) + } }