From 1f25cc7315295b785cbe0a3a94141e026921482c Mon Sep 17 00:00:00 2001 From: Ruben Aleman Date: Tue, 13 Jan 2026 07:55:12 +0100 Subject: [PATCH] feat(edgecloud): add resources and data sources for the edgecloud service --- docs/data-sources/edgecloud_instances.md | 61 +++ docs/data-sources/edgecloud_plans.md | 46 ++ docs/index.md | 1 + docs/resources/edgecloud_instance.md | 63 +++ docs/resources/edgecloud_kubeconfig.md | 63 +++ docs/resources/edgecloud_token.md | 63 +++ .../data-source.tf | 11 + .../stackit_edgecloud_plans/data-source.tf | 3 + .../stackit_edgecloud_instance/resource.tf | 20 + .../stackit_edgecloud_kubeconfig/resource.tf | 19 + .../stackit_edgecloud_token/resource.tf | 19 + go.mod | 1 + go.sum | 2 + stackit/internal/core/core.go | 1 + .../services/edgecloud/edge_acc_test.go | 378 +++++++++++++ .../services/edgecloud/instance/resource.go | 496 ++++++++++++++++++ .../edgecloud/instance/resource_test.go | 212 ++++++++ .../edgecloud/instances/datasource.go | 279 ++++++++++ .../edgecloud/instances/datasource_test.go | 302 +++++++++++ .../services/edgecloud/kubeconfig/resource.go | 439 ++++++++++++++++ .../edgecloud/kubeconfig/resource_test.go | 67 +++ .../services/edgecloud/plans/datasource.go | 205 ++++++++ .../edgecloud/testdata/resource-max.tf | 52 ++ .../edgecloud/testdata/resource-min.tf | 27 + .../services/edgecloud/token/resource.go | 420 +++++++++++++++ .../internal/services/edgecloud/utils/util.go | 66 +++ .../services/edgecloud/utils/util_test.go | 179 +++++++ stackit/internal/testutil/testutil.go | 22 + stackit/internal/utils/utils.go | 1 + stackit/provider.go | 17 + stackit/testdata/provider-all-attributes.tf | 1 + 31 files changed, 3536 insertions(+) create mode 100644 docs/data-sources/edgecloud_instances.md create mode 100644 docs/data-sources/edgecloud_plans.md create mode 100644 docs/resources/edgecloud_instance.md create mode 100644 docs/resources/edgecloud_kubeconfig.md create mode 100644 docs/resources/edgecloud_token.md create mode 100644 examples/data-sources/stackit_edgecloud_instances/data-source.tf create mode 100644 examples/data-sources/stackit_edgecloud_plans/data-source.tf create mode 100644 examples/resources/stackit_edgecloud_instance/resource.tf create mode 100644 examples/resources/stackit_edgecloud_kubeconfig/resource.tf create mode 100644 examples/resources/stackit_edgecloud_token/resource.tf create mode 100644 stackit/internal/services/edgecloud/edge_acc_test.go create mode 100644 stackit/internal/services/edgecloud/instance/resource.go create mode 100644 stackit/internal/services/edgecloud/instance/resource_test.go create mode 100644 stackit/internal/services/edgecloud/instances/datasource.go create mode 100644 stackit/internal/services/edgecloud/instances/datasource_test.go create mode 100644 stackit/internal/services/edgecloud/kubeconfig/resource.go create mode 100644 stackit/internal/services/edgecloud/kubeconfig/resource_test.go create mode 100644 stackit/internal/services/edgecloud/plans/datasource.go create mode 100644 stackit/internal/services/edgecloud/testdata/resource-max.tf create mode 100644 stackit/internal/services/edgecloud/testdata/resource-min.tf create mode 100644 stackit/internal/services/edgecloud/token/resource.go create mode 100644 stackit/internal/services/edgecloud/utils/util.go create mode 100644 stackit/internal/services/edgecloud/utils/util_test.go diff --git a/docs/data-sources/edgecloud_instances.md b/docs/data-sources/edgecloud_instances.md new file mode 100644 index 000000000..0a1d88706 --- /dev/null +++ b/docs/data-sources/edgecloud_instances.md @@ -0,0 +1,61 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_edgecloud_instances Data Source - stackit" +subcategory: "" +description: |- + Edge Cloud is in private Beta and not generally available. + You can contact support if you are interested in trying it out. + ~> This datasource 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_edgecloud_instances (Data Source) + +Edge Cloud is in private Beta and not generally available. + You can contact support if you are interested in trying it out. + +~> This datasource 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 +# returns all Edge Cloud instances created in the given project which are inside the provider default_region +data "stackit_edgecloud_instances" "plan_id" { + project_id = var.project_id +} + +# returns all Edge Cloud instances created in the given project in the given region +data "stackit_edgecloud_instances" "plan_id" { + project_id = var.project_id + region = var.region +} +``` + + +## Schema + +### Required + +- `project_id` (String) STACKIT project ID to which the Edge Cloud instances are associated. + +### Optional + +- `region` (String) The resource region. If not defined, the provider region is used. + +### Read-Only + +- `id` (String) Terraform's internal data source ID, structured as `project_id`,`region`. +- `instances` (Attributes List) A list of Edge Cloud instances. (see [below for nested schema](#nestedatt--instances)) + + +### Nested Schema for `instances` + +Read-Only: + +- `created` (String) The date and time the instance was created. +- `description` (String) Description of the instance. +- `display_name` (String) The display name of the instance. +- `frontend_url` (String) Frontend URL for the Edge Cloud instance. +- `instance_id` (String) The ID of the instance. +- `plan_id` (String) The plan ID for the instance. +- `region` (String) The region where the instance is located. +- `status` (String) The status of the instance. diff --git a/docs/data-sources/edgecloud_plans.md b/docs/data-sources/edgecloud_plans.md new file mode 100644 index 000000000..dc44765b2 --- /dev/null +++ b/docs/data-sources/edgecloud_plans.md @@ -0,0 +1,46 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_edgecloud_plans Data Source - stackit" +subcategory: "" +description: |- + Edge Cloud is in private Beta and not generally available. + You can contact support if you are interested in trying it out. + ~> This datasource 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_edgecloud_plans (Data Source) + +Edge Cloud is in private Beta and not generally available. + You can contact support if you are interested in trying it out. + +~> This datasource 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 +data "stackit_edgecloud_plans" "this" { + project_id = var.project_id +} +``` + + +## Schema + +### Required + +- `project_id` (String) STACKIT project ID the Plans belongs to. + +### Read-Only + +- `id` (String) Terraform's internal data source ID, `project_id` is used here. +- `plans` (Attributes List) A list of Edge Cloud Plans. (see [below for nested schema](#nestedatt--plans)) + + +### Nested Schema for `plans` + +Read-Only: + +- `description` (String) Description of the plan. +- `id` (String) The ID of the plan. +- `max_edge_hosts` (Number) Maximum number of Edge Cloud hosts that can be used. +- `name` (String) The name of the plan. diff --git a/docs/index.md b/docs/index.md index 095ddaeb7..f48b1de5a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -158,6 +158,7 @@ Note: AWS specific checks must be skipped as they do not work on STACKIT. For de - `credentials_path` (String) Path of JSON from where the credentials are read. Takes precedence over the env var `STACKIT_CREDENTIALS_PATH`. Default value is `~/.stackit/credentials.json`. - `default_region` (String) Region will be used as the default location for regional services. Not all services require a region, some are global - `dns_custom_endpoint` (String) Custom endpoint for the DNS service +- `edgecloud_custom_endpoint` (String) Custom endpoint for the Edge Cloud service - `enable_beta_resources` (Boolean) Enable beta resources. Default is false. - `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 diff --git a/docs/resources/edgecloud_instance.md b/docs/resources/edgecloud_instance.md new file mode 100644 index 000000000..381c25961 --- /dev/null +++ b/docs/resources/edgecloud_instance.md @@ -0,0 +1,63 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_edgecloud_instance Resource - stackit" +subcategory: "" +description: |- + Edge Cloud is in private Beta and not generally available. + You can contact support if you are interested in trying it out. + ~> 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_edgecloud_instance (Resource) + +Edge Cloud is in private Beta and not generally available. + You can contact support if you are interested in trying it out. + +~> 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 +locals { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + display_name = "edge" + plan_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + description = "cats live on the edge" + region = "eu01" +} + +resource "stackit_edgecloud_instance" "this" { + project_id = local.project_id + display_name = local.display_name + plan_id = local.plan_id + description = local.description +} + +# Only use the import statement, if you want to import an existing Edge Cloud instance resource +import { + to = stackit_edgecloud_instance.this + id = "${local.project_id},${local.region},INSTANCE_ID" +} +``` + + +## Schema + +### Required + +- `display_name` (String) Display name shown for the Edge Cloud instance. Has to be a valid hostname, with a length between 4 and 8 characters. +- `plan_id` (String) STACKIT Edge Plan ID for the Edge Cloud instance, has to be the UUID of an existing plan. +- `project_id` (String) STACKIT project ID to which the Edge Cloud instance is associated. + +### Optional + +- `description` (String) Description for your STACKIT Edge Cloud instance. Max length is 256 characters +- `region` (String) STACKIT region to use for the instance, providers default_region will be used if unset. + +### Read-Only + +- `created` (String) The date and time the creation of the instance was triggered. +- `frontend_url` (String) Frontend URL for the Edge Cloud instance. +- `id` (String) Terraform's internal resource ID, structured as "`project_id`,`region`,`instance_id`". +- `instance_id` (String) - +- `status` (String) instance status diff --git a/docs/resources/edgecloud_kubeconfig.md b/docs/resources/edgecloud_kubeconfig.md new file mode 100644 index 000000000..6feacf0e7 --- /dev/null +++ b/docs/resources/edgecloud_kubeconfig.md @@ -0,0 +1,63 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_edgecloud_kubeconfig Resource - stackit" +subcategory: "" +description: |- + Edge Cloud is in private Beta and not generally available. + You can contact support if you are interested in trying it out. + ~> 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_edgecloud_kubeconfig (Resource) + +Edge Cloud is in private Beta and not generally available. + You can contact support if you are interested in trying it out. + +~> 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 +# the instance resource only exists here to illustrate the usage of it's attribute +resource "stackit_edgecloud_instance" "this" { + project_id = local.project_id + display_name = "example" + plan_id = var.plan_id + description = "some_description" +} + +resource "stackit_edgecloud_kubeconfig" "by_name" { + project_id = var.project_id + instance_name = stackit_edgecloud_instance.this.display_name + expiration = 3600 # seconds +} + +resource "stackit_edgecloud_kubeconfig" "by_id" { + project_id = var.project_id + instance_id = stackit_edgecloud_instance.this.instance_id + expiration = 3600 # seconds +} +``` + + +## Schema + +### Required + +- `project_id` (String) STACKIT project ID to which the Edge Cloud instance is associated. + +### Optional + +- `expiration` (Number) Expiration time of the kubeconfig, in seconds. Minimum is 600, Maximum is 15552000. Defaults to `3600` +- `instance_id` (String) ID of the Edge Cloud instance. +- `instance_name` (String) Name of the Edge Cloud instance. +- `recreate_before` (Number) Number of seconds before expiration to trigger recreation of the kubeconfig at. +- `region` (String) The resource region. If not defined, the provider region is used. + +### Read-Only + +- `creation_time` (String) Date-time when the kubeconfig was created +- `expires_at` (String) Timestamp when the kubeconfig expires +- `id` (String) Terraform's internal resource ID. It is structured as "`project_id`,`region`,`instance_name` or `instance_id`,`kubeconfig_id`". +- `kubeconfig` (String, Sensitive) Raw kubeconfig. +- `kubeconfig_id` (String) Internally generated UUID to identify a kubeconfig resource in Terraform, since the Edge Cloud API doesn't return a kubeconfig identifier diff --git a/docs/resources/edgecloud_token.md b/docs/resources/edgecloud_token.md new file mode 100644 index 000000000..dfb970356 --- /dev/null +++ b/docs/resources/edgecloud_token.md @@ -0,0 +1,63 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_edgecloud_token Resource - stackit" +subcategory: "" +description: |- + Edge Cloud is in private Beta and not generally available. + You can contact support if you are interested in trying it out. + ~> 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_edgecloud_token (Resource) + +Edge Cloud is in private Beta and not generally available. + You can contact support if you are interested in trying it out. + +~> 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 +# the instance resource only exists here to illustrate the usage of it's attribute +resource "stackit_edgecloud_instance" "this" { + project_id = local.project_id + display_name = "example" + plan_id = var.plan_id + description = "some_description" +} + +resource "stackit_edgecloud_token" "by_name" { + project_id = var.project_id + instance_name = stackit_edgecloud_instance.this.display_name + expiration = 3600 # seconds +} + +resource "stackit_edgecloud_token" "by_id" { + project_id = var.project_id + instance_id = stackit_edgecloud_instance.this.instance_id + expiration = 3600 # seconds +} +``` + + +## Schema + +### Required + +- `project_id` (String) STACKIT project ID to which the Edge Cloud instance is associated. + +### Optional + +- `expiration` (Number) Expiration time of the token, in seconds. Minimum is 600, Maximum is 15552000. Defaults to `3600` +- `instance_id` (String) ID of the Edge Cloud instance. +- `instance_name` (String) Name of the Edge Cloud instance. +- `recreate_before` (Number) Number of seconds before expiration to trigger recreation of the token at. +- `region` (String) The resource region. If not defined, the provider region is used. + +### Read-Only + +- `creation_time` (String) Date-time when the token was created +- `expires_at` (String) Timestamp when the token expires +- `id` (String) Terraform's internal resource ID. It is structured as "`project_id`,`region`,`instance_name` or `instance_id`,`token_id`". +- `token` (String, Sensitive) Raw token. +- `token_id` (String) Internally generated UUID to identify a token resource in Terraform, since the Edge Cloud API doesnt return a token identifier diff --git a/examples/data-sources/stackit_edgecloud_instances/data-source.tf b/examples/data-sources/stackit_edgecloud_instances/data-source.tf new file mode 100644 index 000000000..6610f7df3 --- /dev/null +++ b/examples/data-sources/stackit_edgecloud_instances/data-source.tf @@ -0,0 +1,11 @@ + +# returns all Edge Cloud instances created in the given project which are inside the provider default_region +data "stackit_edgecloud_instances" "plan_id" { + project_id = var.project_id +} + +# returns all Edge Cloud instances created in the given project in the given region +data "stackit_edgecloud_instances" "plan_id" { + project_id = var.project_id + region = var.region +} diff --git a/examples/data-sources/stackit_edgecloud_plans/data-source.tf b/examples/data-sources/stackit_edgecloud_plans/data-source.tf new file mode 100644 index 000000000..d340bc504 --- /dev/null +++ b/examples/data-sources/stackit_edgecloud_plans/data-source.tf @@ -0,0 +1,3 @@ +data "stackit_edgecloud_plans" "this" { + project_id = var.project_id +} diff --git a/examples/resources/stackit_edgecloud_instance/resource.tf b/examples/resources/stackit_edgecloud_instance/resource.tf new file mode 100644 index 000000000..e49dac133 --- /dev/null +++ b/examples/resources/stackit_edgecloud_instance/resource.tf @@ -0,0 +1,20 @@ +locals { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + display_name = "edge" + plan_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + description = "cats live on the edge" + region = "eu01" +} + +resource "stackit_edgecloud_instance" "this" { + project_id = local.project_id + display_name = local.display_name + plan_id = local.plan_id + description = local.description +} + +# Only use the import statement, if you want to import an existing Edge Cloud instance resource +import { + to = stackit_edgecloud_instance.this + id = "${local.project_id},${local.region},INSTANCE_ID" +} diff --git a/examples/resources/stackit_edgecloud_kubeconfig/resource.tf b/examples/resources/stackit_edgecloud_kubeconfig/resource.tf new file mode 100644 index 000000000..af315215a --- /dev/null +++ b/examples/resources/stackit_edgecloud_kubeconfig/resource.tf @@ -0,0 +1,19 @@ +# the instance resource only exists here to illustrate the usage of it's attribute +resource "stackit_edgecloud_instance" "this" { + project_id = local.project_id + display_name = "example" + plan_id = var.plan_id + description = "some_description" +} + +resource "stackit_edgecloud_kubeconfig" "by_name" { + project_id = var.project_id + instance_name = stackit_edgecloud_instance.this.display_name + expiration = 3600 # seconds +} + +resource "stackit_edgecloud_kubeconfig" "by_id" { + project_id = var.project_id + instance_id = stackit_edgecloud_instance.this.instance_id + expiration = 3600 # seconds +} \ No newline at end of file diff --git a/examples/resources/stackit_edgecloud_token/resource.tf b/examples/resources/stackit_edgecloud_token/resource.tf new file mode 100644 index 000000000..777c477be --- /dev/null +++ b/examples/resources/stackit_edgecloud_token/resource.tf @@ -0,0 +1,19 @@ +# the instance resource only exists here to illustrate the usage of it's attribute +resource "stackit_edgecloud_instance" "this" { + project_id = local.project_id + display_name = "example" + plan_id = var.plan_id + description = "some_description" +} + +resource "stackit_edgecloud_token" "by_name" { + project_id = var.project_id + instance_name = stackit_edgecloud_instance.this.display_name + expiration = 3600 # seconds +} + +resource "stackit_edgecloud_token" "by_id" { + project_id = var.project_id + instance_id = stackit_edgecloud_instance.this.instance_id + expiration = 3600 # seconds +} \ No newline at end of file diff --git a/go.mod b/go.mod index dc8fb6ece..ca9331eab 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/stackitcloud/stackit-sdk-go/core v0.20.1 github.com/stackitcloud/stackit-sdk-go/services/cdn v1.9.1 github.com/stackitcloud/stackit-sdk-go/services/dns v0.17.3 + github.com/stackitcloud/stackit-sdk-go/services/edge v0.3.0 github.com/stackitcloud/stackit-sdk-go/services/git v0.10.1 github.com/stackitcloud/stackit-sdk-go/services/iaas v1.3.0 github.com/stackitcloud/stackit-sdk-go/services/iaasalpha v0.1.23-alpha diff --git a/go.sum b/go.sum index c93b21bf5..2116923b4 100644 --- a/go.sum +++ b/go.sum @@ -157,6 +157,8 @@ github.com/stackitcloud/stackit-sdk-go/services/cdn v1.9.1 h1:PiNC8VmLqi1WUnBSPe github.com/stackitcloud/stackit-sdk-go/services/cdn v1.9.1/go.mod h1:Nnfe/Zv4Z8F56Ljw/MfXjL0/2Ajia4bGuL/CZuvIXk8= github.com/stackitcloud/stackit-sdk-go/services/dns v0.17.3 h1:KD/FxU/cJIzfyMvwiOvTlSWq87ISENpHNmw/quznGnw= github.com/stackitcloud/stackit-sdk-go/services/dns v0.17.3/go.mod h1:BNiIZkDqwSV1LkWDjMKxVb9pxQ/HMIsXJ0AQ8pFoAo4= +github.com/stackitcloud/stackit-sdk-go/services/edge v0.3.0 h1:JL34T5IjuZjt+XGOBqkutnZnUd41jz9J9Lr8ZgPUiZI= +github.com/stackitcloud/stackit-sdk-go/services/edge v0.3.0/go.mod h1:tFDkVkK+ESBTiH2XIcMPPR/pJJmeqT1VNDghg+ZxfMI= github.com/stackitcloud/stackit-sdk-go/services/git v0.10.1 h1:3JKXfI5hdcXcRVBjUZg5qprXG5rDmPnM6dsvplMk/vg= github.com/stackitcloud/stackit-sdk-go/services/git v0.10.1/go.mod h1:3nTaj8IGjNNGYUD2CpuXkXwc5c4giTUmoPggFhjVFxo= github.com/stackitcloud/stackit-sdk-go/services/iaas v1.3.0 h1:U/x0tc487X9msMS5yZYjrBAAKrCx87Trmt0kh8JiARA= diff --git a/stackit/internal/core/core.go b/stackit/internal/core/core.go index 688335ec0..0c574d80f 100644 --- a/stackit/internal/core/core.go +++ b/stackit/internal/core/core.go @@ -46,6 +46,7 @@ type ProviderData struct { AuthorizationCustomEndpoint string CdnCustomEndpoint string DnsCustomEndpoint string + EdgeCloudCustomEndpoint string GitCustomEndpoint string IaaSCustomEndpoint string KMSCustomEndpoint string diff --git a/stackit/internal/services/edgecloud/edge_acc_test.go b/stackit/internal/services/edgecloud/edge_acc_test.go new file mode 100644 index 000000000..ac5f54ebd --- /dev/null +++ b/stackit/internal/services/edgecloud/edge_acc_test.go @@ -0,0 +1,378 @@ +package edgecloud_test + +import ( + "context" + _ "embed" + "errors" + "fmt" + "net/http" + "regexp" + "strings" + "testing" + + "github.com/google/uuid" + "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" + coreConfig "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + "github.com/stackitcloud/stackit-sdk-go/services/edge" + "github.com/stackitcloud/stackit-sdk-go/services/edge/wait" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/testutil" +) + +var ( + // Currently the API does not verify if the UUID belongs to a valid plan + // This will change with GA, which will require replacing the random UUIDs with real plan UUIDs + testPlanId = uuid.NewString() + testPlanIdUpdated = uuid.NewString() + minTestName = "min-" + acctest.RandStringFromCharSet(4, acctest.CharSetAlpha) + testDescription = "test description" + testDescriptionUpdated = "test description updated" + testExpiration = 1800 + testRecreateBefore = 120 + testRecreateBeforeUpdated = 100 +) + +//go:embed testdata/resource-min.tf +var resourceMin string + +//go:embed testdata/resource-max.tf +var resourceMax string + +// Minimal configuration +var testConfigVarsMin = config.Variables{ + // region is unset, to verify it is picked up from the provider config + "project_id": config.StringVariable(testutil.ProjectId), + "display_name": config.StringVariable(minTestName), + "plan_id": config.StringVariable(testPlanId), +} + +// Maximal configuration +func configVarsMax(displayName, planId, description string, expiration, recreateBefore int) config.Variables { + return config.Variables{ + "project_id": config.StringVariable(testutil.ProjectId), + "region": config.StringVariable(testutil.Region), + "display_name": config.StringVariable(displayName), + "plan_id": config.StringVariable(planId), + "description": config.StringVariable(description), + "expiration": config.IntegerVariable(expiration), + "recreate_before": config.IntegerVariable(recreateBefore), + } +} + +func TestAccEdgeCloudInstanceMin(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + CheckDestroy: testAccCheckEdgeCloudInstanceDestroy, + Steps: []resource.TestStep{ + // resources + { + Config: testutil.EdgeCloudProviderConfig() + "\n" + resourceMin, + ConfigVariables: testConfigVarsMin, + Check: resource.ComposeAggregateTestCheckFunc( + // instance + resource.TestCheckResourceAttr("stackit_edgecloud_instance.test_instance", "project_id", testutil.ProjectId), + // testutil.Region is also used in testutils.EdgeCloudProviderConfig to define a default_region + // this checks that this is successfully used for the resource, even if no region is specifically set + resource.TestCheckResourceAttr("stackit_edgecloud_instance.test_instance", "region", testutil.Region), + resource.TestCheckResourceAttr("stackit_edgecloud_instance.test_instance", "display_name", minTestName), + resource.TestCheckResourceAttr("stackit_edgecloud_instance.test_instance", "plan_id", testPlanId), + resource.TestCheckResourceAttrSet("stackit_edgecloud_instance.test_instance", "instance_id"), + resource.TestCheckResourceAttrSet("stackit_edgecloud_instance.test_instance", "frontend_url"), + // token + resource.TestCheckResourceAttr("stackit_edgecloud_token.this", "region", testutil.Region), + resource.TestCheckResourceAttr("stackit_edgecloud_token.this", "project_id", testutil.ProjectId), + resource.TestCheckResourceAttrSet("stackit_edgecloud_token.this", "instance_id"), + resource.TestCheckResourceAttrSet("stackit_edgecloud_token.this", "token"), + // kubeconfig + resource.TestCheckResourceAttr("stackit_edgecloud_kubeconfig.this", "region", testutil.Region), + resource.TestCheckResourceAttr("stackit_edgecloud_kubeconfig.this", "project_id", testutil.ProjectId), + resource.TestCheckResourceAttrSet("stackit_edgecloud_kubeconfig.this", "instance_id"), + resource.TestCheckResourceAttrSet("stackit_edgecloud_kubeconfig.this", "kubeconfig"), + ), + }, + // data sources + { + ConfigVariables: testConfigVarsMin, + Config: testutil.EdgeCloudProviderConfig() + "\n" + resourceMin, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("data.stackit_edgecloud_instances.this", "id", fmt.Sprintf("%s,%s", + testutil.ProjectId, + testutil.Region, + )), + resource.TestCheckResourceAttr("data.stackit_edgecloud_plans.this", "id", testutil.ProjectId), + resource.TestCheckResourceAttr("data.stackit_edgecloud_plans.this", "project_id", testutil.ProjectId), + ), + }, + // import + { + ConfigVariables: testConfigVarsMin, + ResourceName: "stackit_edgecloud_instance.test_instance", + ImportStateIdFunc: func(s *terraform.State) (string, error) { + r, ok := s.RootModule().Resources["stackit_edgecloud_instance.test_instance"] + if !ok { + return "", fmt.Errorf("couldn't find resource stackit_edgecloud_instance.test_instance") + } + instanceID, ok := r.Primary.Attributes["instance_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute instance_id") + } + return fmt.Sprintf("%s,%s,%s", testutil.ProjectId, testutil.Region, instanceID), nil + }, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func TestAccEdgeCloudMax(t *testing.T) { + displayName := "mx-in-" + acctest.RandStringFromCharSet(2, acctest.CharSetAlpha) + initialVars := configVarsMax(displayName, testPlanId, testDescription, testExpiration, testRecreateBefore) + updatedVars := configVarsMax(displayName, testPlanIdUpdated, testDescriptionUpdated, testExpiration, testRecreateBeforeUpdated) + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + CheckDestroy: testAccCheckEdgeCloudInstanceDestroy, + Steps: []resource.TestStep{ + // Creation + { + Config: testutil.EdgeCloudProviderConfig() + "\n" + resourceMax, + ConfigVariables: initialVars, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_edgecloud_instance.test_instance", "project_id", testutil.ProjectId), + resource.TestCheckResourceAttr("stackit_edgecloud_instance.test_instance", "region", testutil.Region), + resource.TestCheckResourceAttr("stackit_edgecloud_instance.test_instance", "display_name", displayName), + resource.TestCheckResourceAttr("stackit_edgecloud_instance.test_instance", "plan_id", testPlanId), + resource.TestCheckResourceAttr("stackit_edgecloud_instance.test_instance", "description", testDescription), + resource.TestCheckResourceAttrSet("stackit_edgecloud_instance.test_instance", "instance_id"), + resource.TestCheckResourceAttrSet("stackit_edgecloud_instance.test_instance", "frontend_url"), + resource.TestCheckResourceAttrSet("stackit_edgecloud_instance.test_instance", "status"), + ), + }, + // Data sources + { + ConfigVariables: initialVars, + Config: testutil.EdgeCloudProviderConfig() + "\n" + resourceMax, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("data.stackit_edgecloud_instances.this", "id", fmt.Sprintf("%s,%s", + testutil.ProjectId, + testutil.Region, + )), + resource.TestCheckResourceAttrSet("data.stackit_edgecloud_instances.this", "instances.0.created"), + // TestCheckResourceAttrSet fails if the value is "", which is an allowed value for the description. That's why it has to be checked via regex + resource.TestMatchResourceAttr("data.stackit_edgecloud_instances.this", "instances.0.description", regexp.MustCompile("^.*$")), + resource.TestCheckResourceAttrSet("data.stackit_edgecloud_instances.this", "instances.0.display_name"), + resource.TestCheckResourceAttrSet("data.stackit_edgecloud_instances.this", "instances.0.frontend_url"), + resource.TestCheckResourceAttrSet("data.stackit_edgecloud_instances.this", "instances.0.instance_id"), + resource.TestCheckResourceAttrSet("data.stackit_edgecloud_instances.this", "instances.0.plan_id"), + resource.TestCheckResourceAttrSet("data.stackit_edgecloud_instances.this", "instances.0.region"), + resource.TestCheckResourceAttrSet("data.stackit_edgecloud_instances.this", "instances.0.status"), + // check plans data source + resource.TestCheckResourceAttr("data.stackit_edgecloud_plans.this", "id", testutil.ProjectId), + resource.TestCheckResourceAttr("data.stackit_edgecloud_plans.this", "project_id", testutil.ProjectId), + ), + }, + // Kubeconfig + { + Config: testutil.EdgeCloudProviderConfig() + "\n" + resourceMax, + ConfigVariables: initialVars, + Check: resource.ComposeAggregateTestCheckFunc( + // Kubeconfig by name + resource.TestCheckResourceAttr("stackit_edgecloud_kubeconfig.by_name", "project_id", testutil.ProjectId), + resource.TestCheckResourceAttr("stackit_edgecloud_kubeconfig.by_name", "instance_name", displayName), + resource.TestCheckResourceAttrSet("stackit_edgecloud_kubeconfig.by_name", "kubeconfig_id"), + resource.TestCheckResourceAttrSet("stackit_edgecloud_kubeconfig.by_name", "kubeconfig"), + resource.TestCheckResourceAttrSet("stackit_edgecloud_kubeconfig.by_name", "expires_at"), + resource.TestCheckResourceAttrSet("stackit_edgecloud_kubeconfig.by_name", "creation_time"), + resource.TestCheckResourceAttr("stackit_edgecloud_kubeconfig.by_name", "region", testutil.Region), + // Kubeconfig by id + resource.TestCheckResourceAttr("stackit_edgecloud_kubeconfig.by_id", "project_id", testutil.ProjectId), + resource.TestCheckResourceAttrPair( + "stackit_edgecloud_instance.test_instance", "instance_id", + "stackit_edgecloud_kubeconfig.by_id", "instance_id", + ), + resource.TestCheckResourceAttrSet("stackit_edgecloud_kubeconfig.by_id", "kubeconfig_id"), + resource.TestCheckResourceAttrSet("stackit_edgecloud_kubeconfig.by_id", "kubeconfig"), + resource.TestCheckResourceAttrSet("stackit_edgecloud_kubeconfig.by_id", "expires_at"), + resource.TestCheckResourceAttrSet("stackit_edgecloud_kubeconfig.by_id", "creation_time"), + resource.TestCheckResourceAttr("stackit_edgecloud_kubeconfig.by_id", "region", testutil.Region), + ), + }, + // Token + { + Config: testutil.EdgeCloudProviderConfig() + "\n" + resourceMax, + ConfigVariables: initialVars, + Check: resource.ComposeAggregateTestCheckFunc( + // Token by name + resource.TestCheckResourceAttr("stackit_edgecloud_token.by_name", "project_id", testutil.ProjectId), + resource.TestCheckResourceAttr("stackit_edgecloud_token.by_name", "instance_name", displayName), + resource.TestCheckResourceAttrSet("stackit_edgecloud_token.by_name", "token_id"), + resource.TestCheckResourceAttrSet("stackit_edgecloud_token.by_name", "token"), + resource.TestCheckResourceAttrSet("stackit_edgecloud_token.by_name", "expires_at"), + resource.TestCheckResourceAttrSet("stackit_edgecloud_token.by_name", "creation_time"), + resource.TestCheckResourceAttr("stackit_edgecloud_token.by_name", "region", testutil.Region), + // Token by id + resource.TestCheckResourceAttr("stackit_edgecloud_token.by_id", "project_id", testutil.ProjectId), + resource.TestCheckResourceAttrPair( + "stackit_edgecloud_instance.test_instance", "instance_id", + "stackit_edgecloud_token.by_id", "instance_id", + ), + resource.TestCheckResourceAttrSet("stackit_edgecloud_token.by_id", "token_id"), + resource.TestCheckResourceAttrSet("stackit_edgecloud_token.by_id", "token"), + resource.TestCheckResourceAttrSet("stackit_edgecloud_token.by_id", "expires_at"), + resource.TestCheckResourceAttrSet("stackit_edgecloud_token.by_id", "creation_time"), + resource.TestCheckResourceAttr("stackit_edgecloud_token.by_id", "region", testutil.Region), + ), + }, + // Update + { + Config: testutil.EdgeCloudProviderConfig() + "\n" + resourceMax, + ConfigVariables: updatedVars, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_edgecloud_instance.test_instance", "plan_id", testPlanIdUpdated), + resource.TestCheckResourceAttr("stackit_edgecloud_instance.test_instance", "description", testDescriptionUpdated), + ), + }, + // Import + { + ConfigVariables: updatedVars, + ResourceName: "stackit_edgecloud_instance.test_instance", + ImportStateIdFunc: func(s *terraform.State) (string, error) { + r, ok := s.RootModule().Resources["stackit_edgecloud_instance.test_instance"] + if !ok { + return "", fmt.Errorf("couldn't find resource stackit_edgecloud_instance.test_instance") + } + instanceID, ok := r.Primary.Attributes["instance_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute instance_id") + } + return fmt.Sprintf("%s,%s,%s", testutil.ProjectId, testutil.Region, instanceID), nil + }, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func TestAccEdgeCloudInstance_validation(t *testing.T) { + validDisplayName := "mx-v-" + acctest.RandStringFromCharSet(2, acctest.CharSetAlpha) + tooShortDisplayName := "abc" // Invalid (3 chars) + tooLongDisplayName := "too-long-name" // Invalid (13 chars) + invalidUUID := "not-a-uuid" + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // Display Name Too Short + { + Config: testutil.EdgeCloudProviderConfig() + "\n" + resourceMin, + ConfigVariables: configVarsMax(tooShortDisplayName, testPlanId, testDescription, testExpiration, testRecreateBefore), + ExpectError: regexp.MustCompile(fmt.Sprintf(`string length must be between 4 and 8, got: %d`, len(tooShortDisplayName))), + }, + // Display Name Too Long + { + Config: testutil.EdgeCloudProviderConfig() + "\n" + resourceMin, + ConfigVariables: configVarsMax(tooLongDisplayName, testPlanId, testDescription, testExpiration, testRecreateBefore), + ExpectError: regexp.MustCompile(fmt.Sprintf(`string length must be between 4 and 8, got: %d`, len(tooLongDisplayName))), + }, + // Invalid Project ID + { + Config: testutil.EdgeCloudProviderConfig() + "\n" + resourceMin, + ConfigVariables: config.Variables{ + "project_id": config.StringVariable(invalidUUID), + "region": config.StringVariable(testutil.Region), + "display_name": config.StringVariable(minTestName), + "plan_id": config.StringVariable(testPlanId), + }, + ExpectError: regexp.MustCompile(fmt.Sprintf(`Attribute project_id value must be an UUID, got: %s`, invalidUUID)), + }, + // Invalid Plan ID + { + Config: testutil.EdgeCloudProviderConfig() + "\n" + resourceMin, + ConfigVariables: configVarsMax(validDisplayName, invalidUUID, testDescription, testExpiration, testRecreateBefore), + ExpectError: regexp.MustCompile(fmt.Sprintf(`Attribute plan_id value must be an UUID, got: %s`, invalidUUID)), + }, + // Description Too Long + { + Config: testutil.EdgeCloudProviderConfig() + "\n" + resourceMax, + ConfigVariables: configVarsMax(validDisplayName, testPlanId, acctest.RandString(257), testExpiration, testRecreateBefore), + ExpectError: regexp.MustCompile(`Attribute description string length must be at most 256`), + }, + }, + }) +} + +func TestAccEdgeCloudKubeconfigToken_validation(t *testing.T) { + displayName := "mx-v-" + acctest.RandStringFromCharSet(2, acctest.CharSetAlpha) + tooShortExpiration := 599 + tooLongExpiration := 15552001 + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // Expiration too short + { + Config: testutil.EdgeCloudProviderConfig() + "\n" + resourceMax, + ConfigVariables: configVarsMax(displayName, testPlanId, testDescription, tooShortExpiration, testRecreateBefore), + ExpectError: regexp.MustCompile(fmt.Sprintf(`Attribute expiration value must be between 600 and 15552000, got: %d`, tooShortExpiration)), + }, + // Expiration Too Long + { + Config: testutil.EdgeCloudProviderConfig() + "\n" + resourceMax, + ConfigVariables: configVarsMax(displayName, testPlanId, testDescription, tooLongExpiration, testRecreateBefore), + ExpectError: regexp.MustCompile(fmt.Sprintf(`Attribute expiration value must be between 600 and 15552000, got: %d`, tooLongExpiration)), + }, + }, + }) +} + +// testAccCheckEdgeCloudInstanceDestroy verifies that test resources are properly destroyed +func testAccCheckEdgeCloudInstanceDestroy(s *terraform.State) error { + ctx := context.Background() + var client *edge.APIClient + var err error + + if testutil.EdgeCloudCustomEndpoint != "" { + client, err = edge.NewAPIClient(coreConfig.WithEndpoint(testutil.EdgeCloudCustomEndpoint)) + } else { + client, err = edge.NewAPIClient() + } + if err != nil { + return fmt.Errorf("creating client: %w", err) + } + + for _, rs := range s.RootModule().Resources { + if rs.Type != "stackit_edgecloud_instance" { + continue + } + idParts := strings.Split(rs.Primary.ID, core.Separator) + if len(idParts) != 3 { + return fmt.Errorf("invalid resource ID format: %s", rs.Primary.ID) + } + projectId, region, instanceId := idParts[0], idParts[1], idParts[2] + + _, err := client.GetInstance(ctx, projectId, region, instanceId).Execute() + if err == nil { + return fmt.Errorf("edge instance %q still exists", instanceId) + } + + // If the error is a 404, the resource was successfully deleted + var oapiErr *oapierror.GenericOpenAPIError + ok := errors.As(err, &oapiErr) + if !ok || oapiErr.StatusCode != http.StatusNotFound { + err := client.DeleteInstance(ctx, projectId, region, instanceId).Execute() + if err != nil { + return fmt.Errorf("deleting instance %s during CheckDestroy: %w", instanceId, err) + } + _, err = wait.DeleteInstanceWaitHandler(ctx, client, projectId, region, instanceId).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("waiting for instance deletion %s during CheckDestroy: %w", instanceId, err) + } + } + } + return nil +} diff --git a/stackit/internal/services/edgecloud/instance/resource.go b/stackit/internal/services/edgecloud/instance/resource.go new file mode 100644 index 000000000..8a90cda18 --- /dev/null +++ b/stackit/internal/services/edgecloud/instance/resource.go @@ -0,0 +1,496 @@ +package instance + +import ( + "context" + "errors" + "fmt" + "net/http" + "regexp" + "strings" + + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "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/stringdefault" + "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/edge" + edgewait "github.com/stackitcloud/stackit-sdk-go/services/edge/wait" + "github.com/stackitcloud/stackit-sdk-go/services/serviceenablement" + enablementWait "github.com/stackitcloud/stackit-sdk-go/services/serviceenablement/wait" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features" + edgeutils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/edgecloud/utils" + serviceenablementUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/serviceenablement/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ resource.Resource = &instanceResource{} + _ resource.ResourceWithConfigure = &instanceResource{} + _ resource.ResourceWithImportState = &instanceResource{} + _ resource.ResourceWithModifyPlan = &instanceResource{} +) + +// Model represents the schema for the Edge Cloud instance resource. +type Model struct { + Id types.String `tfsdk:"id"` // Resource ID for Terraform + Created types.String `tfsdk:"created"` + InstanceId types.String `tfsdk:"instance_id"` + Region types.String `tfsdk:"region"` + DisplayName types.String `tfsdk:"display_name"` + ProjectId types.String `tfsdk:"project_id"` + PlanID types.String `tfsdk:"plan_id"` + Description types.String `tfsdk:"description"` + Status types.String `tfsdk:"status"` + FrontendUrl types.String `tfsdk:"frontend_url"` +} + +// NewInstanceResource is a helper function to create a new edge resource instance. +func NewInstanceResource() resource.Resource { + return &instanceResource{} +} + +// instanceResource implements the resource interface for Edge Cloud instances. +type instanceResource struct { + client *edge.APIClient + enablementClient *serviceenablement.APIClient + providerData core.ProviderData +} + +func (i *instanceResource) 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, i.providerData.GetRegion(), resp) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.Plan.Set(ctx, planModel)...) + if resp.Diagnostics.HasError() { + return + } +} + +// descriptions for the attributes in the Schema +var descriptions = map[string]string{ + "id": "Terraform's internal resource ID, structured as \"`project_id`,`region`,`instance_id`\".", + "instance_id": "-", + "display_name": fmt.Sprintf("Display name shown for the Edge Cloud instance. Has to be a valid hostname, with a length between %d and %d characters.", edgeutils.DisplayNameMinimumChars, edgeutils.DisplayNameMaximumChars), + "created": "The date and time the creation of the instance was triggered.", + "frontend_url": "Frontend URL for the Edge Cloud instance.", + "region": "STACKIT region to use for the instance, providers default_region will be used if unset.", + "project_id": "STACKIT project ID to which the Edge Cloud instance is associated.", + "plan_id": "STACKIT Edge Plan ID for the Edge Cloud instance, has to be the UUID of an existing plan.", + "description": fmt.Sprintf("Description for your STACKIT Edge Cloud instance. Max length is %d characters", edgeutils.DescriptionMaxLength), + "status": "instance status", +} + +// Configure sets up the API client for the Edge Cloud instance resource. +func (i *instanceResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + var ok bool + i.providerData, ok = conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics) + if !ok { + return + } + features.CheckBetaResourcesEnabled(ctx, &i.providerData, &resp.Diagnostics, "stackit_edgecloud_instance", "resource") + if resp.Diagnostics.HasError() { + return + } + apiClient := edgeutils.ConfigureClient(ctx, &i.providerData, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + serviceEnablementClient := serviceenablementUtils.ConfigureClient(ctx, &i.providerData, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + i.client = apiClient + i.enablementClient = serviceEnablementClient + tflog.Info(ctx, "edge client configured") +} + +// Metadata sets the resource type name for the Edge Cloud instance resource. +func (i *instanceResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_edgecloud_instance" +} + +// Schema defines the schema for the resource. +func (i *instanceResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: features.AddBetaDescription("Edge Cloud is in private Beta and not generally available.\n You can contact support if you are interested in trying it out.", core.Resource), + + Description: "edge cloud instance resource schema.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: descriptions["id"], + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "project_id": schema.StringAttribute{ + Description: descriptions["project_id"], + Required: true, + Validators: []validator.String{ + validate.UUID(), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "region": schema.StringAttribute{ + Description: descriptions["region"], + Optional: true, + // must be computed to allow for storing the override value from the provider + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "instance_id": schema.StringAttribute{ + Description: descriptions["instance_id"], + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "created": schema.StringAttribute{ + Description: descriptions["created"], + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "frontend_url": schema.StringAttribute{ + Description: descriptions["frontend_url"], + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "status": schema.StringAttribute{ + Description: descriptions["status"], + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "display_name": schema.StringAttribute{ + Description: descriptions["display_name"], + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + stringvalidator.LengthBetween(edgeutils.DisplayNameMinimumChars, edgeutils.DisplayNameMaximumChars), + stringvalidator.RegexMatches( + regexp.MustCompile(`^[a-z]([-a-z0-9]*[a-z0-9])?$`), + "must be a valid hostname label, starting with a letter and containing only letters, numbers, or hyphens", + ), + }, + }, + "plan_id": schema.StringAttribute{ + Description: descriptions["plan_id"], + Required: true, + Validators: []validator.String{ + validate.UUID(), + }, + }, + "description": schema.StringAttribute{ + Description: descriptions["description"], + Optional: true, + Computed: true, + Default: stringdefault.StaticString(""), + Validators: []validator.String{ + stringvalidator.LengthAtMost(edgeutils.DescriptionMaxLength), + }, + }, + }, + } +} + +// Create creates the resource and sets the initial state for the Edge Cloud instance. +func (i *instanceResource) 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 + } + + ctx = core.InitProviderContext(ctx) + + projectId := model.ProjectId.ValueString() + region := model.Region.ValueString() + displayName := model.DisplayName.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "display_name", displayName) + ctx = tflog.SetField(ctx, "region", region) + + // If the service edge-cloud is not enabled, enable it + err := i.enablementClient.EnableServiceRegional(ctx, region, projectId, utils.EdgecloudServiceId).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", fmt.Sprintf("Calling API to enable edge-cloud: %v", err)) + return + } + + _, err = enablementWait.EnableServiceWaitHandler(ctx, i.enablementClient, region, projectId, utils.EdgecloudServiceId).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", fmt.Sprintf("Wait for edge-cloud enablement: %v", err)) + return + } + + tflog.Info(ctx, "Creating new Edge Cloud instance") + payload := toCreatePayload(&model) + createResp, err := i.client.CreateInstance(ctx, projectId, region).CreateInstancePayload(payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", fmt.Sprintf("Calling API: %v", err)) + return + } + + ctx = core.LogResponse(ctx) + + if createResp == nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", "API returned nil response") + return + } + if createResp.Id == nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", "API returned nil Instance ID") + return + } + edgeCloudInstanceId := *createResp.Id + utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{ + "project_id": projectId, + "instance_id": edgeCloudInstanceId, + "region": region, + }) + + waitResp, err := edgewait.CreateOrUpdateInstanceWaitHandler(ctx, i.client, projectId, region, edgeCloudInstanceId).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", fmt.Sprintf("Instance waiting: %v", err)) + return + } + + err = mapFields(waitResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", fmt.Sprintf("Mapping API response fields to model: %v", err)) + return + } + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "edge cloud instance created successfully") +} + +// Read refreshes the state with the latest Edge Cloud instance data. +func (i *instanceResource) 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 + } + + ctx = core.InitProviderContext(ctx) + + projectId := model.ProjectId.ValueString() + region := i.providerData.GetRegionWithOverride(model.Region) + instanceId := model.InstanceId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "instance_id", instanceId) + ctx = tflog.SetField(ctx, "region", region) + + edgeCloudInstanceResp, err := i.client.GetInstance(ctx, projectId, region, instanceId).Execute() + if err != nil { + var oapiErr *oapierror.GenericOpenAPIError + ok := errors.As(err, &oapiErr) + if ok && oapiErr.StatusCode == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading instance", fmt.Sprintf("Calling API: %v", err)) + return + } + + ctx = core.LogResponse(ctx) + + err = mapFields(edgeCloudInstanceResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading instance", fmt.Sprintf("Processing API response: %v", err)) + return + } + diags = resp.State.Set(ctx, &model) + resp.Diagnostics.Append(diags...) +} + +// Update updates the resource and sets the updated Terraform state on success. +func (i *instanceResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { // 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 + } + + ctx = core.InitProviderContext(ctx) + + projectId := model.ProjectId.ValueString() + region := model.Region.ValueString() + instanceId := model.InstanceId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "instance_id", instanceId) + ctx = tflog.SetField(ctx, "region", region) + + tflog.Info(ctx, "Updating Edge Cloud instance", map[string]any{"instance_id": instanceId}) + payload := toUpdatePayload(&model) + err := i.client.UpdateInstance(ctx, projectId, region, instanceId).UpdateInstancePayload(payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating instance", fmt.Sprintf("Calling API: %v", err)) + return + } + + ctx = core.LogResponse(ctx) + + waitResp, err := edgewait.CreateOrUpdateInstanceWaitHandler(ctx, i.client, projectId, region, instanceId).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating instance", fmt.Sprintf("Instance waiting: %v", err)) + return + } + + err = mapFields(waitResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating instance", fmt.Sprintf("Mapping fields: %v", err)) + return + } + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + tflog.Info(ctx, "edge cloud instance successfully updated") +} + +// Delete deletes the Edge Cloud instance. +func (i *instanceResource) 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 + } + + ctx = core.InitProviderContext(ctx) + + projectId := model.ProjectId.ValueString() + region := model.Region.ValueString() + instanceId := model.InstanceId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "instance_id", instanceId) + ctx = tflog.SetField(ctx, "region", region) + + err := i.client.DeleteInstance(ctx, projectId, region, instanceId).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting instance", fmt.Sprintf("Calling API: %v", err)) + return + } + + ctx = core.LogResponse(ctx) + + _, err = edgewait.DeleteInstanceWaitHandler(ctx, i.client, projectId, region, instanceId).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting instance", fmt.Sprintf("Instance deletion waiting: %v", err)) + return + } + tflog.Info(ctx, "edge cloud instance deleted") +} + +// ImportState imports a resource into the state. +func (i *instanceResource) 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 instance", + fmt.Sprintf("Expected import identifier with format: [project_id],[region],[instance_id] Got: %q", req.ID), + ) + return + } + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), idParts[0])...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("region"), idParts[1])...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("instance_id"), idParts[2])...) +} + +// mapFields maps the API response to the Terraform model. +func mapFields(resp *edge.Instance, model *Model) error { + if resp == nil { + return fmt.Errorf("response input is nil") + } + if model == nil { + return fmt.Errorf("model input is nil") + } + + // Build the ID by combining the project id, region and instance id and assign the model's fields. + var instanceId string + if model.InstanceId.ValueString() != "" { + instanceId = model.InstanceId.ValueString() + } else if resp.Id != nil { + instanceId = *resp.Id + } + model.Id = utils.BuildInternalTerraformId(model.ProjectId.ValueString(), model.Region.ValueString(), instanceId) + model.InstanceId = types.StringValue(instanceId) + if resp.Created.String() != "" { + model.Created = types.StringValue(resp.Created.String()) + } else { + model.Created = types.StringNull() + } + model.FrontendUrl = types.StringPointerValue(resp.FrontendUrl) + model.DisplayName = types.StringPointerValue(resp.DisplayName) + model.PlanID = types.StringPointerValue(resp.PlanId) + model.Status = types.StringValue(string(*resp.Status)) + + if resp.Description != nil { + model.Description = types.StringValue(*resp.Description) + } else { + model.Description = types.StringValue("") + } + + return nil +} + +// toCreatePayload creates the payload for creating an Edge Cloud instance. +func toCreatePayload(model *Model) edge.CreateInstancePayload { + return edge.CreateInstancePayload{ + DisplayName: model.DisplayName.ValueStringPointer(), + Description: model.Description.ValueStringPointer(), + PlanId: model.PlanID.ValueStringPointer(), + } +} + +// toUpdatePayload creates the payload for updating an Edge Cloud instance using the correct struct. +func toUpdatePayload(model *Model) edge.UpdateInstancePayload { + return edge.UpdateInstancePayload{ + Description: model.Description.ValueStringPointer(), + PlanId: model.PlanID.ValueStringPointer(), + } +} diff --git a/stackit/internal/services/edgecloud/instance/resource_test.go b/stackit/internal/services/edgecloud/instance/resource_test.go new file mode 100644 index 000000000..02b58f4ed --- /dev/null +++ b/stackit/internal/services/edgecloud/instance/resource_test.go @@ -0,0 +1,212 @@ +package instance + +import ( + "fmt" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/edge" +) + +func TestMapFields(t *testing.T) { + testTime, _ := time.Parse(time.RFC3339, "2023-09-04T10:00:00Z") + uuidString := uuid.NewString() + tests := []struct { + description string + input *edge.Instance + model *Model + expected Model + isValid bool + }{ + { + "all_parameter_set", + &edge.Instance{ + Id: utils.Ptr("iid-123"), + Created: &testTime, + DisplayName: utils.Ptr("test-instance"), + Description: utils.Ptr("Test description"), + PlanId: utils.Ptr(uuidString), + Status: utils.Ptr(edge.InstanceStatus("CREATING")), + FrontendUrl: utils.Ptr("https://iid-123.example.com"), + }, + &Model{ + ProjectId: types.StringValue(uuidString), + Region: types.StringValue("eu01"), + }, + Model{ + Id: types.StringValue(fmt.Sprintf("%s,eu01,iid-123", uuidString)), + ProjectId: types.StringValue(uuidString), + Region: types.StringValue("eu01"), + InstanceId: types.StringValue("iid-123"), + Created: types.StringValue("2023-09-04 10:00:00 +0000 UTC"), + DisplayName: types.StringValue("test-instance"), + Description: types.StringValue("Test description"), + PlanID: types.StringValue(uuidString), + Status: types.StringValue("CREATING"), + FrontendUrl: types.StringValue("https://iid-123.example.com"), + }, + true, + }, + { + "empty_description", + &edge.Instance{ + Id: utils.Ptr("iid-123"), + Created: &testTime, + DisplayName: utils.Ptr("test-instance"), + Description: utils.Ptr(""), + PlanId: utils.Ptr(uuidString), + Status: utils.Ptr(edge.InstanceStatus("ACTIVE")), + FrontendUrl: utils.Ptr("https://iid-123.example.com"), + }, + &Model{ + ProjectId: types.StringValue(uuidString), + Region: types.StringValue("eu01"), + }, + Model{ + Id: types.StringValue(fmt.Sprintf("%s,eu01,iid-123", uuidString)), + ProjectId: types.StringValue(uuidString), + Region: types.StringValue("eu01"), + InstanceId: types.StringValue("iid-123"), + Created: types.StringValue("2023-09-04 10:00:00 +0000 UTC"), + DisplayName: types.StringValue("test-instance"), + Description: types.StringValue(""), + PlanID: types.StringValue(uuidString), + Status: types.StringValue("ACTIVE"), + FrontendUrl: types.StringValue("https://iid-123.example.com"), + }, + true, + }, + { + "nil_response", + nil, + &Model{}, + Model{}, + false, + }, + { + "nil_model", + &edge.Instance{}, + nil, + Model{}, + false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + err := mapFields(tt.input, tt.model) + if !tt.isValid { + if err == nil { + t.Fatalf("Should have failed") + } + return + } + if err != nil { + t.Fatalf("Should not have failed: %v", err) + } + diff := cmp.Diff(tt.model, &tt.expected) + if diff != "" { + t.Errorf("Data does not match: %s", diff) + } + }) + } +} + +func TestToCreatePayload(t *testing.T) { + uuidString := uuid.NewString() + tests := []struct { + description string + input *Model + expected edge.CreateInstancePayload + isValid bool + }{ + { + "all_parameter_set", + &Model{ + DisplayName: types.StringValue("new-instance"), + Description: types.StringValue("A new test instance"), + PlanID: types.StringValue(uuidString), + }, + edge.CreateInstancePayload{ + DisplayName: utils.Ptr("new-instance"), + Description: utils.Ptr("A new test instance"), + PlanId: utils.Ptr(uuidString), + }, + true, + }, + { + "no_description", + &Model{ + DisplayName: types.StringValue("new-instance"), + Description: types.StringNull(), + PlanID: types.StringValue(uuidString), + }, + edge.CreateInstancePayload{ + DisplayName: utils.Ptr("new-instance"), + Description: nil, + PlanId: utils.Ptr(uuidString), + }, + true, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + payload := toCreatePayload(tt.input) + diff := cmp.Diff(payload, tt.expected) + if diff != "" { + t.Errorf("Payload does not match: %s", diff) + } + }) + } +} + +func TestToUpdatePayload(t *testing.T) { + var uuidOne = uuid.NewString() + + tests := []struct { + description string + input *Model + expected edge.UpdateInstancePayload + isValid bool + }{ + { + "all_updatable_parameter_set", + &Model{ + Description: types.StringValue("Updated description"), + PlanID: types.StringValue(uuidOne), + }, + edge.UpdateInstancePayload{ + Description: utils.Ptr("Updated description"), + PlanId: utils.Ptr(uuidOne), + }, + true, + }, + { + "description_null_plan_updated", + &Model{ + Description: types.StringNull(), + PlanID: types.StringValue(uuidOne), + }, + edge.UpdateInstancePayload{ + Description: nil, + PlanId: utils.Ptr(uuidOne), + }, + true, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + payload := toUpdatePayload(tt.input) + diff := cmp.Diff(payload, tt.expected) + if diff != "" { + t.Errorf("Payload does not match: %s", diff) + } + }) + } +} diff --git a/stackit/internal/services/edgecloud/instances/datasource.go b/stackit/internal/services/edgecloud/instances/datasource.go new file mode 100644 index 000000000..011a8c530 --- /dev/null +++ b/stackit/internal/services/edgecloud/instances/datasource.go @@ -0,0 +1,279 @@ +package instances + +import ( + "context" + "fmt" + "net/http" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/diag" + "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/services/edge" + "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" + edgeutils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/edgecloud/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ datasource.DataSource = &instancesDataSource{} +) + +// DataSourceModel maps the data source schema data. +type DataSourceModel struct { + Id types.String `tfsdk:"id"` + ProjectId types.String `tfsdk:"project_id"` + Region types.String `tfsdk:"region"` + Instances types.List `tfsdk:"instances"` // Changed from Map to List +} + +// instanceTypes defines the attribute types for a single instance object. +var instanceTypes = map[string]attr.Type{ + "instance_id": types.StringType, + "display_name": types.StringType, + "created": types.StringType, + "frontend_url": types.StringType, + "region": types.StringType, + "plan_id": types.StringType, + "description": types.StringType, + "status": types.StringType, +} + +// NewInstancesDataSource creates a new instance of the instancesDataSource. +func NewInstancesDataSource() datasource.DataSource { + return &instancesDataSource{} +} + +// instancesDataSource is the data source implementation. +type instancesDataSource struct { + client *edge.APIClient + providerData core.ProviderData +} + +// Configure sets up the API client for the Edge Cloud instance data source. +func (d *instancesDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + var ok bool + d.providerData, ok = conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics) + if !ok { + return + } + + features.CheckBetaResourcesEnabled(ctx, &d.providerData, &resp.Diagnostics, "stackit_edgecloud_instances", "datasource") + if resp.Diagnostics.HasError() { + return + } + + apiClient := edgeutils.ConfigureClient(ctx, &d.providerData, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + d.client = apiClient + tflog.Info(ctx, "edge cloud client configured") +} + +// Metadata provides metadata for the edge datasource. +func (d *instancesDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_edgecloud_instances" +} + +// Schema defines the schema for the Edge Cloud instances data source. +func (d *instancesDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: features.AddBetaDescription("Edge Cloud is in private Beta and not generally available.\n You can contact support if you are interested in trying it out.", core.Datasource), + Description: "edge cloud instances datasource schema.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "Terraform's internal data source ID, structured as `project_id`,`region`.", + Computed: true, + }, + "project_id": schema.StringAttribute{ + Description: "STACKIT project ID to which the Edge Cloud instances are associated.", + Required: true, + Validators: []validator.String{ + validate.UUID(), + }, + }, + "region": schema.StringAttribute{ + Description: "The resource region. If not defined, the provider region is used.", + Optional: true, + }, + "instances": schema.ListNestedAttribute{ + Description: "A list of Edge Cloud instances.", + Computed: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "instance_id": schema.StringAttribute{ + Description: "The ID of the instance.", + Computed: true, + }, + "display_name": schema.StringAttribute{ + Description: "The display name of the instance.", + Computed: true, + }, + "created": schema.StringAttribute{ + Description: "The date and time the instance was created.", + Computed: true, + }, + "frontend_url": schema.StringAttribute{ + Description: "Frontend URL for the Edge Cloud instance.", + Computed: true, + }, + "region": schema.StringAttribute{ + Description: "The region where the instance is located.", + Computed: true, + }, + "plan_id": schema.StringAttribute{ + Description: "The plan ID for the instance.", + Computed: true, + }, + "description": schema.StringAttribute{ + Description: "Description of the instance.", + Computed: true, + }, + "status": schema.StringAttribute{ + Description: "The status of the instance.", + Computed: true, + }, + }, + }, + }, + }, + } +} + +// Read fetches the list of Edge Cloud instances and populates the data source. +func (d *instancesDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform + var state DataSourceModel + diags := req.Config.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + ctx = core.InitProviderContext(ctx) + + projectId := state.ProjectId.ValueString() + region := d.providerData.GetRegionWithOverride(state.Region) + + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "region", region) + + // Fetch all instances for the project and region + instancesResp, err := d.client.ListInstances(ctx, projectId, region).Execute() + if err != nil { + utils.LogError( + ctx, + &resp.Diagnostics, + err, + "Error reading instances:", + fmt.Sprintf("Calling API: %v", err), + map[int]string{ + http.StatusNotFound: fmt.Sprintf("Project %q or region %q not found", projectId, region), + }, + ) + return + } + + ctx = core.LogResponse(ctx) + + if instancesResp == nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading instances", "API response is nil") + return + } + if instancesResp.Instances == nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading instances", "instance field in the API response is nil") + return + } + instancesList := buildInstancesList(ctx, instancesResp.Instances, region, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + // Create ListValue + instancesListValue, diags := types.ListValue(types.ObjectType{AttrTypes: instanceTypes}, instancesList) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Set the Terraform state + state.Id = types.StringValue(fmt.Sprintf("%s,%s", projectId, region)) + state.Instances = instancesListValue + + diags = resp.State.Set(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "read all edgecloud instances") +} + +// buildInstancesList constructs a list of instance attributes +func buildInstancesList(ctx context.Context, instances edge.InstanceListGetInstancesAttributeType, region string, diags *diag.Diagnostics) []attr.Value { + var instancesList []attr.Value + + for _, instance := range *instances { + instanceAttrs, err := mapInstanceToAttrs(instance, region) + if err != nil { + // Keep going in case there are more errors + instanceId := "without id" + if instance.Id != nil { + instanceId = *instance.Id + } + core.LogAndAddError(ctx, diags, "Error reading instances", fmt.Sprintf("Could not process instance %q: %v", instanceId, err)) + continue + } + + instanceObjectValue, objDiags := types.ObjectValue(instanceTypes, instanceAttrs) + diags.Append(objDiags...) + + if objDiags.HasError() { + continue + } + instancesList = append(instancesList, instanceObjectValue) + } + return instancesList +} + +func mapInstanceToAttrs(instance edge.Instance, region string) (map[string]attr.Value, error) { + if instance.Id == nil { + return nil, fmt.Errorf("instance is missing an 'id'") + } + if instance.DisplayName == nil || *instance.DisplayName == "" { + return nil, fmt.Errorf("instance %q is missing a 'displayName'", *instance.Id) + } + if instance.PlanId == nil { + return nil, fmt.Errorf("instance %q is missing a 'planId'", *instance.Id) + } + if instance.FrontendUrl == nil { + return nil, fmt.Errorf("instance %q is missing a 'frontendUrl'", *instance.Id) + } + if instance.Status == nil { + return nil, fmt.Errorf("instance %q is missing a 'status'", *instance.Id) + } + if instance.Created == nil { + return nil, fmt.Errorf("instance %q is missing a 'created' timestamp", *instance.Id) + } + if instance.Description == nil { + return nil, fmt.Errorf("instance %q is missing a 'description'", *instance.Id) + } + + attrs := map[string]attr.Value{ + "instance_id": types.StringValue(*instance.Id), + "display_name": types.StringValue(*instance.DisplayName), + "region": types.StringValue(region), + "plan_id": types.StringValue(*instance.PlanId), + "frontend_url": types.StringValue(*instance.FrontendUrl), + "status": types.StringValue(string(instance.GetStatus())), + "created": types.StringValue(instance.Created.String()), + "description": types.StringValue(*instance.Description), + } + return attrs, nil +} diff --git a/stackit/internal/services/edgecloud/instances/datasource_test.go b/stackit/internal/services/edgecloud/instances/datasource_test.go new file mode 100644 index 000000000..741db3d37 --- /dev/null +++ b/stackit/internal/services/edgecloud/instances/datasource_test.go @@ -0,0 +1,302 @@ +package instances + +import ( + "context" + "fmt" + "maps" + "strings" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/edge" +) + +// testTime is a shared helper for generating consistent timestamps +var testTime, _ = time.Parse(time.RFC3339, "2023-09-04T10:00:00Z") + +// defaultPlanId is defined outside the function to avoid it having a different value each call +var defaultPlanId = uuid.NewString() + +// fixtureInstance creates a valid default instance and applies modifiers. +func fixtureInstance(mods ...func(instance *edge.Instance)) edge.Instance { + id := "some-hash" + displayName := "some" + description := "some-description" + + instance := &edge.Instance{ + Id: utils.Ptr(id), + DisplayName: utils.Ptr(displayName), + PlanId: &defaultPlanId, + FrontendUrl: utils.Ptr(fmt.Sprintf("https://%s.example.com", id)), + Status: utils.Ptr(edge.InstanceStatus("ACTIVE")), + Created: &testTime, + Description: utils.Ptr(description), + } + + for _, mod := range mods { + mod(instance) + } + + return *instance +} + +func fixtureAttrs(base map[string]attr.Value, mods ...func(m map[string]attr.Value)) map[string]attr.Value { + m := maps.Clone(base) + for _, mod := range mods { + mod(m) + } + return m +} + +func TestMapInstanceToAttrs(t *testing.T) { + region := "eu01" + validInstance := fixtureInstance() + + validInstanceAttrs := map[string]attr.Value{ + "instance_id": types.StringValue(*validInstance.Id), + "display_name": types.StringValue(*validInstance.DisplayName), + "region": types.StringValue(region), + "plan_id": types.StringValue(*validInstance.PlanId), + "frontend_url": types.StringValue(*validInstance.FrontendUrl), + "status": types.StringValue(string(*validInstance.Status)), + "created": types.StringValue(testTime.String()), + "description": types.StringValue(*validInstance.Description), + } + + tests := []struct { + description string + instance edge.Instance + expected map[string]attr.Value + expectError bool + errorMsg string + }{ + { + description: "valid instance", + instance: validInstance, + expected: validInstanceAttrs, + expectError: false, + }, + { + description: "valid instance, empty description", + instance: fixtureInstance(func(i *edge.Instance) { + i.Description = utils.Ptr("") + }), + expected: fixtureAttrs(validInstanceAttrs, func(m map[string]attr.Value) { + m["description"] = types.StringValue("") + }), + expectError: false, + }, + { + description: "error, nil display name", + instance: fixtureInstance(func(i *edge.Instance) { + i.DisplayName = nil + }), + expectError: true, + errorMsg: "missing a 'displayName'", + }, + { + description: "error, empty display name", + instance: fixtureInstance(func(i *edge.Instance) { + i.DisplayName = utils.Ptr("") + }), + expectError: true, + errorMsg: "missing a 'displayName'", + }, + { + description: "error, nil id", + instance: fixtureInstance(func(i *edge.Instance) { + i.Id = nil + }), + expectError: true, + errorMsg: "missing an 'id'", + }, + { + description: "error, nil planId", + instance: fixtureInstance(func(i *edge.Instance) { + i.PlanId = nil + }), + expectError: true, + errorMsg: "missing a 'planId'", + }, + { + description: "error, nil frontendUrl", + instance: fixtureInstance(func(i *edge.Instance) { + i.FrontendUrl = nil + }), + expectError: true, + errorMsg: "missing a 'frontendUrl'", + }, + { + description: "error, nil status", + instance: fixtureInstance(func(i *edge.Instance) { + i.Status = nil + }), + expectError: true, + errorMsg: "missing a 'status'", + }, + { + description: "error, nil created", + instance: fixtureInstance(func(i *edge.Instance) { + i.Created = nil + }), + expectError: true, + errorMsg: "missing a 'created' timestamp", + }, + { + description: "error, nil description", + instance: fixtureInstance(func(i *edge.Instance) { + i.Description = nil + }), + expectError: true, + errorMsg: "missing a 'description'", + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + attrs, err := mapInstanceToAttrs(tt.instance, region) + + if tt.expectError { + if err == nil { + t.Fatalf("Expected an error, but got nil") + } + if tt.errorMsg != "" && !strings.Contains(err.Error(), tt.errorMsg) { + t.Errorf("Expected error message to contain %q, but got: %v", tt.errorMsg, err) + } + return + } + + if err != nil { + t.Fatalf("Expected no error, but got: %v", err) + } + + diff := cmp.Diff(tt.expected, attrs, cmpopts.EquateEmpty()) + if diff != "" { + t.Errorf("Resulting attributes do not match expected:\n%s", diff) + } + }) + } +} + +func TestBuildInstancesList(t *testing.T) { + region := "eu01" + ctx := context.Background() + + instance1 := fixtureInstance(func(i *edge.Instance) { + i.Id = utils.Ptr("first-ab75568") + i.DisplayName = utils.Ptr("first") + }) + + instance2 := fixtureInstance(func(i *edge.Instance) { + i.Id = utils.Ptr("second-ab75568") + i.DisplayName = utils.Ptr("second") + }) + + instanceInvalidPlan := fixtureInstance(func(i *edge.Instance) { + i.PlanId = nil + }) + + // Invalid: Nil Display Name + instanceNilName := fixtureInstance(func(i *edge.Instance) { + i.DisplayName = nil + }) + + // Invalid: Empty Display Name + instanceEmptyName := fixtureInstance(func(i *edge.Instance) { + i.DisplayName = utils.Ptr("") + }) + + // Invalid: Nil ID and Nil Display Name + instanceNilIdAndName := fixtureInstance(func(i *edge.Instance) { + i.Id = nil + i.DisplayName = nil + }) + + // Pre-calculate expected mapped objects for the valid instances + attrs1, _ := mapInstanceToAttrs(instance1, region) + obj1, _ := types.ObjectValue(instanceTypes, attrs1) + + attrs2, _ := mapInstanceToAttrs(instance2, region) + obj2, _ := types.ObjectValue(instanceTypes, attrs2) + + tests := []struct { + description string + instances edge.InstanceListGetInstancesAttributeType + expectedList []attr.Value + expectedDiagCount int + }{ + { + description: "empty instance list", + instances: &[]edge.Instance{}, // No test case for nil, since this is checked before buildInstancesList is called + expectedList: []attr.Value{}, + expectedDiagCount: 0, + }, + { + description: "two valid instances", + instances: &[]edge.Instance{instance1, instance2}, + expectedList: []attr.Value{obj1, obj2}, + expectedDiagCount: 0, + }, + { + description: "one valid, one invalid (nil planId)", + instances: &[]edge.Instance{instance1, instanceInvalidPlan}, + expectedList: []attr.Value{obj1}, + expectedDiagCount: 1, + }, + { + description: "one valid, one invalid (nil display name)", + instances: &[]edge.Instance{instance1, instanceNilName}, + expectedList: []attr.Value{obj1}, + expectedDiagCount: 1, + }, + { + description: "one valid, one invalid (empty display name)", + instances: &[]edge.Instance{instance1, instanceEmptyName}, + expectedList: []attr.Value{obj1}, + expectedDiagCount: 1, + }, + { + description: "one valid, one invalid (nil id and nil display name)", + instances: &[]edge.Instance{instance1, instanceNilIdAndName}, + expectedList: []attr.Value{obj1}, + expectedDiagCount: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + var diags diag.Diagnostics + + resultList := buildInstancesList(ctx, tt.instances, region, &diags) + + if tt.expectedDiagCount > 0 { + if !diags.HasError() { + t.Errorf("Expected diagnostics to have errors, but it didn't") + } + if len(diags) != tt.expectedDiagCount { + t.Errorf("Expected %d diagnostic(s), but got %d", tt.expectedDiagCount, len(diags)) + } + for _, d := range diags { + if d.Severity() != diag.SeverityError { + t.Errorf("Expected diagnostic to be an Error, but got %v", d.Severity()) + } + } + } else if diags.HasError() { + t.Errorf("Expected no errors, but got diagnostics: %v", diags) + } + + diff := cmp.Diff(tt.expectedList, resultList, cmpopts.EquateEmpty()) + if diff != "" { + t.Errorf("Resulting list does not match expected:\n%s", diff) + } + }) + } +} diff --git a/stackit/internal/services/edgecloud/kubeconfig/resource.go b/stackit/internal/services/edgecloud/kubeconfig/resource.go new file mode 100644 index 000000000..1e60bb861 --- /dev/null +++ b/stackit/internal/services/edgecloud/kubeconfig/resource.go @@ -0,0 +1,439 @@ +package kubeconfig + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + edgeCloud "github.com/stackitcloud/stackit-sdk-go/services/edge" + edgeCloudWait "github.com/stackitcloud/stackit-sdk-go/services/edge/wait" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features" + edgeCloudUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/edgecloud/utils" + + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-log/tflog" + "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/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" + "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" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ resource.Resource = &kubeconfigResource{} + _ resource.ResourceWithConfigure = &kubeconfigResource{} + _ resource.ResourceWithModifyPlan = &kubeconfigResource{} +) + +type Model struct { + Id types.String `tfsdk:"id"` // needed by TF + InstanceName types.String `tfsdk:"instance_name"` + InstanceId types.String `tfsdk:"instance_id"` + ProjectId types.String `tfsdk:"project_id"` + KubeconfigId types.String `tfsdk:"kubeconfig_id"` // uuid generated internally because kubeconfig has no identifier + Kubeconfig types.String `tfsdk:"kubeconfig"` + Expiration types.Int64 `tfsdk:"expiration"` + RecreateBefore types.Int64 `tfsdk:"recreate_before"` + ExpiresAt types.String `tfsdk:"expires_at"` + CreationTime types.String `tfsdk:"creation_time"` + Region types.String `tfsdk:"region"` +} + +var descriptions = map[string]string{ + "main": "Edge Cloud Instance kubeconfig resource schema. Allows managing edge hosts and edge cluster configuration resources via kubernetes API.", + "id": "Terraform's internal resource ID. It is structured as \"`project_id`,`region`,`instance_name` or `instance_id`,`kubeconfig_id`\".", + "kubeconfig_id": "Internally generated UUID to identify a kubeconfig resource in Terraform, since the Edge Cloud API doesn't return a kubeconfig identifier", + "instance_name": "Name of the Edge Cloud instance.", + "instance_id": "ID of the Edge Cloud instance.", + "project_id": "STACKIT project ID to which the Edge Cloud instance is associated.", + "kubeconfig": "Raw kubeconfig.", + // kubeconfig uses a service token, that's why it has the same min and max duration + "expiration": fmt.Sprintf("Expiration time of the kubeconfig, in seconds. Minimum is %d, Maximum is %d. Defaults to `3600`", edgeCloudUtils.TokenMinDuration, edgeCloudUtils.TokenMaxDuration), + "recreate_before": "Number of seconds before expiration to trigger recreation of the kubeconfig at.", + "expires_at": "Timestamp when the kubeconfig expires", + "creation_time": "Date-time when the kubeconfig was created", + "region": "The resource region. If not defined, the provider region is used.", +} + +// NewKubeconfigResource is a helper function to simplify the provider implementation. +func NewKubeconfigResource() resource.Resource { + return &kubeconfigResource{} +} + +// kubeconfigResource is the resource implementation. +type kubeconfigResource struct { + client *edgeCloud.APIClient + providerData core.ProviderData +} + +// Metadata returns the resource type name. +func (r *kubeconfigResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_edgecloud_kubeconfig" +} + +// Configure adds the provider configured client to the resource. +func (r *kubeconfigResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + var ok bool + r.providerData, ok = conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics) + if !ok { + return + } + features.CheckBetaResourcesEnabled(ctx, &r.providerData, &resp.Diagnostics, "stackit_edgecloud_kubeconfig", "resource") + if resp.Diagnostics.HasError() { + return + } + + apiClient := edgeCloudUtils.ConfigureClient(ctx, &r.providerData, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + r.client = apiClient + tflog.Info(ctx, "Edge Cloud kubeconfig client configured") +} + +// Schema defines the schema for the resource. +func (r *kubeconfigResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: features.AddBetaDescription("Edge Cloud is in private Beta and not generally available.\n You can contact support if you are interested in trying it out.", core.Resource), + + Description: descriptions["main"], + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: descriptions["id"], + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "project_id": schema.StringAttribute{ + Description: descriptions["project_id"], + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "instance_name": schema.StringAttribute{ + Description: descriptions["instance_name"], + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + validate.NoSeparator(), + stringvalidator.ExactlyOneOf(path.MatchRoot("instance_id")), + }, + }, + "instance_id": schema.StringAttribute{ + Description: descriptions["instance_id"], + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + validate.NoSeparator(), + stringvalidator.ExactlyOneOf(path.MatchRoot("instance_name")), + }, + }, + "kubeconfig_id": schema.StringAttribute{ + Description: descriptions["kubeconfig_id"], + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "expiration": schema.Int64Attribute{ + Description: descriptions["expiration"], + Optional: true, + Computed: true, + Default: int64default.StaticInt64(3600), + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.RequiresReplace(), + int64planmodifier.UseStateForUnknown(), + }, + Validators: []validator.Int64{ + // kubeconfig uses a service token, that's why it has the same min and max duration + int64validator.Between(edgeCloudUtils.TokenMinDuration, edgeCloudUtils.TokenMaxDuration), + }, + }, + "recreate_before": schema.Int64Attribute{ + Description: descriptions["recreate_before"], + Optional: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.UseStateForUnknown(), + }, + Validators: []validator.Int64{ + int64validator.AtLeast(1), + }, + }, + "kubeconfig": schema.StringAttribute{ + Description: descriptions["kubeconfig"], + Computed: true, + Sensitive: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "expires_at": schema.StringAttribute{ + Description: descriptions["expires_at"], + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "creation_time": schema.StringAttribute{ + Description: descriptions["creation_time"], + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "region": schema.StringAttribute{ + Optional: true, + // must be computed to allow for storing the override value from the provider + Computed: true, + Description: descriptions["region"], + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + }, + } +} + +func (r *kubeconfigResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { // nolint:gocritic // function signature required by Terraform + if req.Config.Raw.IsNull() { + return + } + + var configModel Model + 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 + } + + if !req.State.Raw.IsNull() { + var stateModel Model + resp.Diagnostics.Append(req.State.Get(ctx, &stateModel)...) + if resp.Diagnostics.HasError() { + return + } + + if !utils.IsUndefined(stateModel.ExpiresAt) { + recreateBefore := planModel.RecreateBefore + if recreateBefore.IsUnknown() { + recreateBefore = types.Int64Null() + } + + shouldRecreate, err := edgeCloudUtils.CheckExpiration(stateModel.ExpiresAt, recreateBefore, time.Now()) + + if err != nil { + resp.Diagnostics.AddError("Error checking kubeconfig expiration in plan", err.Error()) + return + } + + if shouldRecreate { + tflog.Info(ctx, "Forcing kubeconfig recreation based on expiration/recreate_before window", map[string]any{ + "expires_at": stateModel.ExpiresAt.ValueString(), + "recreate_before": recreateBefore.String(), + }) + + planModel.ExpiresAt = types.StringUnknown() + resp.RequiresReplace.Append(path.Root("expires_at")) + } + } + } + + resp.Diagnostics.Append(resp.Plan.Set(ctx, planModel)...) + if resp.Diagnostics.HasError() { + return + } +} + +// Create creates the resource and sets the initial Terraform state. +func (r *kubeconfigResource) 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 + } + + ctx = core.InitProviderContext(ctx) + + projectId := model.ProjectId.ValueString() + expirationSeconds := model.Expiration.ValueInt64() + region := model.Region.ValueString() + kubeconfigUUID := uuid.New().String() + model.KubeconfigId = types.StringValue(kubeconfigUUID) + + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "kubeconfig_id", kubeconfigUUID) + ctx = tflog.SetField(ctx, "region", region) + + var kubeconfigResp *edgeCloud.Kubeconfig + var err error + if !model.InstanceId.IsNull() { + instanceId := model.InstanceId.ValueString() + ctx = tflog.SetField(ctx, "instance_id", model.InstanceId) + kubeconfigResp, err = edgeCloudWait.KubeconfigWaitHandler(ctx, r.client, projectId, region, instanceId, &expirationSeconds).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating kubeconfig", fmt.Sprintf("Kubeconfig creation waiting: %v", err)) + return + } + model.Id = types.StringValue(fmt.Sprintf("%s,%s,%s,%s", projectId, region, instanceId, kubeconfigUUID)) + } else if !model.InstanceName.IsNull() { + instanceName := model.InstanceName.ValueString() + ctx = tflog.SetField(ctx, "instance_name", model.InstanceName) + kubeconfigResp, err = edgeCloudWait.KubeconfigByInstanceNameWaitHandler(ctx, r.client, projectId, region, instanceName, &expirationSeconds).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating kubeconfig", fmt.Sprintf("Kubeconfig creation waiting: %v", err)) + return + } + model.Id = types.StringValue(fmt.Sprintf("%s,%s,%s,%s", projectId, region, instanceName, kubeconfigUUID)) + } + + ctx = core.LogResponse(ctx) + + creationTime := time.Now() + model.CreationTime = types.StringValue(creationTime.Format(time.RFC3339)) + expirationDuration := time.Duration(model.Expiration.ValueInt64()) * time.Second + expiresAtTime := creationTime.Add(expirationDuration) + model.ExpiresAt = types.StringValue(expiresAtTime.Format(time.RFC3339)) + + if kubeconfigResp == nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating kubeconfig", "API response is nil") + return + } + + if kubeconfigResp.Kubeconfig == nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating kubeconfig", "API response kubeconfig field is nil") + return + } + + kubeconfig, err := marshalKubeconfig(*kubeconfigResp.Kubeconfig) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating kubeconfig", fmt.Sprintf("Processing API payload: %v", err)) + return + } + model.Kubeconfig = types.StringValue(kubeconfig) + + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + tflog.Info(ctx, "Edge Cloud kubeconfig created") +} + +func (r *kubeconfigResource) 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 + } + if !model.InstanceId.IsNull() { + ctx = tflog.SetField(ctx, "instance_id", model.InstanceId) + } else if !model.InstanceName.IsNull() { + ctx = tflog.SetField(ctx, "instance_name", model.InstanceName) + } + projectId := model.ProjectId.ValueString() + kubeconfigUUID := model.KubeconfigId.ValueString() + region := model.Region.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "kubeconfig_id", kubeconfigUUID) + ctx = tflog.SetField(ctx, "region", region) + tflog.Info(ctx, "Edge Cloud kubeconfig read") +} + +// Update only works for recreate_before, since this is a provider internal state value. Everything else requires recreation. +func (r *kubeconfigResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform + var model Model + ctx = tflog.SetField(ctx, "region", model.Region.ValueString()) + ctx = tflog.SetField(ctx, "project_id", model.ProjectId.ValueString()) + ctx = tflog.SetField(ctx, "instance_id", model.InstanceId.ValueString()) + ctx = tflog.SetField(ctx, "kubeconfig_id", model.KubeconfigId.ValueString()) + + resp.Diagnostics.Append(req.Plan.Get(ctx, &model)...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, model)...) + if resp.Diagnostics.HasError() { + return + } + + tflog.Info(ctx, "Edge Cloud kubeconfig updated") +} + +// Delete deletes the resource and removes the Terraform state on success. +func (r *kubeconfigResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform + core.LogAndAddWarning(ctx, &resp.Diagnostics, "Deleting kubeconfig", "Deleting this resource will only remove the values from the terraform state, it will not trigger a deletion or revoke the actual kubeconfig since kubernetes does not support the revocation of service tokens. The kubeconfig will still be valid until it expires.") + + // Retrieve values from plan + var model Model + diags := req.State.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + if !model.InstanceId.IsNull() { + ctx = tflog.SetField(ctx, "instance_id", model.InstanceId) + } else if !model.InstanceName.IsNull() { + ctx = tflog.SetField(ctx, "instance_name", model.InstanceName) + } + + ctx = tflog.SetField(ctx, "region", model.Region.ValueString()) + ctx = tflog.SetField(ctx, "project_id", model.ProjectId.ValueString()) + ctx = tflog.SetField(ctx, "kubeconfig_id", model.KubeconfigId.ValueString()) + + tflog.Info(ctx, "Edge Cloud kubeconfig deleted from state") +} + +func marshalKubeconfig(kubeconfigData map[string]any) (string, error) { + // Check for empty/nil input + if len(kubeconfigData) == 0 { + return "", fmt.Errorf("received nil or empty kubeconfig data from the API") + } + + // Marshal to JSON + jsonDataBytes, err := json.Marshal(kubeconfigData) + if err != nil { + return "", fmt.Errorf("could not marshal kubeconfig map to JSON: %w", err) + } + + return string(jsonDataBytes), nil +} diff --git a/stackit/internal/services/edgecloud/kubeconfig/resource_test.go b/stackit/internal/services/edgecloud/kubeconfig/resource_test.go new file mode 100644 index 000000000..46ee49367 --- /dev/null +++ b/stackit/internal/services/edgecloud/kubeconfig/resource_test.go @@ -0,0 +1,67 @@ +package kubeconfig + +import ( + "encoding/json" + "testing" +) + +func TestMarshalKubeconfig(t *testing.T) { + // Valid kubeconfig data + validMapData := map[string]any{ + "apiVersion": "v1", + "kind": "Config", + "clusters": []any{}, + } + // We marshal this here to establish the "expected" string + validKubeconfigJSON, _ := json.Marshal(validMapData) + + // Data that triggers a JSON Marshal error + unmarshalableMap := map[string]any{ + "a": make(chan int), + } + + tests := []struct { + name string + kubeconfigData map[string]any + wantResult string + wantErr bool + }{ + { + name: "Successful marshaling", + kubeconfigData: validMapData, + wantResult: string(validKubeconfigJSON), + wantErr: false, + }, + { + name: "Nil kubeconfig data", + kubeconfigData: nil, + wantResult: "", // Expect empty string on error + wantErr: true, + }, + { + name: "Empty kubeconfig data", + kubeconfigData: map[string]any{}, + wantResult: "", // Expect empty string on error + wantErr: true, + }, + { + name: "JSON marshal error", + kubeconfigData: unmarshalableMap, + wantResult: "", // Expect empty string on error + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotResult, err := marshalKubeconfig(tt.kubeconfigData) + if (err != nil) != tt.wantErr { + t.Errorf("marshalKubeconfig() error = %v, wantErr %v", err, tt.wantErr) + return // Stop if error status is wrong + } + if gotResult != tt.wantResult { + t.Errorf("marshalKubeconfig() = %v, want %v", gotResult, tt.wantResult) + } + }) + } +} diff --git a/stackit/internal/services/edgecloud/plans/datasource.go b/stackit/internal/services/edgecloud/plans/datasource.go new file mode 100644 index 000000000..a4f43d298 --- /dev/null +++ b/stackit/internal/services/edgecloud/plans/datasource.go @@ -0,0 +1,205 @@ +package plan + +import ( + "context" + "fmt" + "net/http" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/stackitcloud/stackit-sdk-go/services/edge" + "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" + edgeutils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/edgecloud/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ datasource.DataSource = &plansDataSource{} +) + +// DataSourceModel maps the data source schema data. +type DataSourceModel struct { + Id types.String `tfsdk:"id"` + ProjectId types.String `tfsdk:"project_id"` + Plans types.List `tfsdk:"plans"` +} + +// planTypes defines the attribute types for a single plan object within the list. +var planTypes = map[string]attr.Type{ + "id": types.StringType, + "name": types.StringType, + "description": types.StringType, + "max_edge_hosts": types.Int64Type, +} + +// NewPlansDataSource creates a new plan data source. +func NewPlansDataSource() datasource.DataSource { + return &plansDataSource{} +} + +// plansDataSource is the datasource implementation. +type plansDataSource struct { + client *edge.APIClient +} + +// Configure sets up the API client for the Edge Cloud plans data source. +func (d *plansDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + providerData, ok := conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics) + if !ok { + return + } + + features.CheckBetaResourcesEnabled(ctx, &providerData, &resp.Diagnostics, "stackit_edgecloud_plans", "datasource") + if resp.Diagnostics.HasError() { + return + } + + d.client = edgeutils.ConfigureClient(ctx, &providerData, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "edge cloud client configured") +} + +// Metadata provides metadata for the Edge Cloud plans data source. +func (d *plansDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_edgecloud_plans" +} + +// Schema defines the schema for the Edge Cloud plans data source. +func (d *plansDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: features.AddBetaDescription("Edge Cloud is in private Beta and not generally available.\n You can contact support if you are interested in trying it out.", core.Datasource), + Description: "The Edge Cloud Plans datasource lists all valid plans for a given project.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "Terraform's internal data source ID, `project_id` is used here.", + Computed: true, + }, + "project_id": schema.StringAttribute{ + Description: "STACKIT project ID the Plans belongs to.", + Required: true, + Validators: []validator.String{ + validate.UUID(), + }, + }, + "plans": schema.ListNestedAttribute{ + Description: "A list of Edge Cloud Plans.", + Computed: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "The ID of the plan.", + Computed: true, + }, + "name": schema.StringAttribute{ + Description: "The name of the plan.", + Computed: true, + }, + "description": schema.StringAttribute{ + Description: "Description of the plan.", + Computed: true, + }, + "max_edge_hosts": schema.Int64Attribute{ + Description: "Maximum number of Edge Cloud hosts that can be used.", + Computed: true, + }, + }, + }, + }, + }, + } +} + +// Read fetches the list of Edge Cloud plans and populates the data source. +func (d *plansDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform + var state DataSourceModel + diags := req.Config.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + ctx = core.InitProviderContext(ctx) + + projectId := state.ProjectId.ValueString() + + ctx = tflog.SetField(ctx, "project_id", projectId) + + // Fetch all Plans for the project + plansResp, err := d.client.ListPlansProject(ctx, projectId).Execute() + if err != nil { + utils.LogError( + ctx, + &resp.Diagnostics, + err, + "Error reading Edge Cloud plans:", + fmt.Sprintf("Calling API: %v", err), + map[int]string{ + http.StatusNotFound: fmt.Sprintf("Project %q not found", projectId), + }, + ) + return + } + + ctx = core.LogResponse(ctx) + + // Process the API response and build the list + var plansList []attr.Value + if plansResp.ValidPlans != nil { + for _, plan := range *plansResp.ValidPlans { + planAttrs, err := mapPlanToAttrs(&plan) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading Edge Cloud plans", fmt.Sprintf("Could not process plans: %v", err)) + return + } + + planObjectValue, diags := types.ObjectValue(planTypes, planAttrs) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + plansList = append(plansList, planObjectValue) + } + } + + state.Id = types.StringValue(projectId) + + planListValue, diags := types.ListValue(types.ObjectType{AttrTypes: planTypes}, plansList) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + state.Plans = planListValue + + diags = resp.State.Set(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "read all Edge Cloud plans") +} + +// mapPlanToAttrs maps a single edge.Plan to a map of Terraform attributes. +func mapPlanToAttrs(plan *edge.Plan) (map[string]attr.Value, error) { + if plan == nil || plan.Id == nil || plan.Name == nil || plan.MaxEdgeHosts == nil { + return nil, fmt.Errorf("received nil or incomplete plan from API") + } + + attrs := map[string]attr.Value{ + "id": types.StringValue(plan.GetId()), + "name": types.StringValue(plan.GetName()), + "description": types.StringValue(plan.GetDescription()), + "max_edge_hosts": types.Int64Value(plan.GetMaxEdgeHosts()), + } + + return attrs, nil +} diff --git a/stackit/internal/services/edgecloud/testdata/resource-max.tf b/stackit/internal/services/edgecloud/testdata/resource-max.tf new file mode 100644 index 000000000..775779ec2 --- /dev/null +++ b/stackit/internal/services/edgecloud/testdata/resource-max.tf @@ -0,0 +1,52 @@ +variable "project_id" {} +variable "region" {} +variable "display_name" {} +variable "plan_id" {} +variable "description" {} +variable "expiration" {} +variable "recreate_before" {} + +resource "stackit_edgecloud_instance" "test_instance" { + project_id = var.project_id + region = var.region + display_name = var.display_name + plan_id = var.plan_id + description = var.description +} + +resource "stackit_edgecloud_kubeconfig" "by_name" { + project_id = var.project_id + instance_name = stackit_edgecloud_instance.test_instance.display_name + expiration = var.expiration + recreate_before = var.recreate_before +} + +resource "stackit_edgecloud_kubeconfig" "by_id" { + project_id = var.project_id + instance_id = stackit_edgecloud_instance.test_instance.instance_id + expiration = var.expiration + recreate_before = var.recreate_before +} + +resource "stackit_edgecloud_token" "by_name" { + project_id = var.project_id + instance_name = stackit_edgecloud_instance.test_instance.display_name + expiration = var.expiration + recreate_before = var.recreate_before +} + +resource "stackit_edgecloud_token" "by_id" { + project_id = var.project_id + instance_id = stackit_edgecloud_instance.test_instance.instance_id + expiration = var.expiration + recreate_before = var.recreate_before +} + +data "stackit_edgecloud_instances" "this" { + project_id = var.project_id +} + + +data "stackit_edgecloud_plans" "this" { + project_id = var.project_id +} diff --git a/stackit/internal/services/edgecloud/testdata/resource-min.tf b/stackit/internal/services/edgecloud/testdata/resource-min.tf new file mode 100644 index 000000000..13391a978 --- /dev/null +++ b/stackit/internal/services/edgecloud/testdata/resource-min.tf @@ -0,0 +1,27 @@ +variable "project_id" {} +variable "display_name" {} +variable "plan_id" {} + +resource "stackit_edgecloud_instance" "test_instance" { + project_id = var.project_id + display_name = var.display_name + plan_id = var.plan_id +} + +resource "stackit_edgecloud_kubeconfig" "this" { + project_id = var.project_id + instance_id = stackit_edgecloud_instance.test_instance.instance_id +} + +resource "stackit_edgecloud_token" "this" { + project_id = var.project_id + instance_id = stackit_edgecloud_instance.test_instance.instance_id +} + +data "stackit_edgecloud_instances" "this" { + project_id = var.project_id +} + +data "stackit_edgecloud_plans" "this" { + project_id = var.project_id +} diff --git a/stackit/internal/services/edgecloud/token/resource.go b/stackit/internal/services/edgecloud/token/resource.go new file mode 100644 index 000000000..7fc625a37 --- /dev/null +++ b/stackit/internal/services/edgecloud/token/resource.go @@ -0,0 +1,420 @@ +package token + +import ( + "context" + "fmt" + "time" + + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + edgeCloud "github.com/stackitcloud/stackit-sdk-go/services/edge" + edgeCloudWait "github.com/stackitcloud/stackit-sdk-go/services/edge/wait" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features" + edgeCloudUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/edgecloud/utils" + + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-log/tflog" + "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/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" + "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" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ resource.Resource = &tokenResource{} + _ resource.ResourceWithConfigure = &tokenResource{} + _ resource.ResourceWithModifyPlan = &tokenResource{} +) + +type Model struct { + Id types.String `tfsdk:"id"` + InstanceName types.String `tfsdk:"instance_name"` + InstanceId types.String `tfsdk:"instance_id"` + ProjectId types.String `tfsdk:"project_id"` + TokenId types.String `tfsdk:"token_id"` // uuid generated internally because token has no identifier + Token types.String `tfsdk:"token"` + Expiration types.Int64 `tfsdk:"expiration"` + RecreateBefore types.Int64 `tfsdk:"recreate_before"` + ExpiresAt types.String `tfsdk:"expires_at"` + CreationTime types.String `tfsdk:"creation_time"` + Region types.String `tfsdk:"region"` +} + +var descriptions = map[string]string{ + "main": "Edge Cloud Instance token resource schema. Allows managing edge hosts and edge cluster configuration resources via kubernetes API.", + "id": "Terraform's internal resource ID. It is structured as \"`project_id`,`region`,`instance_name` or `instance_id`,`token_id`\".", + "token_id": "Internally generated UUID to identify a token resource in Terraform, since the Edge Cloud API doesnt return a token identifier", + "instance_name": "Name of the Edge Cloud instance.", + "instance_id": "ID of the Edge Cloud instance.", + "project_id": "STACKIT project ID to which the Edge Cloud instance is associated.", + "token": "Raw token.", + "expiration": fmt.Sprintf("Expiration time of the token, in seconds. Minimum is %d, Maximum is %d. Defaults to `3600`", edgeCloudUtils.TokenMinDuration, edgeCloudUtils.TokenMaxDuration), + "recreate_before": "Number of seconds before expiration to trigger recreation of the token at.", + "expires_at": "Timestamp when the token expires", + "creation_time": "Date-time when the token was created", + "region": "The resource region. If not defined, the provider region is used.", +} + +// NewTokenResource is a helper function to simplify the provider implementation. +func NewTokenResource() resource.Resource { + return &tokenResource{} +} + +// tokenResource is the resource implementation. +type tokenResource struct { + client *edgeCloud.APIClient + providerData core.ProviderData +} + +// Metadata returns the resource type name. +func (r *tokenResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_edgecloud_token" +} + +// Configure adds the provider configured client to the resource. +func (r *tokenResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + var ok bool + r.providerData, ok = conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics) + if !ok { + return + } + features.CheckBetaResourcesEnabled(ctx, &r.providerData, &resp.Diagnostics, "stackit_edgecloud_token", "resource") + if resp.Diagnostics.HasError() { + return + } + + apiClient := edgeCloudUtils.ConfigureClient(ctx, &r.providerData, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + r.client = apiClient + tflog.Info(ctx, "Edge Cloud token client configured") +} + +// Schema defines the schema for the resource. +func (r *tokenResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: features.AddBetaDescription("Edge Cloud is in private Beta and not generally available.\n You can contact support if you are interested in trying it out.", core.Resource), + + Description: descriptions["main"], + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: descriptions["id"], + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "project_id": schema.StringAttribute{ + Description: descriptions["project_id"], + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "instance_name": schema.StringAttribute{ + Description: descriptions["instance_name"], + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + validate.NoSeparator(), + stringvalidator.ExactlyOneOf(path.MatchRoot("instance_id")), + }, + }, + "instance_id": schema.StringAttribute{ + Description: descriptions["instance_id"], + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + validate.NoSeparator(), + stringvalidator.ExactlyOneOf(path.MatchRoot("instance_name")), + }, + }, + "token_id": schema.StringAttribute{ + Description: descriptions["token_id"], + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "expiration": schema.Int64Attribute{ + Description: descriptions["expiration"], + Optional: true, + Computed: true, + Default: int64default.StaticInt64(3600), + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.RequiresReplace(), + int64planmodifier.UseStateForUnknown(), + }, + Validators: []validator.Int64{ + int64validator.Between(edgeCloudUtils.TokenMinDuration, edgeCloudUtils.TokenMaxDuration), + }, + }, + "recreate_before": schema.Int64Attribute{ + Description: descriptions["recreate_before"], + Optional: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.UseStateForUnknown(), + }, + Validators: []validator.Int64{ + int64validator.AtLeast(1), + }, + }, + "token": schema.StringAttribute{ + Description: descriptions["token"], + Computed: true, + Sensitive: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "expires_at": schema.StringAttribute{ + Description: descriptions["expires_at"], + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "creation_time": schema.StringAttribute{ + Description: descriptions["creation_time"], + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "region": schema.StringAttribute{ + Optional: true, + // must be computed to allow for storing the override value from the provider + Computed: true, + Description: descriptions["region"], + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + }, + } +} + +func (r *tokenResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { // nolint:gocritic // function signature required by Terraform + if req.Config.Raw.IsNull() { + return + } + + var configModel Model + 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 + } + + if !req.State.Raw.IsNull() { + var stateModel Model + resp.Diagnostics.Append(req.State.Get(ctx, &stateModel)...) + if resp.Diagnostics.HasError() { + return + } + + if !utils.IsUndefined(stateModel.ExpiresAt) { + recreateBefore := planModel.RecreateBefore + if recreateBefore.IsUnknown() { + recreateBefore = types.Int64Null() + } + + shouldRecreate, err := edgeCloudUtils.CheckExpiration(stateModel.ExpiresAt, recreateBefore, time.Now()) + + if err != nil { + resp.Diagnostics.AddError("Error checking kubeconfig expiration in plan", err.Error()) + return + } + + if shouldRecreate { + tflog.Info(ctx, "Forcing token recreation based on expiration/recreate_before window", map[string]any{ + "expires_at": stateModel.ExpiresAt.ValueString(), + "recreate_before": recreateBefore.String(), + }) + + planModel.ExpiresAt = types.StringUnknown() + resp.RequiresReplace.Append(path.Root("expires_at")) + } + } + } + + resp.Diagnostics.Append(resp.Plan.Set(ctx, planModel)...) + if resp.Diagnostics.HasError() { + return + } +} + +// Create creates the resource and sets the initial Terraform state. +func (r *tokenResource) 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 + } + + ctx = core.InitProviderContext(ctx) + + projectId := model.ProjectId.ValueString() + expirationSeconds := model.Expiration.ValueInt64() + region := model.Region.ValueString() + tokenUUID := uuid.New().String() + model.TokenId = types.StringValue(tokenUUID) + + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "token_id", tokenUUID) + ctx = tflog.SetField(ctx, "region", region) + + var tokenResp *edgeCloud.Token + var err error + if !model.InstanceId.IsNull() { + instanceId := model.InstanceId.ValueString() + ctx = tflog.SetField(ctx, "instance_id", model.InstanceId) + tokenResp, err = edgeCloudWait.TokenWaitHandler(ctx, r.client, projectId, region, instanceId, &expirationSeconds).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating token", fmt.Sprintf("token waiting: %v", err)) + return + } + model.Id = types.StringValue(fmt.Sprintf("%s,%s,%s,%s", projectId, region, instanceId, tokenUUID)) + } else if !model.InstanceName.IsNull() { + instanceName := model.InstanceName.ValueString() + ctx = tflog.SetField(ctx, "instance_name", model.InstanceName) + tokenResp, err = edgeCloudWait.TokenByInstanceNameWaitHandler(ctx, r.client, projectId, region, instanceName, &expirationSeconds).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating token", fmt.Sprintf("token waiting: %v", err)) + return + } + model.Id = types.StringValue(fmt.Sprintf("%s,%s,%s,%s", projectId, region, instanceName, tokenUUID)) + } + + ctx = core.LogResponse(ctx) + + creationTime := time.Now() + model.CreationTime = types.StringValue(creationTime.Format(time.RFC3339)) + expirationDuration := time.Duration(model.Expiration.ValueInt64()) * time.Second + expiresAtTime := creationTime.Add(expirationDuration) + model.ExpiresAt = types.StringValue(expiresAtTime.Format(time.RFC3339)) + + if tokenResp == nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating token", "API response is nil") + return + } + if tokenResp.Token == nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating token", "token field in the API response is nil") + return + } + model.Token = types.StringPointerValue(tokenResp.Token) + + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Edge Cloud token created") +} + +// Read checks if the token is still valid, i.e. not yet expired. If it is valid, +// it returns. Otherwise, the token will the expired token will be removed from state. +func (r *tokenResource) 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 + } + if !model.InstanceId.IsNull() { + ctx = tflog.SetField(ctx, "instance_id", model.InstanceId) + } else if !model.InstanceName.IsNull() { + ctx = tflog.SetField(ctx, "instance_name", model.InstanceName) + } + projectId := model.ProjectId.ValueString() + tokenUUID := model.TokenId.ValueString() + region := r.providerData.GetRegionWithOverride(model.Region) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "token_id", tokenUUID) + ctx = tflog.SetField(ctx, "region", region) + tflog.Info(ctx, "Edge Cloud token read") +} + +// Update only works for recreate_before, since this is a provider internal state value. Everything else requires recreation. +func (r *tokenResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform + var model Model + ctx = tflog.SetField(ctx, "region", model.Region.ValueString()) + ctx = tflog.SetField(ctx, "project_id", model.ProjectId.ValueString()) + ctx = tflog.SetField(ctx, "instance_id", model.InstanceId.ValueString()) + ctx = tflog.SetField(ctx, "token_id", model.TokenId.ValueString()) + + resp.Diagnostics.Append(req.Plan.Get(ctx, &model)...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, model)...) + if resp.Diagnostics.HasError() { + return + } + + tflog.Info(ctx, "Edge Cloud token updated") +} + +// Delete deletes the resource and removes the Terraform state on success. +func (r *tokenResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform + core.LogAndAddWarning(ctx, &resp.Diagnostics, "Deleting token", "Deleting this resource will only remove the values from the terraform state, it will not trigger a deletion or revoke the actual token since kubernetes does not support the revocation of service tokens. The token will still be valid until it expires.") + + // Retrieve values from plan + var model Model + diags := req.State.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + if !model.InstanceId.IsNull() { + ctx = tflog.SetField(ctx, "instance_id", model.InstanceId) + } else if !model.InstanceName.IsNull() { + ctx = tflog.SetField(ctx, "instance_name", model.InstanceName) + } + + ctx = tflog.SetField(ctx, "region", model.Region.ValueString()) + ctx = tflog.SetField(ctx, "project_id", model.ProjectId.ValueString()) + ctx = tflog.SetField(ctx, "token_id", model.TokenId.ValueString()) + + tflog.Info(ctx, "Edge Cloud token deleted from state") +} diff --git a/stackit/internal/services/edgecloud/utils/util.go b/stackit/internal/services/edgecloud/utils/util.go new file mode 100644 index 000000000..6939c9437 --- /dev/null +++ b/stackit/internal/services/edgecloud/utils/util.go @@ -0,0 +1,66 @@ +package utils + +import ( + "context" + "fmt" + "time" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/services/edge" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" +) + +const ( + DisplayNameMinimumChars = 4 + DisplayNameMaximumChars = 8 + DescriptionMaxLength = 256 + TokenMinDuration = 600 + TokenMaxDuration = 15552000 +) + +func ConfigureClient(ctx context.Context, providerData *core.ProviderData, diags *diag.Diagnostics) *edge.APIClient { + apiClientConfigOptions := []config.ConfigurationOption{ + config.WithCustomAuth(providerData.RoundTripper), + utils.UserAgentConfigOption(providerData.Version), + } + if providerData.EdgeCloudCustomEndpoint != "" { + apiClientConfigOptions = append(apiClientConfigOptions, config.WithEndpoint(providerData.EdgeCloudCustomEndpoint)) + } + apiClient, err := edge.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 +} + +func CheckExpiration(expiresAt types.String, recreateBefore types.Int64, currentTime time.Time) (bool, error) { + if expiresAt.IsNull() { + return true, nil + } + + if expiresAt.IsUnknown() { + return true, nil + } + + expiresAtTime, err := time.Parse(time.RFC3339, expiresAt.ValueString()) + if err != nil { + return false, fmt.Errorf("failed to convert expiresAt field to timestamp: %w", err) + } + + if !recreateBefore.IsNull() { + expiresAtTime = expiresAtTime.Add(-time.Duration(recreateBefore.ValueInt64()) * time.Second) + } + + // The value is considered expired if the expiration time is not after the current time. + // This correctly handles cases where the expiration is before or exactly at the current time. + if !expiresAtTime.After(currentTime) { + return true, nil + } + + return false, nil +} diff --git a/stackit/internal/services/edgecloud/utils/util_test.go b/stackit/internal/services/edgecloud/utils/util_test.go new file mode 100644 index 000000000..b86c9b0c1 --- /dev/null +++ b/stackit/internal/services/edgecloud/utils/util_test.go @@ -0,0 +1,179 @@ +package utils + +import ( + "context" + "os" + "reflect" + "testing" + "time" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + sdkClients "github.com/stackitcloud/stackit-sdk-go/core/clients" + "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/services/edge" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" +) + +const ( + testVersion = "1.2.3" + testCustomEndpoint = "https://edge-custom-endpoint.api.stackit.cloud" +) + +func TestConfigureClient(t *testing.T) { + os.Clearenv() + err := os.Setenv(sdkClients.ServiceAccountToken, "mock-val") + if err != nil { + t.Errorf("error setting env variable: %v", err) + } + + type args struct { + providerData *core.ProviderData + } + tests := []struct { + name string + args args + wantErr bool + expected *edge.APIClient + }{ + { + name: "default endpoint", + args: args{ + providerData: &core.ProviderData{ + Version: testVersion, + }, + }, + expected: func() *edge.APIClient { + apiClient, err := edge.NewAPIClient( + utils.UserAgentConfigOption(testVersion), + ) + if err != nil { + t.Errorf("error configuring client: %v", err) + } + return apiClient + }(), + wantErr: false, + }, + { + name: "custom endpoint", + args: args{ + providerData: &core.ProviderData{ + Version: testVersion, + EdgeCloudCustomEndpoint: testCustomEndpoint, + }, + }, + expected: func() *edge.APIClient { + apiClient, err := edge.NewAPIClient( + utils.UserAgentConfigOption(testVersion), + config.WithEndpoint(testCustomEndpoint), + ) + if err != nil { + t.Errorf("error configuring client: %v", err) + } + return apiClient + }(), + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + diags := diag.Diagnostics{} + + actual := ConfigureClient(ctx, tt.args.providerData, &diags) + if diags.HasError() != tt.wantErr { + t.Errorf("ConfigureClient() error = %v, want %v", diags.HasError(), tt.wantErr) + } + + if !reflect.DeepEqual(actual, tt.expected) { + t.Errorf("ConfigureClient() = %v, want %v", actual, tt.expected) + } + }) + } +} + +func TestCheckExpiration(t *testing.T) { + // Reference time for testing + now := time.Date(2025, 10, 26, 12, 0, 0, 0, time.UTC) + + tests := []struct { + name string + expiresAt types.String + recreateBefore types.Int64 + currentTime time.Time + expectedExpired bool + expectedErr bool + }{ + { + name: "Is not expired", + expiresAt: types.StringValue(now.Add(1 * time.Hour).Format(time.RFC3339)), + recreateBefore: types.Int64Null(), + currentTime: now, + expectedExpired: false, + expectedErr: false, + }, + { + name: "Is expired", + expiresAt: types.StringValue(now.Add(-1 * time.Hour).Format(time.RFC3339)), + recreateBefore: types.Int64Null(), + currentTime: now, + expectedExpired: true, + expectedErr: false, + }, + { + name: "Expires at the exact current time", + expiresAt: types.StringValue(now.Format(time.RFC3339)), + recreateBefore: types.Int64Null(), + currentTime: now, + expectedExpired: true, // Should be considered expired if the times are equal. + expectedErr: false, + }, + { + name: "ExpiresAt is null", + expiresAt: types.StringNull(), + recreateBefore: types.Int64Null(), + currentTime: now, + expectedExpired: true, + expectedErr: false, + }, + { + name: "ExpiresAt is unknown", + expiresAt: types.StringUnknown(), + recreateBefore: types.Int64Null(), + currentTime: now, + expectedExpired: true, // Should be treated as expired to force re-creation + expectedErr: false, + }, + { + name: "ExpiresAt has invalid format", + expiresAt: types.StringValue("invalid-time-format"), + recreateBefore: types.Int64Null(), + currentTime: now, + expectedExpired: false, + expectedErr: true, + }, + { + name: "Is considered expired due to recreateBefore", + expiresAt: types.StringValue(now.Add(30 * time.Minute).Format(time.RFC3339)), + recreateBefore: types.Int64Value(3600), + currentTime: now, + expectedExpired: true, + expectedErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hasExpired, err := CheckExpiration(tt.expiresAt, tt.recreateBefore, tt.currentTime) + + if (err != nil) != tt.expectedErr { + t.Errorf("CheckExpiration() error = %v, wantErr %v", err, tt.expectedErr) + return + } + if hasExpired != tt.expectedExpired { + t.Errorf("CheckExpiration() = %v, want %v", hasExpired, tt.expectedExpired) + } + }) + } +} diff --git a/stackit/internal/testutil/testutil.go b/stackit/internal/testutil/testutil.go index d0f4084b0..5923c76dc 100644 --- a/stackit/internal/testutil/testutil.go +++ b/stackit/internal/testutil/testutil.go @@ -66,6 +66,7 @@ var ( CdnCustomEndpoint = os.Getenv("TF_ACC_CDN_CUSTOM_ENDPOINT") DnsCustomEndpoint = os.Getenv("TF_ACC_DNS_CUSTOM_ENDPOINT") + EdgeCloudCustomEndpoint = os.Getenv("TF_ACC_EDGECLOUD_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") @@ -136,6 +137,27 @@ func DnsProviderConfig() string { ) } +func EdgeCloudProviderConfig() string { + if EdgeCloudCustomEndpoint == "" { + return fmt.Sprintf(` + provider "stackit" { + enable_beta_resources = true + default_region = "%s" + }`, + Region, + ) + } + return fmt.Sprintf(` + provider "stackit" { + edgecloud_custom_endpoint = "%s" + enable_beta_resources = true + default_region = "%s" + }`, + EdgeCloudCustomEndpoint, + Region, + ) +} + func IaaSProviderConfig() string { if IaaSCustomEndpoint == "" { return ` diff --git a/stackit/internal/utils/utils.go b/stackit/internal/utils/utils.go index 9011a6665..57bf189eb 100644 --- a/stackit/internal/utils/utils.go +++ b/stackit/internal/utils/utils.go @@ -24,6 +24,7 @@ import ( const ( SKEServiceId = "cloud.stackit.ske" ModelServingServiceId = "cloud.stackit.model-serving" + EdgecloudServiceId = "cloud.stackit.edge-cloud" ) var ( diff --git a/stackit/provider.go b/stackit/provider.go index e5b3e505d..4879dfe09 100644 --- a/stackit/provider.go +++ b/stackit/provider.go @@ -25,6 +25,11 @@ import ( cdn "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/cdn/distribution" dnsRecordSet "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/dns/recordset" dnsZone "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/dns/zone" + edgeCloudInstance "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/edgecloud/instance" + edgeCloudInstances "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/edgecloud/instances" + edgeCloudKubeconfig "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/edgecloud/kubeconfig" + edgeCloudPlans "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/edgecloud/plans" + edgeCloudToken "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/edgecloud/token" gitInstance "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/git/instance" iaasAffinityGroup "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/affinitygroup" iaasImage "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/image" @@ -143,6 +148,7 @@ type providerModel struct { AuthorizationCustomEndpoint types.String `tfsdk:"authorization_custom_endpoint"` CdnCustomEndpoint types.String `tfsdk:"cdn_custom_endpoint"` DnsCustomEndpoint types.String `tfsdk:"dns_custom_endpoint"` + EdgeCloudCustomEndpoint types.String `tfsdk:"edgecloud_custom_endpoint"` GitCustomEndpoint types.String `tfsdk:"git_custom_endpoint"` IaaSCustomEndpoint types.String `tfsdk:"iaas_custom_endpoint"` KmsCustomEndpoint types.String `tfsdk:"kms_custom_endpoint"` @@ -187,6 +193,7 @@ func (p *Provider) Schema(_ context.Context, _ provider.SchemaRequest, resp *pro "default_region": "Region will be used as the default location for regional services. Not all services require a region, some are global", "cdn_custom_endpoint": "Custom endpoint for the CDN service", "dns_custom_endpoint": "Custom endpoint for the DNS service", + "edgecloud_custom_endpoint": "Custom endpoint for the Edge Cloud 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", @@ -284,6 +291,10 @@ func (p *Provider) Schema(_ context.Context, _ provider.SchemaRequest, resp *pro Optional: true, Description: descriptions["dns_custom_endpoint"], }, + "edgecloud_custom_endpoint": schema.StringAttribute{ + Optional: true, + Description: descriptions["edgecloud_custom_endpoint"], + }, "git_custom_endpoint": schema.StringAttribute{ Optional: true, Description: descriptions["git_custom_endpoint"], @@ -440,6 +451,7 @@ func (p *Provider) Configure(ctx context.Context, req provider.ConfigureRequest, setStringField(providerConfig.AuthorizationCustomEndpoint, func(v string) { providerData.AuthorizationCustomEndpoint = v }) setStringField(providerConfig.CdnCustomEndpoint, func(v string) { providerData.CdnCustomEndpoint = v }) setStringField(providerConfig.DnsCustomEndpoint, func(v string) { providerData.DnsCustomEndpoint = v }) + setStringField(providerConfig.EdgeCloudCustomEndpoint, func(v string) { providerData.EdgeCloudCustomEndpoint = v }) setStringField(providerConfig.GitCustomEndpoint, func(v string) { providerData.GitCustomEndpoint = v }) setStringField(providerConfig.IaaSCustomEndpoint, func(v string) { providerData.IaaSCustomEndpoint = v }) setStringField(providerConfig.KmsCustomEndpoint, func(v string) { providerData.KMSCustomEndpoint = v }) @@ -508,6 +520,8 @@ func (p *Provider) DataSources(_ context.Context) []func() datasource.DataSource cdnCustomDomain.NewCustomDomainDataSource, dnsZone.NewZoneDataSource, dnsRecordSet.NewRecordSetDataSource, + edgeCloudInstances.NewInstancesDataSource, + edgeCloudPlans.NewPlansDataSource, gitInstance.NewGitDataSource, iaasAffinityGroup.NewAffinityGroupDatasource, iaasImage.NewImageDataSource, @@ -585,6 +599,9 @@ func (p *Provider) Resources(_ context.Context) []func() resource.Resource { cdnCustomDomain.NewCustomDomainResource, dnsZone.NewZoneResource, dnsRecordSet.NewRecordSetResource, + edgeCloudInstance.NewInstanceResource, + edgeCloudKubeconfig.NewKubeconfigResource, + edgeCloudToken.NewTokenResource, gitInstance.NewGitResource, iaasAffinityGroup.NewAffinityGroupResource, iaasImage.NewImageResource, diff --git a/stackit/testdata/provider-all-attributes.tf b/stackit/testdata/provider-all-attributes.tf index 895ea245b..6f8067792 100644 --- a/stackit/testdata/provider-all-attributes.tf +++ b/stackit/testdata/provider-all-attributes.tf @@ -12,6 +12,7 @@ provider "stackit" { service_account_email = "abc@abc.de" cdn_custom_endpoint = "https://cdn.api.eu01.stackit.cloud" dns_custom_endpoint = "https://dns.api.stackit.cloud" + edgecloud_custom_endpoint = "https://edge.api.stackit.cloud" git_custom_endpoint = "https://git.api.stackit.cloud" iaas_custom_endpoint = "https://iaas.api.stackit.cloud" mongodbflex_custom_endpoint = "https://mongodbflex.api.stackit.cloud"