diff --git a/equinix/provider.go b/equinix/provider.go index ea0d0c04d..199744767 100644 --- a/equinix/provider.go +++ b/equinix/provider.go @@ -7,9 +7,7 @@ import ( "time" "github.com/equinix/terraform-provider-equinix/internal/config" - metal_project "github.com/equinix/terraform-provider-equinix/internal/resources/metal/project" "github.com/equinix/terraform-provider-equinix/internal/resources/metal/vrf" - "github.com/equinix/ecx-go/v2" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" @@ -106,7 +104,6 @@ func Provider() *schema.Provider { "equinix_metal_device_bgp_neighbors": dataSourceMetalDeviceBGPNeighbors(), "equinix_metal_plans": dataSourceMetalPlans(), "equinix_metal_port": dataSourceMetalPort(), - "equinix_metal_project": metal_project.DataSource(), "equinix_metal_reserved_ip_block": dataSourceMetalReservedIPBlock(), "equinix_metal_spot_market_request": dataSourceMetalSpotMarketRequest(), "equinix_metal_virtual_circuit": dataSourceMetalVirtualCircuit(), @@ -133,7 +130,6 @@ func Provider() *schema.Provider { "equinix_metal_device": resourceMetalDevice(), "equinix_metal_device_network_type": resourceMetalDeviceNetworkType(), "equinix_metal_port": resourceMetalPort(), - "equinix_metal_project": metal_project.Resource(), "equinix_metal_reserved_ip_block": resourceMetalReservedIPBlock(), "equinix_metal_ip_attachment": resourceMetalIPAttachment(), "equinix_metal_spot_market_request": resourceMetalSpotMarketRequest(), diff --git a/go.mod b/go.mod index d1a2dbc26..3c6b226f7 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/hashicorp/go-cty v1.4.1-0.20200723130312-85980079f637 github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-retryablehttp v0.7.5 + github.com/hashicorp/go-uuid v1.0.3 github.com/hashicorp/terraform-plugin-docs v0.18.0 github.com/hashicorp/terraform-plugin-framework v1.6.1 github.com/hashicorp/terraform-plugin-framework-timeouts v0.4.1 @@ -69,7 +70,6 @@ require ( github.com/hashicorp/go-hclog v1.5.0 // indirect github.com/hashicorp/go-plugin v1.6.0 // indirect github.com/hashicorp/go-safetemp v1.0.0 // indirect - github.com/hashicorp/go-uuid v1.0.3 // indirect github.com/hashicorp/go-version v1.6.0 // indirect github.com/hashicorp/hc-install v0.6.3 // indirect github.com/hashicorp/hcl/v2 v2.19.1 // indirect diff --git a/internal/planmodifiers/immutable_int64.go b/internal/planmodifiers/immutable_int64.go new file mode 100644 index 000000000..b45ea9dc0 --- /dev/null +++ b/internal/planmodifiers/immutable_int64.go @@ -0,0 +1,46 @@ +package planmodifiers + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func ImmutableInt64() planmodifier.Int64 { + return &immutableInt64PlanModifier{} +} + +type immutableInt64PlanModifier struct{} + +func (d *immutableInt64PlanModifier) PlanModifyInt64(ctx context.Context, request planmodifier.Int64Request, response *planmodifier.Int64Response) { + if request.StateValue.IsNull() && request.PlanValue.IsUnknown() { + return + } + + oldValue := request.StateValue.ValueInt64() + newValue := request.PlanValue.ValueInt64() + + if oldValue != 0 && newValue != oldValue { + response.Diagnostics.AddAttributeError( + request.Path, + "Change not allowed", + fmt.Sprintf( + "Cannot modify the value of the `%s` field. Resource recreation would be required.", + request.Path.String(), + ), + ) + return + } + + response.PlanValue = types.Int64Value(newValue) +} + +func (d *immutableInt64PlanModifier) Description(ctx context.Context) string { + return "Prevents modification of a int64 value if the old value is not null." +} + +func (d *immutableInt64PlanModifier) MarkdownDescription(ctx context.Context) string { + return d.Description(ctx) +} diff --git a/internal/planmodifiers/immutable_int64_test.go b/internal/planmodifiers/immutable_int64_test.go new file mode 100644 index 000000000..2e56b5a58 --- /dev/null +++ b/internal/planmodifiers/immutable_int64_test.go @@ -0,0 +1,61 @@ +package planmodifiers + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestImmutableStringSet(t *testing.T) { + testCases := []struct { + Old, New, Expected int64 + ExpectError bool + }{ + { + Old: 0, + New: 1234, + Expected: 1234, + ExpectError: false, + }, + { + Old: 1234, + New: 4321, + Expected: 0, + ExpectError: true, + }, + } + + testPlanModifier := ImmutableInt64() + + for i, testCase := range testCases { + stateValue := types.Int64Value(testCase.Old) + planValue := types.Int64Value(testCase.New) + expectedValue := types.Int64Null() + if testCase.Expected != 0 { + expectedValue = types.Int64Value(testCase.Expected) + } + + req := planmodifier.Int64Request{ + StateValue: stateValue, + PlanValue: planValue, + Path: path.Root("test"), + } + + var resp planmodifier.Int64Response + + testPlanModifier.PlanModifyInt64(context.Background(), req, &resp) + + if resp.Diagnostics.HasError() { + if testCase.ExpectError == false { + t.Fatalf("%d: got error modifying plan: %v", i, resp.Diagnostics.Errors()) + } + } + + if !resp.PlanValue.Equal(expectedValue) { + t.Fatalf("%d: output plan value does not equal expected. Want %d plan value, got %d", i, expectedValue, resp.PlanValue.ValueInt64()) + } + } +} \ No newline at end of file diff --git a/internal/planmodifiers/immutable_list.go b/internal/planmodifiers/immutable_list.go new file mode 100644 index 000000000..92921ebc0 --- /dev/null +++ b/internal/planmodifiers/immutable_list.go @@ -0,0 +1,43 @@ +package planmodifiers + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +func ImmutableList() planmodifier.List { + return &immutableListPlanModifier{} +} + +type immutableListPlanModifier struct{} + +func (d *immutableListPlanModifier) PlanModifyList(ctx context.Context, request planmodifier.ListRequest, response *planmodifier.ListResponse) { + + if request.StateValue.IsNull() && request.PlanValue.IsNull() { + return + } + + if request.PlanValue.IsNull() && len(request.StateValue.Elements()) > 0 { + response.Diagnostics.AddAttributeError( + request.Path, + "Change not allowed", + fmt.Sprintf( + "Elements of the `%s` list field can not be removed. Resource recreation would be required.", + request.Path.String(), + ), + ) + return + } + + response.PlanValue = request.PlanValue +} + +func (d *immutableListPlanModifier) Description(ctx context.Context) string { + return "Allows adding elements to a list if it was initially empty and permits modifications, but disallows removals, requiring resource recreation." +} + +func (d *immutableListPlanModifier) MarkdownDescription(ctx context.Context) string { + return d.Description(ctx) +} diff --git a/internal/planmodifiers/immutable_list_test.go b/internal/planmodifiers/immutable_list_test.go new file mode 100644 index 000000000..81429ff2d --- /dev/null +++ b/internal/planmodifiers/immutable_list_test.go @@ -0,0 +1,65 @@ +package planmodifiers + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestImmutableListSet(t *testing.T) { + testCases := []struct { + Old, New, Expected []string + ExpectError bool + }{ + { + Old: []string{}, + New: []string{"test"}, + Expected: []string{"test"}, + ExpectError: false, + }, + { + Old: []string{"test"}, + New: []string{}, + Expected: []string{}, + ExpectError: true, + }, + { + Old: []string{"foo"}, + New: []string{"bar"}, + Expected: []string{"bar"}, + ExpectError: true, + }, + } + + testPlanModifier := ImmutableList() + + for i, testCase := range testCases { + stateValue, _ := types.ListValueFrom(context.Background(), types.StringType, testCase.Old) + planValue, _ := types.ListValueFrom(context.Background(), types.StringType, testCase.New) + expectedValue, _ := types.ListValueFrom(context.Background(), types.StringType, testCase.Expected) + + req := planmodifier.ListRequest{ + StateValue: stateValue, + PlanValue: planValue, + Path: path.Root("test"), + } + + var resp planmodifier.ListResponse + + testPlanModifier.PlanModifyList(context.Background(), req, &resp) + + if resp.Diagnostics.HasError() { + if testCase.ExpectError == false { + t.Fatalf("%d: got error modifying plan: %v", i, resp.Diagnostics.Errors()) + } + } + + if !resp.PlanValue.Equal(expectedValue) { + value, _ := resp.PlanValue.ToListValue(context.Background()) + t.Fatalf("%d: output plan value does not equal expected. Want %v plan value, got %v", i, expectedValue, value) + } + } +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index fab6a8e46..a9e8d4f86 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -9,6 +9,7 @@ import ( metalgateway "github.com/equinix/terraform-provider-equinix/internal/resources/metal/gateway" metalorganization "github.com/equinix/terraform-provider-equinix/internal/resources/metal/organization" metalorganizationmember "github.com/equinix/terraform-provider-equinix/internal/resources/metal/organization_member" + metalproject "github.com/equinix/terraform-provider-equinix/internal/resources/metal/project" 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/equinix/terraform-provider-equinix/internal/resources/metal/vlan" @@ -114,6 +115,7 @@ func (p *FrameworkProvider) MetaSchema( func (p *FrameworkProvider) Resources(ctx context.Context) []func() resource.Resource { return []func() resource.Resource{ metalgateway.NewResource, + metalproject.NewResource, metalprojectsshkey.NewResource, metalsshkey.NewResource, metalconnection.NewResource, @@ -126,6 +128,7 @@ func (p *FrameworkProvider) Resources(ctx context.Context) []func() resource.Res func (p *FrameworkProvider) DataSources(ctx context.Context) []func() datasource.DataSource { return []func() datasource.DataSource{ metalgateway.NewDataSource, + metalproject.NewDataSource, metalprojectsshkey.NewDataSource, metalconnection.NewDataSource, metalorganization.NewDataSource, diff --git a/internal/resources/metal/project/bgp_config.go b/internal/resources/metal/project/bgp_config.go new file mode 100644 index 000000000..09b7228e2 --- /dev/null +++ b/internal/resources/metal/project/bgp_config.go @@ -0,0 +1,86 @@ +package project + +import ( + "context" + + "github.com/equinix/equinix-sdk-go/services/metalv1" + equinix_errors "github.com/equinix/terraform-provider-equinix/internal/errors" + fwtypes "github.com/equinix/terraform-provider-equinix/internal/framework/types" + + "github.com/hashicorp/terraform-plugin-framework/diag" +) + +func fetchBGPConfig(ctx context.Context, client *metalv1.APIClient, projectID string) (*metalv1.BgpConfig, diag.Diagnostics) { + var diags diag.Diagnostics + + bgpConfig, _, err := client.BGPApi.FindBgpConfigByProject(ctx, projectID).Execute() + if err != nil { + friendlyErr := equinix_errors.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 expandBGPConfig(ctx context.Context, bgpConfig fwtypes.ListNestedObjectValueOf[BGPConfigModel]) (*metalv1.BgpConfigRequestInput, error) { + bgpConfigModel, _ := bgpConfig.ToSlice(ctx) + bgpDeploymentType, err := metalv1.NewBgpConfigRequestInputDeploymentTypeFromValue(bgpConfigModel[0].DeploymentType.ValueString()) + if err != nil { + return nil, err + } + bgpCreateRequest := metalv1.BgpConfigRequestInput{ + DeploymentType: *bgpDeploymentType, + Asn: int32(bgpConfigModel[0].ASN.ValueInt64()), + } + if !bgpConfigModel[0].MD5.IsNull() { + bgpCreateRequest.Md5 = bgpConfigModel[0].MD5.ValueStringPointer() + } + + return &bgpCreateRequest, nil +} + +func handleBGPConfigChanges(ctx context.Context, client *metalv1.APIClient, plan, state *ResourceModel, projectID string) (*metalv1.BgpConfig, diag.Diagnostics) { + var diags diag.Diagnostics + var bgpConfig *metalv1.BgpConfig + + if plan.BGPConfig.IsNull() && state.BGPConfig.IsNull() { + return bgpConfig, nil + } + + bgpAdded := !plan.BGPConfig.IsNull() && state.BGPConfig.IsNull() + bgpChanged := !plan.BGPConfig.IsNull() && !state.BGPConfig.IsNull() && !plan.BGPConfig.Equal(state.BGPConfig) + + if bgpAdded || bgpChanged { + // Create BGP Config + bgpCreateRequest, err := expandBGPConfig(ctx, plan.BGPConfig) + if err != nil { + diags.AddError( + "Error creating project", + "Could not validate BGP Config: "+err.Error(), + ) + return nil, diags + } + createResp, err := client.BGPApi.RequestBgpConfig(ctx, projectID).BgpConfigRequestInput(*bgpCreateRequest).Execute() + if err != nil { + err = equinix_errors.FriendlyErrorForMetalGo(err, createResp) + diags.AddError( + "Error creating BGP configuration", + "Could not create BGP configuration for project: "+err.Error(), + ) + return nil, diags + } + // Fetch the newly created BGP Config + bgpConfig, diags = fetchBGPConfig(ctx, client, projectID) + diags.Append(diags...) + } else { // assuming already exists + // Fetch the existing BGP Config + bgpConfig, diags = fetchBGPConfig(ctx, client, projectID) + diags.Append(diags...) + } + + return bgpConfig, diags +} diff --git a/internal/resources/metal/project/datasource.go b/internal/resources/metal/project/datasource.go index 118701895..b49f8214d 100644 --- a/internal/resources/metal/project/datasource.go +++ b/internal/resources/metal/project/datasource.go @@ -3,182 +3,102 @@ package project import ( "context" "fmt" - "path" - "time" - - "github.com/equinix/terraform-provider-equinix/internal/config" "github.com/equinix/equinix-sdk-go/services/metalv1" - "github.com/hashicorp/terraform-plugin-sdk/v2/diag" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" + "github.com/equinix/terraform-provider-equinix/internal/framework" + "github.com/hashicorp/terraform-plugin-framework/datasource" ) -func DataSource() *schema.Resource { - return &schema.Resource{ - ReadWithoutTimeout: dataSourceMetalProjectRead, - Schema: map[string]*schema.Schema{ - "name": { - Type: schema.TypeString, - Description: "The name which is used to look up the project", - Optional: true, - Computed: true, - ConflictsWith: []string{"project_id"}, - }, - "project_id": { - Type: schema.TypeString, - Description: "The UUID by which to look up the project", - Optional: true, - Computed: true, - ConflictsWith: []string{"name"}, - }, - - "created": { - Type: schema.TypeString, - Description: "The timestamp for when the project was created", - Computed: true, - }, - - "updated": { - Type: schema.TypeString, - Description: "The timestamp for the last time the project was updated", - Computed: true, - }, - - "backend_transfer": { - Type: schema.TypeBool, - Description: "Whether Backend Transfer is enabled for this project", - Computed: true, +func NewDataSource() datasource.DataSource { + return &DataSource{ + BaseDataSource: framework.NewBaseDataSource( + framework.BaseDataSourceConfig{ + Name: "equinix_metal_project", }, - - "payment_method_id": { - Type: schema.TypeString, - Description: "The UUID of payment method for this project", - Computed: true, - }, - - "organization_id": { - Type: schema.TypeString, - Description: "The UUID of this project's parent organization", - Computed: true, - }, - "user_ids": { - Type: schema.TypeList, - Description: "List of UUIDs of user accounts which belong to this project", - Computed: true, - Elem: &schema.Schema{Type: schema.TypeString}, - }, - "bgp_config": { - Type: schema.TypeList, - Description: "Optional BGP settings. Refer to [Equinix Metal guide for BGP](https://metal.equinix.com/developers/docs/networking/local-global-bgp/)", - Computed: true, - Elem: &schema.Resource{ - Schema: map[string]*schema.Schema{ - "deployment_type": { - Type: schema.TypeString, - Description: "Private or public, the private is likely to be usable immediately, the public will need to be review by Equinix Metal engineers", - Required: true, - ValidateFunc: validation.StringInSlice([]string{"local", "global"}, false), - }, - "asn": { - Type: schema.TypeInt, - Description: "Autonomous System Number for local BGP deployment", - Required: true, - }, - "md5": { - Type: schema.TypeString, - Description: "Password for BGP session in plaintext (not a checksum)", - Optional: true, - Sensitive: true, - }, - "status": { - Type: schema.TypeString, - Description: "Status of BGP configuration in the project", - Computed: true, - }, - "max_prefix": { - Type: schema.TypeInt, - Description: "The maximum number of route filters allowed per server", - Computed: true, - }, - }, - }, - }, - }, + ), } } -func dataSourceMetalProjectRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client := meta.(*config.Config).NewMetalClientForSDK(d) - nameRaw, nameOK := d.GetOk("name") - projectIdRaw, projectIdOK := d.GetOk("project_id") +type DataSource struct { + framework.BaseDataSource +} + +func (r *DataSource) Schema( + ctx context.Context, + req datasource.SchemaRequest, + resp *datasource.SchemaResponse, +) { + resp.Schema = dataSourceSchema(ctx) +} - if !projectIdOK && !nameOK { - return diag.Errorf("you must supply project_id or name") +func (r *DataSource) Read( + ctx context.Context, + req datasource.ReadRequest, + resp *datasource.ReadResponse, +) { + // Retrieve values from plan + var data DataSourceModel + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return } - var project *metalv1.Project - if nameOK { - name := nameRaw.(string) + // Retrieve the API client from the provider metadata + client := r.Meta.NewMetalClientForFramework(ctx, req.ProviderMeta) + // Use API client to get the current state of the resource + var project *metalv1.Project + if !data.Name.IsNull() { + name := data.Name.ValueString() projects, err := client.ProjectsApi.FindProjects(ctx).Name(name).ExecuteWithPagination() if err != nil { - return diag.FromErr(err) + resp.Diagnostics.AddError( + "Error reading Metal Project", + "Could not read Metal Connection with Name "+name+": "+err.Error(), + ) + return } - - project, err = findProjectByName(projects, name) - if err != nil { - return diag.FromErr(err) + if len(projects.Projects) == 0 { + resp.Diagnostics.AddError( + "Error reading Metal Project", + "No project found with name "+name, + ) + return } + if len(projects.Projects) > 1 { + resp.Diagnostics.AddError( + "Error reading Metal Project", + fmt.Sprintf("too many projects found with name %s (found %d, expected 1)", name, len(projects.Projects)), + ) + return + } + project = &projects.Projects[0] } else { - projectId := projectIdRaw.(string) + id := data.ProjectID.ValueString() var err error - project, _, err = client.ProjectsApi.FindProjectById(ctx, projectId).Execute() + project, _, err = client.ProjectsApi.FindProjectById(ctx, id).Execute() if err != nil { - return diag.FromErr(err) + resp.Diagnostics.AddError( + "Error reading Metal Project", + "Could not read Metal Project with ID "+id+": "+err.Error(), + ) + return } } - d.SetId(project.GetId()) - d.Set("payment_method_id", path.Base(project.PaymentMethod.GetHref())) - d.Set("name", project.GetName()) - d.Set("project_id", project.GetId()) - d.Set("organization_id", path.Base(project.Organization.AdditionalProperties["href"].(string))) // spec: organization has no href - d.Set("created", project.GetCreatedAt().Format(time.RFC3339)) - d.Set("updated", project.GetUpdatedAt().Format(time.RFC3339)) - d.Set("backend_transfer", project.AdditionalProperties["backend_transfer_enabled"].(bool)) // No backend_transfer_enabled property in API spec - bgpConf, _, err := client.BGPApi.FindBgpConfigByProject(ctx, project.GetId()).Execute() - userIds := []string{} - for _, u := range project.GetMembers() { - userIds = append(userIds, path.Base(u.GetHref())) + if err != nil { + resp.Diagnostics.AddError( + "Error reading Metal Project", + "Could not read BGP Config for Metal Project with ID "+project.GetId()+": "+err.Error(), + ) } - d.Set("user_ids", userIds) - - if (err == nil) && (bgpConf != nil) { - // guard against an empty struct - if bgpConf.GetId() != "" { - err := d.Set("bgp_config", flattenBGPConfig(bgpConf)) - if err != nil { - return diag.FromErr(err) - } - } + // Set state to fully populated data + resp.Diagnostics.Append(data.parse(ctx, project, bgpConf)...) + if resp.Diagnostics.HasError() { + return } - return nil -} -func findProjectByName(ps *metalv1.ProjectList, name string) (*metalv1.Project, error) { - results := make([]metalv1.Project, 0) - for _, p := range ps.Projects { - if p.GetName() == name { - results = append(results, p) - } - } - if len(results) == 1 { - return &results[0], nil - } - if len(results) == 0 { - return nil, fmt.Errorf("no project found with name %s", name) - } - return nil, fmt.Errorf("too many projects found with name %s (found %d, expected 1)", name, len(results)) + // Update the Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } diff --git a/internal/resources/metal/project/datasource_schema.go b/internal/resources/metal/project/datasource_schema.go new file mode 100644 index 000000000..ac816cb2a --- /dev/null +++ b/internal/resources/metal/project/datasource_schema.go @@ -0,0 +1,67 @@ +package project + +import ( + "context" + + "github.com/equinix/terraform-provider-equinix/internal/framework" + fwtypes "github.com/equinix/terraform-provider-equinix/internal/framework/types" + "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" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func dataSourceSchema(ctx context.Context) schema.Schema { + return schema.Schema{ + Attributes: map[string]schema.Attribute{ + "id": framework.IDAttributeDefaultDescription(), + "name": schema.StringAttribute{ + Description: "Name of the connection resource", + Optional: true, + Computed: true, + Validators: []validator.String{ + stringvalidator.ExactlyOneOf(path.Expressions{ + path.MatchRoot("project_id"), + }...), + }, + }, + "project_id": schema.StringAttribute{ + Description: "ID of project to which the connection belongs", + Optional: true, + Computed: 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: "Whether Backend Transfer is enabled for this project", + Computed: true, + }, + "payment_method_id": schema.StringAttribute{ + Description: "The UUID of payment method for this project", + Computed: true, + }, + "organization_id": schema.StringAttribute{ + Description: "The UUID of this project's parent organization", + Computed: true, + }, + "user_ids": schema.ListAttribute{ + Description: "List of UUIDs of user accounts which belong to this project", + ElementType: types.StringType, + Computed: true, + }, + "bgp_config": schema.ListAttribute{ + Description: "Optional BGP settings. Refer to [Equinix Metal guide for BGP](https://metal.equinix.com/developers/docs/networking/local-global-bgp/)", + CustomType: fwtypes.NewListNestedObjectTypeOf[BGPConfigModel](ctx), + ElementType: fwtypes.NewObjectTypeOf[BGPConfigModel](ctx), + Computed: true, + }, + }, + } +} diff --git a/internal/resources/metal/project/datasource_test.go b/internal/resources/metal/project/datasource_test.go index 264fff5f0..d81c43fce 100644 --- a/internal/resources/metal/project/datasource_test.go +++ b/internal/resources/metal/project/datasource_test.go @@ -4,14 +4,15 @@ import ( "fmt" "testing" + "github.com/equinix/equinix-sdk-go/services/metalv1" "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/packethost/packngo" + "github.com/hashicorp/terraform-plugin-testing/plancheck" ) func TestAccDataSourceMetalProject_byId(t *testing.T) { - var project packngo.Project + var project metalv1.Project rn := acctest.RandStringFromCharSet(12, "abcdef0123456789") resource.ParallelTest(t, resource.TestCase{ @@ -42,13 +43,36 @@ func TestAccDataSourceMetalProject_byId(t *testing.T) { } func testAccDataSourceMetalProject_byId(r string) string { - return fmt.Sprintf(` -terraform { - provider_meta "equinix" { - module_name = "test" - } + return testAccDataSourceMetalProject_byIdWithVersion(r, "") } +func testAccDataSourceMetalProject_byIdWithVersion(r, version string) string { + + // Add provider info if version is provided + providerInfo := "" + if version != "" { + providerInfo = fmt.Sprintf(` + required_providers { + equinix = { + source = "equinix/equinix" + version = "%s" + } + } +`, version) + } + + // Terraform configuration template + terraformConfig := fmt.Sprintf(` + terraform { + provider_meta "equinix" { + module_name = "test" + } + %s + } + `, providerInfo) + + // Resource template + resourceTemplate := ` resource "equinix_metal_project" "foobar" { name = "tfacc-project-%s" bgp_config { @@ -57,15 +81,21 @@ resource "equinix_metal_project" "foobar" { asn = 65000 } } +` + // Datasource template + dataSourceTemplate := ` data equinix_metal_project "test" { project_id = equinix_metal_project.foobar.id } -`, r) +` + + // Combine templates + return fmt.Sprintf("%s%s%s", terraformConfig, fmt.Sprintf(resourceTemplate, r), dataSourceTemplate) } func TestAccDataSourceMetalProject_byName(t *testing.T) { - var project packngo.Project + var project metalv1.Project rn := acctest.RandStringFromCharSet(12, "abcdef0123456789") resource.ParallelTest(t, resource.TestCase{ @@ -114,3 +144,50 @@ data equinix_metal_project "test" { } `, r) } + +// 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 TestAccDataSourceMetalProject_byId_upgradeFromVersion(t *testing.T) { + var project metalv1.Project + rn := acctest.RandStringFromCharSet(12, "abcdef0123456789") + cfg := testAccDataSourceMetalProject_byId(rn) + sdkProviderVersion := "1.32.0" + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acceptance.TestAccPreCheckMetal(t); acceptance.TestAccPreCheckProviderConfigured(t) }, + CheckDestroy: testAccMetalProjectCheckDestroyed, + Steps: []resource.TestStep{ + { + ExternalProviders: map[string]resource.ExternalProvider{ + "equinix": { + VersionConstraint: sdkProviderVersion, // latest version with resource defined on SDKv2 + Source: "equinix/equinix", + }, + }, + Config: testAccDataSourceMetalProject_byIdWithVersion(rn, sdkProviderVersion), + Check: resource.ComposeTestCheckFunc( + testAccMetalProjectExists("equinix_metal_project.foobar", &project), + resource.TestCheckResourceAttr( + "equinix_metal_project.foobar", "name", fmt.Sprintf("tfacc-project-%s", rn)), + resource.TestCheckResourceAttr( + "equinix_metal_project.foobar", "bgp_config.0.md5", + "2SFsdfsg43"), + resource.TestCheckResourceAttrPair( + "equinix_metal_project.foobar", "id", + "data.equinix_metal_project.test", "id"), + resource.TestCheckResourceAttrPair( + "equinix_metal_project.foobar", "organization_id", + "data.equinix_metal_project.test", "organization_id"), + ), + }, + { + ProtoV5ProviderFactories: acceptance.ProtoV5ProviderFactories, + Config: cfg, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectEmptyPlan(), + }, + }, + }, + }, + }) +} diff --git a/internal/resources/metal/project/models.go b/internal/resources/metal/project/models.go new file mode 100644 index 000000000..f38053958 --- /dev/null +++ b/internal/resources/metal/project/models.go @@ -0,0 +1,125 @@ +package project + +import ( + "context" + "github.com/equinix/equinix-sdk-go/services/metalv1" + "path" + "strings" + "time" + + fwtypes "github.com/equinix/terraform-provider-equinix/internal/framework/types" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +type ResourceModel 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 fwtypes.ListNestedObjectValueOf[BGPConfigModel] `tfsdk:"bgp_config"` +} + +type DataSourceModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + ProjectID types.String `tfsdk:"project_id"` + 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"` + UserIDs types.List `tfsdk:"user_ids"` + BGPConfig fwtypes.ListNestedObjectValueOf[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 (m *ResourceModel) parse(ctx context.Context, project *metalv1.Project, bgpConfig *metalv1.BgpConfig) diag.Diagnostics { + var diags diag.Diagnostics + m.ID = types.StringValue(project.GetId()) + m.Name = types.StringValue(project.GetName()) + m.Created = types.StringValue(project.GetCreatedAt().Format(time.RFC3339)) + m.Updated = types.StringValue(project.GetUpdatedAt().Format(time.RFC3339)) + m.BackendTransfer = types.BoolValue(project.AdditionalProperties["backend_transfer_enabled"].(bool)) // No backend_transfer_enabled property in API spec + m.OrganizationID = types.StringValue(path.Base(project.Organization.AdditionalProperties["href"].(string))) + + m.PaymentMethodID = types.StringValue("") + if len(project.PaymentMethod.GetHref()) != 0 { + newValue := path.Base(project.PaymentMethod.GetHref()) + if !strings.EqualFold(strings.Trim(m.PaymentMethodID.ValueString(), `"`), strings.Trim(newValue, `"`)) { + m.PaymentMethodID = types.StringValue(path.Base(project.PaymentMethod.GetHref())) + } + } + + // Handle BGP Config if present + m.BGPConfig = parseBGPConfig(ctx, bgpConfig) + + return diags +} + +func (m *DataSourceModel) parse(ctx context.Context, project *metalv1.Project, bgpConfig *metalv1.BgpConfig) diag.Diagnostics { + var diags diag.Diagnostics + m.ID = types.StringValue(project.GetId()) + m.ProjectID = types.StringValue(project.GetId()) + m.Name = types.StringValue(project.GetName()) + m.Created = types.StringValue(project.GetCreatedAt().Format(time.RFC3339)) + m.Updated = types.StringValue(project.GetUpdatedAt().Format(time.RFC3339)) + m.BackendTransfer = types.BoolValue(project.AdditionalProperties["backend_transfer_enabled"].(bool)) // No backend_transfer_enabled property in API spec + m.OrganizationID = types.StringValue(path.Base(project.Organization.AdditionalProperties["href"].(string))) + + m.PaymentMethodID = types.StringValue("") + if len(project.PaymentMethod.GetHref()) != 0 { + m.PaymentMethodID = types.StringValue(path.Base(project.PaymentMethod.GetHref())) + } + + // Parse User IDs + projUserIds := []string{} + for _, u := range project.GetMembers() { + projUserIds = append(projUserIds, path.Base(u.GetHref())) + } + userIDs, diags := types.ListValueFrom(ctx, types.StringType, projUserIds) + if diags.HasError() { + return diags + } + m.UserIDs = userIDs + + // Handle BGP Config if present + m.BGPConfig = parseBGPConfig(ctx, bgpConfig) + + return diags +} + +func parseBGPConfig(ctx context.Context, bgpConfig *metalv1.BgpConfig) fwtypes.ListNestedObjectValueOf[BGPConfigModel] { + if !isEmptyMetalBGPConfig(bgpConfig) { + bgpConfigResourceModel := make([]BGPConfigModel, 1) + bgpConfigResourceModel[0] = BGPConfigModel{ + DeploymentType: types.StringValue(string(bgpConfig.GetDeploymentType())), + ASN: types.Int64Value(int64(bgpConfig.GetAsn())), + MD5: types.StringValue(bgpConfig.GetMd5()), + Status: types.StringValue(string(bgpConfig.GetStatus())), + MaxPrefix: types.Int64Value(int64(bgpConfig.GetMaxPrefix())), + } + return fwtypes.NewListNestedObjectValueOfValueSlice[BGPConfigModel](ctx, bgpConfigResourceModel) + } + return fwtypes.NewListNestedObjectValueOfNull[BGPConfigModel](ctx) +} + +// isEmptyBGPConfig checks if the provided BgpConfig is considered empty +func isEmptyMetalBGPConfig(bgp *metalv1.BgpConfig) bool { + if metalv1.IsNil(bgp) { + return true + } + return metalv1.IsNil(bgp.DeploymentType) && + metalv1.IsNil(bgp.Asn) && + metalv1.IsNil(bgp.Status) +} diff --git a/internal/resources/metal/project/resource.go b/internal/resources/metal/project/resource.go index df2a80aa4..fd39d1e60 100644 --- a/internal/resources/metal/project/resource.go +++ b/internal/resources/metal/project/resource.go @@ -3,311 +3,300 @@ package project import ( "context" "fmt" - "path" - "strings" - "time" + "reflect" "github.com/equinix/equinix-sdk-go/services/metalv1" 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/diag" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" + "github.com/equinix/terraform-provider-equinix/internal/framework" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource" ) -func Resource() *schema.Resource { - return &schema.Resource{ - CreateWithoutTimeout: resourceMetalProjectCreate, - ReadWithoutTimeout: resourceMetalProjectRead, - UpdateWithoutTimeout: resourceMetalProjectUpdate, - DeleteWithoutTimeout: resourceMetalProjectDelete, - Importer: &schema.ResourceImporter{ - State: schema.ImportStatePassthrough, - }, - Schema: map[string]*schema.Schema{ - "name": { - Type: schema.TypeString, - Description: "The name of the project. The maximum length is 80 characters.", - Required: true, - }, - "created": { - Type: schema.TypeString, - Description: "The timestamp for when the project was created", - Computed: true, - }, - "updated": { - Type: schema.TypeString, - Description: "The timestamp for the last time the project was updated", - Computed: true, - }, - "backend_transfer": { - Type: schema.TypeBool, - Description: "Enable or disable [Backend Transfer](https://metal.equinix.com/developers/docs/networking/backend-transfer/), default is false", - Optional: true, - Default: false, +func NewResource() resource.Resource { + return &Resource{ + BaseResource: framework.NewBaseResource( + framework.BaseResourceConfig{ + Name: "equinix_metal_project", }, - "payment_method_id": { - Type: schema.TypeString, - Description: "The UUID of payment method for this project. The payment method and the project need to belong to the same organization (passed with organization_id, or default)", - Optional: true, - Computed: true, - DiffSuppressFunc: func(k, old, new string, d *schema.ResourceData) bool { - return strings.EqualFold(strings.Trim(old, `"`), strings.Trim(new, `"`)) - }, - ValidateFunc: validation.Any( - validation.IsUUID, - validation.StringIsEmpty, - ), - }, - "organization_id": { - Type: schema.TypeString, - Description: "The UUID of organization under which you want to create the project. If you leave it out, the project will be create under your the default organization of your account", - Optional: true, - Computed: true, - ForceNew: true, - DiffSuppressFunc: func(k, old, new string, d *schema.ResourceData) bool { - return strings.EqualFold(strings.Trim(old, `"`), strings.Trim(new, `"`)) - }, - ValidateFunc: validation.IsUUID, - }, - "bgp_config": { - Type: schema.TypeList, - Description: "Optional BGP settings. Refer to [Equinix Metal guide for BGP](https://metal.equinix.com/developers/docs/networking/local-global-bgp/)", - MaxItems: 1, - Optional: true, - Elem: &schema.Resource{ - Schema: map[string]*schema.Schema{ - "deployment_type": { - Type: schema.TypeString, - Description: "\"local\" or \"global\", the local is likely to be usable immediately, the global will need to be review by Equinix Metal engineers", - Required: true, - ValidateFunc: validation.StringInSlice([]string{"local", "global"}, false), - }, - "asn": { - Type: schema.TypeInt, - Description: "Autonomous System Number for local BGP deployment", - Required: true, - }, - "md5": { - Type: schema.TypeString, - Description: "Password for BGP session in plaintext (not a checksum)", - Sensitive: true, - Optional: true, - }, - "status": { - Type: schema.TypeString, - Description: "Status of BGP configuration in the project", - Computed: true, - }, - "max_prefix": { - Type: schema.TypeInt, - Description: "The maximum number of route filters allowed per server", - Computed: true, - }, - }, - }, - }, - }, + ), } } -func expandBGPConfig(d *schema.ResourceData) (*metalv1.BgpConfigRequestInput, error) { - bgpDeploymentType, err := metalv1.NewBgpConfigRequestInputDeploymentTypeFromValue(d.Get("bgp_config.0.deployment_type").(string)) - if err != nil { - return nil, err - } - - bgpCreateRequest := metalv1.BgpConfigRequestInput{ - DeploymentType: *bgpDeploymentType, - Asn: int32(d.Get("bgp_config.0.asn").(int)), - } - md5, ok := d.GetOk("bgp_config.0.md5") - if ok { - bgpCreateRequest.Md5 = metalv1.PtrString(md5.(string)) - } +type Resource struct { + framework.BaseResource +} - return &bgpCreateRequest, nil +func (r *Resource) Schema( + ctx context.Context, + req resource.SchemaRequest, + resp *resource.SchemaResponse, +) { + resp.Schema = resourceSchema(ctx) } -func resourceMetalProjectCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client := meta.(*config.Config).NewMetalClientForSDK(d) +func (r *Resource) Create( + ctx context.Context, + req resource.CreateRequest, + resp *resource.CreateResponse, +) { + + var plan ResourceModel + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Retrieve the API client from the provider metadata + client := r.Meta.NewMetalClientForFramework(ctx, req.ProviderMeta) + // Prepare the data for API request createRequest := metalv1.ProjectCreateFromRootInput{ - Name: d.Get("name").(string), + Name: plan.Name.ValueString(), } - organization_id := d.Get("organization_id").(string) - if organization_id != "" { - createRequest.OrganizationId = &organization_id + // Include optional fields if they are set + if !plan.OrganizationID.IsNull() && !plan.OrganizationID.IsUnknown() { + createRequest.OrganizationId = plan.OrganizationID.ValueStringPointer() } - project, resp, err := client.ProjectsApi.CreateProject(ctx).ProjectCreateFromRootInput(createRequest).Execute() - + // API call to create the project + project, createResp, err := client.ProjectsApi.CreateProject(ctx).ProjectCreateFromRootInput(createRequest).Execute() if err != nil { - return diag.FromErr(equinix_errors.FriendlyErrorForMetalGo(err, resp)) + resp.Diagnostics.AddError( + "Error creating project", + "Could not create project: "+equinix_errors.FriendlyErrorForMetalGo(err, createResp).Error(), + ) + return } - d.SetId(project.GetId()) - - _, hasBGPConfig := d.GetOk("bgp_config") - if hasBGPConfig { - bgpCR, err := expandBGPConfig(d) - if err == nil { - resp, err = client.BGPApi.RequestBgpConfig(ctx, project.GetId()).BgpConfigRequestInput(*bgpCR).Execute() + // Handle BGP Config if present + if !plan.BGPConfig.IsNull() { + bgpCreateRequest, err := expandBGPConfig(ctx, plan.BGPConfig) + if err != nil { + resp.Diagnostics.AddError( + "Error creating project", + "Could not validate BGP Config: "+err.Error(), + ) + return } + + createResp, err = client.BGPApi.RequestBgpConfig(ctx, project.GetId()).BgpConfigRequestInput(*bgpCreateRequest).Execute() if err != nil { - return diag.FromErr(equinix_errors.FriendlyErrorForMetalGo(err, resp)) + err = equinix_errors.FriendlyErrorForMetalGo(err, createResp) + resp.Diagnostics.AddError( + "Error creating BGP configuration", + "Could not create BGP configuration for project: "+err.Error(), + ) + return } } - backendTransfer := d.Get("backend_transfer").(bool) - if backendTransfer { + // Enable Backend Transfer if True + if plan.BackendTransfer.ValueBool() { pur := metalv1.ProjectUpdateInput{ - BackendTransferEnabled: &backendTransfer, + BackendTransferEnabled: plan.BackendTransfer.ValueBoolPointer(), } - _, _, err := client.ProjectsApi.UpdateProject(ctx, project.GetId()).ProjectUpdateInput(pur).Execute() + _, updateResp, err := client.ProjectsApi.UpdateProject(ctx, project.GetId()).ProjectUpdateInput(pur).Execute() if err != nil { - return diag.FromErr(equinix_errors.FriendlyErrorForMetalGo(err, resp)) + err = equinix_errors.FriendlyErrorForMetalGo(err, updateResp) + resp.Diagnostics.AddError( + "Error enabling Backend Transfer", + "Could not enable Backend Transfer for project with ID "+project.GetId()+": "+err.Error(), + ) + return } } - return resourceMetalProjectRead(ctx, d, meta) -} - -func resourceMetalProjectRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client := meta.(*config.Config).NewMetalClientForSDK(d) - - proj, resp, err := client.ProjectsApi.FindProjectById(ctx, d.Id()).Execute() - if err != nil { - err = equinix_errors.FriendlyErrorForMetalGo(err, resp) - // If the project somehow already destroyed, mark as successfully gone. - if equinix_errors.IsNotFound(err) { - d.SetId("") + // Use API client to get the current state of the resource + project, diags = fetchProject(ctx, client, project.GetId()) + if diags != nil { + resp.Diagnostics.Append(diags...) + return + } - return nil + // Fetch BGP Config if needed + var bgpConfig *metalv1.BgpConfig + if !plan.BGPConfig.IsNull() { + bgpConfig, diags = fetchBGPConfig(ctx, client, project.GetId()) + diags.Append(diags...) + if diags.HasError() { + return } - - return diag.FromErr(err) } - d.SetId(proj.GetId()) - if len(proj.PaymentMethod.GetHref()) != 0 { - d.Set("payment_method_id", path.Base(proj.PaymentMethod.GetHref())) + // Parse API response into the Terraform state + resp.Diagnostics.Append(plan.parse(ctx, project, bgpConfig)...) + if resp.Diagnostics.HasError() { + return } - d.Set("name", proj.Name) - d.Set("organization_id", path.Base(proj.Organization.AdditionalProperties["href"].(string))) // spec: organization has no href - d.Set("created", proj.GetCreatedAt().Format(time.RFC3339)) - d.Set("updated", proj.GetUpdatedAt().Format(time.RFC3339)) - d.Set("backend_transfer", proj.AdditionalProperties["backend_transfer_enabled"].(bool)) // No backend_transfer_enabled property in API spec - - bgpConf, _, err := client.BGPApi.FindBgpConfigByProject(ctx, proj.GetId()).Execute() - - if (err == nil) && (bgpConf != nil) { - // guard against an empty struct - if bgpConf.GetId() != "" { - err := d.Set("bgp_config", flattenBGPConfig(bgpConf)) - if err != nil { - return diag.FromErr(equinix_errors.FriendlyErrorForMetalGo(err, resp)) - } - } - } - return nil -} -func flattenBGPConfig(l *metalv1.BgpConfig) []map[string]interface{} { - result := make([]map[string]interface{}, 0, 1) + // Set state to fully populated data + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} - if l == nil { - return nil +func (r *Resource) Read( + ctx context.Context, + req resource.ReadRequest, + resp *resource.ReadResponse, +) { + // Retrieve the current state + var state ResourceModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return } - r := make(map[string]interface{}) + // Retrieve the API client from the provider metadata + client := r.Meta.NewMetalClientForFramework(ctx, req.ProviderMeta) - if l.GetStatus() != "" { - r["status"] = l.GetStatus() + // Extract the ID of the resource from the state + id := state.ID.ValueString() + + // Use API client to get the current state of the resource + project, diags := fetchProject(ctx, client, id) + if diags != nil { + resp.Diagnostics.Append(diags...) + return } - if l.GetDeploymentType() != "" { - r["deployment_type"] = l.GetDeploymentType() + + // Use API client to fetch BGP Config + var bgpConfig *metalv1.BgpConfig + bgpConfig, diags = fetchBGPConfig(ctx, client, project.GetId()) + diags.Append(diags...) + if diags.HasError() { + return } - if l.GetMd5() != "" { - r["md5"] = l.GetMd5() + + // Parse the API response into the Terraform state + resp.Diagnostics.Append(state.parse(ctx, project, bgpConfig)...) + if resp.Diagnostics.HasError() { + return } - if l.GetAsn() != 0 { - r["asn"] = l.GetAsn() + + // Update the Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func fetchProject(ctx context.Context, client *metalv1.APIClient, projectID string) (*metalv1.Project, diag.Diagnostics) { + var diags diag.Diagnostics + + project, apiResp, err := client.ProjectsApi.FindProjectById(ctx, projectID).Execute() + if err != nil { + err = equinix_errors.FriendlyErrorForMetalGo(err, apiResp) + + // Check if the Project no longer exists + if equinix_errors.IsNotFound(err) { + 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+": "+err.Error(), + ) + } + return nil, diags } - if l.GetMaxPrefix() != 0 { - r["max_prefix"] = l.GetMaxPrefix() + + return project, diags +} + +func (r *Resource) Update( + ctx context.Context, + req resource.UpdateRequest, + resp *resource.UpdateResponse, +) { + // 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 } - result = append(result, r) + // Retrieve the API client from the provider metadata + client := r.Meta.NewMetalClientForFramework(ctx, req.ProviderMeta) - return result -} + // Extract the ID of the resource from the state + id := state.ID.ValueString() + + // Handle BGP Config changes + bgpConfig, diags := handleBGPConfigChanges(ctx, client, &plan, &state, id) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } -func resourceMetalProjectUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client := meta.(*config.Config).NewMetalClientForSDK(d) + // Prepare Project update request based on the changes updateRequest := metalv1.ProjectUpdateInput{} - if d.HasChange("name") { - pName := d.Get("name").(string) - updateRequest.Name = &pName + if state.Name != plan.Name { + updateRequest.Name = plan.Name.ValueStringPointer() } - if d.HasChange("payment_method_id") { - pPayment := d.Get("payment_method_id").(string) - updateRequest.PaymentMethodId = &pPayment + if state.PaymentMethodID != plan.PaymentMethodID { + updateRequest.PaymentMethodId = plan.PaymentMethodID.ValueStringPointer() } - if d.HasChange("backend_transfer") { - pBT := d.Get("backend_transfer").(bool) - updateRequest.BackendTransferEnabled = &pBT + if state.BackendTransfer != plan.BackendTransfer { + updateRequest.BackendTransferEnabled = plan.BackendTransfer.ValueBoolPointer() } - if d.HasChange("bgp_config") { - o, n := d.GetChange("bgp_config") - oldarr := o.([]interface{}) - newarr := n.([]interface{}) - if len(newarr) == 1 { - bgpCreateRequest, err := expandBGPConfig(d) - if err != nil { - return diag.FromErr(err) - } - - resp, err := client.BGPApi.RequestBgpConfig(ctx, d.Id()).BgpConfigRequestInput(*bgpCreateRequest).Execute() - if err != nil { - return diag.FromErr(equinix_errors.FriendlyErrorForMetalGo(err, resp)) - } - } else { - if len(oldarr) == 1 { - m := oldarr[0].(map[string]interface{}) - - bgpConfStr := fmt.Sprintf( - "bgp_config {\n"+ - " deployment_type = \"%s\"\n"+ - " md5 = \"%s\"\n"+ - " asn = %d\n"+ - "}", m["deployment_type"].(string), m["md5"].(string), - m["asn"].(int)) - - return diag.Errorf("BGP Config can not be removed from a project, please add back\n%s", bgpConfStr) - } - } - } else { - _, resp, err := client.ProjectsApi.UpdateProject(ctx, d.Id()).ProjectUpdateInput(updateRequest).Execute() + + var project *metalv1.Project + // Check if any update was requested + if !reflect.DeepEqual(updateRequest, metalv1.ProjectUpdateInput{}) { + // API call to update the project + _, updateResp, err := client.ProjectsApi.UpdateProject(ctx, id).ProjectUpdateInput(updateRequest).Execute() if err != nil { - return diag.FromErr(equinix_errors.FriendlyErrorForMetalGo(err, resp)) + friendlyErr := equinix_errors.FriendlyErrorForMetalGo(err, updateResp) + resp.Diagnostics.AddError( + "Error updating project", + "Could not update project with ID "+id+": "+friendlyErr.Error(), + ) + return } } - return resourceMetalProjectRead(ctx, d, meta) -} + // Use API client to get the current state of the resource + project, diags = fetchProject(ctx, client, id) + if diags != nil { + resp.Diagnostics.Append(diags...) + return + } + + // Set state to fully populated data + resp.Diagnostics.Append(plan.parse(ctx, project, bgpConfig)...) + if resp.Diagnostics.HasError() { + return + } -func resourceMetalProjectDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client := meta.(*config.Config).NewMetalClientForSDK(d) + // Set the updated state back into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} - resp, err := client.ProjectsApi.DeleteProject(ctx, d.Id()).Execute() - if equinix_errors.IgnoreHttpResponseErrors(equinix_errors.HttpForbidden, equinix_errors.HttpNotFound)(resp, err) != nil { - return diag.FromErr(equinix_errors.FriendlyErrorForMetalGo(err, resp)) +func (r *Resource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + // Retrieve the current state + var state ResourceModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return } - d.SetId("") - return nil + // Retrieve the API client from the provider metadata + client := r.Meta.NewMetalClientForFramework(ctx, req.ProviderMeta) + + // Extract the ID of the resource from the state + id := state.ID.ValueString() + + // API call to delete the project + deleteResp, err := client.ProjectsApi.DeleteProject(ctx, id).Execute() + if equinix_errors.IgnoreHttpResponseErrors(equinix_errors.HttpForbidden, equinix_errors.HttpNotFound)(deleteResp, err) != nil { + err = equinix_errors.FriendlyErrorForMetalGo(err, deleteResp) + resp.Diagnostics.AddError( + fmt.Sprintf("Failed to delete Project %s", id), + err.Error(), + ) + } } diff --git a/internal/resources/metal/project/resource_schema.go b/internal/resources/metal/project/resource_schema.go new file mode 100644 index 000000000..f9c3ecd44 --- /dev/null +++ b/internal/resources/metal/project/resource_schema.go @@ -0,0 +1,124 @@ +package project + +import ( + "context" + + "github.com/equinix/terraform-provider-equinix/internal/framework" + fwtypes "github.com/equinix/terraform-provider-equinix/internal/framework/types" + equinix_planmodifiers "github.com/equinix/terraform-provider-equinix/internal/planmodifiers" + equinix_validation "github.com/equinix/terraform-provider-equinix/internal/validation" + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "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/int64planmodifier" + "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" +) + +func resourceSchema(ctx context.Context) schema.Schema { + return schema.Schema{ + Attributes: map[string]schema.Attribute{ + "id": framework.IDAttributeDefaultDescription(), + "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, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "updated": schema.StringAttribute{ + Description: "The timestamp for the last time the project was updated", + Computed: true, + }, + "backend_transfer": schema.BoolAttribute{ + MarkdownDescription: "Enable or disable [Backend Transfer](https://metal.equinix.com/developers/docs/networking/backend-transfer/), default is false", + Description: "Enable or disable Backend Transfer, default is false", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + }, + "payment_method_id": schema.StringAttribute{ + Description: "The UUID of payment method for this project. The payment method and the project need to belong to the same organization (passed with organization_id, or default)", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + equinix_validation.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(), + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + equinix_validation.UUID(), + }, + }, + }, + Blocks: map[string]schema.Block{ + "bgp_config": schema.ListNestedBlock{ + Description: "Address information block", + Validators: []validator.List{ + listvalidator.SizeAtLeast(1), + listvalidator.SizeAtMost(1), + }, + PlanModifiers: []planmodifier.List{ + equinix_planmodifiers.ImmutableList(), + }, + CustomType: fwtypes.NewListNestedObjectTypeOf[BGPConfigModel](ctx), + NestedObject: schema.NestedBlockObject{ + Attributes: bgpConfigSchema, + }, + }, + }, + } +} + +var bgpConfigSchema = map[string]schema.Attribute{ + "deployment_type": schema.StringAttribute{ + MarkdownDescription: "The BGP deployment type, either 'local' or 'global'. The local is likely to be usable immediately, the global will need to be review by Equinix Metal engineers", + 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, + PlanModifiers: []planmodifier.Int64{ + equinix_planmodifiers.ImmutableInt64(), + }, + }, + "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, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "max_prefix": schema.Int64Attribute{ + Description: "The maximum number of route filters allowed per server", + Computed: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.UseStateForUnknown(), + }, + }, +} diff --git a/internal/resources/metal/project/resource_test.go b/internal/resources/metal/project/resource_test.go index 8910f4a79..69e961678 100644 --- a/internal/resources/metal/project/resource_test.go +++ b/internal/resources/metal/project/resource_test.go @@ -8,20 +8,25 @@ import ( "regexp" "testing" + "github.com/equinix/equinix-sdk-go/services/metalv1" "github.com/equinix/terraform-provider-equinix/equinix" "github.com/equinix/terraform-provider-equinix/internal/acceptance" "github.com/equinix/terraform-provider-equinix/internal/config" + "github.com/equinix/terraform-provider-equinix/internal/provider" - "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "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" + + "github.com/hashicorp/terraform-plugin-framework/providerserver" + "github.com/hashicorp/terraform-plugin-go/tfprotov5" + "github.com/hashicorp/terraform-plugin-mux/tf5muxserver" ) func TestAccMetalProject_basic(t *testing.T) { - var project packngo.Project + var project metalv1.Project rInt := acctest.RandInt() resource.ParallelTest(t, resource.TestCase{ @@ -45,31 +50,19 @@ func TestAccMetalProject_basic(t *testing.T) { // TODO(displague) How do we test this without TF_ACC set? func TestAccMetalProject_errorHandling(t *testing.T) { rInt := acctest.RandInt() - handler := func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnprocessableEntity) } mockAPI := httptest.NewServer(http.HandlerFunc(handler)) - mockEquinix := equinix.Provider() - mockEquinix.ConfigureContextFunc = func(ctx context.Context, d *schema.ResourceData) (interface{}, diag.Diagnostics) { - config := config.Config{ - BaseURL: mockAPI.URL, - Token: "fake-for-mock-test", - AuthToken: "fake-for-mock-test", - } - err := config.Load(ctx) - return &config, diag.FromErr(err) - } + providerConfig := testAccMetalProviderConfig(mockAPI.URL, "fake-for-mock-test", "fake-for-mock-test") + projectConfig := testAccMetalProjectConfig_basic(rInt) - mockProviders := map[string]*schema.Provider{ - "equinix": mockEquinix, - } resource.ParallelTest(t, resource.TestCase{ - Providers: mockProviders, + ProtoV5ProviderFactories: mockProviderFactories(), Steps: []resource.TestStep{ { - Config: testAccMetalProjectConfig_basic(rInt), - ExpectError: regexp.MustCompile(`Error: HTTP 422`), + Config: providerConfig + "\n" + projectConfig, + ExpectError: regexp.MustCompile(`\bCould not create project: HTTP 422\b`), }, }, }) @@ -78,40 +71,52 @@ func TestAccMetalProject_errorHandling(t *testing.T) { // TODO(displague) How do we test this without TF_ACC set? func TestAccMetalProject_apiErrorHandling(t *testing.T) { rInt := acctest.RandInt() - handler := func(w http.ResponseWriter, r *http.Request) { w.Header().Add("Content-Type", "application/json") w.Header().Add("X-Request-Id", "needed for equinix_errors.FriendlyError") w.WriteHeader(http.StatusUnprocessableEntity) } mockAPI := httptest.NewServer(http.HandlerFunc(handler)) - mockEquinix := equinix.Provider() - mockEquinix.ConfigureContextFunc = func(ctx context.Context, d *schema.ResourceData) (interface{}, diag.Diagnostics) { - config := config.Config{ - BaseURL: mockAPI.URL, - Token: "fake-for-mock-test", - AuthToken: "fake-for-mock-test", - } - err := config.Load(ctx) - return &config, diag.FromErr(err) - } + providerConfig := testAccMetalProviderConfig(mockAPI.URL, "fake-for-mock-test", "fake-for-mock-test") + projectConfig := testAccMetalProjectConfig_basic(rInt) - mockProviders := map[string]*schema.Provider{ - "equinix": mockEquinix, - } resource.ParallelTest(t, resource.TestCase{ - Providers: mockProviders, + ProtoV5ProviderFactories: mockProviderFactories(), Steps: []resource.TestStep{ { - Config: testAccMetalProjectConfig_basic(rInt), - ExpectError: regexp.MustCompile(`Error: API Error HTTP 422`), + Config: providerConfig + "\n" + projectConfig, + ExpectError: regexp.MustCompile(`\bCould not create project: API Error HTTP 422\b`), }, }, }) } +func mockProviderFactories() map[string]func() (tfprotov5.ProviderServer, error) { + mockProviders := map[string]*schema.Provider{ + "equinix": equinix.Provider(), + } + mockFrameworkProvider := provider.CreateFrameworkProvider("version") + mockProviderFactories := map[string]func() (tfprotov5.ProviderServer, error){ + "equinix": func() (tfprotov5.ProviderServer, error) { + ctx := context.Background() + providers := []func() tfprotov5.ProviderServer{ + mockProviders["equinix"].GRPCProvider, + providerserver.NewProtocol5( + mockFrameworkProvider, + ), + } + muxServer, err := tf5muxserver.NewMuxServer(ctx, providers...) + if err != nil { + return nil, err + } + return muxServer.ProviderServer(), nil + }, + } + return mockProviderFactories +} + func TestAccMetalProject_BGPBasic(t *testing.T) { - var project packngo.Project + var project metalv1.Project rInt := acctest.RandInt() resource.ParallelTest(t, resource.TestCase{ @@ -139,7 +144,7 @@ func TestAccMetalProject_BGPBasic(t *testing.T) { } func TestAccMetalProject_backendTransferUpdate(t *testing.T) { - var project packngo.Project + var project metalv1.Project rInt := acctest.RandInt() resource.ParallelTest(t, resource.TestCase{ @@ -182,7 +187,7 @@ func TestAccMetalProject_backendTransferUpdate(t *testing.T) { } func TestAccMetalProject_update(t *testing.T) { - var project packngo.Project + var project metalv1.Project rInt := acctest.RandInt() resource.ParallelTest(t, resource.TestCase{ @@ -211,17 +216,17 @@ func TestAccMetalProject_update(t *testing.T) { }) } -func testAccCheckMetalSameProject(t *testing.T, before, after *packngo.Project) resource.TestCheckFunc { +func testAccCheckMetalSameProject(t *testing.T, before, after *metalv1.Project) resource.TestCheckFunc { return func(s *terraform.State) error { - if before.ID != after.ID { - t.Fatalf("Expected device to be the same, but it was recreated: %s -> %s", before.ID, after.ID) + if before.GetId() != after.GetId() { + t.Fatalf("Expected project to be the same, but it was recreated: %s -> %s", before.GetId(), after.GetId()) } return nil } } func TestAccMetalProject_BGPUpdate(t *testing.T) { - var p1, p2, p3 packngo.Project + var p1, p2, p3 metalv1.Project rInt := acctest.RandInt() res := "equinix_metal_project.foobar" @@ -283,7 +288,7 @@ func testAccMetalProjectCheckDestroyed(s *terraform.State) error { return nil } -func testAccMetalProjectExists(n string, project *packngo.Project) resource.TestCheckFunc { +func testAccMetalProjectExists(n string, project *metalv1.Project) resource.TestCheckFunc { return func(s *terraform.State) error { rs, ok := s.RootModule().Resources[n] if !ok { @@ -293,13 +298,13 @@ func testAccMetalProjectExists(n string, project *packngo.Project) resource.Test return fmt.Errorf("No Record ID is set") } - client := acceptance.TestAccProvider.Meta().(*config.Config).Metal + client := acceptance.TestAccProvider.Meta().(*config.Config).NewMetalClientForTesting() - foundProject, _, err := client.Projects.Get(rs.Primary.ID, nil) + foundProject, _, err := client.ProjectsApi.FindProjectById(context.Background(), rs.Primary.ID).Execute() if err != nil { return err } - if foundProject.ID != rs.Primary.ID { + if foundProject.GetId() != rs.Primary.ID { return fmt.Errorf("Record not found: %v - %v", rs.Primary.ID, foundProject) } @@ -309,6 +314,20 @@ func testAccMetalProjectExists(n string, project *packngo.Project) resource.Test } } +func testAccMetalProviderConfig( + endpoint string, + token string, + authToken string, +) string { + return fmt.Sprintf(` +provider "equinix" { + endpoint = "%s" + token = "%s" + auth_token = "%s" +} +`, endpoint, token, authToken) +} + func testAccMetalProjectConfig_BT(r int) string { return fmt.Sprintf(` resource "equinix_metal_project" "foobar" { @@ -355,7 +374,7 @@ resource "equinix_metal_project" "foobar" { } func TestAccMetalProject_organization(t *testing.T) { - var project packngo.Project + var project metalv1.Project rn := acctest.RandStringFromCharSet(12, "abcdef0123456789") resource.ParallelTest(t, resource.TestCase{ @@ -401,3 +420,40 @@ func TestAccMetalProject_importBasic(t *testing.T) { }, }) } + +// 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 TestAccMetalProject_basic_upgradeFromVersion(t *testing.T) { + var project metalv1.Project + rInt := acctest.RandInt() + cfg := testAccMetalProjectConfig_basic(rInt) + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acceptance.TestAccPreCheckMetal(t); acceptance.TestAccPreCheckProviderConfigured(t) }, + CheckDestroy: testAccMetalProjectCheckDestroyed, + Steps: []resource.TestStep{ + { + ExternalProviders: map[string]resource.ExternalProvider{ + "equinix": { + VersionConstraint: "1.30.0", // latest version with resource defined on SDKv2 + Source: "equinix/equinix", + }, + }, + Config: cfg, + Check: resource.ComposeTestCheckFunc( + testAccMetalProjectExists("equinix_metal_project.foobar", &project), + resource.TestCheckResourceAttr( + "equinix_metal_project.foobar", "name", fmt.Sprintf("tfacc-project-%d", rInt)), + ), + }, + { + ProtoV5ProviderFactories: acceptance.ProtoV5ProviderFactories, + Config: cfg, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectEmptyPlan(), + }, + }, + }, + }, + }) +} diff --git a/internal/validation/uuid.go b/internal/validation/uuid.go new file mode 100644 index 000000000..518a332fd --- /dev/null +++ b/internal/validation/uuid.go @@ -0,0 +1,52 @@ +package validation + +import ( + "context" + + "github.com/hashicorp/go-uuid" + "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// uuidValidator validates that a string Attribute's value is valid UUID. +type uuidValidator struct{} + +// Description describes the validation in plain text formatting. +func (validator uuidValidator) Description(_ context.Context) string { + return "value must be valid UUID" +} + +// MarkdownDescription describes the validation in Markdown formatting. +func (validator uuidValidator) MarkdownDescription(ctx context.Context) string { + return validator.Description(ctx) +} + +// Validate performs the validation. +func (validator uuidValidator) ValidateString(ctx context.Context, request validator.StringRequest, response *validator.StringResponse) { + configValue := request.ConfigValue + + if configValue.IsNull() || configValue.IsUnknown() { + return + } + + valueString := configValue.ValueString() + + if _, err := uuid.ParseUUID(valueString); err != nil { + response.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic( + request.Path, + validator.Description(ctx), + valueString, + )) + return + } +} + +// UUID returns a string validator which ensures that any configured +// attribute value: +// +// - Is a string, which represents valid UUID. +// +// Null (unconfigured) and unknown (known after apply) values are skipped. +func UUID() validator.String { + return uuidValidator{} +}