* Revert "Revert "Implement key pair resource (#578)" (#581)"
This reverts commit 600847a2ea.
* feat: Update iaas SDK module version; Use beta API in key pair resource
411 lines
14 KiB
Go
411 lines
14 KiB
Go
package keypair
|
|
|
|
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/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"
|
|
)
|
|
|
|
// 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 = &keyPairResource{}
|
|
_ resource.ResourceWithConfigure = &keyPairResource{}
|
|
_ resource.ResourceWithImportState = &keyPairResource{}
|
|
)
|
|
|
|
type Model struct {
|
|
Id types.String `tfsdk:"id"` // needed by TF
|
|
Name types.String `tfsdk:"name"`
|
|
PublicKey types.String `tfsdk:"public_key"`
|
|
Fingerprint types.String `tfsdk:"fingerprint"`
|
|
Labels types.Map `tfsdk:"labels"`
|
|
}
|
|
|
|
// NewKeyPairResource is a helper function to simplify the provider implementation.
|
|
func NewKeyPairResource() resource.Resource {
|
|
return &keyPairResource{}
|
|
}
|
|
|
|
// keyPairResource is the resource implementation.
|
|
type keyPairResource struct {
|
|
client *iaas.APIClient
|
|
}
|
|
|
|
// Metadata returns the resource type name.
|
|
func (r *keyPairResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
|
|
resp.TypeName = req.ProviderTypeName + "_key_pair"
|
|
}
|
|
|
|
// Configure adds the provider configured client to the resource.
|
|
func (r *keyPairResource) 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_key_pair", "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 *keyPairResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
|
|
description := "Key pair resource schema. Must have a `region` specified in the provider configuration. Allows uploading an SSH public key to be used for server authentication."
|
|
|
|
resp.Schema = schema.Schema{
|
|
MarkdownDescription: features.AddBetaDescription(description + "\n\n" + exampleUsageWithServer),
|
|
Description: description,
|
|
Attributes: map[string]schema.Attribute{
|
|
"id": schema.StringAttribute{
|
|
Description: "Terraform's internal resource ID. It takes the value of the key pair \"`name`\".",
|
|
Computed: true,
|
|
PlanModifiers: []planmodifier.String{
|
|
stringplanmodifier.UseStateForUnknown(),
|
|
},
|
|
},
|
|
"name": schema.StringAttribute{
|
|
Description: "The name of the SSH key pair.",
|
|
Required: true,
|
|
PlanModifiers: []planmodifier.String{
|
|
stringplanmodifier.UseStateForUnknown(),
|
|
},
|
|
},
|
|
"public_key": schema.StringAttribute{
|
|
Description: "A string representation of the public SSH key. E.g., `ssh-rsa <key_data>` or `ssh-ed25519 <key-data>`.",
|
|
Required: true,
|
|
PlanModifiers: []planmodifier.String{
|
|
stringplanmodifier.RequiresReplace(),
|
|
},
|
|
},
|
|
"fingerprint": schema.StringAttribute{
|
|
Description: "The fingerprint of the public SSH key.",
|
|
Computed: true,
|
|
PlanModifiers: []planmodifier.String{
|
|
stringplanmodifier.UseStateForUnknown(),
|
|
},
|
|
},
|
|
"labels": schema.MapAttribute{
|
|
Description: "Labels are key-value string pairs which can be attached to a resource container.",
|
|
ElementType: types.StringType,
|
|
Optional: true,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// ModifyPlan will be called in the Plan phase.
|
|
// It will check if the plan contains a change that requires replacement. If yes, it will show a warning to the user.
|
|
func (r *keyPairResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { // nolint:gocritic // function signature required by Terraform
|
|
// If the state is empty we are creating a new resource
|
|
// If the plan is empty we are deleting the resource
|
|
// In both cases we don't need to check for replacement
|
|
if req.Plan.Raw.IsNull() || req.State.Raw.IsNull() {
|
|
return
|
|
}
|
|
|
|
var planModel Model
|
|
diags := req.Plan.Get(ctx, &planModel)
|
|
resp.Diagnostics.Append(diags...)
|
|
|
|
var stateModel Model
|
|
diags = req.State.Get(ctx, &stateModel)
|
|
resp.Diagnostics.Append(diags...)
|
|
|
|
if planModel.PublicKey.ValueString() != stateModel.PublicKey.ValueString() {
|
|
core.LogAndAddWarning(ctx, &resp.Diagnostics, "Key pair public key change", "Changing the public key will trigger a replacement of the key pair resource. The new key pair will not be valid to access servers on which the old key was used, as the key is only registered during server creation.")
|
|
}
|
|
}
|
|
|
|
// Create creates the resource and sets the initial Terraform state.
|
|
func (r *keyPairResource) 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
|
|
}
|
|
|
|
name := model.Name.ValueString()
|
|
ctx = tflog.SetField(ctx, "name", name)
|
|
|
|
// Generate API request body from model
|
|
payload, err := toCreatePayload(ctx, &model)
|
|
if err != nil {
|
|
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating key pair", fmt.Sprintf("Creating API payload: %v", err))
|
|
return
|
|
}
|
|
|
|
// Create new key pair
|
|
|
|
keyPair, err := r.client.CreateKeyPair(ctx).CreateKeyPairPayload(*payload).Execute()
|
|
if err != nil {
|
|
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating key pair", fmt.Sprintf("Calling API: %v", err))
|
|
return
|
|
}
|
|
|
|
// Map response body to schema
|
|
err = mapFields(ctx, keyPair, &model)
|
|
if err != nil {
|
|
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating key pair", 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, "Key pair created")
|
|
}
|
|
|
|
// Read refreshes the Terraform state with the latest data.
|
|
func (r *keyPairResource) 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
|
|
}
|
|
name := model.Name.ValueString()
|
|
ctx = tflog.SetField(ctx, "name", name)
|
|
|
|
keyPairResp, err := r.client.GetKeyPair(ctx, name).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 key pair", fmt.Sprintf("Calling API: %v", err))
|
|
return
|
|
}
|
|
|
|
// Map response body to schema
|
|
err = mapFields(ctx, keyPairResp, &model)
|
|
if err != nil {
|
|
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading key pair", 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, "Key pair read")
|
|
}
|
|
|
|
// Update updates the resource and sets the updated Terraform state on success.
|
|
func (r *keyPairResource) 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
|
|
}
|
|
name := model.Name.ValueString()
|
|
ctx = tflog.SetField(ctx, "name", name)
|
|
|
|
// 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 key pair", fmt.Sprintf("Creating API payload: %v", err))
|
|
return
|
|
}
|
|
// Update existing key pair
|
|
updatedKeyPair, err := r.client.UpdateKeyPair(ctx, name).UpdateKeyPairPayload(*payload).Execute()
|
|
if err != nil {
|
|
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating key pair", fmt.Sprintf("Calling API: %v", err))
|
|
return
|
|
}
|
|
|
|
err = mapFields(ctx, updatedKeyPair, &model)
|
|
if err != nil {
|
|
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating key pair", 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, "key pair updated")
|
|
}
|
|
|
|
// Delete deletes the resource and removes the Terraform state on success.
|
|
func (r *keyPairResource) 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
|
|
}
|
|
|
|
name := model.Name.ValueString()
|
|
ctx = tflog.SetField(ctx, "name", name)
|
|
|
|
// Delete existing key pair
|
|
err := r.client.DeleteKeyPair(ctx, name).Execute()
|
|
if err != nil {
|
|
core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting key pair", fmt.Sprintf("Calling API: %v", err))
|
|
return
|
|
}
|
|
|
|
tflog.Info(ctx, "Key pair deleted")
|
|
}
|
|
|
|
// ImportState imports a resource into the Terraform state on success.
|
|
// The expected format of the resource import identifier is: project_id,key_pair_id
|
|
func (r *keyPairResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
|
|
idParts := strings.Split(req.ID, core.Separator)
|
|
|
|
if len(idParts) != 1 || idParts[0] == "" {
|
|
core.LogAndAddError(ctx, &resp.Diagnostics,
|
|
"Error importing key pair",
|
|
fmt.Sprintf("Expected import identifier with format: [name] Got: %q", req.ID),
|
|
)
|
|
return
|
|
}
|
|
|
|
name := idParts[0]
|
|
ctx = tflog.SetField(ctx, "name", name)
|
|
|
|
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("name"), name)...)
|
|
tflog.Info(ctx, "Key pair state imported")
|
|
}
|
|
|
|
func mapFields(ctx context.Context, keyPairResp *iaas.Keypair, model *Model) error {
|
|
if keyPairResp == nil {
|
|
return fmt.Errorf("response input is nil")
|
|
}
|
|
if model == nil {
|
|
return fmt.Errorf("model input is nil")
|
|
}
|
|
|
|
var name string
|
|
if model.Name.ValueString() != "" {
|
|
name = model.Name.ValueString()
|
|
} else if keyPairResp.Name != nil {
|
|
name = *keyPairResp.Name
|
|
} else {
|
|
return fmt.Errorf("key pair name not present")
|
|
}
|
|
|
|
model.Id = types.StringValue(name)
|
|
model.PublicKey = types.StringPointerValue(keyPairResp.PublicKey)
|
|
model.Fingerprint = types.StringPointerValue(keyPairResp.Fingerprint)
|
|
|
|
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 keyPairResp.Labels != nil && len(*keyPairResp.Labels) != 0 {
|
|
var diags diag.Diagnostics
|
|
labels, diags = types.MapValueFrom(ctx, types.StringType, *keyPairResp.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.Labels = labels
|
|
|
|
return nil
|
|
}
|
|
|
|
func toCreatePayload(ctx context.Context, model *Model) (*iaas.CreateKeyPairPayload, 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 &iaas.CreateKeyPairPayload{
|
|
Name: conversion.StringValueToPointer(model.Name),
|
|
PublicKey: conversion.StringValueToPointer(model.PublicKey),
|
|
Labels: &labels,
|
|
}, nil
|
|
}
|
|
|
|
func toUpdatePayload(ctx context.Context, model *Model, currentLabels types.Map) (*iaas.UpdateKeyPairPayload, 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 &iaas.UpdateKeyPairPayload{
|
|
Labels: &labels,
|
|
}, nil
|
|
}
|