Onboard public_ip_associate resource (#582)

* onboard public_ip_associate resource

* add warnings

* add example

* edit warning message

* Update stackit/internal/services/iaas/publicipassociate/resource.go

Co-authored-by: João Palet <joao.palet@outlook.com>

* Update stackit/internal/services/iaas/publicipassociate/resource.go

Co-authored-by: João Palet <joao.palet@outlook.com>

* Update stackit/internal/services/iaas/publicipassociate/resource.go

Co-authored-by: João Palet <joao.palet@outlook.com>

* Update stackit/internal/services/iaas/publicipassociate/resource.go

Co-authored-by: João Palet <joao.palet@outlook.com>

* Update stackit/internal/services/iaas/publicipassociate/resource.go

Co-authored-by: João Palet <joao.palet@outlook.com>

* Update stackit/internal/services/iaas/publicipassociate/resource.go

Co-authored-by: João Palet <joao.palet@outlook.com>

* Update stackit/internal/services/iaas/publicipassociate/resource.go

Co-authored-by: João Palet <joao.palet@outlook.com>

* Update stackit/internal/services/iaas/publicipassociate/resource.go

Co-authored-by: João Palet <joao.palet@outlook.com>

* add requiresReplace in network_interface_id

* change function name to toCreatePayload

* change deletion of the public ip association

* add unit test

* Add missing space between sentences

* update conflict warning message

* update conflict warning message

* Adjust warning message

---------

Co-authored-by: João Palet <joao.palet@outlook.com>
Co-authored-by: João Palet <joao.palet@freiheit.com>
This commit is contained in:
GokceGK 2024-11-22 11:14:44 +01:00 committed by GitHub
parent 7b693acc2d
commit 2a2d62a429
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 545 additions and 0 deletions

View file

@ -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 generated by tfplugindocs -->
## 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.

View file

@ -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"
}

View file

@ -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
}

View file

@ -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)
}
}
})
}
}

View file

@ -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,