diff --git a/docs/data-sources/cdn_custom_domain.md b/docs/data-sources/cdn_custom_domain.md new file mode 100644 index 00000000..38c2da3e --- /dev/null +++ b/docs/data-sources/cdn_custom_domain.md @@ -0,0 +1,39 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_cdn_custom_domain 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_custom_domain (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_custom_domain" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + distribution_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "https://xxx.xxx" +} +``` + + +## Schema + +### Required + +- `distribution_id` (String) CDN distribution ID +- `name` (String) +- `project_id` (String) STACKIT project ID associated with the distribution + +### Read-Only + +- `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 diff --git a/docs/guides/stackit_cdn_with_custom_domain.md b/docs/guides/stackit_cdn_with_custom_domain.md new file mode 100644 index 00000000..78d9cb06 --- /dev/null +++ b/docs/guides/stackit_cdn_with_custom_domain.md @@ -0,0 +1,60 @@ +--- +page_title: "Using STACKIT CDN with your own domain" +--- +# Using STACKIT CDN with your own domain + +## Overview + +This guide outlines the process of creating a STACKIT CDN distribution and configuring it to make use of an existing domain using STACKIT DNS. + +## Steps + +1. **Create a STACKIT CDN and DNS Zone** + + Create the CDN distribution and the DNS zone. + + ```terraform + resource "stackit_cdn_distribution" "example_distribution" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + config = { + backend = { + type = "http" + origin_url = "mybackend.onstackit.cloud" + } + regions = ["EU", "US", "ASIA", "AF", "SA"] + } + } + + resource "stackit_dns_zone" "example_zone" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "My DNS zone" + dns_name = "myapp.runs.onstackit.cloud" + contact_email = "aa@bb.ccc" + type = "primary" + } + ``` + +2. **Add CNAME record to your DNS zone** + + If you want to redirect your entire domain to the CDN, you can instead use an A record. + ```terraform + resource "stackit_dns_record_set" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + zone_id = stackit_dns_zone.example_zone.zone_id + name = "cdn" + type = "CNAME" + records = ["${stackit_cdn_distribution.domains[0].name}."] + } + ``` + +3. **Create a STACKIT CDN Custom Domain** + ```terraform + # Create a CDN custom domain + resource "stackit_cdn_custom_domain" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + distribution_id = stackit_cdn_distribution.example_distribution.distribution_id + name = "${stackit_dns_record_set.example.name}.${stackit_dns_zone.dns_name}" + } + ``` + + Now, you can access your content on the url `cdn.myapp.runs.onstackit.cloud`. diff --git a/docs/resources/cdn_custom_domain.md b/docs/resources/cdn_custom_domain.md new file mode 100644 index 00000000..fbceba30 --- /dev/null +++ b/docs/resources/cdn_custom_domain.md @@ -0,0 +1,40 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_cdn_custom_domain 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_custom_domain (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 custom domain +resource "stackit_cdn_custom_domain" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + distribution_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "https://xxx.xxx" +} +``` + + +## Schema + +### Required + +- `distribution_id` (String) CDN distribution ID +- `name` (String) +- `project_id` (String) STACKIT project ID associated with the distribution + +### Read-Only + +- `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 diff --git a/docs/resources/cdn_distribution.md b/docs/resources/cdn_distribution.md index 2704a1e6..aa6bed52 100644 --- a/docs/resources/cdn_distribution.md +++ b/docs/resources/cdn_distribution.md @@ -40,7 +40,7 @@ resource "stackit_cdn_distribution" "example_distribution" { ### Read-Only - `created_at` (String) Time when the distribution was created -- `distribution_id` (String) STACKIT project ID associated with the distribution +- `distribution_id` (String) CDN distribution ID - `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`". diff --git a/examples/data-sources/stackit_cdn_custom_domain/data-source.tf b/examples/data-sources/stackit_cdn_custom_domain/data-source.tf new file mode 100644 index 00000000..23504bb6 --- /dev/null +++ b/examples/data-sources/stackit_cdn_custom_domain/data-source.tf @@ -0,0 +1,6 @@ +data "stackit_cdn_custom_domain" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + distribution_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "https://xxx.xxx" +} + diff --git a/examples/resources/stackit_cdn_custom_domain/resource.tf b/examples/resources/stackit_cdn_custom_domain/resource.tf new file mode 100644 index 00000000..3ba9af21 --- /dev/null +++ b/examples/resources/stackit_cdn_custom_domain/resource.tf @@ -0,0 +1,6 @@ +# Create a CDN custom domain +resource "stackit_cdn_custom_domain" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + distribution_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "https://xxx.xxx" +} diff --git a/stackit/internal/services/cdn/cdn_acc_test.go b/stackit/internal/services/cdn/cdn_acc_test.go index c7a6541a..6d3b021e 100644 --- a/stackit/internal/services/cdn/cdn_acc_test.go +++ b/stackit/internal/services/cdn/cdn_acc_test.go @@ -3,9 +3,12 @@ package cdn_test import ( "context" "fmt" + "net" "strings" "testing" + "time" + "github.com/google/uuid" "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/hashicorp/terraform-plugin-testing/terraform" "github.com/stackitcloud/stackit-sdk-go/core/config" @@ -21,6 +24,7 @@ var instanceResource = map[string]string{ "config_backend_origin_url": "https://test-backend-1.cdn-dev.runs.onstackit.cloud", "config_regions": "\"EU\", \"US\"", "config_regions_updated": "\"EU\", \"US\", \"ASIA\"", + "custom_domain_prefix": uuid.NewString(), // we use a different domain prefix each test run due to inconsistent upstream release of domains, which might impair consecutive test runs } func configResources(regions string) string { @@ -37,7 +41,35 @@ func configResources(regions string) string { regions = [%s] } } - `, testutil.CdnProviderConfig(), testutil.ProjectId, instanceResource["config_backend_origin_url"], regions) + + resource "stackit_dns_zone" "dns_zone" { + project_id = "%s" + name = "cdn_acc_test_zone" + dns_name = "cdntestzone.stackit.gg" + contact_email = "aa@bb.cc" + type = "primary" + default_ttl = 3600 + } + resource "stackit_dns_record_set" "dns_record" { + project_id = "%s" + zone_id = stackit_dns_zone.dns_zone.zone_id + name = "%s" + type = "CNAME" + records = ["${stackit_cdn_distribution.distribution.domains[0].name}."] + } + `, testutil.CdnProviderConfig(), testutil.ProjectId, instanceResource["config_backend_origin_url"], regions, testutil.ProjectId, testutil.ProjectId, instanceResource["custom_domain_prefix"]) +} + +func configCustomDomainResources(regions string) string { + return fmt.Sprintf(` + %s + + resource "stackit_cdn_custom_domain" "custom_domain" { + project_id = stackit_cdn_distribution.distribution.project_id + distribution_id = stackit_cdn_distribution.distribution.distribution_id + name = "${stackit_dns_record_set.dns_record.name}.cdntestzone.stackit.gg" + } +`, configResources(regions)) } func configDatasources(regions string) string { @@ -48,7 +80,13 @@ func configDatasources(regions string) string { project_id = stackit_cdn_distribution.distribution.project_id distribution_id = stackit_cdn_distribution.distribution.distribution_id } - `, configResources(regions)) + + data "stackit_cdn_custom_domain" "custom_domain" { + project_id = stackit_cdn_custom_domain.custom_domain.project_id + distribution_id = stackit_cdn_custom_domain.custom_domain.distribution_id + name = stackit_cdn_custom_domain.custom_domain.name + } + `, configCustomDomainResources(regions)) } func TestAccCDNDistributionResource(t *testing.T) { @@ -56,7 +94,7 @@ func TestAccCDNDistributionResource(t *testing.T) { ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, CheckDestroy: testAccCheckCDNDistributionDestroy, Steps: []resource.TestStep{ - // Create + // Distribution Create { Config: configResources(instanceResource["config_regions"]), Check: resource.ComposeAggregateTestCheckFunc( @@ -65,6 +103,7 @@ func TestAccCDNDistributionResource(t *testing.T) { 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.type", "managed"), 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"), @@ -73,6 +112,24 @@ func TestAccCDNDistributionResource(t *testing.T) { resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "status", "ACTIVE"), ), }, + // Wait step, that confirms the CNAME record has "propagated" + { + Config: configResources(instanceResource["config_regions"]), + Check: func(_ *terraform.State) error { + _, err := blockUntilDomainResolves(instanceResource["custom_domain_prefix"] + ".cdntestzone.stackit.gg") + return err + }, + }, + // Custom Domain Create + { + Config: configCustomDomainResources(instanceResource["config_regions"]), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_cdn_custom_domain.custom_domain", "status", "ACTIVE"), + resource.TestCheckResourceAttr("stackit_cdn_custom_domain.custom_domain", "name", instanceResource["custom_domain_prefix"]+".cdntestzone.stackit.gg"), + resource.TestCheckResourceAttrPair("stackit_cdn_distribution.distribution", "distribution_id", "stackit_cdn_custom_domain.custom_domain", "distribution_id"), + resource.TestCheckResourceAttrPair("stackit_cdn_distribution.distribution", "project_id", "stackit_cdn_custom_domain.custom_domain", "project_id"), + ), + }, // Import { ResourceName: "stackit_cdn_distribution.distribution", @@ -88,6 +145,28 @@ func TestAccCDNDistributionResource(t *testing.T) { return fmt.Sprintf("%s,%s", testutil.ProjectId, distributionId), nil }, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"domains"}, // we added a domain in the meantime... + }, + { + ResourceName: "stackit_cdn_custom_domain.custom_domain", + ImportStateIdFunc: func(s *terraform.State) (string, error) { + r, ok := s.RootModule().Resources["stackit_cdn_custom_domain.custom_domain"] + if !ok { + return "", fmt.Errorf("couldn't find resource stackit_cdn_custom_domain.custom_domain") + } + distributionId, ok := r.Primary.Attributes["distribution_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute distribution_id") + } + name, ok := r.Primary.Attributes["name"] + if !ok { + return "", fmt.Errorf("couldn't find attribute name") + } + + return fmt.Sprintf("%s,%s,%s", testutil.ProjectId, distributionId, name), nil + }, ImportState: true, ImportStateVerify: true, }, @@ -95,35 +174,50 @@ func TestAccCDNDistributionResource(t *testing.T) { { 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"), + resource.TestCheckResourceAttrSet("data.stackit_cdn_distribution.distribution", "distribution_id"), + resource.TestCheckResourceAttrSet("data.stackit_cdn_distribution.distribution", "created_at"), + resource.TestCheckResourceAttrSet("data.stackit_cdn_distribution.distribution", "updated_at"), + resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "domains.#", "2"), + resource.TestCheckResourceAttrSet("data.stackit_cdn_distribution.distribution", "domains.0.name"), + resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "domains.1.name", instanceResource["custom_domain_prefix"]+".cdntestzone.stackit.gg"), + resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "domains.0.status", "ACTIVE"), + resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "domains.1.status", "ACTIVE"), + resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "domains.0.type", "managed"), + resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "domains.1.type", "custom"), + resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "config.regions.#", "2"), + resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "config.regions.0", "EU"), + resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "config.regions.1", "US"), + resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "project_id", testutil.ProjectId), + resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "status", "ACTIVE"), + resource.TestCheckResourceAttr("data.stackit_cdn_custom_domain.custom_domain", "status", "ACTIVE"), + resource.TestCheckResourceAttr("data.stackit_cdn_custom_domain.custom_domain", "name", instanceResource["custom_domain_prefix"]+".cdntestzone.stackit.gg"), + resource.TestCheckResourceAttrPair("stackit_cdn_distribution.distribution", "distribution_id", "stackit_cdn_custom_domain.custom_domain", "distribution_id"), ), }, // Update { - Config: configResources(instanceResource["config_regions_updated"]), + Config: configCustomDomainResources(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.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "domains.#", "2"), resource.TestCheckResourceAttrSet("stackit_cdn_distribution.distribution", "domains.0.name"), + resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "domains.1.name", instanceResource["custom_domain_prefix"]+".cdntestzone.stackit.gg"), resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "domains.0.status", "ACTIVE"), + resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "domains.1.status", "ACTIVE"), + resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "domains.0.type", "managed"), + resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "domains.1.type", "custom"), 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"), + resource.TestCheckResourceAttr("stackit_cdn_custom_domain.custom_domain", "status", "ACTIVE"), + resource.TestCheckResourceAttr("stackit_cdn_custom_domain.custom_domain", "name", instanceResource["custom_domain_prefix"]+".cdntestzone.stackit.gg"), + resource.TestCheckResourceAttrPair("stackit_cdn_distribution.distribution", "distribution_id", "stackit_cdn_custom_domain.custom_domain", "distribution_id"), + resource.TestCheckResourceAttrPair("stackit_cdn_distribution.distribution", "project_id", "stackit_cdn_custom_domain.custom_domain", "project_id"), ), }, }, @@ -165,3 +259,39 @@ func testAccCheckCDNDistributionDestroy(s *terraform.State) error { } return nil } + +const ( + recordCheckInterval time.Duration = 3 * time.Second + recordCheckAttempts = 100 // wait up to 5 minutes for record to be come available (normally takes less than 2 minutes) +) + +func blockUntilDomainResolves(domain string) (net.IP, error) { + // wait until it becomes ready + isReady := func() (net.IP, error) { + ips, err := net.LookupIP(domain) + if err != nil { + return nil, fmt.Errorf("error looking up IP for domain %s: %w", domain, err) + } + for _, ip := range ips { + if ip.String() != "" { + return ip, nil + } + } + return nil, fmt.Errorf("no IP for domain: %v", domain) + } + return retry(recordCheckAttempts, recordCheckInterval, isReady) +} + +func retry[T any](attempts int, sleep time.Duration, f func() (T, error)) (T, error) { + var zero T + var errOuter error + for i := 0; i < attempts; i++ { + dist, err := f() + if err == nil { + return dist, nil + } + errOuter = err + time.Sleep(sleep) + } + return zero, fmt.Errorf("retry timed out, last error: %w", errOuter) +} diff --git a/stackit/internal/services/cdn/customdomain/datasource.go b/stackit/internal/services/cdn/customdomain/datasource.go new file mode 100644 index 00000000..7f08fa58 --- /dev/null +++ b/stackit/internal/services/cdn/customdomain/datasource.go @@ -0,0 +1,150 @@ +package cdn + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + "github.com/stackitcloud/stackit-sdk-go/services/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/validate" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ datasource.DataSource = &customDomainDataSource{} + _ datasource.DataSourceWithConfigure = &customDomainDataSource{} +) + +type customDomainDataSource struct { + client *cdn.APIClient +} + +func NewCustomDomainDataSource() datasource.DataSource { + return &customDomainDataSource{} +} + +func (d *customDomainDataSource) 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_custom_domain", "datasource") + if resp.Diagnostics.HasError() { + return + } + + var apiClient *cdn.APIClient + var err error + if providerData.CdnCustomEndpoint != "" { + ctx = tflog.SetField(ctx, "cdn_custom_endpoint", 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, "CDN client configured") +} + +func (r *customDomainDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_cdn_custom_domain" +} + +func (r *customDomainDataSource) 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: customDomainSchemaDescriptions["id"], + Computed: true, + }, + "name": schema.StringAttribute{ + Description: customDomainSchemaDescriptions["name"], + Required: true, + }, + "distribution_id": schema.StringAttribute{ + Description: customDomainSchemaDescriptions["distribution_id"], + Required: true, + Validators: []validator.String{validate.UUID()}, + }, + "project_id": schema.StringAttribute{ + Description: customDomainSchemaDescriptions["project_id"], + Required: true, + }, + "status": schema.StringAttribute{ + Computed: true, + Description: customDomainSchemaDescriptions["status"], + }, + "errors": schema.ListAttribute{ + ElementType: types.StringType, + Computed: true, + Description: customDomainSchemaDescriptions["errors"], + }, + }, + } +} + +func (r *customDomainDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform + var model CustomDomainModel + diags := req.Config.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + projectId := model.ProjectId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + distributionId := model.DistributionId.ValueString() + ctx = tflog.SetField(ctx, "distribution_id", distributionId) + name := model.Name.ValueString() + ctx = tflog.SetField(ctx, "name", name) + + customDomainResp, err := r.client.GetCustomDomain(ctx, projectId, distributionId, name).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 custom domain", fmt.Sprintf("Calling API: %v", err)) + return + } + err = mapCustomDomainFields(customDomainResp.CustomDomain, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading CDN custom domain", 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 custom domain read") +} diff --git a/stackit/internal/services/cdn/customdomain/resource.go b/stackit/internal/services/cdn/customdomain/resource.go new file mode 100644 index 00000000..402b5f77 --- /dev/null +++ b/stackit/internal/services/cdn/customdomain/resource.go @@ -0,0 +1,310 @@ +package cdn + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "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-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 = &customDomainResource{} + _ resource.ResourceWithConfigure = &customDomainResource{} + _ resource.ResourceWithImportState = &customDomainResource{} +) + +var customDomainSchemaDescriptions = 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", + "errors": "List of distribution errors", +} + +type CustomDomainModel 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 + Name types.String `tfsdk:"name"` // The custom domain + Status types.String `tfsdk:"status"` // The status of the cdn distribution + Errors types.List `tfsdk:"errors"` // Any errors that the distribution has +} + +type customDomainResource struct { + client *cdn.APIClient + providerData core.ProviderData +} + +func NewCustomDomainResource() resource.Resource { + return &customDomainResource{} +} + +func (r *customDomainResource) 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_custom_domain", "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 *customDomainResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_cdn_custom_domain" +} + +func (r *customDomainResource) 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: customDomainSchemaDescriptions["id"], + Computed: true, + }, + "name": schema.StringAttribute{ + Description: customDomainSchemaDescriptions["name"], + Required: true, + Optional: false, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "distribution_id": schema.StringAttribute{ + Description: customDomainSchemaDescriptions["distribution_id"], + Required: true, + Optional: false, + Validators: []validator.String{validate.UUID()}, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "project_id": schema.StringAttribute{ + Description: customDomainSchemaDescriptions["project_id"], + Required: true, + Optional: false, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "status": schema.StringAttribute{ + Computed: true, + Description: customDomainSchemaDescriptions["status"], + }, + "errors": schema.ListAttribute{ + ElementType: types.StringType, + Computed: true, + Description: customDomainSchemaDescriptions["errors"], + }, + }, + } +} + +func (r *customDomainResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform + var model CustomDomainModel + 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) + distributionId := model.DistributionId.ValueString() + ctx = tflog.SetField(ctx, "distribution_id", distributionId) + name := model.Name.ValueString() + ctx = tflog.SetField(ctx, "name", name) + + payload := cdn.PutCustomDomainPayload{IntentId: cdn.PtrString(uuid.NewString())} + + _, err := r.client.PutCustomDomain(ctx, projectId, distributionId, name).PutCustomDomainPayload(payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating CDN custom domain", fmt.Sprintf("Calling API: %v", err)) + return + } + waitResp, err := wait.CreateCDNCustomDomainWaitHandler(ctx, r.client, projectId, distributionId, name).SetTimeout(5 * time.Minute).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating CDN custom domain", fmt.Sprintf("Waiting for create: %v", err)) + return + } + + err = mapCustomDomainFields(waitResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating CDN custom domain", 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 custom domain created") +} + +func (r *customDomainResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform + var model CustomDomainModel + diags := req.State.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + projectId := model.ProjectId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + distributionId := model.DistributionId.ValueString() + ctx = tflog.SetField(ctx, "distribution_id", distributionId) + name := model.Name.ValueString() + ctx = tflog.SetField(ctx, "name", name) + + customDomainResp, err := r.client.GetCustomDomain(ctx, projectId, distributionId, name).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 custom domain", fmt.Sprintf("Calling API: %v", err)) + return + } + err = mapCustomDomainFields(customDomainResp.CustomDomain, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading CDN custom domain", 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 custom domain read") +} + +func (r *customDomainResource) Update(ctx context.Context, _ resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform + // Update shouldn't be called; custom domains have only computed fields and fields that require replacement when changed + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating CDN custom domain", "Custom domain cannot be updated") +} + +func (r *customDomainResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform + var model CustomDomainModel + diags := req.State.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + projectId := model.ProjectId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + distributionId := model.DistributionId.ValueString() + ctx = tflog.SetField(ctx, "distribution_id", distributionId) + name := model.Name.ValueString() + ctx = tflog.SetField(ctx, "name", name) + + _, err := r.client.DeleteCustomDomain(ctx, projectId, distributionId, name).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Delete CDN custom domain", fmt.Sprintf("Delete custom domain: %v", err)) + } + _, err = wait.DeleteCDNCustomDomainWaitHandler(ctx, r.client, projectId, distributionId, name).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Delete CDN custom domain", fmt.Sprintf("Waiting for deletion: %v", err)) + return + } + tflog.Info(ctx, "CDN custom domain deleted") +} + +func (r *customDomainResource) 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 CDN custom domain", fmt.Sprintf("Expected import identifier on the format: [project_id]%q[distribution_id]%q[custom_domain_name], got %q", core.Separator, 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])...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("name"), idParts[2])...) + tflog.Info(ctx, "CDN custom domain state imported") +} + +func mapCustomDomainFields(customDomain *cdn.CustomDomain, model *CustomDomainModel) error { + if customDomain == nil { + return fmt.Errorf("response input is nil") + } + if model == nil { + return fmt.Errorf("model input is nil") + } + + if customDomain.Name == nil { + return fmt.Errorf("Name is missing in response") + } + + if customDomain.Status == nil { + return fmt.Errorf("Status missing in response") + } + + id := model.ProjectId.ValueString() + core.Separator + model.DistributionId.ValueString() + core.Separator + *customDomain.Name + + model.ID = types.StringValue(id) + model.Status = types.StringValue(string(*customDomain.Status)) + + customDomainErrors := []attr.Value{} + if customDomain.Errors != nil { + for _, e := range *customDomain.Errors { + if e.En == nil { + return fmt.Errorf("Error description missing") + } + customDomainErrors = append(customDomainErrors, types.StringValue(*e.En)) + } + } + modelErrors, diags := types.ListValue(types.StringType, customDomainErrors) + if diags.HasError() { + return core.DiagsToError(diags) + } + model.Errors = modelErrors + + return nil +} diff --git a/stackit/internal/services/cdn/customdomain/resource_test.go b/stackit/internal/services/cdn/customdomain/resource_test.go new file mode 100644 index 00000000..1d72ea11 --- /dev/null +++ b/stackit/internal/services/cdn/customdomain/resource_test.go @@ -0,0 +1,90 @@ +package cdn + +import ( + "testing" + + "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 TestMapFields(t *testing.T) { + emtpyErrorsList := types.ListValueMust(types.StringType, []attr.Value{}) + expectedModel := func(mods ...func(*CustomDomainModel)) *CustomDomainModel { + model := &CustomDomainModel{ + ID: types.StringValue("test-project-id,test-distribution-id,https://testdomain.com"), + DistributionId: types.StringValue("test-distribution-id"), + ProjectId: types.StringValue("test-project-id"), + Status: types.StringValue("ACTIVE"), + Errors: emtpyErrorsList, + } + for _, mod := range mods { + mod(model) + } + return model + } + customDomainFixture := func(mods ...func(*cdn.CustomDomain)) *cdn.CustomDomain { + distribution := &cdn.CustomDomain{ + Errors: &[]cdn.StatusError{}, + Name: cdn.PtrString("https://testdomain.com"), + Status: cdn.DOMAINSTATUS_ACTIVE.Ptr(), + } + for _, mod := range mods { + mod(distribution) + } + return distribution + } + tests := map[string]struct { + Input *cdn.CustomDomain + Expected *CustomDomainModel + IsValid bool + }{ + "happy_path": { + Expected: expectedModel(), + Input: customDomainFixture(), + IsValid: true, + }, + "happy_path_status_error": { + Expected: expectedModel(func(m *CustomDomainModel) { + m.Status = types.StringValue("ERROR") + }), + Input: customDomainFixture(func(d *cdn.CustomDomain) { + d.Status = cdn.DOMAINSTATUS_ERROR.Ptr() + }), + IsValid: true, + }, + "sad_path_custom_domain_nil": { + Expected: expectedModel(), + Input: nil, + IsValid: false, + }, + "sad_path_name_missing": { + Expected: expectedModel(), + Input: customDomainFixture(func(d *cdn.CustomDomain) { + d.Name = nil + }), + IsValid: false, + }, + } + for tn, tc := range tests { + t.Run(tn, func(t *testing.T) { + model := &CustomDomainModel{} + model.DistributionId = tc.Expected.DistributionId + model.ProjectId = tc.Expected.ProjectId + err := mapCustomDomainFields(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/services/cdn/distribution/resource.go b/stackit/internal/services/cdn/distribution/resource.go index b4125c1a..e7332f63 100644 --- a/stackit/internal/services/cdn/distribution/resource.go +++ b/stackit/internal/services/cdn/distribution/resource.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "strings" + "time" "github.com/google/uuid" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" @@ -154,7 +155,7 @@ func (r *distributionResource) Schema(_ context.Context, _ resource.SchemaReques Computed: true, }, "distribution_id": schema.StringAttribute{ - Description: schemaDescriptions["project_id"], + Description: schemaDescriptions["distribution_id"], Computed: true, Validators: []validator.String{validate.UUID()}, PlanModifiers: []planmodifier.String{ @@ -266,7 +267,7 @@ func (r *distributionResource) Create(ctx context.Context, req resource.CreateRe 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) + waitResp, err := wait.CreateDistributionPoolWaitHandler(ctx, r.client, projectId, *createResp.Distribution.Id).SetTimeout(5 * time.Minute).WaitWithContext(ctx) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating CDN distribution", fmt.Sprintf("Waiting for create: %v", err)) return diff --git a/stackit/provider.go b/stackit/provider.go index 4502199d..e39586f3 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" + cdnCustomDomain "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/cdn/customdomain" 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" @@ -476,6 +477,7 @@ func (p *Provider) DataSources(_ context.Context) []func() datasource.DataSource return []func() datasource.DataSource{ alertGroup.NewAlertGroupDataSource, cdn.NewDistributionDataSource, + cdnCustomDomain.NewCustomDomainDataSource, dnsZone.NewZoneDataSource, dnsRecordSet.NewRecordSetDataSource, iaasAffinityGroup.NewAffinityGroupDatasource, @@ -532,6 +534,7 @@ func (p *Provider) Resources(_ context.Context) []func() resource.Resource { resources := []func() resource.Resource{ alertGroup.NewAlertGroupResource, cdn.NewDistributionResource, + cdnCustomDomain.NewCustomDomainResource, dnsZone.NewZoneResource, dnsRecordSet.NewRecordSetResource, iaasAffinityGroup.NewAffinityGroupResource, diff --git a/templates/guides/stackit_cdn_with_custom_domain.md.tmpl b/templates/guides/stackit_cdn_with_custom_domain.md.tmpl new file mode 100644 index 00000000..78d9cb06 --- /dev/null +++ b/templates/guides/stackit_cdn_with_custom_domain.md.tmpl @@ -0,0 +1,60 @@ +--- +page_title: "Using STACKIT CDN with your own domain" +--- +# Using STACKIT CDN with your own domain + +## Overview + +This guide outlines the process of creating a STACKIT CDN distribution and configuring it to make use of an existing domain using STACKIT DNS. + +## Steps + +1. **Create a STACKIT CDN and DNS Zone** + + Create the CDN distribution and the DNS zone. + + ```terraform + resource "stackit_cdn_distribution" "example_distribution" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + config = { + backend = { + type = "http" + origin_url = "mybackend.onstackit.cloud" + } + regions = ["EU", "US", "ASIA", "AF", "SA"] + } + } + + resource "stackit_dns_zone" "example_zone" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "My DNS zone" + dns_name = "myapp.runs.onstackit.cloud" + contact_email = "aa@bb.ccc" + type = "primary" + } + ``` + +2. **Add CNAME record to your DNS zone** + + If you want to redirect your entire domain to the CDN, you can instead use an A record. + ```terraform + resource "stackit_dns_record_set" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + zone_id = stackit_dns_zone.example_zone.zone_id + name = "cdn" + type = "CNAME" + records = ["${stackit_cdn_distribution.domains[0].name}."] + } + ``` + +3. **Create a STACKIT CDN Custom Domain** + ```terraform + # Create a CDN custom domain + resource "stackit_cdn_custom_domain" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + distribution_id = stackit_cdn_distribution.example_distribution.distribution_id + name = "${stackit_dns_record_set.example.name}.${stackit_dns_zone.dns_name}" + } + ``` + + Now, you can access your content on the url `cdn.myapp.runs.onstackit.cloud`.