Feature: CDN custom domain resource and data source (#801)

* Feature: CDN custom domain resource and data source

* stabilize acceptance tests

* add guide

* review changes

---------

Co-authored-by: Malte Ehrlen <malte.ehrlen@freiheit.com>
This commit is contained in:
Malte Ehrlen 2025-05-05 14:10:43 +03:00 committed by GitHub
parent 0a86417cbb
commit 2d757a93fd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 914 additions and 19 deletions

View file

@ -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 generated by tfplugindocs -->
## 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

View file

@ -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`.

View file

@ -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 generated by tfplugindocs -->
## 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

View file

@ -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`".

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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() != "<nil>" {
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)
}

View file

@ -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")
}

View file

@ -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
}

View file

@ -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)
}
}
})
}
}

View file

@ -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

View file

@ -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,

View file

@ -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`.