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"