From 3a2182e7b0ce29c83099d200249e38c1ad1bf45d Mon Sep 17 00:00:00 2001 From: Ruben Hoenle Date: Tue, 4 Nov 2025 12:49:27 +0100 Subject: [PATCH 1/2] feat(kms): add keyring resource and datasource relates to STACKITTPR-410 --- CONTRIBUTION.md | 4 +- docs/data-sources/kms_keyring.md | 35 ++ docs/index.md | 1 + docs/resources/kms_keyring.md | 42 +++ .../stackit_kms_keyring/data-source.tf | 4 + .../resources/stackit_kms_keyring/resource.tf | 5 + go.mod | 3 +- go.sum | 6 +- stackit/internal/core/core.go | 1 + .../services/kms/keyring/datasource.go | 138 +++++++ .../internal/services/kms/keyring/resource.go | 345 ++++++++++++++++++ .../services/kms/keyring/resource_test.go | 173 +++++++++ stackit/internal/services/kms/kms_acc_test.go | 204 +++++++++++ .../services/kms/testdata/keyring-max.tf | 10 + .../services/kms/testdata/keyring-min.tf | 8 + stackit/internal/services/kms/utils/util.go | 29 ++ stackit/internal/testutil/testutil.go | 16 + stackit/provider.go | 10 + 18 files changed, 1028 insertions(+), 6 deletions(-) create mode 100644 docs/data-sources/kms_keyring.md create mode 100644 docs/resources/kms_keyring.md create mode 100644 examples/data-sources/stackit_kms_keyring/data-source.tf create mode 100644 examples/resources/stackit_kms_keyring/resource.tf create mode 100644 stackit/internal/services/kms/keyring/datasource.go create mode 100644 stackit/internal/services/kms/keyring/resource.go create mode 100644 stackit/internal/services/kms/keyring/resource_test.go create mode 100644 stackit/internal/services/kms/kms_acc_test.go create mode 100644 stackit/internal/services/kms/testdata/keyring-max.tf create mode 100644 stackit/internal/services/kms/testdata/keyring-min.tf create mode 100644 stackit/internal/services/kms/utils/util.go diff --git a/CONTRIBUTION.md b/CONTRIBUTION.md index 78566b44f..c9fe41f77 100644 --- a/CONTRIBUTION.md +++ b/CONTRIBUTION.md @@ -80,9 +80,7 @@ If you want to onboard resources of a STACKIT service `foo` that was not yet in 2. Add a `foo_custom_endpoint` attribute to the provider's `Schema`, in `stackit/provider.go` 3. Check if the custom endpoint is defined and, if yes, use it. In the `Configure` method, add: ```go - if !(providerConfig.FooCustomEndpoint.IsUnknown() || providerConfig.FooCustomEndpoint.IsNull()) { - providerData.FooCustomEndpoint = providerConfig.FooCustomEndpoint.ValueString() - } + setStringField(providerConfig.FooCustomEndpoint, func(v string) { providerData.FooCustomEndpoint = v }) ``` 4. Create a utils package, for service `foo` it would be `stackit/internal/foo/utils`. Add a `ConfigureClient()` func and use it in your resource and datasource implementations. diff --git a/docs/data-sources/kms_keyring.md b/docs/data-sources/kms_keyring.md new file mode 100644 index 000000000..cd0004cef --- /dev/null +++ b/docs/data-sources/kms_keyring.md @@ -0,0 +1,35 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_kms_keyring Data Source - stackit" +subcategory: "" +description: |- + KMS Keyring resource schema. +--- + +# stackit_kms_keyring (Data Source) + +KMS Keyring resource schema. + +## Example Usage + +```terraform +data "stackit_kms_keyring" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + keyring_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} +``` + + +## Schema + +### Required + +- `keyring_id` (String) An auto generated unique id which identifies the keyring. +- `project_id` (String) STACKIT project ID to which the keyring is associated. + +### Read-Only + +- `description` (String) A user chosen description to distinguish multiple keyrings. +- `display_name` (String) The display name to distinguish multiple keyrings. +- `id` (String) Terraform's internal resource ID. It is structured as "`project_id`,`region`,`keyring_id`". +- `region` (String) The resource region. If not defined, the provider region is used. diff --git a/docs/index.md b/docs/index.md index efc6ca4f8..4e9d3e2b2 100644 --- a/docs/index.md +++ b/docs/index.md @@ -162,6 +162,7 @@ Note: AWS specific checks must be skipped as they do not work on STACKIT. For de - `experiments` (List of String) Enables experiments. These are unstable features without official support. More information can be found in the README. Available Experiments: iam, routing-tables, network - `git_custom_endpoint` (String) Custom endpoint for the Git service - `iaas_custom_endpoint` (String) Custom endpoint for the IaaS service +- `kms_custom_endpoint` (String) Custom endpoint for the KMS service - `loadbalancer_custom_endpoint` (String) Custom endpoint for the Load Balancer service - `logme_custom_endpoint` (String) Custom endpoint for the LogMe service - `mariadb_custom_endpoint` (String) Custom endpoint for the MariaDB service diff --git a/docs/resources/kms_keyring.md b/docs/resources/kms_keyring.md new file mode 100644 index 000000000..779a7eff4 --- /dev/null +++ b/docs/resources/kms_keyring.md @@ -0,0 +1,42 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_kms_keyring Resource - stackit" +subcategory: "" +description: |- + KMS Keyring resource schema. + ~> Keyrings will not be destroyed by terraform during a terraform destroy. They will just be thrown out of the Terraform state and not deleted on API side. This way we can ensure no keyring setups are deleted by accident and it gives you the option to recover your keys within the grace period. +--- + +# stackit_kms_keyring (Resource) + +KMS Keyring resource schema. + + ~> Keyrings will **not** be destroyed by terraform during a `terraform destroy`. They will just be thrown out of the Terraform state and not deleted on API side. **This way we can ensure no keyring setups are deleted by accident and it gives you the option to recover your keys within the grace period.** + +## Example Usage + +```terraform +resource "stackit_kms_keyring" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + display_name = "example name" + description = "example description" +} +``` + + +## Schema + +### Required + +- `display_name` (String) The display name to distinguish multiple keyrings. +- `project_id` (String) STACKIT project ID to which the keyring is associated. + +### Optional + +- `description` (String) A user chosen description to distinguish multiple keyrings. +- `region` (String) The resource region. If not defined, the provider region is used. + +### Read-Only + +- `id` (String) Terraform's internal resource ID. It is structured as "`project_id`,`region`,`keyring_id`". +- `keyring_id` (String) An auto generated unique id which identifies the keyring. diff --git a/examples/data-sources/stackit_kms_keyring/data-source.tf b/examples/data-sources/stackit_kms_keyring/data-source.tf new file mode 100644 index 000000000..863c896a6 --- /dev/null +++ b/examples/data-sources/stackit_kms_keyring/data-source.tf @@ -0,0 +1,4 @@ +data "stackit_kms_keyring" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + keyring_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} diff --git a/examples/resources/stackit_kms_keyring/resource.tf b/examples/resources/stackit_kms_keyring/resource.tf new file mode 100644 index 000000000..15f2d5e45 --- /dev/null +++ b/examples/resources/stackit_kms_keyring/resource.tf @@ -0,0 +1,5 @@ +resource "stackit_kms_keyring" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + display_name = "example name" + description = "example description" +} diff --git a/go.mod b/go.mod index 7a3ea7309..6a9071392 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/stackitcloud/stackit-sdk-go/services/git v0.8.0 github.com/stackitcloud/stackit-sdk-go/services/iaas v0.31.0 github.com/stackitcloud/stackit-sdk-go/services/iaasalpha v0.1.21-alpha + github.com/stackitcloud/stackit-sdk-go/services/kms v1.0.0 github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v1.6.0 github.com/stackitcloud/stackit-sdk-go/services/logme v0.25.1 github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.25.1 @@ -80,7 +81,7 @@ require ( github.com/mitchellh/go-wordwrap v1.0.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect - github.com/oklog/run v1.1.0 // indirect + github.com/oklog/run v1.2.0 // indirect github.com/rogpeppe/go-internal v1.13.1 // indirect github.com/stackitcloud/stackit-sdk-go/services/authorization v0.9.0 github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect diff --git a/go.sum b/go.sum index 46219f3ef..8c8036eed 100644 --- a/go.sum +++ b/go.sum @@ -137,8 +137,8 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= -github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= -github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= +github.com/oklog/run v1.2.0 h1:O8x3yXwah4A73hJdlrwo/2X6J62gE5qTMusH0dvz60E= +github.com/oklog/run v1.2.0/go.mod h1:mgDbKRSwPhJfesJ4PntqFUbKQRZ50NgmZTSPlFA0YFk= github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= @@ -166,6 +166,8 @@ github.com/stackitcloud/stackit-sdk-go/services/iaas v0.31.0 h1:dnEjyapuv8WwRN5v github.com/stackitcloud/stackit-sdk-go/services/iaas v0.31.0/go.mod h1:854gnLR92NvAbJAA1xZEumrtNh1DoBP1FXTMvhwYA6w= github.com/stackitcloud/stackit-sdk-go/services/iaasalpha v0.1.21-alpha h1:m1jq6a8dbUe+suFuUNdHmM/cSehpGLUtDbK1CqLqydg= github.com/stackitcloud/stackit-sdk-go/services/iaasalpha v0.1.21-alpha/go.mod h1:Nu1b5Phsv8plgZ51+fkxPVsU91ZJ5Ayz+cthilxdmQ8= +github.com/stackitcloud/stackit-sdk-go/services/kms v1.0.0 h1:zxoOv7Fu+FmdsvTKiKkbmLItrMKfL+QoVtz9ReEF30E= +github.com/stackitcloud/stackit-sdk-go/services/kms v1.0.0/go.mod h1:KEPVoO21pC4bjy5l0nyhjUJ0+uVwVWb+k2TYrzJ8xYw= github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v1.6.0 h1:q33ZaCBVEBUsnMDxYyuJKtJvGcE5nKgvuPed3s8zXNI= github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v1.6.0/go.mod h1:20QOZ3rBC9wTGgzXzLz9M6YheX0VaxWE0/JI+s8On7k= github.com/stackitcloud/stackit-sdk-go/services/logme v0.25.1 h1:hv5WrRU9rN6Jx4OwdOGJRyaQrfA9p1tzEoQK6/CDyoA= diff --git a/stackit/internal/core/core.go b/stackit/internal/core/core.go index d993cddb3..6748da6e8 100644 --- a/stackit/internal/core/core.go +++ b/stackit/internal/core/core.go @@ -33,6 +33,7 @@ type ProviderData struct { DnsCustomEndpoint string GitCustomEndpoint string IaaSCustomEndpoint string + KMSCustomEndpoint string LoadBalancerCustomEndpoint string LogMeCustomEndpoint string MariaDBCustomEndpoint string diff --git a/stackit/internal/services/kms/keyring/datasource.go b/stackit/internal/services/kms/keyring/datasource.go new file mode 100644 index 000000000..956372c23 --- /dev/null +++ b/stackit/internal/services/kms/keyring/datasource.go @@ -0,0 +1,138 @@ +package kms + +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-log/tflog" + "github.com/stackitcloud/stackit-sdk-go/services/kms" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + kmsUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/kms/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" +) + +var ( + _ datasource.DataSource = &keyRingDataSource{} +) + +func NewKeyRingDataSource() datasource.DataSource { + return &keyRingDataSource{} +} + +type keyRingDataSource struct { + client *kms.APIClient + providerData core.ProviderData +} + +func (k *keyRingDataSource) Metadata(_ context.Context, request datasource.MetadataRequest, response *datasource.MetadataResponse) { + response.TypeName = request.ProviderTypeName + "_kms_keyring" +} + +func (k *keyRingDataSource) Configure(ctx context.Context, request datasource.ConfigureRequest, response *datasource.ConfigureResponse) { + var ok bool + k.providerData, ok = conversion.ParseProviderData(ctx, request.ProviderData, &response.Diagnostics) + if !ok { + return + } + + apiClient := kmsUtils.ConfigureClient(ctx, &k.providerData, &response.Diagnostics) + if response.Diagnostics.HasError() { + return + } + + k.client = apiClient + tflog.Info(ctx, "Keyring configured") +} + +func (k *keyRingDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, response *datasource.SchemaResponse) { + response.Schema = schema.Schema{ + Description: "KMS Keyring resource schema.", + Attributes: map[string]schema.Attribute{ + "description": schema.StringAttribute{ + Description: "A user chosen description to distinguish multiple keyrings.", + Computed: true, + }, + "display_name": schema.StringAttribute{ + Description: "The display name to distinguish multiple keyrings.", + Computed: true, + }, + "keyring_id": schema.StringAttribute{ + Description: "An auto generated unique id which identifies the keyring.", + Computed: false, + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "id": schema.StringAttribute{ + Description: "Terraform's internal resource ID. It is structured as \"`project_id`,`region`,`keyring_id`\".", + Computed: true, + }, + "project_id": schema.StringAttribute{ + Description: "STACKIT project ID to which the keyring is associated.", + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "region": schema.StringAttribute{ + Computed: true, + Description: "The resource region. If not defined, the provider region is used.", + }, + }, + } +} + +func (k *keyRingDataSource) Read(ctx context.Context, request datasource.ReadRequest, response *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform + var model Model + + diags := request.Config.Get(ctx, &model) + response.Diagnostics.Append(diags...) + if response.Diagnostics.HasError() { + return + } + projectId := model.ProjectId.ValueString() + keyRingId := model.KeyRingId.ValueString() + region := k.providerData.GetRegionWithOverride(model.Region) + + ctx = tflog.SetField(ctx, "keyring_id", keyRingId) + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "region", region) + + keyRingResponse, err := k.client.GetKeyRing(ctx, projectId, region, keyRingId).Execute() + if err != nil { + utils.LogError( + ctx, + &response.Diagnostics, + err, + "Reading keyring", + fmt.Sprintf("Keyring with ID %q does not exist in project %q.", keyRingId, projectId), + map[int]string{ + http.StatusForbidden: fmt.Sprintf("Project with ID %q not found or forbidden access", projectId), + }, + ) + response.State.RemoveResource(ctx) + return + } + + err = mapFields(keyRingResponse, &model, region) + if err != nil { + core.LogAndAddError(ctx, &response.Diagnostics, "Error reading keyring", fmt.Sprintf("Processing API payload: %v", err)) + return + } + + diags = response.State.Set(ctx, &model) + response.Diagnostics.Append(diags...) + if response.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Key ring read") +} diff --git a/stackit/internal/services/kms/keyring/resource.go b/stackit/internal/services/kms/keyring/resource.go new file mode 100644 index 000000000..5657df47a --- /dev/null +++ b/stackit/internal/services/kms/keyring/resource.go @@ -0,0 +1,345 @@ +package kms + +import ( + "context" + "fmt" + "net/http" + "strings" + "time" + + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "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/oapierror" + "github.com/stackitcloud/stackit-sdk-go/services/kms" + "github.com/stackitcloud/stackit-sdk-go/services/kms/wait" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + kmsUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/kms/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" +) + +const ( + deletionWarning = "Keyrings will **not** be destroyed by terraform during a `terraform destroy`. They will just be thrown out of the Terraform state and not deleted on API side. **This way we can ensure no keyring setups are deleted by accident and it gives you the option to recover your keys within the grace period.**" +) + +var ( + _ resource.Resource = &keyRingResource{} + _ resource.ResourceWithConfigure = &keyRingResource{} + _ resource.ResourceWithImportState = &keyRingResource{} + _ resource.ResourceWithModifyPlan = &keyRingResource{} +) + +type Model struct { + Description types.String `tfsdk:"description"` + DisplayName types.String `tfsdk:"display_name"` + KeyRingId types.String `tfsdk:"keyring_id"` + Id types.String `tfsdk:"id"` // needed by TF + ProjectId types.String `tfsdk:"project_id"` + Region types.String `tfsdk:"region"` +} + +func NewKeyRingResource() resource.Resource { + return &keyRingResource{} +} + +type keyRingResource struct { + client *kms.APIClient + providerData core.ProviderData +} + +func (r *keyRingResource) Metadata(_ context.Context, request resource.MetadataRequest, response *resource.MetadataResponse) { + response.TypeName = request.ProviderTypeName + "_kms_keyring" +} + +func (r *keyRingResource) Configure(ctx context.Context, request resource.ConfigureRequest, response *resource.ConfigureResponse) { + var ok bool + r.providerData, ok = conversion.ParseProviderData(ctx, request.ProviderData, &response.Diagnostics) + if !ok { + return + } + + apiClient := kmsUtils.ConfigureClient(ctx, &r.providerData, &response.Diagnostics) + if response.Diagnostics.HasError() { + return + } + r.client = apiClient +} + +// ModifyPlan implements resource.ResourceWithModifyPlan. +// Use the modifier to set the effective region in the current plan. +func (r *keyRingResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { // nolint:gocritic // function signature required by Terraform + var configModel Model + // skip initial empty configuration to avoid follow-up errors + if req.Config.Raw.IsNull() { + return + } + resp.Diagnostics.Append(req.Config.Get(ctx, &configModel)...) + if resp.Diagnostics.HasError() { + return + } + + var planModel Model + resp.Diagnostics.Append(req.Plan.Get(ctx, &planModel)...) + if resp.Diagnostics.HasError() { + return + } + + utils.AdaptRegion(ctx, configModel.Region, &planModel.Region, r.providerData.GetRegion(), resp) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.Plan.Set(ctx, planModel)...) + if resp.Diagnostics.HasError() { + return + } +} + +func (r *keyRingResource) Schema(_ context.Context, _ resource.SchemaRequest, response *resource.SchemaResponse) { + description := "KMS Keyring resource schema." + + response.Schema = schema.Schema{ + Description: description, + MarkdownDescription: fmt.Sprintf("%s\n\n ~> %s", description, deletionWarning), + Attributes: map[string]schema.Attribute{ + "description": schema.StringAttribute{ + Description: "A user chosen description to distinguish multiple keyrings.", + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "display_name": schema.StringAttribute{ + Description: "The display name to distinguish multiple keyrings.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, + }, + "keyring_id": schema.StringAttribute{ + Description: "An auto generated unique id which identifies the keyring.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "id": schema.StringAttribute{ + Description: "Terraform's internal resource ID. It is structured as \"`project_id`,`region`,`keyring_id`\".", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "project_id": schema.StringAttribute{ + Description: "STACKIT project ID to which the keyring is associated.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "region": schema.StringAttribute{ + Optional: true, + // must be computed to allow for storing the override value from the provider + Computed: true, + Description: "The resource region. If not defined, the provider region is used.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + }, + } +} + +func (r *keyRingResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform + var model Model + diags := req.Plan.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + projectId := model.ProjectId.ValueString() + region := r.providerData.GetRegionWithOverride(model.Region) + + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "region", region) + + payload, err := toCreatePayload(&model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating keyring", fmt.Sprintf("Creating API payload: %v", err)) + return + } + createResponse, err := r.client.CreateKeyRing(ctx, projectId, region).CreateKeyRingPayload(*payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating keyring", fmt.Sprintf("Calling API: %v", err)) + return + } + + keyRingId := *createResponse.Id + // Write id attributes to state before polling via the wait handler - just in case anything goes wrong during the wait handler + utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{ + "project_id": projectId, + "region": region, + "keyring_id": keyRingId, + }) + + waitResp, err := wait.CreateKeyRingWaitHandler(ctx, r.client, projectId, region, keyRingId).SetSleepBeforeWait(5 * time.Second).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating keyring", fmt.Sprintf("Key Ring creation waiting: %v", err)) + return + } + + err = mapFields(waitResp, &model, region) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating keyring", 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 Ring created") +} + +func (r *keyRingResource) 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() + keyRingId := model.KeyRingId.ValueString() + region := r.providerData.GetRegionWithOverride(model.Region) + + ctx = tflog.SetField(ctx, "keyring_id", keyRingId) + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "region", region) + + keyRingResponse, err := r.client.GetKeyRing(ctx, projectId, region, keyRingId).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 keyring", fmt.Sprintf("Calling API: %v", err)) + return + } + + err = mapFields(keyRingResponse, &model, region) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading keyring", 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 ring read") +} + +func (r *keyRingResource) Update(ctx context.Context, _ resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform + // keyrings cannot be updated, so we log an error. + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating keyring", "Keyrings can't be updated") +} + +func (r *keyRingResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // 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 + } + + // The keyring can't be deleted by Terraform because it potentially has still keys inside it. + // These keys might be *scheduled* for deletion, but aren't deleted completely, so the delete request would fail. + core.LogAndAddWarning(ctx, &resp.Diagnostics, "Keyring not deleted on API side", deletionWarning) + + tflog.Info(ctx, "keyring deleted") +} + +func (r *keyRingResource) 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 keyring", + fmt.Sprintf("Exptected import identifier with format: [project_id],[region],[keyring_id], got :%q", req.ID), + ) + return + } + + utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{ + "project_id": idParts[0], + "region": idParts[1], + "keyring_id": idParts[2], + }) + + tflog.Info(ctx, "keyring state imported") +} + +func mapFields(keyRing *kms.KeyRing, model *Model, region string) error { + if keyRing == nil { + return fmt.Errorf("response input is nil") + } + if model == nil { + return fmt.Errorf("model input is nil") + } + + var keyRingId string + if model.KeyRingId.ValueString() != "" { + keyRingId = model.KeyRingId.ValueString() + } else if keyRing.Id != nil { + keyRingId = *keyRing.Id + } else { + return fmt.Errorf("keyring id not present") + } + + model.Id = utils.BuildInternalTerraformId(model.ProjectId.ValueString(), region, keyRingId) + model.KeyRingId = types.StringValue(keyRingId) + model.DisplayName = types.StringPointerValue(keyRing.DisplayName) + model.Region = types.StringValue(region) + + // TODO: workaround - remove once STACKITKMS-377 is resolved (just write the return value from the API to the state then) + if !(model.Description.IsNull() && keyRing.Description != nil && *keyRing.Description == "") { + model.Description = types.StringPointerValue(keyRing.Description) + } else { + model.Description = types.StringNull() + } + + return nil +} + +func toCreatePayload(model *Model) (*kms.CreateKeyRingPayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + + return &kms.CreateKeyRingPayload{ + Description: conversion.StringValueToPointer(model.Description), + DisplayName: conversion.StringValueToPointer(model.DisplayName), + }, nil +} diff --git a/stackit/internal/services/kms/keyring/resource_test.go b/stackit/internal/services/kms/keyring/resource_test.go new file mode 100644 index 000000000..9645c5f0a --- /dev/null +++ b/stackit/internal/services/kms/keyring/resource_test.go @@ -0,0 +1,173 @@ +package kms + +import ( + "fmt" + "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/kms" +) + +const testRegion = "eu01" + +func TestMapFields(t *testing.T) { + tests := []struct { + description string + state Model + input *kms.KeyRing + expected Model + isValid bool + }{ + { + "default values", + Model{ + KeyRingId: types.StringValue("krid"), + ProjectId: types.StringValue("pid"), + }, + &kms.KeyRing{ + Id: utils.Ptr("krid"), + }, + Model{ + Description: types.StringNull(), + DisplayName: types.StringNull(), + KeyRingId: types.StringValue("krid"), + Id: types.StringValue("pid,eu01,krid"), + ProjectId: types.StringValue("pid"), + Region: types.StringValue(testRegion), + }, + true, + }, + { + "values_ok", + Model{ + KeyRingId: types.StringValue("krid"), + ProjectId: types.StringValue("pid"), + }, + &kms.KeyRing{ + Description: utils.Ptr("descr"), + DisplayName: utils.Ptr("name"), + Id: utils.Ptr("krid"), + }, + Model{ + Description: types.StringValue("descr"), + DisplayName: types.StringValue("name"), + KeyRingId: types.StringValue("krid"), + Id: types.StringValue("pid,eu01,krid"), + ProjectId: types.StringValue("pid"), + Region: types.StringValue(testRegion), + }, + true, + }, + { + "nil_response_field", + Model{}, + &kms.KeyRing{ + Id: nil, + }, + Model{}, + false, + }, + { + "nil_response", + Model{}, + nil, + Model{}, + false, + }, + { + "no_resource_id", + Model{ + Region: types.StringValue(testRegion), + ProjectId: types.StringValue("pid"), + }, + &kms.KeyRing{}, + Model{}, + false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + state := &Model{ + ProjectId: tt.expected.ProjectId, + KeyRingId: tt.expected.KeyRingId, + } + err := mapFields(tt.input, state, testRegion) + 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(state, &tt.expected) + if diff != "" { + fmt.Println("state: ", state, " expected: ", tt.expected) + t.Fatalf("Data does not match") + } + } + }) + } +} + +func TestToCreatePayload(t *testing.T) { + tests := []struct { + description string + input *Model + expected *kms.CreateKeyRingPayload + isValid bool + }{ + { + "default_values", + &Model{}, + &kms.CreateKeyRingPayload{}, + true, + }, + { + "simple_values", + &Model{ + DisplayName: types.StringValue("name"), + }, + &kms.CreateKeyRingPayload{ + DisplayName: utils.Ptr("name"), + }, + true, + }, + { + "null_fields", + &Model{ + DisplayName: types.StringValue(""), + Description: types.StringValue(""), + }, + &kms.CreateKeyRingPayload{ + DisplayName: utils.Ptr(""), + Description: utils.Ptr(""), + }, + true, + }, + { + "nil_model", + nil, + nil, + false, + }, + } + 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) + } + } + }) + } +} diff --git a/stackit/internal/services/kms/kms_acc_test.go b/stackit/internal/services/kms/kms_acc_test.go new file mode 100644 index 000000000..77afc3e2e --- /dev/null +++ b/stackit/internal/services/kms/kms_acc_test.go @@ -0,0 +1,204 @@ +package kms_test + +import ( + _ "embed" + "fmt" + "maps" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/config" + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/testutil" +) + +var ( + //go:embed testdata/keyring-min.tf + resourceKeyRingMinConfig string + + //go:embed testdata/keyring-max.tf + resourceKeyRingMaxConfig string +) + +var testConfigKeyRingVarsMin = config.Variables{ + "project_id": config.StringVariable(testutil.ProjectId), + "display_name": config.StringVariable("tf-acc-" + acctest.RandStringFromCharSet(8, acctest.CharSetAlpha)), +} + +var testConfigKeyRingVarsMinUpdated = func() config.Variables { + updatedConfig := config.Variables{} + maps.Copy(updatedConfig, testConfigKeyRingVarsMin) + updatedConfig["display_name"] = config.StringVariable(fmt.Sprintf("%s-updated", testutil.ConvertConfigVariable(updatedConfig["display_name"]))) + return updatedConfig +} + +var testConfigKeyRingVarsMax = config.Variables{ + "project_id": config.StringVariable(testutil.ProjectId), + "description": config.StringVariable("description"), + "display_name": config.StringVariable("tf-acc-" + acctest.RandStringFromCharSet(8, acctest.CharSetAlpha)), +} + +var testConfigKeyRingVarsMaxUpdated = func() config.Variables { + updatedConfig := config.Variables{} + maps.Copy(updatedConfig, testConfigKeyRingVarsMax) + updatedConfig["description"] = config.StringVariable("updated description") + updatedConfig["display_name"] = config.StringVariable(fmt.Sprintf("%s-updated", testutil.ConvertConfigVariable(updatedConfig["display_name"]))) + return updatedConfig +} + +func TestAccKeyRingMin(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // Creation + { + ConfigVariables: testConfigKeyRingVarsMin, + Config: fmt.Sprintf("%s\n%s", testutil.KMSProviderConfig(), resourceKeyRingMinConfig), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_kms_keyring.keyring", "project_id", testutil.ProjectId), + resource.TestCheckResourceAttr("stackit_kms_keyring.keyring", "region", testutil.Region), + resource.TestCheckResourceAttr("stackit_kms_keyring.keyring", "display_name", testutil.ConvertConfigVariable(testConfigKeyRingVarsMin["display_name"])), + resource.TestCheckResourceAttrSet("stackit_kms_keyring.keyring", "keyring_id"), + resource.TestCheckNoResourceAttr("stackit_kms_keyring.keyring", "description"), + ), + }, + // Data source + { + ConfigVariables: testConfigKeyRingVarsMin, + Config: fmt.Sprintf(` + %s + %s + + data "stackit_kms_keyring" "keyring" { + project_id = stackit_kms_keyring.keyring.project_id + keyring_id = stackit_kms_keyring.keyring.keyring_id + } + `, + testutil.KMSProviderConfig(), resourceKeyRingMinConfig, + ), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("data.stackit_kms_keyring.keyring", "project_id", testutil.ProjectId), + resource.TestCheckResourceAttr("data.stackit_kms_keyring.keyring", "region", testutil.Region), + resource.TestCheckResourceAttrPair( + "stackit_kms_keyring.keyring", "keyring_id", + "data.stackit_kms_keyring.keyring", "keyring_id", + ), + resource.TestCheckResourceAttr("data.stackit_kms_keyring.keyring", "display_name", testutil.ConvertConfigVariable(testConfigKeyRingVarsMin["display_name"])), + resource.TestCheckNoResourceAttr("data.stackit_kms_keyring.keyring", "description"), + ), + }, + // Import + { + ConfigVariables: testConfigKeyRingVarsMin, + ResourceName: "stackit_kms_keyring.keyring", + ImportStateIdFunc: func(s *terraform.State) (string, error) { + r, ok := s.RootModule().Resources["stackit_kms_keyring.keyring"] + if !ok { + return "", fmt.Errorf("couldn't find resource stackit_kms_keyring.keyring") + } + keyRingId, ok := r.Primary.Attributes["keyring_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute keyring_id") + } + + return fmt.Sprintf("%s,%s,%s", testutil.ProjectId, testutil.Region, keyRingId), nil + }, + ImportState: true, + ImportStateVerify: true, + }, + // Update + { + ConfigVariables: testConfigKeyRingVarsMinUpdated(), + Config: fmt.Sprintf("%s\n%s", testutil.KMSProviderConfig(), resourceKeyRingMinConfig), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_kms_keyring.keyring", "project_id", testutil.ProjectId), + resource.TestCheckResourceAttr("stackit_kms_keyring.keyring", "region", testutil.Region), + resource.TestCheckResourceAttr("stackit_kms_keyring.keyring", "display_name", testutil.ConvertConfigVariable(testConfigKeyRingVarsMinUpdated()["display_name"])), + resource.TestCheckResourceAttrSet("stackit_kms_keyring.keyring", "keyring_id"), + resource.TestCheckNoResourceAttr("stackit_kms_keyring.keyring", "description"), + ), + }, + // Deletion is done by the framework implicitly + }, + }) +} + +func TestAccKeyRingMax(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // Creation + { + ConfigVariables: testConfigKeyRingVarsMax, + Config: fmt.Sprintf("%s\n%s", testutil.KMSProviderConfig(), resourceKeyRingMaxConfig), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_kms_keyring.keyring", "project_id", testutil.ProjectId), + resource.TestCheckResourceAttr("stackit_kms_keyring.keyring", "region", testutil.Region), + resource.TestCheckResourceAttr("stackit_kms_keyring.keyring", "description", testutil.ConvertConfigVariable(testConfigKeyRingVarsMax["description"])), + resource.TestCheckResourceAttrSet("stackit_kms_keyring.keyring", "keyring_id"), + resource.TestCheckResourceAttr("stackit_kms_keyring.keyring", "display_name", testutil.ConvertConfigVariable(testConfigKeyRingVarsMax["display_name"])), + ), + }, + // Data Source + { + ConfigVariables: testConfigKeyRingVarsMax, + Config: fmt.Sprintf(` + %s + %s + + data "stackit_kms_keyring" "keyring" { + project_id = stackit_kms_keyring.keyring.project_id + keyring_id = stackit_kms_keyring.keyring.keyring_id + } + `, + testutil.KMSProviderConfig(), resourceKeyRingMaxConfig, + ), + Check: resource.ComposeAggregateTestCheckFunc( + resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("data.stackit_kms_keyring.keyring", "project_id", testutil.ProjectId), + resource.TestCheckResourceAttr("data.stackit_kms_keyring.keyring", "region", testutil.Region), + resource.TestCheckResourceAttrPair( + "stackit_kms_keyring.keyring", "keyring_id", + "data.stackit_kms_keyring.keyring", "keyring_id", + ), + resource.TestCheckResourceAttr("data.stackit_kms_keyring.keyring", "description", testutil.ConvertConfigVariable(testConfigKeyRingVarsMax["description"])), + resource.TestCheckResourceAttr("data.stackit_kms_keyring.keyring", "display_name", testutil.ConvertConfigVariable(testConfigKeyRingVarsMax["display_name"])), + ), + ), + }, + // Import + { + ConfigVariables: testConfigKeyRingVarsMax, + ResourceName: "stackit_kms_keyring.keyring", + ImportStateIdFunc: func(s *terraform.State) (string, error) { + r, ok := s.RootModule().Resources["stackit_kms_keyring.keyring"] + if !ok { + return "", fmt.Errorf("couldn't find resource stackit_kms_keyring.keyring") + } + keyRingId, ok := r.Primary.Attributes["keyring_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute keyring_id") + } + + return fmt.Sprintf("%s,%s,%s", testutil.ProjectId, testutil.Region, keyRingId), nil + }, + ImportState: true, + ImportStateVerify: true, + }, + // Update + { + ConfigVariables: testConfigKeyRingVarsMaxUpdated(), + Config: fmt.Sprintf("%s\n%s", testutil.KMSProviderConfig(), resourceKeyRingMaxConfig), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_kms_keyring.keyring", "project_id", testutil.ProjectId), + resource.TestCheckResourceAttr("stackit_kms_keyring.keyring", "region", testutil.Region), + resource.TestCheckResourceAttr("stackit_kms_keyring.keyring", "display_name", testutil.ConvertConfigVariable(testConfigKeyRingVarsMaxUpdated()["display_name"])), + resource.TestCheckResourceAttrSet("stackit_kms_keyring.keyring", "keyring_id"), + resource.TestCheckResourceAttr("stackit_kms_keyring.keyring", "description", testutil.ConvertConfigVariable(testConfigKeyRingVarsMaxUpdated()["description"])), + ), + }, + // Deletion is done by the framework implicitly + }, + }) +} diff --git a/stackit/internal/services/kms/testdata/keyring-max.tf b/stackit/internal/services/kms/testdata/keyring-max.tf new file mode 100644 index 000000000..74497f67a --- /dev/null +++ b/stackit/internal/services/kms/testdata/keyring-max.tf @@ -0,0 +1,10 @@ +variable "project_id" {} + +variable "display_name" {} +variable "description" {} + +resource "stackit_kms_keyring" "keyring" { + project_id = var.project_id + display_name = var.display_name + description = var.description +} diff --git a/stackit/internal/services/kms/testdata/keyring-min.tf b/stackit/internal/services/kms/testdata/keyring-min.tf new file mode 100644 index 000000000..cb38cad32 --- /dev/null +++ b/stackit/internal/services/kms/testdata/keyring-min.tf @@ -0,0 +1,8 @@ +variable "project_id" {} + +variable "display_name" {} + +resource "stackit_kms_keyring" "keyring" { + project_id = var.project_id + display_name = var.display_name +} diff --git a/stackit/internal/services/kms/utils/util.go b/stackit/internal/services/kms/utils/util.go new file mode 100644 index 000000000..9f6f64d81 --- /dev/null +++ b/stackit/internal/services/kms/utils/util.go @@ -0,0 +1,29 @@ +package utils + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/services/kms" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" +) + +func ConfigureClient(ctx context.Context, providerData *core.ProviderData, diags *diag.Diagnostics) *kms.APIClient { + apiClientConfigOptions := []config.ConfigurationOption{ + config.WithCustomAuth(providerData.RoundTripper), + utils.UserAgentConfigOption(providerData.Version), + } + if providerData.KMSCustomEndpoint != "" { + apiClientConfigOptions = append(apiClientConfigOptions, config.WithEndpoint(providerData.KMSCustomEndpoint)) + } + apiClient, err := kms.NewAPIClient(apiClientConfigOptions...) + if err != nil { + core.LogAndAddError(ctx, diags, "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 nil + } + + return apiClient +} diff --git a/stackit/internal/testutil/testutil.go b/stackit/internal/testutil/testutil.go index a2b83119b..ea15ce31b 100644 --- a/stackit/internal/testutil/testutil.go +++ b/stackit/internal/testutil/testutil.go @@ -55,6 +55,7 @@ var ( DnsCustomEndpoint = os.Getenv("TF_ACC_DNS_CUSTOM_ENDPOINT") GitCustomEndpoint = os.Getenv("TF_ACC_GIT_CUSTOM_ENDPOINT") IaaSCustomEndpoint = os.Getenv("TF_ACC_IAAS_CUSTOM_ENDPOINT") + KMSCustomEndpoint = os.Getenv("TF_ACC_KMS_CUSTOM_ENDPOINT") LoadBalancerCustomEndpoint = os.Getenv("TF_ACC_LOADBALANCER_CUSTOM_ENDPOINT") LogMeCustomEndpoint = os.Getenv("TF_ACC_LOGME_CUSTOM_ENDPOINT") MariaDBCustomEndpoint = os.Getenv("TF_ACC_MARIADB_CUSTOM_ENDPOINT") @@ -169,6 +170,21 @@ func IaaSProviderConfigWithExperiments() string { ) } +func KMSProviderConfig() string { + if KMSCustomEndpoint == "" { + return ` + provider "stackit" { + default_region = "eu01" + }` + } + return fmt.Sprintf(` + provider "stackit" { + kms_custom_endpoint = "%s" + }`, + KMSCustomEndpoint, + ) +} + func LoadBalancerProviderConfig() string { if LoadBalancerCustomEndpoint == "" { return ` diff --git a/stackit/provider.go b/stackit/provider.go index 5f51fc33c..61fa04f75 100644 --- a/stackit/provider.go +++ b/stackit/provider.go @@ -48,6 +48,7 @@ import ( iaasalphaRoutingTableRoutes "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaasalpha/routingtable/routes" iaasalphaRoutingTable "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaasalpha/routingtable/table" iaasalphaRoutingTables "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaasalpha/routingtable/tables" + kmsKeyRing "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/kms/keyring" loadBalancer "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/loadbalancer/loadbalancer" loadBalancerObservabilityCredential "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/loadbalancer/observability-credential" logMeCredential "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/logme/credential" @@ -131,6 +132,7 @@ type providerModel struct { DNSCustomEndpoint types.String `tfsdk:"dns_custom_endpoint"` GitCustomEndpoint types.String `tfsdk:"git_custom_endpoint"` IaaSCustomEndpoint types.String `tfsdk:"iaas_custom_endpoint"` + KMSCustomEndpoint types.String `tfsdk:"kms_custom_endpoint"` PostgresFlexCustomEndpoint types.String `tfsdk:"postgresflex_custom_endpoint"` MongoDBFlexCustomEndpoint types.String `tfsdk:"mongodbflex_custom_endpoint"` ModelServingCustomEndpoint types.String `tfsdk:"modelserving_custom_endpoint"` @@ -173,6 +175,7 @@ func (p *Provider) Schema(_ context.Context, _ provider.SchemaRequest, resp *pro "dns_custom_endpoint": "Custom endpoint for the DNS service", "git_custom_endpoint": "Custom endpoint for the Git service", "iaas_custom_endpoint": "Custom endpoint for the IaaS service", + "kms_custom_endpoint": "Custom endpoint for the KMS service", "mongodbflex_custom_endpoint": "Custom endpoint for the MongoDB Flex service", "modelserving_custom_endpoint": "Custom endpoint for the AI Model Serving service", "loadbalancer_custom_endpoint": "Custom endpoint for the Load Balancer service", @@ -264,6 +267,10 @@ func (p *Provider) Schema(_ context.Context, _ provider.SchemaRequest, resp *pro Optional: true, Description: descriptions["iaas_custom_endpoint"], }, + "kms_custom_endpoint": schema.StringAttribute{ + Optional: true, + Description: descriptions["kms_custom_endpoint"], + }, "postgresflex_custom_endpoint": schema.StringAttribute{ Optional: true, Description: descriptions["postgresflex_custom_endpoint"], @@ -414,6 +421,7 @@ func (p *Provider) Configure(ctx context.Context, req provider.ConfigureRequest, setStringField(providerConfig.GitCustomEndpoint, func(v string) { providerData.GitCustomEndpoint = v }) setStringField(providerConfig.IaaSCustomEndpoint, func(v string) { providerData.IaaSCustomEndpoint = v }) setStringField(providerConfig.PostgresFlexCustomEndpoint, func(v string) { providerData.PostgresFlexCustomEndpoint = v }) + setStringField(providerConfig.KMSCustomEndpoint, func(v string) { providerData.KMSCustomEndpoint = v }) setStringField(providerConfig.ModelServingCustomEndpoint, func(v string) { providerData.ModelServingCustomEndpoint = v }) setStringField(providerConfig.MongoDBFlexCustomEndpoint, func(v string) { providerData.MongoDBFlexCustomEndpoint = v }) setStringField(providerConfig.LoadBalancerCustomEndpoint, func(v string) { providerData.LoadBalancerCustomEndpoint = v }) @@ -486,6 +494,7 @@ func (p *Provider) DataSources(_ context.Context) []func() datasource.DataSource iaasalphaRoutingTables.NewRoutingTablesDataSource, iaasalphaRoutingTableRoutes.NewRoutingTableRoutesDataSource, iaasSecurityGroupRule.NewSecurityGroupRuleDataSource, + kmsKeyRing.NewKeyRingDataSource, loadBalancer.NewLoadBalancerDataSource, logMeInstance.NewInstanceDataSource, logMeCredential.NewCredentialDataSource, @@ -554,6 +563,7 @@ func (p *Provider) Resources(_ context.Context) []func() resource.Resource { iaasSecurityGroupRule.NewSecurityGroupRuleResource, iaasalphaRoutingTable.NewRoutingTableResource, iaasalphaRoutingTableRoute.NewRoutingTableRouteResource, + kmsKeyRing.NewKeyRingResource, loadBalancer.NewLoadBalancerResource, loadBalancerObservabilityCredential.NewObservabilityCredentialResource, logMeInstance.NewInstanceResource, From 21a82e5992c35fc7fb56386bebd2c660d1fd3779 Mon Sep 17 00:00:00 2001 From: Ruben Hoenle Date: Wed, 12 Nov 2025 11:49:33 +0100 Subject: [PATCH 2/2] review findings --- docs/data-sources/kms_keyring.md | 9 +- docs/resources/kms_keyring.md | 4 +- stackit/internal/core/core.go | 9 +- .../services/kms/keyring/datasource.go | 7 +- .../internal/services/kms/keyring/resource.go | 16 ++- stackit/internal/services/kms/kms_acc_test.go | 111 ++++++++++++++++++ 6 files changed, 141 insertions(+), 15 deletions(-) diff --git a/docs/data-sources/kms_keyring.md b/docs/data-sources/kms_keyring.md index cd0004cef..6b8201941 100644 --- a/docs/data-sources/kms_keyring.md +++ b/docs/data-sources/kms_keyring.md @@ -3,12 +3,12 @@ page_title: "stackit_kms_keyring Data Source - stackit" subcategory: "" description: |- - KMS Keyring resource schema. + KMS Keyring datasource schema. Uses the default_region specified in the provider configuration as a fallback in case no region is defined on datasource level. --- # stackit_kms_keyring (Data Source) -KMS Keyring resource schema. +KMS Keyring datasource schema. Uses the `default_region` specified in the provider configuration as a fallback in case no `region` is defined on datasource level. ## Example Usage @@ -27,9 +27,12 @@ data "stackit_kms_keyring" "example" { - `keyring_id` (String) An auto generated unique id which identifies the keyring. - `project_id` (String) STACKIT project ID to which the keyring is associated. +### Optional + +- `region` (String) The resource region. If not defined, the provider region is used. + ### Read-Only - `description` (String) A user chosen description to distinguish multiple keyrings. - `display_name` (String) The display name to distinguish multiple keyrings. - `id` (String) Terraform's internal resource ID. It is structured as "`project_id`,`region`,`keyring_id`". -- `region` (String) The resource region. If not defined, the provider region is used. diff --git a/docs/resources/kms_keyring.md b/docs/resources/kms_keyring.md index 779a7eff4..1d8561372 100644 --- a/docs/resources/kms_keyring.md +++ b/docs/resources/kms_keyring.md @@ -3,13 +3,13 @@ page_title: "stackit_kms_keyring Resource - stackit" subcategory: "" description: |- - KMS Keyring resource schema. + KMS Keyring resource schema. Uses the default_region specified in the provider configuration as a fallback in case no region is defined on resource level. ~> Keyrings will not be destroyed by terraform during a terraform destroy. They will just be thrown out of the Terraform state and not deleted on API side. This way we can ensure no keyring setups are deleted by accident and it gives you the option to recover your keys within the grace period. --- # stackit_kms_keyring (Resource) -KMS Keyring resource schema. +KMS Keyring resource schema. Uses the `default_region` specified in the provider configuration as a fallback in case no `region` is defined on resource level. ~> Keyrings will **not** be destroyed by terraform during a `terraform destroy`. They will just be thrown out of the Terraform state and not deleted on API side. **This way we can ensure no keyring setups are deleted by accident and it gives you the option to recover your keys within the grace period.** diff --git a/stackit/internal/core/core.go b/stackit/internal/core/core.go index 6748da6e8..405bf0c90 100644 --- a/stackit/internal/core/core.go +++ b/stackit/internal/core/core.go @@ -12,14 +12,17 @@ import ( "github.com/hashicorp/terraform-plugin-log/tflog" ) -// Separator used for concatenation of TF-internal resource ID -const Separator = "," - type ResourceType string const ( Resource ResourceType = "resource" Datasource ResourceType = "datasource" + + // Separator used for concatenation of TF-internal resource ID + Separator = "," + + ResourceRegionFallbackDocstring = "Uses the `default_region` specified in the provider configuration as a fallback in case no `region` is defined on resource level." + DatasourceRegionFallbackDocstring = "Uses the `default_region` specified in the provider configuration as a fallback in case no `region` is defined on datasource level." ) type ProviderData struct { diff --git a/stackit/internal/services/kms/keyring/datasource.go b/stackit/internal/services/kms/keyring/datasource.go index 956372c23..55398f820 100644 --- a/stackit/internal/services/kms/keyring/datasource.go +++ b/stackit/internal/services/kms/keyring/datasource.go @@ -47,12 +47,12 @@ func (k *keyRingDataSource) Configure(ctx context.Context, request datasource.Co } k.client = apiClient - tflog.Info(ctx, "Keyring configured") + tflog.Info(ctx, "KMS client configured") } func (k *keyRingDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, response *datasource.SchemaResponse) { response.Schema = schema.Schema{ - Description: "KMS Keyring resource schema.", + Description: fmt.Sprintf("KMS Keyring datasource schema. %s", core.DatasourceRegionFallbackDocstring), Attributes: map[string]schema.Attribute{ "description": schema.StringAttribute{ Description: "A user chosen description to distinguish multiple keyrings.", @@ -64,7 +64,6 @@ func (k *keyRingDataSource) Schema(_ context.Context, _ datasource.SchemaRequest }, "keyring_id": schema.StringAttribute{ Description: "An auto generated unique id which identifies the keyring.", - Computed: false, Required: true, Validators: []validator.String{ validate.UUID(), @@ -84,6 +83,8 @@ func (k *keyRingDataSource) Schema(_ context.Context, _ datasource.SchemaRequest }, }, "region": schema.StringAttribute{ + Optional: true, + // must be computed to allow for storing the override value from the provider Computed: true, Description: "The resource region. If not defined, the provider region is used.", }, diff --git a/stackit/internal/services/kms/keyring/resource.go b/stackit/internal/services/kms/keyring/resource.go index 5657df47a..93da26cf1 100644 --- a/stackit/internal/services/kms/keyring/resource.go +++ b/stackit/internal/services/kms/keyring/resource.go @@ -2,6 +2,7 @@ package kms import ( "context" + "errors" "fmt" "net/http" "strings" @@ -65,11 +66,12 @@ func (r *keyRingResource) Configure(ctx context.Context, request resource.Config return } - apiClient := kmsUtils.ConfigureClient(ctx, &r.providerData, &response.Diagnostics) + r.client = kmsUtils.ConfigureClient(ctx, &r.providerData, &response.Diagnostics) if response.Diagnostics.HasError() { return } - r.client = apiClient + + tflog.Info(ctx, "KMS client configured") } // ModifyPlan implements resource.ResourceWithModifyPlan. @@ -103,7 +105,7 @@ func (r *keyRingResource) ModifyPlan(ctx context.Context, req resource.ModifyPla } func (r *keyRingResource) Schema(_ context.Context, _ resource.SchemaRequest, response *resource.SchemaResponse) { - description := "KMS Keyring resource schema." + description := fmt.Sprintf("KMS Keyring resource schema. %s", core.ResourceRegionFallbackDocstring) response.Schema = schema.Schema{ Description: description, @@ -193,6 +195,11 @@ func (r *keyRingResource) Create(ctx context.Context, req resource.CreateRequest return } + if createResponse == nil || createResponse.Id == nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating keyring", "API returned empty response") + return + } + keyRingId := *createResponse.Id // Write id attributes to state before polling via the wait handler - just in case anything goes wrong during the wait handler utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{ @@ -239,7 +246,8 @@ func (r *keyRingResource) Read(ctx context.Context, req resource.ReadRequest, re keyRingResponse, err := r.client.GetKeyRing(ctx, projectId, region, keyRingId).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 + var oapiErr *oapierror.GenericOpenAPIError + ok := errors.As(err, &oapiErr) if ok && oapiErr.StatusCode == http.StatusNotFound { resp.State.RemoveResource(ctx) return diff --git a/stackit/internal/services/kms/kms_acc_test.go b/stackit/internal/services/kms/kms_acc_test.go index 77afc3e2e..3a7082648 100644 --- a/stackit/internal/services/kms/kms_acc_test.go +++ b/stackit/internal/services/kms/kms_acc_test.go @@ -1,11 +1,22 @@ package kms_test import ( + "context" _ "embed" + "errors" "fmt" "maps" + "net/http" + "strings" + "sync" "testing" + "github.com/hashicorp/terraform-plugin-testing/plancheck" + coreConfig "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + "github.com/stackitcloud/stackit-sdk-go/services/kms" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/hashicorp/terraform-plugin-testing/config" "github.com/hashicorp/terraform-plugin-testing/helper/acctest" "github.com/hashicorp/terraform-plugin-testing/helper/resource" @@ -50,11 +61,17 @@ var testConfigKeyRingVarsMaxUpdated = func() config.Variables { func TestAccKeyRingMin(t *testing.T) { resource.Test(t, resource.TestCase{ ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + CheckDestroy: testAccCheckDestroy, Steps: []resource.TestStep{ // Creation { ConfigVariables: testConfigKeyRingVarsMin, Config: fmt.Sprintf("%s\n%s", testutil.KMSProviderConfig(), resourceKeyRingMinConfig), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("stackit_kms_keyring.keyring", plancheck.ResourceActionCreate), + }, + }, Check: resource.ComposeAggregateTestCheckFunc( resource.TestCheckResourceAttr("stackit_kms_keyring.keyring", "project_id", testutil.ProjectId), resource.TestCheckResourceAttr("stackit_kms_keyring.keyring", "region", testutil.Region), @@ -77,6 +94,11 @@ func TestAccKeyRingMin(t *testing.T) { `, testutil.KMSProviderConfig(), resourceKeyRingMinConfig, ), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("stackit_kms_keyring.keyring", plancheck.ResourceActionNoop), + }, + }, Check: resource.ComposeAggregateTestCheckFunc( resource.TestCheckResourceAttr("data.stackit_kms_keyring.keyring", "project_id", testutil.ProjectId), resource.TestCheckResourceAttr("data.stackit_kms_keyring.keyring", "region", testutil.Region), @@ -111,6 +133,11 @@ func TestAccKeyRingMin(t *testing.T) { { ConfigVariables: testConfigKeyRingVarsMinUpdated(), Config: fmt.Sprintf("%s\n%s", testutil.KMSProviderConfig(), resourceKeyRingMinConfig), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("stackit_kms_keyring.keyring", plancheck.ResourceActionReplace), + }, + }, Check: resource.ComposeAggregateTestCheckFunc( resource.TestCheckResourceAttr("stackit_kms_keyring.keyring", "project_id", testutil.ProjectId), resource.TestCheckResourceAttr("stackit_kms_keyring.keyring", "region", testutil.Region), @@ -127,11 +154,17 @@ func TestAccKeyRingMin(t *testing.T) { func TestAccKeyRingMax(t *testing.T) { resource.Test(t, resource.TestCase{ ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + CheckDestroy: testAccCheckDestroy, Steps: []resource.TestStep{ // Creation { ConfigVariables: testConfigKeyRingVarsMax, Config: fmt.Sprintf("%s\n%s", testutil.KMSProviderConfig(), resourceKeyRingMaxConfig), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("stackit_kms_keyring.keyring", plancheck.ResourceActionCreate), + }, + }, Check: resource.ComposeAggregateTestCheckFunc( resource.TestCheckResourceAttr("stackit_kms_keyring.keyring", "project_id", testutil.ProjectId), resource.TestCheckResourceAttr("stackit_kms_keyring.keyring", "region", testutil.Region), @@ -154,6 +187,11 @@ func TestAccKeyRingMax(t *testing.T) { `, testutil.KMSProviderConfig(), resourceKeyRingMaxConfig, ), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("stackit_kms_keyring.keyring", plancheck.ResourceActionNoop), + }, + }, Check: resource.ComposeAggregateTestCheckFunc( resource.ComposeAggregateTestCheckFunc( resource.TestCheckResourceAttr("data.stackit_kms_keyring.keyring", "project_id", testutil.ProjectId), @@ -190,6 +228,11 @@ func TestAccKeyRingMax(t *testing.T) { { ConfigVariables: testConfigKeyRingVarsMaxUpdated(), Config: fmt.Sprintf("%s\n%s", testutil.KMSProviderConfig(), resourceKeyRingMaxConfig), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("stackit_kms_keyring.keyring", plancheck.ResourceActionReplace), + }, + }, Check: resource.ComposeAggregateTestCheckFunc( resource.TestCheckResourceAttr("stackit_kms_keyring.keyring", "project_id", testutil.ProjectId), resource.TestCheckResourceAttr("stackit_kms_keyring.keyring", "region", testutil.Region), @@ -202,3 +245,71 @@ func TestAccKeyRingMax(t *testing.T) { }, }) } + +func testAccCheckDestroy(s *terraform.State) error { + checkFunctions := []func(s *terraform.State) error{ + testAccCheckKeyRingDestroy, + } + + var errs []error + + wg := sync.WaitGroup{} + wg.Add(len(checkFunctions)) + + for _, f := range checkFunctions { + go func() { + err := f(s) + if err != nil { + errs = append(errs, err) + } + wg.Done() + }() + } + wg.Wait() + return errors.Join(errs...) +} + +func testAccCheckKeyRingDestroy(s *terraform.State) error { + ctx := context.Background() + var client *kms.APIClient + var err error + if testutil.KMSCustomEndpoint == "" { + client, err = kms.NewAPIClient() + } else { + client, err = kms.NewAPIClient( + coreConfig.WithEndpoint(testutil.KMSCustomEndpoint), + ) + } + if err != nil { + return fmt.Errorf("creating client: %w", err) + } + + var errs []error + + for _, rs := range s.RootModule().Resources { + if rs.Type != "stackit_kms_keyring" { + continue + } + keyRingId := strings.Split(rs.Primary.ID, core.Separator)[2] + err := client.DeleteKeyRingExecute(ctx, testutil.ProjectId, testutil.Region, keyRingId) + if err != nil { + var oapiErr *oapierror.GenericOpenAPIError + if errors.As(err, &oapiErr) { + if oapiErr.StatusCode == http.StatusNotFound { + continue + } + + // Workaround: when the delete endpoint is called for a keyring which has keys inside it (no matter if + // they are scheduled for deletion or not, it will throw an HTTP 400 error and the keyring can't be + // deleted then). + // But at least we can delete all empty keyrings created by the keyring acc tests this way. + if oapiErr.StatusCode == http.StatusBadRequest { + continue + } + } + errs = append(errs, fmt.Errorf("cannot trigger keyring deletion %q: %w", keyRingId, err)) + } + } + + return errors.Join(errs...) +}