diff --git a/go.mod b/go.mod index a9f01e4e..086c59f4 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,7 @@ require ( github.com/stackitcloud/stackit-sdk-go/services/argus v0.11.0 github.com/stackitcloud/stackit-sdk-go/services/dns v0.12.0 github.com/stackitcloud/stackit-sdk-go/services/iaas v0.16.0 - github.com/stackitcloud/stackit-sdk-go/services/iaasalpha v0.1.12-alpha + github.com/stackitcloud/stackit-sdk-go/services/iaasalpha v0.1.13-alpha github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v0.17.0 github.com/stackitcloud/stackit-sdk-go/services/logme v0.20.0 github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.20.0 diff --git a/go.sum b/go.sum index bbbc4aa4..4c5ee092 100644 --- a/go.sum +++ b/go.sum @@ -157,8 +157,8 @@ github.com/stackitcloud/stackit-sdk-go/services/dns v0.12.0 h1:NypnmRbvjCX7ANJej github.com/stackitcloud/stackit-sdk-go/services/dns v0.12.0/go.mod h1:mv8U7kuclXo+0VpDHtBCkve/3i9h1yT+RAId/MUi+C8= github.com/stackitcloud/stackit-sdk-go/services/iaas v0.16.0 h1:geyW780gqNxzSsPvmlxy3kUUJaRA4eiF9V3b2Ibcdjs= github.com/stackitcloud/stackit-sdk-go/services/iaas v0.16.0/go.mod h1:YfuN+eXuqr846xeRyW2Vf1JM2jU0ikeQa76dDI66RsM= -github.com/stackitcloud/stackit-sdk-go/services/iaasalpha v0.1.12-alpha h1:jwpif4t2gthmKmCXsQ84rmtDdcZkw4QQTFiCd7nTW8M= -github.com/stackitcloud/stackit-sdk-go/services/iaasalpha v0.1.12-alpha/go.mod h1:nW/6vvumUHA7o1/JOOqsrEOBNrRHombEKB1U4jmg2wU= +github.com/stackitcloud/stackit-sdk-go/services/iaasalpha v0.1.13-alpha h1:s+Ew1kJSZhvOrN7OwJ9uKlPtzt8xR+Aqi9bZfdT+4CM= +github.com/stackitcloud/stackit-sdk-go/services/iaasalpha v0.1.13-alpha/go.mod h1:nW/6vvumUHA7o1/JOOqsrEOBNrRHombEKB1U4jmg2wU= github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v0.17.0 h1:06CGP64CEk3Zg6i9kZCMRdmCzLLiyMWQqGK1teBr9Oc= github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v0.17.0/go.mod h1:JL94zc8K0ebWs+DBGXR28vNCF0EFV54ZLUtrlXOvWgA= github.com/stackitcloud/stackit-sdk-go/services/logme v0.20.0 h1:V0UGP7JEa4Q8SsZFUJsKgLGaoPruLn2KVKnqQtaoWCU= diff --git a/stackit/internal/services/iaas/iaas_acc_test.go b/stackit/internal/services/iaas/iaas_acc_test.go index bef29e09..3b40aabf 100644 --- a/stackit/internal/services/iaas/iaas_acc_test.go +++ b/stackit/internal/services/iaas/iaas_acc_test.go @@ -12,7 +12,6 @@ import ( "github.com/stackitcloud/stackit-sdk-go/core/config" "github.com/stackitcloud/stackit-sdk-go/core/utils" "github.com/stackitcloud/stackit-sdk-go/services/iaas" - "github.com/stackitcloud/stackit-sdk-go/services/iaasalpha" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/testutil" ) @@ -53,7 +52,7 @@ var networkAreaRouteResource = map[string]string{ var networkInterfaceResource = map[string]string{ "project_id": testutil.ProjectId, "network_id": networkResource["network_id"], - "name": "name", + "name": fmt.Sprintf("tf-acc-%s", acctest.RandStringFromCharSet(5, acctest.CharSetAlpha)), } // Volume resource data @@ -110,6 +109,14 @@ var keyPairResource = map[string]string{ "label1-updated": "value1-updated", } +// Virtual IP resource data +var virtualIpResource = map[string]string{ + "project_id": testutil.ProjectId, + "name": fmt.Sprintf("acc-test-%s", acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum)), + "label1": "value1", + "label1-updated": "value1-updated", +} + func networkResourceConfig(name, nameservers string) string { return fmt.Sprintf(` resource "stackit_network" "network" { @@ -304,6 +311,37 @@ func serviceAccountAttachmentResourceConfig() string { ) } +func virtualIPResourceConfig(labelValue string) string { + // Network resource name must match the one from "networkResourceConfig" + return fmt.Sprintf(` + resource "stackit_virtual_ip" "virtual_ip" { + project_id = "%s" + network_id = stackit_network.network.network_id + name = "%s" + labels = { + "label1" = "%s" + } + } + `, + testutil.ProjectId, + virtualIpResource["name"], + labelValue, + ) +} + +func virtualIPMemberResourceConfig() string { + return fmt.Sprintf(` + resource "stackit_virtual_ip_member" "virtual_ip_member" { + project_id = "%s" + network_id = stackit_network.network.network_id + virtual_ip_id = stackit_virtual_ip.virtual_ip.virtual_ip_id + network_interface_id = stackit_network_interface.network_interface.network_interface_id + } + `, + testutil.ProjectId, + ) +} + func testAccNetworkAreaConfig(areaname, networkranges, routeLabelValue string) string { return fmt.Sprintf("%s\n\n%s\n\n%s", testutil.IaaSProviderConfig(), @@ -354,6 +392,16 @@ func testAccKeyPairConfig(keyPairResourceConfig string) string { ) } +func testAccVirtualIPConfig(labelValue string) string { + return fmt.Sprintf("%s\n\n%s\n\n%s\n\n%s\n\n%s", + testutil.IaaSProviderConfig(), + networkResourceConfig(networkResource["name"], fmt.Sprintf(`["%s", "%s"]`, networkResource["nameserver0"], networkResource["nameserver1"])), + networkInterfaceResourceConfig(networkInterfaceResource["name"]), + virtualIPResourceConfig(labelValue), + virtualIPMemberResourceConfig(), + ) +} + func TestAccNetworkArea(t *testing.T) { resource.Test(t, resource.TestCase{ ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, @@ -1282,6 +1330,105 @@ func TestAccKeyPair(t *testing.T) { }) } +func TestAccVirtualIP(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + CheckDestroy: testAccCheckIaaSNetworkDestroy, // Virtual IP is deleted with the network + Steps: []resource.TestStep{ + + // Creation + { + Config: testAccVirtualIPConfig(virtualIpResource["label1"]), + Check: resource.ComposeAggregateTestCheckFunc( + // Virtual IP + resource.TestCheckResourceAttr("stackit_virtual_ip.virtual_ip", "project_id", virtualIpResource["project_id"]), + resource.TestCheckResourceAttr("stackit_virtual_ip.virtual_ip", "labels.label1", virtualIpResource["label1"]), + resource.TestCheckResourceAttr("stackit_virtual_ip.virtual_ip", "name", virtualIpResource["name"]), + resource.TestCheckResourceAttrSet("stackit_virtual_ip.virtual_ip", "virtual_ip_id"), + resource.TestCheckResourceAttrSet("stackit_virtual_ip.virtual_ip", "ip"), + resource.TestCheckResourceAttrPair("stackit_virtual_ip.virtual_ip", "network_id", "stackit_network.network", "network_id"), + + // Virtual IP member + resource.TestCheckResourceAttrPair( + "stackit_virtual_ip_member.virtual_ip_member", "project_id", + "stackit_virtual_ip.virtual_ip", "project_id", + ), + resource.TestCheckResourceAttrPair( + "stackit_virtual_ip_member.virtual_ip_member", "network_id", + "stackit_virtual_ip.virtual_ip", "network_id", + ), + resource.TestCheckResourceAttrPair( + "stackit_virtual_ip_member.virtual_ip_member", "virtual_ip_id", + "stackit_virtual_ip.virtual_ip", "virtual_ip_id", + ), + resource.TestCheckResourceAttrPair( + "stackit_virtual_ip_member.virtual_ip_member", "network_interface_id", + "stackit_network_interface.network_interface", "network_interface_id", + ), + ), + }, + // Data source + { + Config: fmt.Sprintf(` + %s + + data "stackit_virtual_ip" "virtual_ip" { + project_id = stackit_virtual_ip.virtual_ip.project_id + network_id = stackit_virtual_ip.virtual_ip.network_id + virtual_ip_id = stackit_virtual_ip.virtual_ip.virtual_ip_id + } + `, + testAccVirtualIPConfig(virtualIpResource["label1"]), + ), + Check: resource.ComposeAggregateTestCheckFunc( + // Instance + resource.TestCheckResourceAttr("data.stackit_virtual_ip.virtual_ip", "project_id", virtualIpResource["project_id"]), + resource.TestCheckResourceAttrPair( + "stackit_virtual_ip.virtual_ip", "virtual_ip_id", + "data.stackit_virtual_ip.virtual_ip", "virtual_ip_id", + ), + resource.TestCheckResourceAttr("stackit_virtual_ip.virtual_ip", "labels.label1", virtualIpResource["label1"]), + resource.TestCheckResourceAttr("stackit_virtual_ip.virtual_ip", "name", virtualIpResource["name"]), + resource.TestCheckResourceAttrPair("stackit_virtual_ip.virtual_ip", "ip", "data.stackit_virtual_ip.virtual_ip", "ip"), + resource.TestCheckResourceAttrPair("stackit_virtual_ip.virtual_ip", "network_id", "data.stackit_virtual_ip.virtual_ip", "network_id"), + ), + }, + // Import + { + ResourceName: "stackit_virtual_ip.virtual_ip", + ImportStateIdFunc: func(s *terraform.State) (string, error) { + r, ok := s.RootModule().Resources["stackit_virtual_ip.virtual_ip"] + if !ok { + return "", fmt.Errorf("couldn't find resource stackit_virtual_ip.virtual_ip") + } + networkId, ok := r.Primary.Attributes["network_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute network_id") + } + virtualIpId, ok := r.Primary.Attributes["virtual_ip_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute virtual_ip_id") + } + return fmt.Sprintf("%s,%s,%s", testutil.ProjectId, networkId, virtualIpId), nil + }, + ImportState: true, + ImportStateVerify: true, + }, + // Update + { + Config: testAccVirtualIPConfig(virtualIpResource["label1-updated"]), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_virtual_ip.virtual_ip", "project_id", virtualIpResource["project_id"]), + resource.TestCheckResourceAttr("stackit_virtual_ip.virtual_ip", "labels.label1", virtualIpResource["label1-updated"]), + resource.TestCheckResourceAttr("stackit_virtual_ip.virtual_ip", "name", virtualIpResource["name"]), + resource.TestCheckResourceAttrSet("stackit_virtual_ip.virtual_ip", "virtual_ip_id"), + ), + }, + // Deletion is done by the framework implicitly + }, + }) +} + func testAccCheckNetworkAreaDestroy(s *terraform.State) error { ctx := context.Background() var client *iaas.APIClient @@ -1378,27 +1525,19 @@ func testAccCheckIaaSVolumeDestroy(s *terraform.State) error { func testAccCheckServerDestroy(s *terraform.State) error { ctx := context.Background() - var alphaClient *iaas.APIClient var client *iaas.APIClient var err error - var alphaErr error if testutil.IaaSCustomEndpoint == "" { - alphaClient, alphaErr = iaas.NewAPIClient( - config.WithRegion("eu01"), - ) client, err = iaas.NewAPIClient( config.WithRegion("eu01"), ) } else { - alphaClient, alphaErr = iaas.NewAPIClient( - config.WithEndpoint(testutil.IaaSCustomEndpoint), - ) client, err = iaas.NewAPIClient( config.WithRegion("eu01"), ) } - if err != nil || alphaErr != nil { - return fmt.Errorf("creating client: %w, %w", err, alphaErr) + if err != nil { + return fmt.Errorf("creating client: %w", err) } // Servers @@ -1413,7 +1552,7 @@ func testAccCheckServerDestroy(s *terraform.State) error { serversToDestroy = append(serversToDestroy, serverId) } - serversResp, err := alphaClient.ListServersExecute(ctx, testutil.ProjectId) + serversResp, err := client.ListServersExecute(ctx, testutil.ProjectId) if err != nil { return fmt.Errorf("getting serversResp: %w", err) } @@ -1424,7 +1563,7 @@ func testAccCheckServerDestroy(s *terraform.State) error { continue } if utils.Contains(serversToDestroy, *servers[i].Id) { - err := alphaClient.DeleteServerExecute(ctx, testutil.ProjectId, *servers[i].Id) + err := client.DeleteServerExecute(ctx, testutil.ProjectId, *servers[i].Id) if err != nil { return fmt.Errorf("destroying server %s during CheckDestroy: %w", *servers[i].Id, err) } @@ -1560,14 +1699,14 @@ func testAccCheckIaaSPublicIpDestroy(s *terraform.State) error { func testAccCheckIaaSKeyPairDestroy(s *terraform.State) error { ctx := context.Background() - var client *iaasalpha.APIClient + var client *iaas.APIClient var err error if testutil.IaaSCustomEndpoint == "" { - client, err = iaasalpha.NewAPIClient( + client, err = iaas.NewAPIClient( config.WithRegion("eu01"), ) } else { - client, err = iaasalpha.NewAPIClient( + client, err = iaas.NewAPIClient( config.WithEndpoint(testutil.IaaSCustomEndpoint), ) } @@ -1603,3 +1742,51 @@ func testAccCheckIaaSKeyPairDestroy(s *terraform.State) error { } return nil } + +func testAccCheckIaaSNetworkDestroy(s *terraform.State) error { + ctx := context.Background() + var client *iaas.APIClient + var err error + if testutil.IaaSCustomEndpoint == "" { + client, err = iaas.NewAPIClient( + config.WithRegion("eu01"), + ) + } else { + client, err = iaas.NewAPIClient( + config.WithEndpoint(testutil.IaaSCustomEndpoint), + ) + } + if err != nil { + return fmt.Errorf("creating client: %w", err) + } + + networksToDestroy := []string{} + for _, rs := range s.RootModule().Resources { + if rs.Type != "stackit_network" { + continue + } + // network terraform ID: "[project_id],[network_id]" + networkId := strings.Split(rs.Primary.ID, core.Separator)[1] + networksToDestroy = append(networksToDestroy, networkId) + } + + networksResp, err := client.ListNetworksExecute(ctx, testutil.ProjectId) + if err != nil { + return fmt.Errorf("getting networksResp: %w", err) + } + + networks := *networksResp.Items + for i := range networks { + if networks[i].NetworkId == nil { + continue + } + if utils.Contains(networksToDestroy, *networks[i].NetworkId) { + err := client.DeleteNetworkExecute(ctx, testutil.ProjectId, *networks[i].NetworkId) + if err != nil { + return fmt.Errorf("destroying network %s during CheckDestroy: %w", *networks[i].NetworkId, err) + } + } + } + + return nil +} diff --git a/stackit/internal/services/iaas/publicipassociate/resource.go b/stackit/internal/services/iaas/publicipassociate/resource.go index 84d1872c..f2737a59 100644 --- a/stackit/internal/services/iaas/publicipassociate/resource.go +++ b/stackit/internal/services/iaas/publicipassociate/resource.go @@ -99,7 +99,7 @@ func (r *publicIpAssociateResource) Configure(ctx context.Context, req resource. return } - core.LogAndAddWarning(ctx, &resp.Diagnostics, "The `stackit_public_ip_associate` resource should not be used together with the `stackit_public_ip` resource for the same public IP or for the same network interface.", + core.LogAndAddWarning(ctx, &resp.Diagnostics, `The "stackit_public_ip_associate" resource should not be used together with the "stackit_public_ip" resource for the same public IP or for the same network interface.`, "Using both resources together for the same public IP or network interface WILL lead to conflicts, as they both have control of the public IP and network interface association.") r.client = apiClient @@ -112,7 +112,7 @@ func (r *publicIpAssociateResource) Schema(_ context.Context, _ resource.SchemaR "main": "Associates an existing public IP to a network interface. " + "This is useful for situations where you have a pre-allocated public IP or unable to use the `stackit_public_ip` resource to create a new public IP. " + "Must have a `region` specified in the provider configuration.", - "warning_message": "The `stackit_public_ip_associate` resource should not be used together with the `stackit_public_ip` resource for the same public IP or for the same network interface. \n" + + "warning_message": `The "stackit_public_ip_associate" resource should not be used together with the "stackit_public_ip" resource for the same public IP or for the same network interface. \n` + "Using both resources together for the same public IP or network interface WILL lead to conflicts, as they both have control of the public IP and network interface association.", } resp.Schema = schema.Schema{ diff --git a/stackit/internal/services/iaas/virtualip/datasource.go b/stackit/internal/services/iaas/virtualip/datasource.go new file mode 100644 index 00000000..21c36a32 --- /dev/null +++ b/stackit/internal/services/iaas/virtualip/datasource.go @@ -0,0 +1,177 @@ +package virtualip + +import ( + "context" + "fmt" + "net/http" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + "github.com/stackitcloud/stackit-sdk-go/services/iaasalpha" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" +) + +// virtualIPDataSourceBetaCheckDone is used to prevent multiple checks for beta resources. +// This is a workaround for the lack of a global state in the provider and +// needs to exist because the Configure method is called twice. +var virtualIPDataSourceBetaCheckDone bool + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ datasource.DataSource = &virtualIPDataSource{} +) + +// NewVirtualIPDataSource is a helper function to simplify the provider implementation. +func NewVirtualIPDataSource() datasource.DataSource { + return &virtualIPDataSource{} +} + +// networkDataSource is the data source implementation. +type virtualIPDataSource struct { + client *iaasalpha.APIClient +} + +// Metadata returns the data source type name. +func (d *virtualIPDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_virtual_ip" +} + +func (d *virtualIPDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + var apiClient *iaasalpha.APIClient + var err error + + providerData, ok := req.ProviderData.(core.ProviderData) + if !ok { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Expected configure type stackit.ProviderData, got %T", req.ProviderData)) + return + } + + if !virtualIPDataSourceBetaCheckDone { + features.CheckBetaResourcesEnabled(ctx, &providerData, &resp.Diagnostics, "stackit_virtual_ip", "data source") + if resp.Diagnostics.HasError() { + return + } + virtualIPDataSourceBetaCheckDone = true + } + + if providerData.IaaSCustomEndpoint != "" { + apiClient, err = iaasalpha.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithEndpoint(providerData.IaaSCustomEndpoint), + ) + } else { + apiClient, err = iaasalpha.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithRegion(providerData.Region), + ) + } + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Configuring client: %v. This is an error related to the provider configuration, not to the data source configuration", err)) + return + } + + d.client = apiClient + tflog.Info(ctx, "IaaS client configured") +} + +// Schema defines the schema for the data source. +func (d *virtualIPDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Virtual IP data resource schema. Must have a `region` specified in the provider configuration.", + MarkdownDescription: features.AddBetaDescription("Virtual IP data source schema. Must have a `region` specified in the provider configuration."), + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "Terraform's internal data source ID. It is structured as \"`project_id`,`network_id`,`virtual_ip_id`\".", + Computed: true, + }, + "project_id": schema.StringAttribute{ + Description: "STACKIT organization ID to which the network area is associated.", + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "network_id": schema.StringAttribute{ + Description: "The network area ID to which the virtual IP is associated.", + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "virtual_ip_id": schema.StringAttribute{ + Description: "The virtual IP ID.", + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "name": schema.StringAttribute{ + Description: "The name of virtual IP.", + Computed: true, + }, + "ip": schema.StringAttribute{ + Description: "The IP address of the virtual IP.", + Computed: true, + }, + "labels": schema.MapAttribute{ + Description: "Labels are key-value string pairs which can be attached to a resource container", + ElementType: types.StringType, + Computed: true, + }, + }, + } +} + +// Read refreshes the Terraform state with the latest data. +func (d *virtualIPDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform + var model Model + diags := req.Config.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + projectId := model.ProjectId.ValueString() + networkId := model.NetworkId.ValueString() + virtualIpId := model.VirtualIpId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "network_id", networkId) + ctx = tflog.SetField(ctx, "virtual_ip_id", virtualIpId) + + virtualIpResp, err := d.client.GetVirtualIP(ctx, projectId, networkId, virtualIpId).Execute() + if err != nil { + oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped + if ok && oapiErr.StatusCode == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading virtual IP", fmt.Sprintf("Calling API: %v", err)) + return + } + + err = mapFields(ctx, virtualIpResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading virtual IP", fmt.Sprintf("Processing API payload: %v", err)) + return + } + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Virtual IP read") +} diff --git a/stackit/internal/services/iaas/virtualip/resource.go b/stackit/internal/services/iaas/virtualip/resource.go new file mode 100644 index 00000000..b7094f0d --- /dev/null +++ b/stackit/internal/services/iaas/virtualip/resource.go @@ -0,0 +1,489 @@ +package virtualip + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + "github.com/stackitcloud/stackit-sdk-go/core/runtime" + "github.com/stackitcloud/stackit-sdk-go/services/iaasalpha" + "github.com/stackitcloud/stackit-sdk-go/services/iaasalpha/wait" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" +) + +// resourceBetaCheckDone is used to prevent multiple checks for beta resources. +// This is a workaround for the lack of a global state in the provider and +// needs to exist because the Configure method is called twice. +var resourceBetaCheckDone bool + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ resource.Resource = &virtualIPResource{} + _ resource.ResourceWithConfigure = &virtualIPResource{} + _ resource.ResourceWithImportState = &virtualIPResource{} +) + +type Model struct { + Id types.String `tfsdk:"id"` // needed by TF + ProjectId types.String `tfsdk:"project_id"` + NetworkId types.String `tfsdk:"network_id"` + VirtualIpId types.String `tfsdk:"virtual_ip_id"` + Name types.String `tfsdk:"name"` + IP types.String `tfsdk:"ip"` + Labels types.Map `tfsdk:"labels"` +} + +// NewVirtualIPResource is a helper function to simplify the provider implementation. +func NewVirtualIPResource() resource.Resource { + return &virtualIPResource{} +} + +// networkResource is the resource implementation. +type virtualIPResource struct { + client *iaasalpha.APIClient +} + +// Metadata returns the resource type name. +func (r *virtualIPResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_virtual_ip" +} + +// Configure adds the provider configured client to the resource. +func (r *virtualIPResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + providerData, ok := req.ProviderData.(core.ProviderData) + if !ok { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Expected configure type stackit.ProviderData, got %T", req.ProviderData)) + return + } + + if !resourceBetaCheckDone { + features.CheckBetaResourcesEnabled(ctx, &providerData, &resp.Diagnostics, "stackit_virtual_ip", "resource") + if resp.Diagnostics.HasError() { + return + } + resourceBetaCheckDone = true + } + + var apiClient *iaasalpha.APIClient + var err error + if providerData.IaaSCustomEndpoint != "" { + ctx = tflog.SetField(ctx, "iaas_custom_endpoint", providerData.IaaSCustomEndpoint) + apiClient, err = iaasalpha.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithEndpoint(providerData.IaaSCustomEndpoint), + ) + } else { + apiClient, err = iaasalpha.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithRegion(providerData.Region), + ) + } + + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Configuring client: %v. This is an error related to the provider configuration, not to the resource configuration", err)) + return + } + + r.client = apiClient + tflog.Info(ctx, "IaaS client configured") +} + +// Schema defines the schema for the resource. +func (r *virtualIPResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Virtual IP resource schema. Must have a `region` specified in the provider configuration.", + MarkdownDescription: features.AddBetaDescription("Virtual IP resource schema. Must have a `region` specified in the provider configuration."), + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "Terraform's internal resource ID. It is structured as \"`project_id`,`network_id`,`virtual_ip_id`\".", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "project_id": schema.StringAttribute{ + Description: "STACKIT project ID to which the virtual IP is associated.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "network_id": schema.StringAttribute{ + Description: "The network ID to which the virtual IP is associated.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "virtual_ip_id": schema.StringAttribute{ + Description: "The virtual IP ID.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "name": schema.StringAttribute{ + Description: "The name of virtual IP.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "ip": schema.StringAttribute{ + Description: "The IP address of the virtual IP.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.IP(), + }, + }, + "labels": schema.MapAttribute{ + Description: "Labels are key-value string pairs which can be attached to a resource container", + ElementType: types.StringType, + Optional: true, + }, + }, + } +} + +// Create creates the resource and sets the initial Terraform state. +func (r *virtualIPResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform + // Retrieve values from plan + var model Model + diags := req.Plan.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + projectId := model.ProjectId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + networkId := model.NetworkId.ValueString() + ctx = tflog.SetField(ctx, "network_id", networkId) + + // Generate API request body from model + payload, err := toCreatePayload(ctx, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating virtual IP", fmt.Sprintf("Creating API payload: %v", err)) + return + } + + // Create new virtual IP + virtualIp, err := r.client.CreateVirtualIP(ctx, projectId, networkId).CreateVirtualIPPayload(*payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating virtual IP", fmt.Sprintf("Calling API: %v", err)) + return + } + if virtualIp == nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating virtual IP.", "Empty response from API") + return + } + + _, err = wait.CreateVirtualIPWaitHandler(ctx, r.client, projectId, networkId, *virtualIp.Id).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting virtual IP", fmt.Sprintf("Waiting for deletion: %v", err)) + return + } + + ctx = tflog.SetField(ctx, "virtual_ip_id", virtualIp.Id) + + // Map response body to schema + err = mapFields(ctx, virtualIp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating virtual IP.", fmt.Sprintf("Processing API payload: %v", err)) + return + } + // Set state to fully populated data + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Virtual IP created") +} + +// Read refreshes the Terraform state with the latest data. +func (r *virtualIPResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform + var model Model + diags := req.State.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + projectId := model.ProjectId.ValueString() + networkId := model.NetworkId.ValueString() + virtualIpId := model.VirtualIpId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "network_id", networkId) + ctx = tflog.SetField(ctx, "virtual_ip_id", virtualIpId) + + virtualIp, err := r.client.GetVirtualIP(ctx, projectId, networkId, virtualIpId).Execute() + if err != nil { + oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped + if ok && oapiErr.StatusCode == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading virtual IP.", fmt.Sprintf("Calling API: %v", err)) + return + } + + // Map response body to schema + err = mapFields(ctx, virtualIp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading virtual IP", fmt.Sprintf("Processing API payload: %v", err)) + return + } + // Set refreshed state + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Virtual IP read") +} + +// Delete deletes the resource and removes the Terraform state on success. +func (r *virtualIPResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform + // Retrieve values from state + var model Model + diags := req.State.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + projectId := model.ProjectId.ValueString() + networkId := model.NetworkId.ValueString() + virtualIpId := model.VirtualIpId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "network_id", networkId) + ctx = tflog.SetField(ctx, "virtual_ip_id", virtualIpId) + + // Delete existing virtual IP + err := r.client.DeleteVirtualIP(ctx, projectId, networkId, virtualIpId).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting virtual IP", fmt.Sprintf("Calling API: %v", err)) + return + } + + _, err = wait.DeleteVirtualIPWaitHandler(ctx, r.client, projectId, networkId, virtualIpId).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting virtual IP", fmt.Sprintf("Waiting for deletion: %v", err)) + return + } + + tflog.Info(ctx, "Virtual IP deleted") +} + +// Update updates the resource and sets the updated Terraform state on success. +func (r *virtualIPResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform + // Retrieve values from plan + var model Model + diags := req.Plan.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + projectId := model.ProjectId.ValueString() + networkAreaId := model.NetworkId.ValueString() + networkAreaRouteId := model.VirtualIpId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "network_id", networkAreaId) + ctx = tflog.SetField(ctx, "virtual_ip_id", networkAreaRouteId) + + // Retrieve values from state + var stateModel Model + diags = req.State.Get(ctx, &stateModel) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Generate API request body from model + payload, err := toUpdatePayload(ctx, &model, stateModel.Labels) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating virtual IP", fmt.Sprintf("Creating API payload: %v", err)) + return + } + // Update existing virtual IP + var httpResp *http.Response + ctxWithHTTPResp := runtime.WithCaptureHTTPResponse(ctx, &httpResp) + err = r.client.UpdateVirtualIP(ctxWithHTTPResp, projectId, networkAreaId, networkAreaRouteId).UpdateVirtualIPPayload(*payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating virtual IP", fmt.Sprintf("Calling API: %v", err)) + return + } + requestId := httpResp.Header[wait.XRequestIDHeader][0] + _, err = wait.ProjectRequestWaitHandler(ctx, r.client, projectId, requestId).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating virtual IP", fmt.Sprintf("Waiting for update: %v", err)) + return + } + + // Get updated virtual IP + virtualIpResp, err := r.client.GetVirtualIP(ctx, projectId, networkAreaId, networkAreaRouteId).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating virtual IP", fmt.Sprintf("Calling API: %v", err)) + return + } + + err = mapFields(ctx, virtualIpResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating virtual IP", fmt.Sprintf("Processing API payload: %v", err)) + return + } + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Virtual IP updated") +} + +// ImportState imports a resource into the Terraform state on success. +// The expected format of the resource import identifier is: project_id,network_id,virtual_ip_id +func (r *virtualIPResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + idParts := strings.Split(req.ID, core.Separator) + + if len(idParts) != 3 || idParts[0] == "" || idParts[1] == "" || idParts[2] == "" { + core.LogAndAddError(ctx, &resp.Diagnostics, + "Error importing virtual IP", + fmt.Sprintf("Expected import identifier with format: [project_id],[network_id],[virtual_ip_id] Got: %q", req.ID), + ) + return + } + + projectId := idParts[0] + networkId := idParts[1] + virtualIpId := idParts[2] + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "network_id", networkId) + ctx = tflog.SetField(ctx, "virtual_ip_id", virtualIpId) + + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), projectId)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("network_id"), networkId)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("virtual_ip_id"), virtualIpId)...) + tflog.Info(ctx, "Virtual IP state imported") +} + +func mapFields(ctx context.Context, virtualIp *iaasalpha.VirtualIp, model *Model) error { + if virtualIp == nil { + return fmt.Errorf("response input is nil") + } + if model == nil { + return fmt.Errorf("model input is nil") + } + + var virtualIpId string + if model.VirtualIpId.ValueString() != "" { + virtualIpId = model.VirtualIpId.ValueString() + } else if virtualIp.Id != nil { + virtualIpId = *virtualIp.Id + } else { + return fmt.Errorf("virtual IP id not present") + } + + idParts := []string{ + model.ProjectId.ValueString(), + model.NetworkId.ValueString(), + virtualIpId, + } + model.Id = types.StringValue( + strings.Join(idParts, core.Separator), + ) + + labels, diags := types.MapValueFrom(ctx, types.StringType, map[string]interface{}{}) + if diags.HasError() { + return fmt.Errorf("converting labels to StringValue map: %w", core.DiagsToError(diags)) + } + if virtualIp.Labels != nil && len(*virtualIp.Labels) != 0 { + var diags diag.Diagnostics + labels, diags = types.MapValueFrom(ctx, types.StringType, *virtualIp.Labels) + if diags.HasError() { + return fmt.Errorf("converting labels to StringValue map: %w", core.DiagsToError(diags)) + } + } else if model.Labels.IsNull() { + labels = types.MapNull(types.StringType) + } + + model.VirtualIpId = types.StringValue(virtualIpId) + model.Name = types.StringPointerValue(virtualIp.Name) + model.IP = types.StringPointerValue(virtualIp.Ip) + model.Labels = labels + + return nil +} + +func toCreatePayload(ctx context.Context, model *Model) (*iaasalpha.CreateVirtualIPPayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + + labels, err := conversion.ToStringInterfaceMap(ctx, model.Labels) + if err != nil { + return nil, fmt.Errorf("converting to Go map: %w", err) + } + + return &iaasalpha.CreateVirtualIPPayload{ + Name: conversion.StringValueToPointer(model.Name), + Ip: conversion.StringValueToPointer(model.IP), + Labels: &labels, + }, nil +} + +func toUpdatePayload(ctx context.Context, model *Model, currentLabels types.Map) (*iaasalpha.UpdateVirtualIPPayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + + labels, err := conversion.ToJSONMapPartialUpdatePayload(ctx, currentLabels, model.Labels) + if err != nil { + return nil, fmt.Errorf("converting to Go map: %w", err) + } + + return &iaasalpha.UpdateVirtualIPPayload{ + Name: conversion.StringValueToPointer(model.Name), + Labels: &labels, + }, nil +} diff --git a/stackit/internal/services/iaas/virtualip/resource_test.go b/stackit/internal/services/iaas/virtualip/resource_test.go new file mode 100644 index 00000000..8bfc381d --- /dev/null +++ b/stackit/internal/services/iaas/virtualip/resource_test.go @@ -0,0 +1,204 @@ +package virtualip + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaasalpha" +) + +func TestMapFields(t *testing.T) { + tests := []struct { + description string + state Model + input *iaasalpha.VirtualIp + expected Model + isValid bool + }{ + { + "id_ok", + Model{ + ProjectId: types.StringValue("pid"), + NetworkId: types.StringValue("nid"), + VirtualIpId: types.StringValue("vipid"), + }, + &iaasalpha.VirtualIp{ + Id: utils.Ptr("pid,nid,vipid"), + }, + Model{ + Id: types.StringValue("pid,nid,vipid"), + ProjectId: types.StringValue("pid"), + NetworkId: types.StringValue("nid"), + VirtualIpId: types.StringValue("vipid"), + Name: types.StringNull(), + IP: types.StringNull(), + Labels: types.MapNull(types.StringType), + }, + true, + }, + { + "values_ok", + Model{ + ProjectId: types.StringValue("pid"), + NetworkId: types.StringValue("nid"), + VirtualIpId: types.StringValue("vipid"), + }, + &iaasalpha.VirtualIp{ + Id: utils.Ptr("pid,nid,vipid"), + Name: utils.Ptr("vip-name"), + Ip: utils.Ptr("10.0.0.1"), + Labels: &map[string]interface{}{ + "key": "value", + }, + }, + Model{ + Id: types.StringValue("pid,nid,vipid"), + ProjectId: types.StringValue("pid"), + NetworkId: types.StringValue("nid"), + VirtualIpId: types.StringValue("vipid"), + Name: types.StringValue("vip-name"), + IP: types.StringValue("10.0.0.1"), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + }), + }, + true, + }, + { + "response_fields_nil_fail", + Model{}, + &iaasalpha.VirtualIp{ + Name: nil, + Ip: nil, + }, + Model{}, + false, + }, + { + "response_nil_fail", + Model{}, + nil, + Model{}, + false, + }, + { + "no_resource_id", + Model{ + ProjectId: types.StringValue("pid"), + NetworkId: types.StringValue("nid"), + }, + &iaasalpha.VirtualIp{}, + Model{}, + false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + err := mapFields(context.Background(), tt.input, &tt.state) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if tt.isValid { + diff := cmp.Diff(tt.state, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} + +func TestToCreatePayload(t *testing.T) { + tests := []struct { + description string + input *Model + expected *iaasalpha.CreateVirtualIPPayload + isValid bool + }{ + { + description: "default_ok", + input: &Model{ + Name: types.StringValue("vip-name"), + IP: types.StringValue("10.0.0.1"), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + }), + }, + expected: &iaasalpha.CreateVirtualIPPayload{ + Name: utils.Ptr("vip-name"), + Ip: utils.Ptr("10.0.0.1"), + Labels: &map[string]interface{}{ + "key": "value", + }, + }, + isValid: true, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output, err := toCreatePayload(context.Background(), tt.input) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if tt.isValid { + diff := cmp.Diff(output, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} + +func TestToUpdatePayload(t *testing.T) { + tests := []struct { + description string + input *Model + expected *iaasalpha.UpdateVirtualIPPayload + isValid bool + }{ + { + "default_ok", + &Model{ + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{ + "key1": types.StringValue("value1"), + "key2": types.StringValue("value2"), + }), + }, + &iaasalpha.UpdateVirtualIPPayload{ + Labels: &map[string]interface{}{ + "key1": "value1", + "key2": "value2", + }, + }, + true, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output, err := toUpdatePayload(context.Background(), tt.input, types.MapNull(types.StringType)) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if tt.isValid { + diff := cmp.Diff(output, tt.expected, cmp.AllowUnexported(iaasalpha.NullableString{})) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} diff --git a/stackit/internal/services/iaas/virtualipmember/resource.go b/stackit/internal/services/iaas/virtualipmember/resource.go new file mode 100644 index 00000000..072fef54 --- /dev/null +++ b/stackit/internal/services/iaas/virtualipmember/resource.go @@ -0,0 +1,355 @@ +package virtualipmember + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/services/iaasalpha" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" +) + +// resourceBetaCheckDone is used to prevent multiple checks for beta resources. +// This is a workaround for the lack of a global state in the provider and +// needs to exist because the Configure method is called twice. +var resourceBetaCheckDone bool + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ resource.Resource = &virtualIPResource{} + _ resource.ResourceWithConfigure = &virtualIPResource{} + _ resource.ResourceWithImportState = &virtualIPResource{} +) + +type Model struct { + Id types.String `tfsdk:"id"` // needed by TF + ProjectId types.String `tfsdk:"project_id"` + NetworkId types.String `tfsdk:"network_id"` + VirtualIpId types.String `tfsdk:"virtual_ip_id"` + NetworkInterfaceId types.String `tfsdk:"network_interface_id"` +} + +// NewVirtualIPMemberResource is a helper function to simplify the provider implementation. +func NewVirtualIPMemberResource() resource.Resource { + return &virtualIPResource{} +} + +// networkResource is the resource implementation. +type virtualIPResource struct { + client *iaasalpha.APIClient +} + +// Metadata returns the resource type name. +func (r *virtualIPResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_virtual_ip_member" +} + +// Configure adds the provider configured client to the resource. +func (r *virtualIPResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + providerData, ok := req.ProviderData.(core.ProviderData) + if !ok { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Expected configure type stackit.ProviderData, got %T", req.ProviderData)) + return + } + + if !resourceBetaCheckDone { + features.CheckBetaResourcesEnabled(ctx, &providerData, &resp.Diagnostics, "stackit_virtual_ip_member", "resource") + if resp.Diagnostics.HasError() { + return + } + resourceBetaCheckDone = true + } + + var apiClient *iaasalpha.APIClient + var err error + if providerData.IaaSCustomEndpoint != "" { + ctx = tflog.SetField(ctx, "iaas_custom_endpoint", providerData.IaaSCustomEndpoint) + apiClient, err = iaasalpha.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithEndpoint(providerData.IaaSCustomEndpoint), + ) + } else { + apiClient, err = iaasalpha.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithRegion(providerData.Region), + ) + } + + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Configuring client: %v. This is an error related to the provider configuration, not to the resource configuration", err)) + return + } + + r.client = apiClient + tflog.Info(ctx, "IaaS client configured") +} + +// Schema defines the schema for the resource. +func (r *virtualIPResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Virtual IP member resource schema. This resource allows you to add a network interface as a member of a virtual IP. Must have a `region` specified in the provider configuration.", + MarkdownDescription: features.AddBetaDescription("Virtual IP member resource schema. This resource allows you to add a network interface as a member of a virtual IP. Must have a `region` specified in the provider configuration."), + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "Terraform's internal resource ID. It is structured as \"`project_id`,`network_id`,`virtual_ip_id`\".", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "project_id": schema.StringAttribute{ + Description: "STACKIT project ID to which the virtual IP is associated.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "network_id": schema.StringAttribute{ + Description: "The network ID to which the virtual IP is associated.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "virtual_ip_id": schema.StringAttribute{ + Description: "The virtual IP ID.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "network_interface_id": schema.StringAttribute{ + Description: "The ID of the network interface to add as a member of the virtual IP.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + }, + } +} + +// Create creates the resource and sets the initial Terraform state. +func (r *virtualIPResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform + // Retrieve values from plan + var model Model + diags := req.Plan.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + projectId := model.ProjectId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + networkId := model.NetworkId.ValueString() + ctx = tflog.SetField(ctx, "network_id", networkId) + virtualIpId := model.VirtualIpId.ValueString() + ctx = tflog.SetField(ctx, "virtual_ip_id", virtualIpId) + + // Generate API request body from model + payload, err := toCreatePayload(&model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error adding virtual IP member", fmt.Sprintf("Creating API payload: %v", err)) + return + } + + // Add member to virtual IP + virtualIp, err := r.client.AddMemberToVirtualIP(ctx, projectId, networkId, virtualIpId).AddMemberToVirtualIPPayload(*payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error adding virtual IP member", fmt.Sprintf("Calling API: %v", err)) + return + } + + // Map response body to schema + err = mapFields(virtualIp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating virtual IP.", fmt.Sprintf("Processing API payload: %v", err)) + return + } + + // Set state to fully populated data + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Virtual IP member added") +} + +// Read refreshes the Terraform state with the latest data. +func (r *virtualIPResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform + var model Model + diags := req.State.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + projectId := model.ProjectId.ValueString() + networkAreaId := model.NetworkId.ValueString() + networkAreaRouteId := model.VirtualIpId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "network_id", networkAreaId) + ctx = tflog.SetField(ctx, "virtual_ip_id", networkAreaRouteId) + + // Set refreshed state + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Virtual IP member read") +} + +// Delete deletes the resource and removes the Terraform state on success. +func (r *virtualIPResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform + // Retrieve values from state + var model Model + diags := req.State.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + projectId := model.ProjectId.ValueString() + networkId := model.NetworkId.ValueString() + virtualIpId := model.VirtualIpId.ValueString() + networkInterfaceId := model.NetworkInterfaceId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "network_id", networkId) + ctx = tflog.SetField(ctx, "virtual_ip_id", virtualIpId) + ctx = tflog.SetField(ctx, "network_interface_id", networkInterfaceId) + + // Generate API request body from model + payload, err := toDeletePayload(&model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error adding virtual IP member", fmt.Sprintf("Creating API payload: %v", err)) + return + } + + // Delete existing virtual IP + _, err = r.client.RemoveMemberFromVirtualIP(ctx, projectId, networkId, virtualIpId).RemoveMemberFromVirtualIPPayload(*payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error removing virtual IP member", fmt.Sprintf("Calling API: %v", err)) + return + } + + tflog.Info(ctx, "Virtual IP member removed") +} + +// Update updates the resource and sets the updated Terraform state on success. +func (r *virtualIPResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform + // Update is not supported, all fields require replace +} + +// ImportState imports a resource into the Terraform state on success. +// The expected format of the resource import identifier is: project_id,network_aread_id,virtual_ip_id +func (r *virtualIPResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + idParts := strings.Split(req.ID, core.Separator) + + if len(idParts) != 4 || idParts[0] == "" || idParts[1] == "" || idParts[2] == "" || idParts[3] == "" { + core.LogAndAddError(ctx, &resp.Diagnostics, + "Error importing virtual IP", + fmt.Sprintf("Expected import identifier with format: [project_id],[network_id],[virtual_ip_id][member] Got: %q", req.ID), + ) + return + } + + projectId := idParts[0] + networkAreaId := idParts[1] + networkAreaRouteId := idParts[2] + networkInterfaceId := idParts[3] + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "network_id", networkAreaId) + ctx = tflog.SetField(ctx, "virtual_ip_id", networkAreaRouteId) + ctx = tflog.SetField(ctx, "network_interface_id", networkInterfaceId) + + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), projectId)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("network_area_id"), networkAreaId)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("virtual_ip_id"), networkAreaRouteId)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("network_interface_id"), networkInterfaceId)...) + tflog.Info(ctx, "Virtual IP member state imported") +} + +func mapFields(virtualIp *iaasalpha.VirtualIp, model *Model) error { + if virtualIp == nil { + return fmt.Errorf("response input is nil") + } + if model == nil { + return fmt.Errorf("model input is nil") + } + + var virtualIpId string + if model.VirtualIpId.ValueString() != "" { + virtualIpId = model.VirtualIpId.ValueString() + } else if virtualIp.Id != nil { + virtualIpId = *virtualIp.Id + } else { + return fmt.Errorf("virtual IP id not present") + } + + idParts := []string{ + model.ProjectId.ValueString(), + model.NetworkId.ValueString(), + virtualIpId, + model.NetworkInterfaceId.ValueString(), + } + model.Id = types.StringValue( + strings.Join(idParts, core.Separator), + ) + + return nil +} + +func toCreatePayload(model *Model) (*iaasalpha.AddMemberToVirtualIPPayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + + return &iaasalpha.AddMemberToVirtualIPPayload{ + Member: conversion.StringValueToPointer(model.NetworkInterfaceId), + }, nil +} + +func toDeletePayload(model *Model) (*iaasalpha.RemoveMemberFromVirtualIPPayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + + return &iaasalpha.RemoveMemberFromVirtualIPPayload{ + Member: conversion.StringValueToPointer(model.NetworkInterfaceId), + }, nil +} diff --git a/stackit/internal/services/iaas/virtualipmember/resource_test.go b/stackit/internal/services/iaas/virtualipmember/resource_test.go new file mode 100644 index 00000000..5b927c55 --- /dev/null +++ b/stackit/internal/services/iaas/virtualipmember/resource_test.go @@ -0,0 +1,159 @@ +package virtualipmember + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaasalpha" +) + +func TestMapFields(t *testing.T) { + tests := []struct { + description string + state Model + input *iaasalpha.VirtualIp + expected Model + isValid bool + }{ + { + "id_ok", + Model{ + ProjectId: types.StringValue("pid"), + NetworkId: types.StringValue("nid"), + VirtualIpId: types.StringValue("vipid"), + NetworkInterfaceId: types.StringValue("nicid"), + }, + &iaasalpha.VirtualIp{ + Id: utils.Ptr("pid,nid,vipid"), + }, + Model{ + Id: types.StringValue("pid,nid,vipid,nicid"), + ProjectId: types.StringValue("pid"), + NetworkId: types.StringValue("nid"), + VirtualIpId: types.StringValue("vipid"), + NetworkInterfaceId: types.StringValue("nicid"), + }, + true, + }, + { + "response_fields_nil_fail", + Model{}, + &iaasalpha.VirtualIp{ + Name: nil, + Ip: nil, + }, + Model{}, + false, + }, + { + "response_nil_fail", + Model{}, + nil, + Model{}, + false, + }, + { + "no_resource_id", + Model{ + ProjectId: types.StringValue("pid"), + NetworkId: types.StringValue("nid"), + }, + &iaasalpha.VirtualIp{}, + Model{}, + false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + err := mapFields(tt.input, &tt.state) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if tt.isValid { + diff := cmp.Diff(tt.state, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} + +func TestToCreatePayload(t *testing.T) { + tests := []struct { + description string + input *Model + expected *iaasalpha.AddMemberToVirtualIPPayload + isValid bool + }{ + { + description: "default_ok", + input: &Model{ + NetworkInterfaceId: types.StringValue("nic-id"), + }, + expected: &iaasalpha.AddMemberToVirtualIPPayload{ + Member: utils.Ptr("nic-id"), + }, + isValid: true, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output, err := toCreatePayload(tt.input) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if tt.isValid { + diff := cmp.Diff(output, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} + +func TestToDeletePayload(t *testing.T) { + tests := []struct { + description string + input *Model + expected *iaasalpha.RemoveMemberFromVirtualIPPayload + isValid bool + }{ + { + "default_ok", + &Model{ + NetworkInterfaceId: types.StringValue("nic-id"), + }, + &iaasalpha.RemoveMemberFromVirtualIPPayload{ + Member: utils.Ptr("nic-id"), + }, + true, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output, err := toDeletePayload(tt.input) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if tt.isValid { + diff := cmp.Diff(output, tt.expected, cmp.AllowUnexported(iaasalpha.NullableString{})) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} diff --git a/stackit/provider.go b/stackit/provider.go index 12257682..e1ff0e80 100644 --- a/stackit/provider.go +++ b/stackit/provider.go @@ -26,6 +26,8 @@ import ( iaasSecurityGroupRule "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/securitygrouprule" iaasServer "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/server" iaasServiceAccountAttach "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/serviceaccountattach" + iaasVirtualIP "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/virtualip" + iaasVirtualIPMember "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/virtualipmember" iaasVolume "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/volume" iaasVolumeAttach "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/volumeattach" loadBalancerCredential "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/loadbalancer/credential" @@ -415,6 +417,7 @@ func (p *Provider) DataSources(_ context.Context) []func() datasource.DataSource iaasVolume.NewVolumeDataSource, iaasPublicIp.NewPublicIpDataSource, iaasKeyPair.NewKeyPairDataSource, + iaasVirtualIP.NewVirtualIPDataSource, iaasServer.NewServerDataSource, iaasSecurityGroup.NewSecurityGroupDataSource, iaasSecurityGroupRule.NewSecurityGroupRuleDataSource, @@ -469,6 +472,8 @@ func (p *Provider) Resources(_ context.Context) []func() resource.Resource { iaasVolumeAttach.NewVolumeAttachResource, iaasNetworkInterfaceAttach.NewNetworkInterfaceAttachResource, iaasServiceAccountAttach.NewServiceAccountAttachResource, + iaasVirtualIP.NewVirtualIPResource, + iaasVirtualIPMember.NewVirtualIPMemberResource, iaasPublicIpAssociate.NewPublicIpAssociateResource, iaasServer.NewServerResource, iaasSecurityGroup.NewSecurityGroupResource,