diff --git a/docs/data-sources/cdn_distribution.md b/docs/data-sources/cdn_distribution.md
new file mode 100644
index 00000000..7d82a8a3
--- /dev/null
+++ b/docs/data-sources/cdn_distribution.md
@@ -0,0 +1,70 @@
+---
+# generated by https://github.com/hashicorp/terraform-plugin-docs
+page_title: "stackit_cdn_distribution Data Source - stackit"
+subcategory: ""
+description: |-
+ CDN distribution data source schema.
+ ~> 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_cdn_distribution (Data Source)
+
+CDN distribution data source schema.
+
+~> 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
+data "stackit_cdn_distribution" "example" {
+ project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+ distribution_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+}
+```
+
+
+## Schema
+
+### Required
+
+- `distribution_id` (String) STACKIT project ID associated with the distribution
+- `project_id` (String) STACKIT project ID associated with the distribution
+
+### Read-Only
+
+- `config` (Attributes) The distribution configuration (see [below for nested schema](#nestedatt--config))
+- `created_at` (String) Time when the distribution was created
+- `domains` (Attributes List) List of configured domains for the distribution (see [below for nested schema](#nestedatt--domains))
+- `errors` (List of String) List of distribution errors
+- `id` (String) Terraform's internal resource identifier. It is structured as "`project_id`,`distribution_id`".
+- `status` (String) Status of the distribution
+- `updated_at` (String) Time when the distribution was last updated
+
+
+### Nested Schema for `config`
+
+Read-Only:
+
+- `backend` (Attributes) The configured backend for the distribution (see [below for nested schema](#nestedatt--config--backend))
+- `regions` (List of String) The configured regions where content will be hosted
+
+
+### Nested Schema for `config.backend`
+
+Read-Only:
+
+- `origin_request_headers` (Map of String) The configured origin request headers for the backend
+- `origin_url` (String) The configured backend type for the distribution
+- `type` (String) The configured backend type
+
+
+
+
+### Nested Schema for `domains`
+
+Read-Only:
+
+- `errors` (List of String) List of domain errors
+- `name` (String) The name of the domain
+- `status` (String) The status of the domain
+- `type` (String) The type of the domain. Each distribution has one domain of type "managed", and domains of type "custom" may be additionally created by the user
diff --git a/docs/index.md b/docs/index.md
index 6e1573d4..a7653c04 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -153,6 +153,7 @@ Note: AWS specific checks must be skipped as they do not work on STACKIT. For de
- `argus_custom_endpoint` (String, Deprecated) Custom endpoint for the Argus service
- `authorization_custom_endpoint` (String) Custom endpoint for the Membership service
+- `cdn_custom_endpoint` (String) Custom endpoint for the CDN service
- `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
diff --git a/docs/resources/cdn_distribution.md b/docs/resources/cdn_distribution.md
new file mode 100644
index 00000000..fed0d775
--- /dev/null
+++ b/docs/resources/cdn_distribution.md
@@ -0,0 +1,80 @@
+---
+# generated by https://github.com/hashicorp/terraform-plugin-docs
+page_title: "stackit_cdn_distribution Resource - stackit"
+subcategory: ""
+description: |-
+ CDN distribution data source schema.
+ ~> 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_cdn_distribution (Resource)
+
+CDN distribution data source schema.
+
+~> 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
+# Create a CDN distribution
+resource "stackit_cdn_distribution" "example_distribution" {
+ project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+ config = {
+ backend = {
+ type = "http"
+ origin_url = "mybackend.onstackit.cloud"
+ }
+ regions = ["EN", "US", "ASIA", "AF", "SA"]
+ }
+}
+```
+
+
+## Schema
+
+### Required
+
+- `config` (Attributes) The distribution configuration (see [below for nested schema](#nestedatt--config))
+- `project_id` (String) STACKIT project ID associated with the distribution
+
+### Read-Only
+
+- `created_at` (String) Time when the distribution was created
+- `distribution_id` (String) STACKIT project ID associated with the distribution
+- `domains` (Attributes List) List of configured domains for the distribution (see [below for nested schema](#nestedatt--domains))
+- `errors` (List of String) List of distribution errors
+- `id` (String) Terraform's internal resource identifier. It is structured as "`project_id`,`distribution_id`".
+- `status` (String) Status of the distribution
+- `updated_at` (String) Time when the distribution was last updated
+
+
+### Nested Schema for `config`
+
+Required:
+
+- `backend` (Attributes) The configured backend for the distribution (see [below for nested schema](#nestedatt--config--backend))
+- `regions` (List of String) The configured regions where content will be hosted
+
+
+### Nested Schema for `config.backend`
+
+Required:
+
+- `origin_url` (String) The configured backend type for the distribution
+- `type` (String) The configured backend type
+
+Optional:
+
+- `origin_request_headers` (Map of String) The configured origin request headers for the backend
+
+
+
+
+### Nested Schema for `domains`
+
+Read-Only:
+
+- `errors` (List of String) List of domain errors
+- `name` (String) The name of the domain
+- `status` (String) The status of the domain
+- `type` (String) The type of the domain. Each distribution has one domain of type "managed", and domains of type "custom" may be additionally created by the user
diff --git a/examples/data-sources/stackit_cdn_distribution/data-source.tf b/examples/data-sources/stackit_cdn_distribution/data-source.tf
new file mode 100644
index 00000000..be24c0bc
--- /dev/null
+++ b/examples/data-sources/stackit_cdn_distribution/data-source.tf
@@ -0,0 +1,5 @@
+data "stackit_cdn_distribution" "example" {
+ project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+ distribution_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+}
+
diff --git a/examples/resources/stackit_cdn_distribution/resource.tf b/examples/resources/stackit_cdn_distribution/resource.tf
new file mode 100644
index 00000000..1c16ba4e
--- /dev/null
+++ b/examples/resources/stackit_cdn_distribution/resource.tf
@@ -0,0 +1,11 @@
+# Create a CDN distribution
+resource "stackit_cdn_distribution" "example_distribution" {
+ project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+ config = {
+ backend = {
+ type = "http"
+ origin_url = "mybackend.onstackit.cloud"
+ }
+ regions = ["EN", "US", "ASIA", "AF", "SA"]
+ }
+}
diff --git a/go.mod b/go.mod
index d1445693..a5db4d97 100644
--- a/go.mod
+++ b/go.mod
@@ -12,6 +12,7 @@ require (
github.com/hashicorp/terraform-plugin-log v0.9.0
github.com/hashicorp/terraform-plugin-testing v1.12.0
github.com/stackitcloud/stackit-sdk-go/core v0.17.1
+ github.com/stackitcloud/stackit-sdk-go/services/cdn v0.3.0
github.com/stackitcloud/stackit-sdk-go/services/dns v0.13.1
github.com/stackitcloud/stackit-sdk-go/services/iaas v0.22.0
github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v1.0.1
diff --git a/go.sum b/go.sum
index e3577dfb..22c26bd0 100644
--- a/go.sum
+++ b/go.sum
@@ -154,6 +154,8 @@ github.com/stackitcloud/stackit-sdk-go/core v0.17.1 h1:TTrVoB1lERd/qfWzpe6HpwCJS
github.com/stackitcloud/stackit-sdk-go/core v0.17.1/go.mod h1:8KIw3czdNJ9sdil9QQimxjR6vHjeINFrRv0iZ67wfn0=
github.com/stackitcloud/stackit-sdk-go/services/authorization v0.6.1 h1:2lq6SG8qOgPOx2OIA5Bca8mwRSlect3Yljk57bXqd5I=
github.com/stackitcloud/stackit-sdk-go/services/authorization v0.6.1/go.mod h1:in9kC4GIBU5DpzXKFDL7RDl0fKyvN/RUIc7YbyWYEUA=
+github.com/stackitcloud/stackit-sdk-go/services/cdn v0.3.0 h1:l3COE8uny+AVkHW7MElzEGdriy+QzhpRhYgLkYJlnLU=
+github.com/stackitcloud/stackit-sdk-go/services/cdn v0.3.0/go.mod h1:O5esCqh35n0ERX7/Sqpf09ZRDWckhHUuflJFbUvx9QM=
github.com/stackitcloud/stackit-sdk-go/services/dns v0.13.1 h1:W5zQhg/nA2RVSkUtRjsGcJMdYlOicoE5gBGE9zMT9Eo=
github.com/stackitcloud/stackit-sdk-go/services/dns v0.13.1/go.mod h1:+i7jQpfgj/OuZNVZ9A9aUHdVUR/j2SfICLeHbtNn+5c=
github.com/stackitcloud/stackit-sdk-go/services/iaas v0.22.0 h1:xaNory8kBIsBG7PJnBfPP1cERc+ERqjebxmEmEOvRHU=
diff --git a/stackit/internal/core/core.go b/stackit/internal/core/core.go
index f4502311..8b7d62c1 100644
--- a/stackit/internal/core/core.go
+++ b/stackit/internal/core/core.go
@@ -21,6 +21,7 @@ type ProviderData struct {
DefaultRegion string
ArgusCustomEndpoint string
AuthorizationCustomEndpoint string
+ CdnCustomEndpoint string
DnsCustomEndpoint string
IaaSCustomEndpoint string
LoadBalancerCustomEndpoint string
diff --git a/stackit/internal/services/cdn/cdn_acc_test.go b/stackit/internal/services/cdn/cdn_acc_test.go
new file mode 100644
index 00000000..c7a6541a
--- /dev/null
+++ b/stackit/internal/services/cdn/cdn_acc_test.go
@@ -0,0 +1,167 @@
+package cdn_test
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "testing"
+
+ "github.com/hashicorp/terraform-plugin-testing/helper/resource"
+ "github.com/hashicorp/terraform-plugin-testing/terraform"
+ "github.com/stackitcloud/stackit-sdk-go/core/config"
+ "github.com/stackitcloud/stackit-sdk-go/services/cdn"
+ "github.com/stackitcloud/stackit-sdk-go/services/cdn/wait"
+ "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
+ "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/testutil"
+)
+
+var instanceResource = map[string]string{
+ "project_id": testutil.ProjectId,
+ "config_backend_type": "http",
+ "config_backend_origin_url": "https://test-backend-1.cdn-dev.runs.onstackit.cloud",
+ "config_regions": "\"EU\", \"US\"",
+ "config_regions_updated": "\"EU\", \"US\", \"ASIA\"",
+}
+
+func configResources(regions string) string {
+ return fmt.Sprintf(`
+ %s
+
+ resource "stackit_cdn_distribution" "distribution" {
+ project_id = "%s"
+ config = {
+ backend = {
+ type = "http"
+ origin_url = "%s"
+ }
+ regions = [%s]
+ }
+ }
+ `, testutil.CdnProviderConfig(), testutil.ProjectId, instanceResource["config_backend_origin_url"], regions)
+}
+
+func configDatasources(regions string) string {
+ return fmt.Sprintf(`
+ %s
+
+ data "stackit_cdn_distribution" "distribution" {
+ project_id = stackit_cdn_distribution.distribution.project_id
+ distribution_id = stackit_cdn_distribution.distribution.distribution_id
+ }
+ `, configResources(regions))
+}
+
+func TestAccCDNDistributionResource(t *testing.T) {
+ resource.Test(t, resource.TestCase{
+ ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories,
+ CheckDestroy: testAccCheckCDNDistributionDestroy,
+ Steps: []resource.TestStep{
+ // Create
+ {
+ Config: configResources(instanceResource["config_regions"]),
+ Check: resource.ComposeAggregateTestCheckFunc(
+ resource.TestCheckResourceAttrSet("stackit_cdn_distribution.distribution", "distribution_id"),
+ resource.TestCheckResourceAttrSet("stackit_cdn_distribution.distribution", "created_at"),
+ resource.TestCheckResourceAttrSet("stackit_cdn_distribution.distribution", "updated_at"),
+ resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "domains.#", "1"),
+ resource.TestCheckResourceAttrSet("stackit_cdn_distribution.distribution", "domains.0.name"),
+ resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "domains.0.status", "ACTIVE"),
+ resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.regions.#", "2"),
+ resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.regions.0", "EU"),
+ resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.regions.1", "US"),
+ resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "project_id", testutil.ProjectId),
+ resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "status", "ACTIVE"),
+ ),
+ },
+ // Import
+ {
+ ResourceName: "stackit_cdn_distribution.distribution",
+ ImportStateIdFunc: func(s *terraform.State) (string, error) {
+ r, ok := s.RootModule().Resources["stackit_cdn_distribution.distribution"]
+ if !ok {
+ return "", fmt.Errorf("couldn't find resource stackit_cdn_distribution.distribution")
+ }
+ distributionId, ok := r.Primary.Attributes["distribution_id"]
+ if !ok {
+ return "", fmt.Errorf("couldn't find attribute distribution_id")
+ }
+
+ return fmt.Sprintf("%s,%s", testutil.ProjectId, distributionId), nil
+ },
+ ImportState: true,
+ ImportStateVerify: true,
+ },
+ // Data Source
+ {
+ Config: configDatasources(instanceResource["config_regions"]),
+ Check: resource.ComposeAggregateTestCheckFunc(
+ resource.TestCheckResourceAttrSet("stackit_cdn_distribution.distribution", "distribution_id"),
+ resource.TestCheckResourceAttrSet("stackit_cdn_distribution.distribution", "created_at"),
+ resource.TestCheckResourceAttrSet("stackit_cdn_distribution.distribution", "updated_at"),
+ resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "domains.#", "1"),
+ resource.TestCheckResourceAttrSet("stackit_cdn_distribution.distribution", "domains.0.name"),
+ resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "domains.0.status", "ACTIVE"),
+ resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.regions.#", "2"),
+ resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.regions.0", "EU"),
+ resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.regions.1", "US"),
+ resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "project_id", testutil.ProjectId),
+ resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "status", "ACTIVE"),
+ ),
+ },
+ // Update
+ {
+ Config: configResources(instanceResource["config_regions_updated"]),
+ Check: resource.ComposeAggregateTestCheckFunc(
+ resource.TestCheckResourceAttrSet("stackit_cdn_distribution.distribution", "distribution_id"),
+ resource.TestCheckResourceAttrSet("stackit_cdn_distribution.distribution", "created_at"),
+ resource.TestCheckResourceAttrSet("stackit_cdn_distribution.distribution", "updated_at"),
+ resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "domains.#", "1"),
+ resource.TestCheckResourceAttrSet("stackit_cdn_distribution.distribution", "domains.0.name"),
+ resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "domains.0.status", "ACTIVE"),
+ resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.regions.#", "3"),
+ resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.regions.0", "EU"),
+ resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.regions.1", "US"),
+ resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.regions.2", "ASIA"),
+ resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "project_id", testutil.ProjectId),
+ resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "status", "ACTIVE"),
+ ),
+ },
+ },
+ })
+}
+func testAccCheckCDNDistributionDestroy(s *terraform.State) error {
+ ctx := context.Background()
+ var client *cdn.APIClient
+ var err error
+ if testutil.MongoDBFlexCustomEndpoint == "" {
+ client, err = cdn.NewAPIClient()
+ } else {
+ client, err = cdn.NewAPIClient(
+ config.WithEndpoint(testutil.MongoDBFlexCustomEndpoint),
+ )
+ }
+ if err != nil {
+ return fmt.Errorf("creating client: %w", err)
+ }
+
+ distributionsToDestroy := []string{}
+ for _, rs := range s.RootModule().Resources {
+ if rs.Type != "stackit_mongodbflex_instance" {
+ continue
+ }
+ distributionId := strings.Split(rs.Primary.ID, core.Separator)[1]
+ distributionsToDestroy = append(distributionsToDestroy, distributionId)
+ }
+
+ for _, dist := range distributionsToDestroy {
+ _, err := client.DeleteDistribution(ctx, testutil.ProjectId, dist).Execute()
+ if err != nil {
+ return fmt.Errorf("destroying CDN distribution %s during CheckDestroy: %w", dist, err)
+ }
+ _, err = wait.DeleteDistributionWaitHandler(ctx, client, testutil.ProjectId, dist).WaitWithContext(ctx)
+ if err != nil {
+ return fmt.Errorf("destroying CDN distribution %s during CheckDestroy: waiting for deletion %w", dist, err)
+ }
+ }
+ return nil
+}
diff --git a/stackit/internal/services/cdn/distribution/datasource.go b/stackit/internal/services/cdn/distribution/datasource.go
new file mode 100644
index 00000000..0a28a097
--- /dev/null
+++ b/stackit/internal/services/cdn/distribution/datasource.go
@@ -0,0 +1,203 @@
+package cdn
+
+import (
+ "context"
+ "fmt"
+
+ "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/core/config"
+ "github.com/stackitcloud/stackit-sdk-go/services/cdn"
+ "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
+ "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features"
+ "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
+ "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate"
+)
+
+type distributionDataSource struct {
+ client *cdn.APIClient
+}
+
+// Ensure the implementation satisfies the expected interfaces.
+var (
+ _ datasource.DataSource = &distributionDataSource{}
+)
+
+func NewDistributionDataSource() datasource.DataSource {
+ return &distributionDataSource{}
+}
+
+func (d *distributionDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
+ if req.ProviderData == nil {
+ return
+ }
+
+ providerData, ok := req.ProviderData.(core.ProviderData)
+ if !ok {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Expected configure type stackit.ProviderData, got %T", req.ProviderData))
+ return
+ }
+
+ features.CheckBetaResourcesEnabled(ctx, &providerData, &resp.Diagnostics, "stackit_cdn_distribution", "datasource")
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ var apiClient *cdn.APIClient
+ var err error
+ if providerData.CdnCustomEndpoint != "" {
+ apiClient, err = cdn.NewAPIClient(
+ config.WithCustomAuth(providerData.RoundTripper),
+ config.WithEndpoint(providerData.CdnCustomEndpoint),
+ )
+ } else {
+ apiClient, err = cdn.NewAPIClient(
+ config.WithCustomAuth(providerData.RoundTripper),
+ )
+ }
+
+ if err != nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Configuring client: %v. This is an error related to the provider configuration, not to the resource configuration", err))
+ return
+ }
+
+ d.client = apiClient
+ tflog.Info(ctx, "Service Account client configured")
+}
+
+func (r *distributionDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
+ resp.TypeName = req.ProviderTypeName + "_cdn_distribution"
+}
+
+func (r *distributionDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
+ resp.Schema = schema.Schema{
+ MarkdownDescription: features.AddBetaDescription("CDN distribution data source schema."),
+ Description: "CDN distribution data source schema.",
+ Attributes: map[string]schema.Attribute{
+ "id": schema.StringAttribute{
+ Description: schemaDescriptions["id"],
+ Computed: true,
+ },
+ "distribution_id": schema.StringAttribute{
+ Description: schemaDescriptions["project_id"],
+ Required: true,
+ Validators: []validator.String{
+ validate.UUID(),
+ },
+ },
+ "project_id": schema.StringAttribute{
+ Description: schemaDescriptions["project_id"],
+ Required: true,
+ Validators: []validator.String{
+ validate.UUID(),
+ },
+ },
+ "status": schema.StringAttribute{
+ Computed: true,
+ Description: schemaDescriptions["status"],
+ },
+ "created_at": schema.StringAttribute{
+ Computed: true,
+ Description: schemaDescriptions["created_at"],
+ },
+ "updated_at": schema.StringAttribute{
+ Computed: true,
+ Description: schemaDescriptions["updated_at"],
+ },
+ "errors": schema.ListAttribute{
+ ElementType: types.StringType,
+ Computed: true,
+ Description: schemaDescriptions["errors"],
+ },
+ "domains": schema.ListNestedAttribute{
+ Computed: true,
+ Description: schemaDescriptions["domains"],
+ NestedObject: schema.NestedAttributeObject{
+ Attributes: map[string]schema.Attribute{
+ "name": schema.StringAttribute{
+ Computed: true,
+ Description: schemaDescriptions["domain_name"],
+ },
+ "status": schema.StringAttribute{
+ Computed: true,
+ Description: schemaDescriptions["domain_status"],
+ },
+ "type": schema.StringAttribute{
+ Computed: true,
+ Description: schemaDescriptions["domain_type"],
+ },
+ "errors": schema.ListAttribute{
+ Computed: true,
+ Description: schemaDescriptions["domain_errors"],
+ ElementType: types.StringType,
+ },
+ },
+ },
+ },
+ "config": schema.SingleNestedAttribute{
+ Computed: true,
+ Description: schemaDescriptions["config"],
+ Attributes: map[string]schema.Attribute{
+ "backend": schema.SingleNestedAttribute{
+ Computed: true,
+ Description: schemaDescriptions["config_backend"],
+ Attributes: map[string]schema.Attribute{
+ "type": schema.StringAttribute{
+ Computed: true,
+ Description: schemaDescriptions["config_backend_type"],
+ },
+ "origin_url": schema.StringAttribute{
+ Computed: true,
+ Description: schemaDescriptions["config_backend_origin_url"],
+ },
+ "origin_request_headers": schema.MapAttribute{
+ Computed: true,
+ Description: schemaDescriptions["config_backend_origin_request_headers"],
+ ElementType: types.StringType,
+ },
+ },
+ },
+ "regions": schema.ListAttribute{
+ Computed: true,
+ Description: schemaDescriptions["config_regions"],
+ ElementType: types.StringType,
+ }},
+ },
+ },
+ }
+}
+
+func (r *distributionDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform
+ var model Model
+ diags := req.Config.Get(ctx, &model)
+ resp.Diagnostics.Append(diags...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ projectId := model.ProjectId.ValueString()
+ distributionId := model.DistributionId.ValueString()
+ distributionResp, err := r.client.GetDistributionExecute(ctx, projectId, distributionId)
+ if err != nil {
+ utils.LogError(
+ ctx,
+ &resp.Diagnostics,
+ err,
+ "Reading CDN distribution",
+ fmt.Sprintf("Unable to access CDN distribution %q.", distributionId),
+ map[int]string{},
+ )
+ resp.State.RemoveResource(ctx)
+ return
+ }
+ err = mapFields(distributionResp.Distribution, &model)
+ if err != nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading CDN distribution", fmt.Sprintf("Error processing API response: %v", err))
+ return
+ }
+ diags = resp.State.Set(ctx, &model)
+ resp.Diagnostics.Append(diags...)
+}
diff --git a/stackit/internal/services/cdn/distribution/resource.go b/stackit/internal/services/cdn/distribution/resource.go
new file mode 100644
index 00000000..1fe028f9
--- /dev/null
+++ b/stackit/internal/services/cdn/distribution/resource.go
@@ -0,0 +1,613 @@
+package cdn
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+
+ "github.com/google/uuid"
+ "github.com/hashicorp/terraform-plugin-framework/attr"
+ "github.com/hashicorp/terraform-plugin-framework/path"
+ "github.com/hashicorp/terraform-plugin-framework/resource"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
+ "github.com/hashicorp/terraform-plugin-framework/schema/validator"
+ "github.com/hashicorp/terraform-plugin-framework/types"
+ "github.com/hashicorp/terraform-plugin-framework/types/basetypes"
+ "github.com/hashicorp/terraform-plugin-log/tflog"
+ "github.com/stackitcloud/stackit-sdk-go/core/config"
+ "github.com/stackitcloud/stackit-sdk-go/core/oapierror"
+ "github.com/stackitcloud/stackit-sdk-go/services/cdn"
+ "github.com/stackitcloud/stackit-sdk-go/services/cdn/wait"
+ "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
+ "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features"
+ "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate"
+)
+
+// Ensure the implementation satisfies the expected interfaces.
+var (
+ _ resource.Resource = &distributionResource{}
+ _ resource.ResourceWithConfigure = &distributionResource{}
+ _ resource.ResourceWithImportState = &distributionResource{}
+)
+
+var schemaDescriptions = map[string]string{
+ "id": "Terraform's internal resource identifier. It is structured as \"`project_id`,`distribution_id`\".",
+ "distribution_id": "CDN distribution ID",
+ "project_id": "STACKIT project ID associated with the distribution",
+ "status": "Status of the distribution",
+ "created_at": "Time when the distribution was created",
+ "updated_at": "Time when the distribution was last updated",
+ "errors": "List of distribution errors",
+ "domains": "List of configured domains for the distribution",
+ "config": "The distribution configuration",
+ "config_backend": "The configured backend for the distribution",
+ "config_regions": "The configured regions where content will be hosted",
+ "config_backend_type": "The configured backend type",
+ "config_backend_origin_url": "The configured backend type for the distribution",
+ "config_backend_origin_request_headers": "The configured origin request headers for the backend",
+ "domain_name": "The name of the domain",
+ "domain_status": "The status of the domain",
+ "domain_type": "The type of the domain. Each distribution has one domain of type \"managed\", and domains of type \"custom\" may be additionally created by the user",
+ "domain_errors": "List of domain errors",
+}
+
+type Model struct {
+ ID types.String `tfsdk:"id"` // Required by Terraform
+ DistributionId types.String `tfsdk:"distribution_id"` // DistributionID associated with the cdn distribution
+ ProjectId types.String `tfsdk:"project_id"` // ProjectId associated with the cdn distribution
+ Status types.String `tfsdk:"status"` // The status of the cdn distribution
+ CreatedAt types.String `tfsdk:"created_at"` // When the distribution was created
+ UpdatedAt types.String `tfsdk:"updated_at"` // When the distribution was last updated
+ Errors types.List `tfsdk:"errors"` // Any errors that the distribution has
+ Domains types.List `tfsdk:"domains"` // The domains associated with the distribution
+ Config types.Object `tfsdk:"config"` // the configuration of the distribution
+}
+
+type distributionConfig struct {
+ Backend backend `tfsdk:"backend"` // The backend associated with the distribution
+ Regions *[]string `tfsdk:"regions"` // The regions in which data will be cached
+}
+
+type backend struct {
+ Type string `tfsdk:"type"` // The type of the backend. Currently, only "http" backend is supported
+ OriginURL string `tfsdk:"origin_url"` // The origin URL of the backend
+ OriginRequestHeaders *map[string]string `tfsdk:"origin_request_headers"` // Request headers that should be added by the CDN distribution to incoming requests
+}
+
+var configTypes = map[string]attr.Type{
+ "backend": types.ObjectType{AttrTypes: backendTypes},
+ "regions": types.ListType{ElemType: types.StringType},
+}
+
+var backendTypes = map[string]attr.Type{
+ "type": types.StringType,
+ "origin_url": types.StringType,
+ "origin_request_headers": types.MapType{ElemType: types.StringType},
+}
+
+var domainTypes = map[string]attr.Type{
+ "name": types.StringType,
+ "status": types.StringType,
+ "type": types.StringType,
+ "errors": types.ListType{ElemType: types.StringType},
+}
+
+type distributionResource struct {
+ client *cdn.APIClient
+ providerData core.ProviderData
+}
+
+func NewDistributionResource() resource.Resource {
+ return &distributionResource{}
+}
+
+func (r *distributionResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
+ if req.ProviderData == nil {
+ return
+ }
+ var ok bool
+ if r.providerData, ok = req.ProviderData.(core.ProviderData); !ok {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Expected configure type stackit.ProviderData, got %T", req.ProviderData))
+ return
+ }
+ features.CheckBetaResourcesEnabled(ctx, &r.providerData, &resp.Diagnostics, "stackit_cdn_distribution", "resource")
+
+ var apiClient *cdn.APIClient
+ var err error
+ if r.providerData.CdnCustomEndpoint != "" {
+ ctx = tflog.SetField(ctx, "cdn_custom_endpoint", r.providerData.CdnCustomEndpoint)
+ apiClient, err = cdn.NewAPIClient(
+ config.WithCustomAuth(r.providerData.RoundTripper),
+ config.WithEndpoint(r.providerData.CdnCustomEndpoint),
+ )
+ } else {
+ apiClient, err = cdn.NewAPIClient(
+ config.WithCustomAuth(r.providerData.RoundTripper),
+ )
+ }
+ if err != nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Configuring client: %v. This is an error related to the provider configuration, not to the resource configuration", err))
+ return
+ }
+ r.client = apiClient
+ tflog.Info(ctx, "CDN client configured")
+}
+
+func (r *distributionResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
+ resp.TypeName = req.ProviderTypeName + "_cdn_distribution"
+}
+
+func (r *distributionResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
+ resp.Schema = schema.Schema{
+ MarkdownDescription: features.AddBetaDescription("CDN distribution data source schema."),
+ Description: "CDN distribution data source schema.",
+ Attributes: map[string]schema.Attribute{
+ "id": schema.StringAttribute{
+ Description: schemaDescriptions["id"],
+ Computed: true,
+ },
+ "distribution_id": schema.StringAttribute{
+ Description: schemaDescriptions["project_id"],
+ Computed: true,
+ Validators: []validator.String{validate.UUID()},
+ PlanModifiers: []planmodifier.String{
+ stringplanmodifier.UseStateForUnknown(),
+ },
+ },
+ "project_id": schema.StringAttribute{
+ Description: schemaDescriptions["project_id"],
+ Required: true,
+ Optional: false,
+ PlanModifiers: []planmodifier.String{
+ stringplanmodifier.RequiresReplace(),
+ },
+ },
+ "status": schema.StringAttribute{
+ Computed: true,
+ Description: schemaDescriptions["status"],
+ },
+ "created_at": schema.StringAttribute{
+ Computed: true,
+ Description: schemaDescriptions["created_at"],
+ },
+ "updated_at": schema.StringAttribute{
+ Computed: true,
+ Description: schemaDescriptions["updated_at"],
+ },
+ "errors": schema.ListAttribute{
+ ElementType: types.StringType,
+ Computed: true,
+ Description: schemaDescriptions["errors"],
+ },
+ "domains": schema.ListNestedAttribute{
+ Computed: true,
+ Description: schemaDescriptions["domains"],
+ NestedObject: schema.NestedAttributeObject{
+ Attributes: map[string]schema.Attribute{
+ "name": schema.StringAttribute{
+ Computed: true,
+ Description: schemaDescriptions["domain_name"],
+ },
+ "status": schema.StringAttribute{
+ Computed: true,
+ Description: schemaDescriptions["domain_status"],
+ },
+ "type": schema.StringAttribute{
+ Computed: true,
+ Description: schemaDescriptions["domain_type"],
+ },
+ "errors": schema.ListAttribute{
+ Computed: true,
+ Description: schemaDescriptions["domain_errors"],
+ ElementType: types.StringType,
+ },
+ },
+ },
+ },
+ "config": schema.SingleNestedAttribute{
+ Required: true,
+ Description: schemaDescriptions["config"],
+ Attributes: map[string]schema.Attribute{
+ "backend": schema.SingleNestedAttribute{
+ Required: true,
+ Description: schemaDescriptions["config_backend"],
+ Attributes: map[string]schema.Attribute{
+ "type": schema.StringAttribute{
+ Required: true,
+ Description: schemaDescriptions["config_backend_type"],
+ },
+ "origin_url": schema.StringAttribute{
+ Required: true,
+ Description: schemaDescriptions["config_backend_origin_url"],
+ },
+ "origin_request_headers": schema.MapAttribute{
+ Optional: true,
+ Description: schemaDescriptions["config_backend_origin_request_headers"],
+ ElementType: types.StringType,
+ },
+ },
+ },
+ "regions": schema.ListAttribute{
+ Required: true,
+ Description: schemaDescriptions["config_regions"],
+ ElementType: types.StringType,
+ }},
+ },
+ },
+ }
+}
+
+func (r *distributionResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform
+ var model Model
+ diags := req.Plan.Get(ctx, &model)
+ resp.Diagnostics.Append(diags...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+ projectId := model.ProjectId.ValueString()
+ ctx = tflog.SetField(ctx, "project_id", projectId)
+
+ payload, err := toCreatePayload(ctx, &model)
+ if err != nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating CDN distribution", fmt.Sprintf("Creating API payload: %v", err))
+ return
+ }
+
+ createResp, err := r.client.CreateDistribution(ctx, projectId).CreateDistributionPayload(*payload).Execute()
+ if err != nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating CDN distribution", fmt.Sprintf("Calling API: %v", err))
+ return
+ }
+ waitResp, err := wait.CreateDistributionPoolWaitHandler(ctx, r.client, projectId, *createResp.Distribution.Id).WaitWithContext(ctx)
+ if err != nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating CDN distribution", fmt.Sprintf("Waiting for create: %v", err))
+ return
+ }
+
+ err = mapFields(waitResp.Distribution, &model)
+ if err != nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating CDN distribution", fmt.Sprintf("Processing API payload: %v", err))
+ return
+ }
+
+ diags = resp.State.Set(ctx, model)
+ resp.Diagnostics.Append(diags...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+ tflog.Info(ctx, "CDN distribution created")
+}
+
+func (r *distributionResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform
+ var model Model
+ diags := req.State.Get(ctx, &model)
+ resp.Diagnostics.Append(diags...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ projectId := model.ProjectId.ValueString()
+ distributionId := model.DistributionId.ValueString()
+ ctx = tflog.SetField(ctx, "project_id", projectId)
+ ctx = tflog.SetField(ctx, "distribution_id", distributionId)
+
+ cdnResp, err := r.client.GetDistribution(ctx, projectId, distributionId).Execute()
+ if err != nil {
+ var oapiErr *oapierror.GenericOpenAPIError
+ // n.b. err is caught here if of type *oapierror.GenericOpenAPIError, which the stackit SDK client returns
+ if errors.As(err, &oapiErr) {
+ if oapiErr.StatusCode == http.StatusNotFound {
+ resp.State.RemoveResource(ctx)
+ return
+ }
+ }
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading CDN distribution", fmt.Sprintf("Calling API: %v", err))
+ return
+ }
+ err = mapFields(cdnResp.Distribution, &model)
+ if err != nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading CDN ditribution", fmt.Sprintf("Processing API payload: %v", err))
+ return
+ }
+ // Set refreshed state
+ diags = resp.State.Set(ctx, model)
+ resp.Diagnostics.Append(diags...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+ tflog.Info(ctx, "CDN distribution read")
+}
+
+func (r *distributionResource) 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
+ }
+
+ projectId := model.ProjectId.ValueString()
+ distributionId := model.DistributionId.ValueString()
+ ctx = tflog.SetField(ctx, "project_id", projectId)
+ ctx = tflog.SetField(ctx, "distribution_id", distributionId)
+
+ configModel := distributionConfig{}
+ diags = model.Config.As(ctx, &configModel, basetypes.ObjectAsOptions{
+ UnhandledNullAsEmpty: false,
+ UnhandledUnknownAsEmpty: false,
+ })
+ if diags.HasError() {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Update CDN distribution", "Error mapping config")
+ return
+ }
+
+ regions := []cdn.Region{}
+ for _, r := range *configModel.Regions {
+ regionEnum, err := cdn.NewRegionFromValue(r)
+ if err != nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Update CDN distribution", fmt.Sprintf("Map regions: %v", err))
+ return
+ }
+ regions = append(regions, *regionEnum)
+ }
+ _, err := r.client.PatchDistribution(ctx, projectId, distributionId).PatchDistributionPayload(cdn.PatchDistributionPayload{
+ Config: &cdn.ConfigPatch{
+ Backend: &cdn.ConfigPatchBackend{
+ HttpBackendPatch: &cdn.HttpBackendPatch{
+ OriginRequestHeaders: configModel.Backend.OriginRequestHeaders,
+ OriginUrl: &configModel.Backend.OriginURL,
+ Type: &configModel.Backend.Type,
+ },
+ },
+ Regions: ®ions,
+ },
+ IntentId: cdn.PtrString(uuid.NewString()),
+ }).Execute()
+ if err != nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Update CDN distribution", fmt.Sprintf("Patch distribution: %v", err))
+ }
+ waitResp, err := wait.UpdateDistributionWaitHandler(ctx, r.client, projectId, distributionId).WaitWithContext(ctx)
+ if err != nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Update CDN distribution", fmt.Sprintf("Waiting for update: %v", err))
+ return
+ }
+
+ err = mapFields(waitResp.Distribution, &model)
+ if err != nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Update CDN distribution", fmt.Sprintf("Processing API payload: %v", err))
+ return
+ }
+ diags = resp.State.Set(ctx, model)
+ resp.Diagnostics.Append(diags...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+ tflog.Info(ctx, "CDN distribution updated")
+}
+
+func (r *distributionResource) 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
+ }
+ projectId := model.ProjectId.ValueString()
+ distributionId := model.DistributionId.ValueString()
+ ctx = tflog.SetField(ctx, "project_id", projectId)
+ ctx = tflog.SetField(ctx, "distribution_id", distributionId)
+
+ _, err := r.client.DeleteDistribution(ctx, projectId, distributionId).Execute()
+ if err != nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Delete CDN distribution", fmt.Sprintf("Delete distribution: %v", err))
+ }
+ _, err = wait.DeleteDistributionWaitHandler(ctx, r.client, projectId, distributionId).WaitWithContext(ctx)
+ if err != nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Delete CDN distribution", fmt.Sprintf("Waiting for deletion: %v", err))
+ return
+ }
+ tflog.Info(ctx, "CDN distribution deleted")
+}
+
+func (r *distributionResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
+ idParts := strings.Split(req.ID, core.Separator)
+
+ if len(idParts) != 2 || idParts[0] == "" || idParts[1] == "" {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error importing CDN distribution", fmt.Sprintf("Expected import identifier on the format: [project_id]%q[distribution_id], got %q", core.Separator, req.ID))
+ }
+ resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), idParts[0])...)
+ resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("distribution_id"), idParts[1])...)
+ tflog.Info(ctx, "CDN distribution state imported")
+}
+
+func mapFields(distribution *cdn.Distribution, model *Model) error {
+ if distribution == nil {
+ return fmt.Errorf("response input is nil")
+ }
+ if model == nil {
+ return fmt.Errorf("model input is nil")
+ }
+
+ if distribution.ProjectId == nil {
+ return fmt.Errorf("Project ID not present")
+ }
+
+ if distribution.Id == nil {
+ return fmt.Errorf("CDN distribution ID not present")
+ }
+
+ if distribution.CreatedAt == nil {
+ return fmt.Errorf("CreatedAt missing in response")
+ }
+
+ if distribution.UpdatedAt == nil {
+ return fmt.Errorf("UpdatedAt missing in response")
+ }
+
+ if distribution.Status == nil {
+ return fmt.Errorf("Status missing in response")
+ }
+
+ id := *distribution.ProjectId + core.Separator + *distribution.Id
+
+ model.ID = types.StringValue(id)
+ model.DistributionId = types.StringValue(*distribution.Id)
+ model.ProjectId = types.StringValue(*distribution.ProjectId)
+ model.Status = types.StringValue(*distribution.Status)
+ model.CreatedAt = types.StringValue(distribution.CreatedAt.String())
+ model.UpdatedAt = types.StringValue(distribution.UpdatedAt.String())
+
+ distributionErrors := []attr.Value{}
+ if distribution.Errors != nil {
+ for _, e := range *distribution.Errors {
+ distributionErrors = append(distributionErrors, types.StringValue(*e.En))
+ }
+ }
+ modelErrors, diags := types.ListValue(types.StringType, distributionErrors)
+ if diags.HasError() {
+ return core.DiagsToError(diags)
+ }
+ model.Errors = modelErrors
+
+ regions := []attr.Value{}
+ for _, r := range *distribution.Config.Regions {
+ regions = append(regions, types.StringValue(string(r)))
+ }
+ modelRegions, diags := types.ListValue(types.StringType, regions)
+ if diags.HasError() {
+ return core.DiagsToError(diags)
+ }
+ originRequestHeaders := types.MapNull(types.StringType)
+ if origHeaders := distribution.Config.Backend.HttpBackend.OriginRequestHeaders; origHeaders != nil && len(*origHeaders) > 0 {
+ headers := map[string]attr.Value{}
+ for k, v := range *origHeaders {
+ headers[k] = types.StringValue(v)
+ }
+ mappedHeaders, diags := types.MapValue(types.StringType, headers)
+ originRequestHeaders = mappedHeaders
+ if diags.HasError() {
+ return core.DiagsToError(diags)
+ }
+ }
+ // note that httpbackend is hardcoded here as long as it is the only available backend
+ backend, diags := types.ObjectValue(backendTypes, map[string]attr.Value{
+ "type": types.StringValue(*distribution.Config.Backend.HttpBackend.Type),
+ "origin_url": types.StringValue(*distribution.Config.Backend.HttpBackend.OriginUrl),
+ "origin_request_headers": originRequestHeaders,
+ })
+ if diags.HasError() {
+ return core.DiagsToError(diags)
+ }
+ cfg, diags := types.ObjectValue(configTypes, map[string]attr.Value{
+ "backend": backend,
+ "regions": modelRegions,
+ })
+ if diags.HasError() {
+ return core.DiagsToError(diags)
+ }
+ model.Config = cfg
+
+ domains := []attr.Value{}
+ if distribution.Domains != nil {
+ for _, d := range *distribution.Domains {
+ domainErrors := []attr.Value{}
+ if d.Errors != nil {
+ for _, e := range *d.Errors {
+ if e.En == nil {
+ return fmt.Errorf("error description missing")
+ }
+ domainErrors = append(domainErrors, types.StringValue(*e.En))
+ }
+ }
+ modelDomainErrors, diags := types.ListValue(types.StringType, domainErrors)
+ if diags.HasError() {
+ return core.DiagsToError(diags)
+ }
+ if d.Name == nil || d.Status == nil || d.Type == nil {
+ return fmt.Errorf("domain entry incomplete")
+ }
+ modelDomain, diags := types.ObjectValue(domainTypes, map[string]attr.Value{
+ "name": types.StringValue(*d.Name),
+ "status": types.StringValue(string(*d.Status)),
+ "type": types.StringValue(*d.Type),
+ "errors": modelDomainErrors,
+ })
+ if diags.HasError() {
+ return core.DiagsToError(diags)
+ }
+
+ domains = append(domains, modelDomain)
+ }
+ }
+
+ modelDomains, diags := types.ListValue(types.ObjectType{AttrTypes: domainTypes}, domains)
+ if diags.HasError() {
+ return core.DiagsToError(diags)
+ }
+ model.Domains = modelDomains
+ return nil
+}
+
+func toCreatePayload(ctx context.Context, model *Model) (*cdn.CreateDistributionPayload, error) {
+ if model == nil {
+ return nil, fmt.Errorf("missing model")
+ }
+ cfg, err := convertConfig(ctx, model)
+ if err != nil {
+ return nil, err
+ }
+ payload := &cdn.CreateDistributionPayload{
+ IntentId: cdn.PtrString(uuid.NewString()),
+ OriginUrl: cfg.Backend.HttpBackend.OriginUrl,
+ Regions: cfg.Regions,
+ OriginRequestHeaders: cfg.Backend.HttpBackend.OriginRequestHeaders,
+ }
+
+ return payload, nil
+}
+
+func convertConfig(ctx context.Context, model *Model) (*cdn.Config, error) {
+ if model == nil {
+ return nil, errors.New("model cannot be nil")
+ }
+ if model.Config.IsNull() || model.Config.IsUnknown() {
+ return nil, errors.New("config cannot be nil or unknown")
+ }
+ configModel := distributionConfig{}
+ diags := model.Config.As(ctx, &configModel, basetypes.ObjectAsOptions{
+ UnhandledNullAsEmpty: false,
+ UnhandledUnknownAsEmpty: false,
+ })
+ if diags.HasError() {
+ return nil, core.DiagsToError(diags)
+ }
+ regions := []cdn.Region{}
+ for _, r := range *configModel.Regions {
+ regionEnum, err := cdn.NewRegionFromValue(r)
+ if err != nil {
+ return nil, err
+ }
+ regions = append(regions, *regionEnum)
+ }
+
+ originRequestHeaders := map[string]string{}
+ if configModel.Backend.OriginRequestHeaders != nil {
+ for k, v := range *configModel.Backend.OriginRequestHeaders {
+ originRequestHeaders[k] = v
+ }
+ }
+ return &cdn.Config{
+ Backend: &cdn.ConfigBackend{
+ HttpBackend: &cdn.HttpBackend{
+ OriginRequestHeaders: &originRequestHeaders,
+ OriginUrl: &configModel.Backend.OriginURL,
+ Type: &configModel.Backend.Type,
+ },
+ },
+ Regions: ®ions,
+ }, nil
+}
diff --git a/stackit/internal/services/cdn/distribution/resource_test.go b/stackit/internal/services/cdn/distribution/resource_test.go
new file mode 100644
index 00000000..ceecb5ca
--- /dev/null
+++ b/stackit/internal/services/cdn/distribution/resource_test.go
@@ -0,0 +1,342 @@
+package cdn
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/google/go-cmp/cmp"
+ "github.com/hashicorp/terraform-plugin-framework/attr"
+ "github.com/hashicorp/terraform-plugin-framework/types"
+ "github.com/stackitcloud/stackit-sdk-go/services/cdn"
+)
+
+func TestToCreatePayload(t *testing.T) {
+ headers := map[string]attr.Value{
+ "testHeader0": types.StringValue("testHeaderValue0"),
+ "testHeader1": types.StringValue("testHeaderValue1"),
+ }
+ originRequestHeaders := types.MapValueMust(types.StringType, headers)
+ backend := types.ObjectValueMust(backendTypes, map[string]attr.Value{
+ "type": types.StringValue("http"),
+ "origin_url": types.StringValue("https://www.mycoolapp.com"),
+ "origin_request_headers": originRequestHeaders,
+ })
+ regions := []attr.Value{types.StringValue("EU"), types.StringValue("US")}
+ regionsFixture := types.ListValueMust(types.StringType, regions)
+ config := types.ObjectValueMust(configTypes, map[string]attr.Value{
+ "backend": backend,
+ "regions": regionsFixture,
+ })
+ modelFixture := func(mods ...func(*Model)) *Model {
+ model := &Model{
+ DistributionId: types.StringValue("test-distribution-id"),
+ ProjectId: types.StringValue("test-project-id"),
+ Config: config,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+ }
+ tests := map[string]struct {
+ Input *Model
+ Expected *cdn.CreateDistributionPayload
+ IsValid bool
+ }{
+ "happy_path": {
+ Input: modelFixture(),
+ Expected: &cdn.CreateDistributionPayload{
+ OriginRequestHeaders: &map[string]string{
+ "testHeader0": "testHeaderValue0",
+ "testHeader1": "testHeaderValue1",
+ },
+ OriginUrl: cdn.PtrString("https://www.mycoolapp.com"),
+ Regions: &[]cdn.Region{"EU", "US"},
+ },
+ IsValid: true,
+ },
+ "sad_path_model_nil": {
+ Input: nil,
+ Expected: nil,
+ IsValid: false,
+ },
+ "sad_path_config_error": {
+ Input: modelFixture(func(m *Model) {
+ m.Config = types.ObjectNull(configTypes)
+ }),
+ Expected: nil,
+ IsValid: false,
+ },
+ }
+ for tn, tc := range tests {
+ t.Run(tn, func(t *testing.T) {
+ res, err := toCreatePayload(context.Background(), tc.Input)
+ if err != nil && tc.IsValid {
+ t.Fatalf("Error converting model to create payload: %v", err)
+ }
+ if err == nil && !tc.IsValid {
+ t.Fatalf("Should have failed")
+ }
+ if tc.IsValid {
+ // set generated ID before diffing
+ tc.Expected.IntentId = res.IntentId
+
+ diff := cmp.Diff(res, tc.Expected)
+ if diff != "" {
+ t.Fatalf("Create Payload not as expected: %s", diff)
+ }
+ }
+ })
+ }
+}
+
+func TestConvertConfig(t *testing.T) {
+ headers := map[string]attr.Value{
+ "testHeader0": types.StringValue("testHeaderValue0"),
+ "testHeader1": types.StringValue("testHeaderValue1"),
+ }
+ originRequestHeaders := types.MapValueMust(types.StringType, headers)
+ backend := types.ObjectValueMust(backendTypes, map[string]attr.Value{
+ "type": types.StringValue("http"),
+ "origin_url": types.StringValue("https://www.mycoolapp.com"),
+ "origin_request_headers": originRequestHeaders,
+ })
+ regions := []attr.Value{types.StringValue("EU"), types.StringValue("US")}
+ regionsFixture := types.ListValueMust(types.StringType, regions)
+ config := types.ObjectValueMust(configTypes, map[string]attr.Value{
+ "backend": backend,
+ "regions": regionsFixture,
+ })
+ modelFixture := func(mods ...func(*Model)) *Model {
+ model := &Model{
+ DistributionId: types.StringValue("test-distribution-id"),
+ ProjectId: types.StringValue("test-project-id"),
+ Config: config,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+ }
+ tests := map[string]struct {
+ Input *Model
+ Expected *cdn.Config
+ IsValid bool
+ }{
+ "happy_path": {
+ Input: modelFixture(),
+ Expected: &cdn.Config{
+ Backend: &cdn.ConfigBackend{
+ HttpBackend: &cdn.HttpBackend{
+ OriginRequestHeaders: &map[string]string{
+ "testHeader0": "testHeaderValue0",
+ "testHeader1": "testHeaderValue1",
+ },
+ OriginUrl: cdn.PtrString("https://www.mycoolapp.com"),
+ Type: cdn.PtrString("http"),
+ },
+ },
+ Regions: &[]cdn.Region{"EU", "US"},
+ },
+ IsValid: true,
+ },
+ "sad_path_model_nil": {
+ Input: nil,
+ Expected: nil,
+ IsValid: false,
+ },
+ "sad_path_config_error": {
+ Input: modelFixture(func(m *Model) {
+ m.Config = types.ObjectNull(configTypes)
+ }),
+ Expected: nil,
+ IsValid: false,
+ },
+ }
+ for tn, tc := range tests {
+ t.Run(tn, func(t *testing.T) {
+ res, err := convertConfig(context.Background(), tc.Input)
+ if err != nil && tc.IsValid {
+ t.Fatalf("Error converting model to create payload: %v", err)
+ }
+ if err == nil && !tc.IsValid {
+ t.Fatalf("Should have failed")
+ }
+ if tc.IsValid {
+ diff := cmp.Diff(res, tc.Expected)
+ if diff != "" {
+ t.Fatalf("Create Payload not as expected: %s", diff)
+ }
+ }
+ })
+ }
+}
+
+func TestMapFields(t *testing.T) {
+ createdAt := time.Now()
+ updatedAt := time.Now()
+ headers := map[string]attr.Value{
+ "testHeader0": types.StringValue("testHeaderValue0"),
+ "testHeader1": types.StringValue("testHeaderValue1"),
+ }
+ originRequestHeaders := types.MapValueMust(types.StringType, headers)
+ backend := types.ObjectValueMust(backendTypes, map[string]attr.Value{
+ "type": types.StringValue("http"),
+ "origin_url": types.StringValue("https://www.mycoolapp.com"),
+ "origin_request_headers": originRequestHeaders,
+ })
+ regions := []attr.Value{types.StringValue("EU"), types.StringValue("US")}
+ regionsFixture := types.ListValueMust(types.StringType, regions)
+ config := types.ObjectValueMust(configTypes, map[string]attr.Value{
+ "backend": backend,
+ "regions": regionsFixture,
+ })
+ emtpyErrorsList := types.ListValueMust(types.StringType, []attr.Value{})
+ managedDomain := types.ObjectValueMust(domainTypes, map[string]attr.Value{
+ "name": types.StringValue("test.stackit-cdn.com"),
+ "status": types.StringValue("ACTIVE"),
+ "type": types.StringValue("managed"),
+ "errors": types.ListValueMust(types.StringType, []attr.Value{}),
+ })
+ domains := types.ListValueMust(types.ObjectType{AttrTypes: domainTypes}, []attr.Value{managedDomain})
+ expectedModel := func(mods ...func(*Model)) *Model {
+ model := &Model{
+ ID: types.StringValue("test-project-id,test-distribution-id"),
+ DistributionId: types.StringValue("test-distribution-id"),
+ ProjectId: types.StringValue("test-project-id"),
+ Config: config,
+ Status: types.StringValue("ACTIVE"),
+ CreatedAt: types.StringValue(createdAt.String()),
+ UpdatedAt: types.StringValue(updatedAt.String()),
+ Errors: emtpyErrorsList,
+ Domains: domains,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+ }
+ distributionFixture := func(mods ...func(*cdn.Distribution)) *cdn.Distribution {
+ distribution := &cdn.Distribution{
+ Config: &cdn.Config{Backend: &cdn.ConfigBackend{
+ HttpBackend: &cdn.HttpBackend{
+ OriginRequestHeaders: &map[string]string{
+ "testHeader0": "testHeaderValue0",
+ "testHeader1": "testHeaderValue1",
+ },
+ OriginUrl: cdn.PtrString("https://www.mycoolapp.com"),
+ Type: cdn.PtrString("http"),
+ },
+ },
+ Regions: &[]cdn.Region{"EU", "US"},
+ },
+ CreatedAt: &createdAt,
+ Domains: &[]cdn.Domain{
+ {
+ Name: cdn.PtrString("test.stackit-cdn.com"),
+ Status: cdn.DOMAINSTATUS_ACTIVE.Ptr(),
+ Type: cdn.PtrString("managed"),
+ },
+ },
+ Id: cdn.PtrString("test-distribution-id"),
+ ProjectId: cdn.PtrString("test-project-id"),
+ Status: cdn.PtrString("ACTIVE"),
+ UpdatedAt: &updatedAt,
+ }
+ for _, mod := range mods {
+ mod(distribution)
+ }
+ return distribution
+ }
+ tests := map[string]struct {
+ Input *cdn.Distribution
+ Expected *Model
+ IsValid bool
+ }{
+ "happy_path": {
+ Expected: expectedModel(),
+ Input: distributionFixture(),
+ IsValid: true,
+ },
+ "happy_path_status_error": {
+ Expected: expectedModel(func(m *Model) {
+ m.Status = types.StringValue("ERROR")
+ }),
+ Input: distributionFixture(func(d *cdn.Distribution) {
+ d.Status = cdn.PtrString("ERROR")
+ }),
+ IsValid: true,
+ },
+ "happy_path_custom_domain": {
+ Expected: expectedModel(func(m *Model) {
+ managedDomain := types.ObjectValueMust(domainTypes, map[string]attr.Value{
+ "name": types.StringValue("test.stackit-cdn.com"),
+ "status": types.StringValue("ACTIVE"),
+ "type": types.StringValue("managed"),
+ "errors": types.ListValueMust(types.StringType, []attr.Value{}),
+ })
+ customDomain := types.ObjectValueMust(domainTypes, map[string]attr.Value{
+ "name": types.StringValue("mycoolapp.info"),
+ "status": types.StringValue("ACTIVE"),
+ "type": types.StringValue("custom"),
+ "errors": types.ListValueMust(types.StringType, []attr.Value{}),
+ })
+ domains := types.ListValueMust(types.ObjectType{AttrTypes: domainTypes}, []attr.Value{managedDomain, customDomain})
+ m.Domains = domains
+ }),
+ Input: distributionFixture(func(d *cdn.Distribution) {
+ d.Domains = &[]cdn.Domain{
+ {
+ Name: cdn.PtrString("test.stackit-cdn.com"),
+ Status: cdn.DOMAINSTATUS_ACTIVE.Ptr(),
+ Type: cdn.PtrString("managed"),
+ },
+ {
+ Name: cdn.PtrString("mycoolapp.info"),
+ Status: cdn.DOMAINSTATUS_ACTIVE.Ptr(),
+ Type: cdn.PtrString("custom"),
+ },
+ }
+ }),
+ IsValid: true,
+ },
+ "sad_path_distribution_nil": {
+ Expected: nil,
+ Input: nil,
+ IsValid: false,
+ },
+ "sad_path_project_id_missing": {
+ Expected: expectedModel(),
+ Input: distributionFixture(func(d *cdn.Distribution) {
+ d.ProjectId = nil
+ }),
+ IsValid: false,
+ },
+ "sad_path_distribution_id_missing": {
+ Expected: expectedModel(),
+ Input: distributionFixture(func(d *cdn.Distribution) {
+ d.Id = nil
+ }),
+ IsValid: false,
+ },
+ }
+ for tn, tc := range tests {
+ t.Run(tn, func(t *testing.T) {
+ model := &Model{}
+ err := mapFields(tc.Input, model)
+ if err != nil && tc.IsValid {
+ t.Fatalf("Error mapping fields: %v", err)
+ }
+ if err == nil && !tc.IsValid {
+ t.Fatalf("Should have failed")
+ }
+ if tc.IsValid {
+ diff := cmp.Diff(model, tc.Expected)
+ if diff != "" {
+ t.Fatalf("Create Payload not as expected: %s", diff)
+ }
+ }
+ })
+ }
+}
diff --git a/stackit/internal/testutil/testutil.go b/stackit/internal/testutil/testutil.go
index 763d9f3a..66485e94 100644
--- a/stackit/internal/testutil/testutil.go
+++ b/stackit/internal/testutil/testutil.go
@@ -51,6 +51,7 @@ var (
TestImageLocalFilePath = getenv("TF_ACC_TEST_IMAGE_LOCAL_FILE_PATH", "default")
ArgusCustomEndpoint = os.Getenv("TF_ACC_ARGUS_CUSTOM_ENDPOINT")
+ CdnCustomEndpoint = os.Getenv("TF_ACC_CDN_CUSTOM_ENDPOINT")
DnsCustomEndpoint = os.Getenv("TF_ACC_DNS_CUSTOM_ENDPOINT")
IaaSCustomEndpoint = os.Getenv("TF_ACC_IAAS_CUSTOM_ENDPOINT")
LoadBalancerCustomEndpoint = os.Getenv("TF_ACC_LOADBALANCER_CUSTOM_ENDPOINT")
@@ -105,6 +106,17 @@ func ObservabilityProviderConfig() string {
ObservabilityCustomEndpoint,
)
}
+func CdnProviderConfig() string {
+ if CdnCustomEndpoint == "" {
+ return `provider "stackit" {}`
+ }
+ return fmt.Sprintf(`
+ provider "stackit" {
+ cdn_custom_endpoint = "%s"
+ }`,
+ CdnCustomEndpoint,
+ )
+}
func DnsProviderConfig() string {
if DnsCustomEndpoint == "" {
diff --git a/stackit/provider.go b/stackit/provider.go
index 3d6b679a..4502199d 100644
--- a/stackit/provider.go
+++ b/stackit/provider.go
@@ -14,6 +14,7 @@ import (
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features"
roleAssignements "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/authorization/roleassignments"
+ 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"
iaasAffinityGroup "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/affinitygroup"
@@ -114,6 +115,7 @@ type providerModel struct {
Region types.String `tfsdk:"region"`
DefaultRegion types.String `tfsdk:"default_region"`
ArgusCustomEndpoint types.String `tfsdk:"argus_custom_endpoint"`
+ CdnCustomEndpoint types.String `tfsdk:"cdn_custom_endpoint"`
DNSCustomEndpoint types.String `tfsdk:"dns_custom_endpoint"`
IaaSCustomEndpoint types.String `tfsdk:"iaas_custom_endpoint"`
PostgresFlexCustomEndpoint types.String `tfsdk:"postgresflex_custom_endpoint"`
@@ -154,6 +156,7 @@ func (p *Provider) Schema(_ context.Context, _ provider.SchemaRequest, resp *pro
"region": "Region will be used as the default location for regional services. Not all services require a region, some are global",
"default_region": "Region will be used as the default location for regional services. Not all services require a region, some are global",
"argus_custom_endpoint": "Custom endpoint for the Argus service",
+ "cdn_custom_endpoint": "Custom endpoint for the CDN service",
"dns_custom_endpoint": "Custom endpoint for the DNS service",
"iaas_custom_endpoint": "Custom endpoint for the IaaS service",
"mongodbflex_custom_endpoint": "Custom endpoint for the MongoDB Flex service",
@@ -232,6 +235,10 @@ func (p *Provider) Schema(_ context.Context, _ provider.SchemaRequest, resp *pro
Description: descriptions["argus_custom_endpoint"],
DeprecationMessage: "Argus service has been deprecated and integration will be removed after February 26th 2025. Please use `observability_custom_endpoint` and `observability` resources instead, which offer the exact same functionality.",
},
+ "cdn_custom_endpoint": schema.StringAttribute{
+ Optional: true,
+ Description: descriptions["cdn_custom_endpoint"],
+ },
"dns_custom_endpoint": schema.StringAttribute{
Optional: true,
Description: descriptions["dns_custom_endpoint"],
@@ -373,6 +380,9 @@ func (p *Provider) Configure(ctx context.Context, req provider.ConfigureRequest,
} else if !(providerConfig.Region.IsUnknown() || providerConfig.Region.IsNull()) { // nolint:staticcheck // preliminary handling of deprecated attribute
providerData.Region = providerConfig.Region.ValueString() // nolint:staticcheck // preliminary handling of deprecated attribute
}
+ if !(providerConfig.CdnCustomEndpoint.IsUnknown() || providerConfig.CdnCustomEndpoint.IsNull()) {
+ providerData.CdnCustomEndpoint = providerConfig.CdnCustomEndpoint.ValueString()
+ }
if !(providerConfig.DNSCustomEndpoint.IsUnknown() || providerConfig.DNSCustomEndpoint.IsNull()) {
providerData.DnsCustomEndpoint = providerConfig.DNSCustomEndpoint.ValueString()
}
@@ -465,6 +475,7 @@ func (p *Provider) Configure(ctx context.Context, req provider.ConfigureRequest,
func (p *Provider) DataSources(_ context.Context) []func() datasource.DataSource {
return []func() datasource.DataSource{
alertGroup.NewAlertGroupDataSource,
+ cdn.NewDistributionDataSource,
dnsZone.NewZoneDataSource,
dnsRecordSet.NewRecordSetDataSource,
iaasAffinityGroup.NewAffinityGroupDatasource,
@@ -520,6 +531,7 @@ func (p *Provider) DataSources(_ context.Context) []func() datasource.DataSource
func (p *Provider) Resources(_ context.Context) []func() resource.Resource {
resources := []func() resource.Resource{
alertGroup.NewAlertGroupResource,
+ cdn.NewDistributionResource,
dnsZone.NewZoneResource,
dnsRecordSet.NewRecordSetResource,
iaasAffinityGroup.NewAffinityGroupResource,