diff --git a/docs/resources/public_ip_associate.md b/docs/resources/public_ip_associate.md new file mode 100644 index 00000000..75311af4 --- /dev/null +++ b/docs/resources/public_ip_associate.md @@ -0,0 +1,41 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_public_ip_associate Resource - stackit" +subcategory: "" +description: |- + 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. + !> The stackit_public_ip_associate resource should not be used together with the stackit_public_ip resource for the same network interface, as they both have control of the network interface association and this will lead to conflicts. + ~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our guide https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources for how to opt-in to use beta resources. +--- + +# stackit_public_ip_associate (Resource) + +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. + +!> The `stackit_public_ip_associate` resource should not be used together with the `stackit_public_ip` resource for the same network interface, as they both have control of the network interface association and this will lead to conflicts. + +~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our [guide](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources) for how to opt-in to use beta resources. + +## Example Usage + +```terraform +resource "stackit_public_ip_associate" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + public_ip_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + network_interface_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} +``` + + +## Schema + +### Required + +- `network_interface_id` (String) The ID of the network interface (or virtual IP) to which the public IP should be attached to. +- `project_id` (String) STACKIT project ID to which the public IP is associated. +- `public_ip_id` (String) The public IP ID. + +### Read-Only + +- `id` (String) Terraform's internal resource ID. It is structured as "`project_id`,`public_ip_id`,`network_interface_id`". +- `ip` (String) The IP address. diff --git a/examples/resources/stackit_public_ip_associate/resource.tf b/examples/resources/stackit_public_ip_associate/resource.tf new file mode 100644 index 00000000..280f60fe --- /dev/null +++ b/examples/resources/stackit_public_ip_associate/resource.tf @@ -0,0 +1,5 @@ +resource "stackit_public_ip_associate" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + public_ip_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + network_interface_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} diff --git a/stackit/internal/services/iaas/publicipassociate/resource.go b/stackit/internal/services/iaas/publicipassociate/resource.go new file mode 100644 index 00000000..80b5db91 --- /dev/null +++ b/stackit/internal/services/iaas/publicipassociate/resource.go @@ -0,0 +1,365 @@ +package publicipassociate + +import ( + "context" + "fmt" + "net/http" + "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/core/oapierror" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "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 = &publicIpAssociateResource{} + _ resource.ResourceWithConfigure = &publicIpAssociateResource{} + _ resource.ResourceWithImportState = &publicIpAssociateResource{} +) + +type Model struct { + Id types.String `tfsdk:"id"` // needed by TF + ProjectId types.String `tfsdk:"project_id"` + PublicIpId types.String `tfsdk:"public_ip_id"` + Ip types.String `tfsdk:"ip"` + NetworkInterfaceId types.String `tfsdk:"network_interface_id"` +} + +// NewPublicIpAssociateResource is a helper function to simplify the provider implementation. +func NewPublicIpAssociateResource() resource.Resource { + return &publicIpAssociateResource{} +} + +// publicIpAssociateResource is the resource implementation. +type publicIpAssociateResource struct { + client *iaas.APIClient +} + +// Metadata returns the resource type name. +func (r *publicIpAssociateResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_public_ip_associate" +} + +// Configure adds the provider configured client to the resource. +func (r *publicIpAssociateResource) 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_public_ip_associate", "resource") + if resp.Diagnostics.HasError() { + return + } + resourceBetaCheckDone = true + } + + var apiClient *iaas.APIClient + var err error + if providerData.IaaSCustomEndpoint != "" { + ctx = tflog.SetField(ctx, "iaas_custom_endpoint", providerData.IaaSCustomEndpoint) + apiClient, err = iaas.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithEndpoint(providerData.IaaSCustomEndpoint), + ) + } else { + apiClient, err = iaas.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 *publicIpAssociateResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + descriptions := map[string]string{ + "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 network interface, as they both have control of the network interface association and this will lead to conflicts.", + } + resp.Schema = schema.Schema{ + MarkdownDescription: features.AddBetaDescription(fmt.Sprintf("%s\n\n!> %s", descriptions["main"], descriptions["warning_message"])), + Description: fmt.Sprintf("%s\n\n%s", descriptions["main"], descriptions["warning_message"]), + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "Terraform's internal resource ID. It is structured as \"`project_id`,`public_ip_id`,`network_interface_id`\".", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "project_id": schema.StringAttribute{ + Description: "STACKIT project ID to which the public IP is associated.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "public_ip_id": schema.StringAttribute{ + Description: "The public IP ID.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "ip": schema.StringAttribute{ + Description: "The IP address.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + validate.IP(), + }, + }, + "network_interface_id": schema.StringAttribute{ + Description: "The ID of the network interface (or virtual IP) to which the public IP should be attached to.", + 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 *publicIpAssociateResource) 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() + publicIpId := model.PublicIpId.ValueString() + networkInterfaceId := model.NetworkInterfaceId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "public_ip_id", publicIpId) + ctx = tflog.SetField(ctx, "network_interface_id", networkInterfaceId) + + 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 network interface.", + "The `stackit_public_ip_associate` resource should not be used together with the `stackit_public_ip` resource for the same network interface, as they both have control of the network interface association and this will lead to conflicts.") + + // Generate API request body from model + payload, err := toCreatePayload(&model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error associating public IP to network interface", fmt.Sprintf("Creating API payload: %v", err)) + return + } + // Update existing public IP + updatedPublicIp, err := r.client.UpdatePublicIP(ctx, projectId, publicIpId).UpdatePublicIPPayload(*payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error associating public IP to network interface", fmt.Sprintf("Calling API: %v", err)) + return + } + + err = mapFields(updatedPublicIp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error associating public IP to network interface", 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, "public IP associated to network interface") +} + +// Read refreshes the Terraform state with the latest data. +func (r *publicIpAssociateResource) 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() + publicIpId := model.PublicIpId.ValueString() + networkInterfaceId := model.NetworkInterfaceId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "public_ip_id", publicIpId) + ctx = tflog.SetField(ctx, "network_interface_id", networkInterfaceId) + + publicIpResp, err := r.client.GetPublicIP(ctx, projectId, publicIpId).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 public IP association", fmt.Sprintf("Calling API: %v", err)) + return + } + + // Map response body to schema + err = mapFields(publicIpResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading public IP association", 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, "public IP associate read") +} + +// Update updates the resource and sets the updated Terraform state on success. +func (r *publicIpAssociateResource) Update(_ context.Context, _ resource.UpdateRequest, _ *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform + // Update is not supported, all fields require replace +} + +// Delete deletes the resource and removes the Terraform state on success. +func (r *publicIpAssociateResource) 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() + publicIpId := model.PublicIpId.ValueString() + networkInterfaceId := model.NetworkInterfaceId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "public_ip_id", publicIpId) + ctx = tflog.SetField(ctx, "network_interface_id", networkInterfaceId) + + payload := &iaas.UpdatePublicIPPayload{ + NetworkInterface: iaas.NewNullableString(nil), + } + + _, err := r.client.UpdatePublicIP(ctx, projectId, publicIpId).UpdatePublicIPPayload(*payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting public IP association", fmt.Sprintf("Calling API: %v", err)) + return + } + + tflog.Info(ctx, "public IP association deleted") +} + +// ImportState imports a resource into the Terraform state on success. +// The expected format of the resource import identifier is: project_id,public_ip_id +func (r *publicIpAssociateResource) 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 public IP associate", + fmt.Sprintf("Expected import identifier with format: [project_id],[public_ip_id],[network_interface_id] Got: %q", req.ID), + ) + return + } + + projectId := idParts[0] + publicIpId := idParts[1] + networkInterfaceId := idParts[2] + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "public_ip_id", publicIpId) + 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("public_ip_id"), publicIpId)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("network_interface_id"), networkInterfaceId)...) + tflog.Info(ctx, "public IP state imported") +} + +func mapFields(publicIpResp *iaas.PublicIp, model *Model) error { + if publicIpResp == nil { + return fmt.Errorf("response input is nil") + } + if model == nil { + return fmt.Errorf("model input is nil") + } + + var publicIpId string + if model.PublicIpId.ValueString() != "" { + publicIpId = model.PublicIpId.ValueString() + } else if publicIpResp.Id != nil { + publicIpId = *publicIpResp.Id + } else { + return fmt.Errorf("public IP id not present") + } + + if publicIpResp.NetworkInterface != nil { + model.NetworkInterfaceId = types.StringPointerValue(publicIpResp.GetNetworkInterface()) + } else { + model.NetworkInterfaceId = types.StringNull() + } + + idParts := []string{ + model.ProjectId.ValueString(), + publicIpId, + model.NetworkInterfaceId.ValueString(), + } + model.Id = types.StringValue( + strings.Join(idParts, core.Separator), + ) + + model.PublicIpId = types.StringValue(publicIpId) + model.Ip = types.StringPointerValue(publicIpResp.Ip) + + return nil +} + +func toCreatePayload(model *Model) (*iaas.UpdatePublicIPPayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + + return &iaas.UpdatePublicIPPayload{ + NetworkInterface: iaas.NewNullableString(conversion.StringValueToPointer(model.NetworkInterfaceId)), + }, nil +} diff --git a/stackit/internal/services/iaas/publicipassociate/resource_test.go b/stackit/internal/services/iaas/publicipassociate/resource_test.go new file mode 100644 index 00000000..a15cf34b --- /dev/null +++ b/stackit/internal/services/iaas/publicipassociate/resource_test.go @@ -0,0 +1,132 @@ +package publicipassociate + +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/iaas" +) + +func TestMapFields(t *testing.T) { + tests := []struct { + description string + state Model + input *iaas.PublicIp + expected Model + isValid bool + }{ + { + "default_values", + Model{ + ProjectId: types.StringValue("pid"), + PublicIpId: types.StringValue("pipid"), + NetworkInterfaceId: types.StringValue("nicid"), + }, + &iaas.PublicIp{ + Id: utils.Ptr("pipid"), + NetworkInterface: iaas.NewNullableString(utils.Ptr("nicid")), + }, + Model{ + Id: types.StringValue("pid,pipid,nicid"), + ProjectId: types.StringValue("pid"), + PublicIpId: types.StringValue("pipid"), + Ip: types.StringNull(), + NetworkInterfaceId: types.StringValue("nicid"), + }, + true, + }, + { + "simple_values", + Model{ + ProjectId: types.StringValue("pid"), + PublicIpId: types.StringValue("pipid"), + NetworkInterfaceId: types.StringValue("nicid"), + }, + &iaas.PublicIp{ + Id: utils.Ptr("pipid"), + Ip: utils.Ptr("ip"), + NetworkInterface: iaas.NewNullableString(utils.Ptr("nicid")), + }, + Model{ + Id: types.StringValue("pid,pipid,nicid"), + ProjectId: types.StringValue("pid"), + PublicIpId: types.StringValue("pipid"), + Ip: types.StringValue("ip"), + NetworkInterfaceId: types.StringValue("nicid"), + }, + true, + }, + { + "response_nil_fail", + Model{}, + nil, + Model{}, + false, + }, + { + "no_resource_id", + Model{ + ProjectId: types.StringValue("pid"), + }, + &iaas.PublicIp{}, + 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 *iaas.UpdatePublicIPPayload + isValid bool + }{ + { + "default_ok", + &Model{ + NetworkInterfaceId: types.StringValue("interface"), + }, + &iaas.UpdatePublicIPPayload{ + NetworkInterface: iaas.NewNullableString(utils.Ptr("interface")), + }, + 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, cmp.AllowUnexported(iaas.NullableString{})) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} diff --git a/stackit/provider.go b/stackit/provider.go index f3fd0286..12257682 100644 --- a/stackit/provider.go +++ b/stackit/provider.go @@ -21,6 +21,7 @@ import ( iaasNetworkInterface "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/networkinterface" iaasNetworkInterfaceAttach "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/networkinterfaceattach" iaasPublicIp "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/publicip" + iaasPublicIpAssociate "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/publicipassociate" iaasSecurityGroup "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/securitygroup" iaasSecurityGroupRule "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/securitygrouprule" iaasServer "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/server" @@ -468,6 +469,7 @@ func (p *Provider) Resources(_ context.Context) []func() resource.Resource { iaasVolumeAttach.NewVolumeAttachResource, iaasNetworkInterfaceAttach.NewNetworkInterfaceAttachResource, iaasServiceAccountAttach.NewServiceAccountAttachResource, + iaasPublicIpAssociate.NewPublicIpAssociateResource, iaasServer.NewServerResource, iaasSecurityGroup.NewSecurityGroupResource, iaasSecurityGroupRule.NewSecurityGroupRuleResource,