diff --git a/docs/data-sources/cdn_distribution.md b/docs/data-sources/cdn_distribution.md
index 4c8618e4a..623edd810 100644
--- a/docs/data-sources/cdn_distribution.md
+++ b/docs/data-sources/cdn_distribution.md
@@ -58,10 +58,12 @@ Read-Only:
Read-Only:
+- `bucket_url` (String) The URL of the bucket (e.g. https://s3.example.com). Required if type is 'bucket'.
- `geofencing` (Map of List of String) A map of URLs to a list of countries where content is allowed.
- `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. Possible values are: `http`.
+- `region` (String) The region where the bucket is hosted. Required if type is 'bucket'.
+- `type` (String) The configured backend type. Possible values are: `http`, `bucket`.
diff --git a/docs/resources/cdn_distribution.md b/docs/resources/cdn_distribution.md
index 66338f0cb..52c96bd31 100644
--- a/docs/resources/cdn_distribution.md
+++ b/docs/resources/cdn_distribution.md
@@ -35,6 +35,28 @@ resource "stackit_cdn_distribution" "example_distribution" {
}
}
+resource "stackit_cdn_distribution" "example_bucket_distribution" {
+ project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+ config = {
+ backend = {
+ type = "bucket"
+ bucket_url = "https://my-private-bucket.s3.eu-central-1.amazonaws.com"
+ region = "eu01"
+
+ # Credentials are required for bucket backends
+ # It is strongly recommended to use variables for secrets
+ access_key = var.bucket_access_key
+ secret_key = var.bucket_secret_key
+ }
+ regions = ["EU", "US"]
+ blocked_countries = ["CN", "RU"]
+
+ optimizer = {
+ enabled = false
+ }
+ }
+}
+
# Only use the import statement, if you want to import an existing cdn distribution
import {
to = stackit_cdn_distribution.import-example
@@ -78,13 +100,17 @@ Optional:
Required:
-- `origin_url` (String) The configured backend type for the distribution
-- `type` (String) The configured backend type. Possible values are: `http`.
+- `type` (String) The configured backend type. Possible values are: `http`, `bucket`.
Optional:
+- `access_key` (String, Sensitive) The access key for the bucket. Required if type is 'bucket'.
+- `bucket_url` (String) The URL of the bucket (e.g. https://s3.example.com). Required if type is 'bucket'.
- `geofencing` (Map of List of String) A map of URLs to a list of countries where content is allowed.
- `origin_request_headers` (Map of String) The configured origin request headers for the backend
+- `origin_url` (String) The configured backend type for the distribution
+- `region` (String) The region where the bucket is hosted. Required if type is 'bucket'.
+- `secret_key` (String, Sensitive) The secret key for the bucket. Required if type is 'bucket'.
diff --git a/examples/resources/stackit_cdn_distribution/resource.tf b/examples/resources/stackit_cdn_distribution/resource.tf
index e69a7e619..deac01b20 100644
--- a/examples/resources/stackit_cdn_distribution/resource.tf
+++ b/examples/resources/stackit_cdn_distribution/resource.tf
@@ -17,6 +17,28 @@ resource "stackit_cdn_distribution" "example_distribution" {
}
}
+resource "stackit_cdn_distribution" "example_bucket_distribution" {
+ project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+ config = {
+ backend = {
+ type = "bucket"
+ bucket_url = "https://my-private-bucket.s3.eu-central-1.amazonaws.com"
+ region = "eu01"
+
+ # Credentials are required for bucket backends
+ # It is strongly recommended to use variables for secrets
+ access_key = var.bucket_access_key
+ secret_key = var.bucket_secret_key
+ }
+ regions = ["EU", "US"]
+ blocked_countries = ["CN", "RU"]
+
+ optimizer = {
+ enabled = false
+ }
+ }
+}
+
# Only use the import statement, if you want to import an existing cdn distribution
import {
to = stackit_cdn_distribution.import-example
diff --git a/stackit/internal/services/cdn/cdn_acc_test.go b/stackit/internal/services/cdn/cdn_acc_test.go
index 0dd031a5b..a79c71e2a 100644
--- a/stackit/internal/services/cdn/cdn_acc_test.go
+++ b/stackit/internal/services/cdn/cdn_acc_test.go
@@ -333,6 +333,112 @@ func TestAccCDNDistributionResource(t *testing.T) {
},
})
}
+func configBucketResources(bucketUrl, region, accessKey, secretKey string) string {
+ return fmt.Sprintf(`
+ %s
+
+ resource "stackit_cdn_distribution" "distribution" {
+ project_id = "%s"
+ config = {
+ backend = {
+ type = "bucket"
+ bucket_url = "%s"
+ region = "%s"
+ access_key = "%s"
+ secret_key = "%s"
+ }
+ regions = ["EU", "US"]
+ blocked_countries = ["CN", "RU"]
+
+ optimizer = {
+ enabled = false
+ }
+ }
+ }
+ `, testutil.CdnProviderConfig(), testutil.ProjectId, bucketUrl, region, accessKey, secretKey)
+}
+func randomString(n int) string {
+ const letters = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
+ bytes := make([]byte, n)
+ if _, err := cryptoRand.Read(bytes); err != nil {
+ panic("failed to generate random string: " + err.Error())
+ }
+ for i, b := range bytes {
+ bytes[i] = letters[b%byte(len(letters))]
+ }
+ return string(bytes)
+}
+func TestAccCDNDistributionBucketResource(t *testing.T) {
+ // Define dummy bucket data
+ // Note: We use dummy credentials. The CDN API might validate format,
+ // but usually accepts valid-looking strings for async creation.
+ bucketUrl := "https://my-private-bucket.s3.eu-central-1.amazonaws.com"
+ bucketRegion := "eu01"
+ accessKey := strings.ToUpper(randomString(16))
+ secretKey := randomString(40)
+
+ resource.Test(t, resource.TestCase{
+ ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories,
+ CheckDestroy: testAccCheckCDNDistributionDestroy,
+ Steps: []resource.TestStep{
+ // 1. Distribution Create with Bucket Backend
+ {
+ Config: configBucketResources(bucketUrl, bucketRegion, accessKey, secretKey),
+ Check: resource.ComposeAggregateTestCheckFunc(
+ // Basic Identity Checks
+ resource.TestCheckResourceAttrSet("stackit_cdn_distribution.distribution", "distribution_id"),
+ resource.TestCheckResourceAttrSet("stackit_cdn_distribution.distribution", "created_at"),
+ resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "status", "ACTIVE"),
+
+ // Backend Type Check
+ resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.backend.type", "bucket"),
+
+ // Bucket Specific Field Checks
+ resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.backend.bucket_url", bucketUrl),
+ resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.backend.region", bucketRegion),
+
+ // CRITICAL: Check Credentials Persistence
+ // The API returns null for these, so if this passes,
+ // it means your logic in resource.go (mapFields) correctly
+ // restored the secrets from the Terraform state.
+ resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.backend.access_key", accessKey),
+ resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.backend.secret_key", secretKey),
+
+ // Ensure HTTP fields are not set
+ resource.TestCheckNoResourceAttr("stackit_cdn_distribution.distribution", "config.backend.origin_url"),
+ ),
+ },
+ // 2. Import Test
+ // This verifies that importing a bucket distribution works,
+ // although note that imported resources usually lose secrets (access_key/secret_key)
+ // because they are not in the API response.
+ {
+ 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,
+ // We MUST ignore credentials on import verification
+ // because the API does not return them, so Terraform will see them as null/empty
+ // in the new state, differing from the config.
+ ImportStateVerifyIgnore: []string{
+ "config.backend.access_key",
+ "config.backend.secret_key",
+ },
+ },
+ },
+ })
+}
func testAccCheckCDNDistributionDestroy(s *terraform.State) error {
ctx := context.Background()
var client *cdn.APIClient
@@ -376,9 +482,27 @@ const (
)
func blockUntilDomainResolves(domain string) (net.IP, error) {
+
+ // Create a custom resolver that bypasses the local system DNS settings/cache
+ // and queries Google DNS (8.8.8.8) directly.
+ r := &net.Resolver{
+ PreferGo: true,
+ Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
+ d := net.Dialer{
+ Timeout: time.Millisecond * time.Duration(10000),
+ }
+ // Force query to Google DNS
+ return d.DialContext(ctx, network, "8.8.8.8:53")
+ },
+ }
+
// wait until it becomes ready
isReady := func() (net.IP, error) {
- ips, err := net.LookupIP(domain)
+ // Use a context for the individual query timeout
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ ips, err := r.LookupIP(ctx, "ip", domain)
if err != nil {
return nil, fmt.Errorf("error looking up IP for domain %s: %w", domain, err)
}
@@ -389,6 +513,7 @@ func blockUntilDomainResolves(domain string) (net.IP, error) {
}
return nil, fmt.Errorf("no IP for domain: %v", domain)
}
+
return retry(recordCheckAttempts, recordCheckInterval, isReady)
}
diff --git a/stackit/internal/services/cdn/distribution/datasource.go b/stackit/internal/services/cdn/distribution/datasource.go
index ce3f749c7..47185f1a2 100644
--- a/stackit/internal/services/cdn/distribution/datasource.go
+++ b/stackit/internal/services/cdn/distribution/datasource.go
@@ -7,8 +7,10 @@ import (
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
cdnUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/cdn/utils"
+ "github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/datasource"
"github.com/hashicorp/terraform-plugin-framework/datasource/schema"
+ "github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-log/tflog"
@@ -19,6 +21,25 @@ import (
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate"
)
+// Define Backend Types specifically for Data Source (NO SECRETS)
+var dataSourceBackendTypes = map[string]attr.Type{
+ "type": types.StringType,
+ "origin_url": types.StringType,
+ "origin_request_headers": types.MapType{ElemType: types.StringType},
+ "geofencing": geofencingTypes, // Shared from resource.go
+ "bucket_url": types.StringType,
+ "region": types.StringType,
+}
+
+var dataSourceConfigTypes = map[string]attr.Type{
+ "backend": types.ObjectType{AttrTypes: dataSourceBackendTypes},
+ "regions": types.ListType{ElemType: types.StringType},
+ "blocked_countries": types.ListType{ElemType: types.StringType},
+ "optimizer": types.ObjectType{
+ AttrTypes: optimizerTypes, // Shared from resource.go
+ },
+}
+
type distributionDataSource struct {
client *cdn.APIClient
}
@@ -56,7 +77,7 @@ func (r *distributionDataSource) Metadata(_ context.Context, req datasource.Meta
}
func (r *distributionDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
- backendOptions := []string{"http"}
+ backendOptions := []string{"http", "bucket"}
resp.Schema = schema.Schema{
MarkdownDescription: features.AddBetaDescription("CDN distribution data source schema.", core.Datasource),
Description: "CDN distribution data source schema.",
@@ -149,6 +170,14 @@ func (r *distributionDataSource) Schema(_ context.Context, _ datasource.SchemaRe
ElemType: types.StringType,
},
},
+ "bucket_url": schema.StringAttribute{
+ Computed: true,
+ Description: schemaDescriptions["config_backend_bucket_url"],
+ },
+ "region": schema.StringAttribute{
+ Computed: true,
+ Description: schemaDescriptions["config_backend_region"],
+ },
},
},
"regions": schema.ListAttribute{
@@ -204,7 +233,8 @@ func (r *distributionDataSource) Read(ctx context.Context, req datasource.ReadRe
ctx = core.LogResponse(ctx)
- err = mapFields(ctx, distributionResp.Distribution, &model)
+ // Use specific Data Source mapping function
+ err = mapDataSourceFields(ctx, distributionResp.Distribution, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading CDN distribution", fmt.Sprintf("Error processing API response: %v", err))
return
@@ -212,3 +242,184 @@ func (r *distributionDataSource) Read(ctx context.Context, req datasource.ReadRe
diags = resp.State.Set(ctx, &model)
resp.Diagnostics.Append(diags...)
}
+
+// mapDataSourceFields is a specialized version of mapFields for the Data Source.
+// It uses dataSourceConfigTypes (excludes bucket access and secrets) and skips state restoration logic.
+func mapDataSourceFields(ctx context.Context, 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")
+ }
+
+ // Basic fields mapping (same as resource)
+ if distribution.ProjectId == nil || distribution.Id == nil || distribution.CreatedAt == nil || distribution.UpdatedAt == nil || distribution.Status == nil {
+ return fmt.Errorf("missing required fields in response")
+ }
+
+ model.ID = utils.BuildInternalTerraformId(*distribution.ProjectId, *distribution.Id)
+ model.DistributionId = types.StringValue(*distribution.Id)
+ model.ProjectId = types.StringValue(*distribution.ProjectId)
+ model.Status = types.StringValue(string(distribution.GetStatus()))
+ model.CreatedAt = types.StringValue(distribution.CreatedAt.String())
+ model.UpdatedAt = types.StringValue(distribution.UpdatedAt.String())
+
+ // Distribution Errors
+ 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
+ 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)
+ }
+
+ // Blocked Countries
+ var blockedCountries []attr.Value
+ if distribution.Config != nil && distribution.Config.BlockedCountries != nil {
+ for _, c := range *distribution.Config.BlockedCountries {
+ blockedCountries = append(blockedCountries, types.StringValue(string(c)))
+ }
+ }
+ modelBlockedCountries, diags := types.ListValue(types.StringType, blockedCountries)
+ if diags.HasError() {
+ return core.DiagsToError(diags)
+ }
+
+ // Prepare Backend Values
+ var backendValues map[string]attr.Value
+ originRequestHeaders := types.MapNull(types.StringType)
+ geofencingVal := types.MapNull(geofencingTypes.ElemType)
+
+ // If HTTP Backend is present
+ if distribution.Config.Backend.HttpBackend != nil {
+ // Headers
+ 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)
+ if diags.HasError() {
+ return core.DiagsToError(diags)
+ }
+ originRequestHeaders = mappedHeaders
+ }
+
+ // Geofencing
+ if geofencingAPI := distribution.Config.Backend.HttpBackend.Geofencing; geofencingAPI != nil && len(*geofencingAPI) > 0 {
+ geofencingMapElems := make(map[string]attr.Value)
+ for url, countries := range *geofencingAPI {
+ listVal, diags := types.ListValueFrom(ctx, types.StringType, countries)
+ if diags.HasError() {
+ return core.DiagsToError(diags)
+ }
+ geofencingMapElems[url] = listVal
+ }
+ mappedGeofencing, diags := types.MapValue(geofencingTypes.ElemType, geofencingMapElems)
+ if diags.HasError() {
+ return core.DiagsToError(diags)
+ }
+ geofencingVal = mappedGeofencing
+ }
+
+ backendValues = map[string]attr.Value{
+ "type": types.StringValue("http"),
+ "origin_url": types.StringValue(*distribution.Config.Backend.HttpBackend.OriginUrl),
+ "origin_request_headers": originRequestHeaders,
+ "geofencing": geofencingVal,
+ "bucket_url": types.StringNull(),
+ "region": types.StringNull(),
+ }
+ } else if distribution.Config.Backend.BucketBackend != nil {
+ // For Data Source, we strictly return what API gives us. No secret restoration.
+ backendValues = map[string]attr.Value{
+ "type": types.StringValue("bucket"),
+ "bucket_url": types.StringValue(*distribution.Config.Backend.BucketBackend.BucketUrl),
+ "region": types.StringValue(*distribution.Config.Backend.BucketBackend.Region),
+ "origin_url": types.StringNull(),
+ "origin_request_headers": types.MapNull(types.StringType),
+ "geofencing": types.MapNull(geofencingTypes.ElemType),
+ }
+ }
+
+ // Use dataSourceBackendTypes (NO SECRETS)
+ backend, diags := types.ObjectValue(dataSourceBackendTypes, backendValues)
+ if diags.HasError() {
+ return core.DiagsToError(diags)
+ }
+
+ // Optimizer
+ optimizerVal := types.ObjectNull(optimizerTypes)
+ if o := distribution.Config.Optimizer; o != nil {
+ if enabled, ok := o.GetEnabledOk(); ok {
+ var diags diag.Diagnostics
+ optimizerVal, diags = types.ObjectValue(optimizerTypes, map[string]attr.Value{
+ "enabled": types.BoolValue(enabled),
+ })
+ if diags.HasError() {
+ return core.DiagsToError(diags)
+ }
+ }
+ }
+
+ // Use dataSourceConfigTypes
+ cfg, diags := types.ObjectValue(dataSourceConfigTypes, map[string]attr.Value{
+ "backend": backend,
+ "regions": modelRegions,
+ "blocked_countries": modelBlockedCountries,
+ "optimizer": optimizerVal,
+ })
+ if diags.HasError() {
+ return core.DiagsToError(diags)
+ }
+ model.Config = cfg
+
+ // Domains
+ domains := []attr.Value{}
+ if distribution.Domains != nil {
+ for _, d := range *distribution.Domains {
+ domainErrors := []attr.Value{}
+ if d.Errors != nil {
+ for _, e := range *d.Errors {
+ domainErrors = append(domainErrors, types.StringValue(*e.En))
+ }
+ }
+ modelDomainErrors, diags := types.ListValue(types.StringType, domainErrors)
+ if diags.HasError() {
+ return core.DiagsToError(diags)
+ }
+ modelDomain, diags := types.ObjectValue(domainTypes, map[string]attr.Value{
+ "name": types.StringValue(*d.Name),
+ "status": types.StringValue(string(*d.Status)),
+ "type": types.StringValue(string(*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
+}
diff --git a/stackit/internal/services/cdn/distribution/datasource_test.go b/stackit/internal/services/cdn/distribution/datasource_test.go
new file mode 100644
index 000000000..5bf117032
--- /dev/null
+++ b/stackit/internal/services/cdn/distribution/datasource_test.go
@@ -0,0 +1,267 @@
+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 TestMapDataSourceFields(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(dataSourceBackendTypes, map[string]attr.Value{
+ "type": types.StringValue("http"),
+ "origin_url": types.StringValue("https://www.mycoolapp.com"),
+ "origin_request_headers": originRequestHeaders,
+ "geofencing": types.MapNull(geofencingTypes.ElemType),
+ "bucket_url": types.StringNull(),
+ "region": types.StringNull(),
+ })
+ regions := []attr.Value{types.StringValue("EU"), types.StringValue("US")}
+ regionsFixture := types.ListValueMust(types.StringType, regions)
+ blockedCountries := []attr.Value{types.StringValue("XX"), types.StringValue("YY"), types.StringValue("ZZ")}
+ blockedCountriesFixture := types.ListValueMust(types.StringType, blockedCountries)
+ geofencingCountries := types.ListValueMust(types.StringType, []attr.Value{types.StringValue("DE"), types.StringValue("BR")})
+ geofencing := types.MapValueMust(geofencingTypes.ElemType, map[string]attr.Value{
+ "test/": geofencingCountries,
+ })
+ geofencingInput := map[string][]string{"test/": {"DE", "BR"}}
+ optimizer := types.ObjectValueMust(optimizerTypes, map[string]attr.Value{
+ "enabled": types.BoolValue(true),
+ })
+ config := types.ObjectValueMust(dataSourceConfigTypes, map[string]attr.Value{
+ "backend": backend,
+ "regions": regionsFixture,
+ "blocked_countries": blockedCountriesFixture,
+ "optimizer": types.ObjectNull(optimizerTypes),
+ })
+
+ 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"},
+ BlockedCountries: &[]string{"XX", "YY", "ZZ"},
+ Optimizer: nil,
+ },
+ CreatedAt: &createdAt,
+ Domains: &[]cdn.Domain{
+ {
+ Name: cdn.PtrString("test.stackit-cdn.com"),
+ Status: cdn.DOMAINSTATUS_ACTIVE.Ptr(),
+ Type: cdn.DOMAINTYPE_MANAGED.Ptr(),
+ },
+ },
+ Id: cdn.PtrString("test-distribution-id"),
+ ProjectId: cdn.PtrString("test-project-id"),
+ Status: cdn.DISTRIBUTIONSTATUS_ACTIVE.Ptr(),
+ UpdatedAt: &updatedAt,
+ }
+ for _, mod := range mods {
+ mod(distribution)
+ }
+ return distribution
+ }
+
+ bucketBackendExpected := types.ObjectValueMust(dataSourceBackendTypes, map[string]attr.Value{
+ "type": types.StringValue("bucket"),
+ "bucket_url": types.StringValue("https://s3.example.com"),
+ "region": types.StringValue("eu01"),
+ "origin_url": types.StringNull(),
+ "origin_request_headers": types.MapNull(types.StringType),
+ "geofencing": types.MapNull(geofencingTypes.ElemType),
+ })
+ tests := map[string]struct {
+ Input *cdn.Distribution
+ Expected *Model
+ IsValid bool
+ }{
+ "happy_path": {
+ Expected: expectedModel(),
+ Input: distributionFixture(),
+ IsValid: true,
+ },
+ "happy_path_with_optimizer": {
+ Expected: expectedModel(func(m *Model) {
+ m.Config = types.ObjectValueMust(dataSourceConfigTypes, map[string]attr.Value{
+ "backend": backend,
+ "regions": regionsFixture,
+ "optimizer": optimizer,
+ "blocked_countries": blockedCountriesFixture,
+ })
+ }),
+ Input: distributionFixture(func(d *cdn.Distribution) {
+ d.Config.Optimizer = &cdn.Optimizer{
+ Enabled: cdn.PtrBool(true),
+ }
+ }),
+ IsValid: true,
+ },
+ "happy_path_bucket": {
+ Input: distributionFixture(func(d *cdn.Distribution) {
+ d.Config.Backend = &cdn.ConfigBackend{
+ BucketBackend: &cdn.BucketBackend{
+ Type: cdn.PtrString("bucket"),
+ BucketUrl: cdn.PtrString("https://s3.example.com"),
+ Region: cdn.PtrString("eu01"),
+ },
+ }
+ }),
+ Expected: expectedModel(func(m *Model) {
+ m.Config = types.ObjectValueMust(dataSourceConfigTypes, map[string]attr.Value{
+ "backend": bucketBackendExpected,
+ "regions": regionsFixture,
+ "blocked_countries": blockedCountriesFixture,
+ "optimizer": types.ObjectNull(optimizerTypes),
+ })
+ }),
+ IsValid: true,
+ },
+ "happy_path_with_geofencing": {
+ Expected: expectedModel(func(m *Model) {
+ backendWithGeofencing := types.ObjectValueMust(dataSourceBackendTypes, map[string]attr.Value{
+ "type": types.StringValue("http"),
+ "origin_url": types.StringValue("https://www.mycoolapp.com"),
+ "origin_request_headers": originRequestHeaders,
+ "geofencing": geofencing,
+ "bucket_url": types.StringNull(),
+ "region": types.StringNull(),
+ })
+ m.Config = types.ObjectValueMust(dataSourceConfigTypes, map[string]attr.Value{
+ "backend": backendWithGeofencing,
+ "regions": regionsFixture,
+ "optimizer": types.ObjectNull(optimizerTypes),
+ "blocked_countries": blockedCountriesFixture,
+ })
+ }),
+ Input: distributionFixture(func(d *cdn.Distribution) {
+ d.Config.Backend.HttpBackend.Geofencing = &geofencingInput
+ }),
+ 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.DISTRIBUTIONSTATUS_ERROR.Ptr()
+ }),
+ 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.DOMAINTYPE_MANAGED.Ptr(),
+ },
+ {
+ Name: cdn.PtrString("mycoolapp.info"),
+ Status: cdn.DOMAINSTATUS_ACTIVE.Ptr(),
+ Type: cdn.DOMAINTYPE_CUSTOM.Ptr(),
+ },
+ }
+ }),
+ 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 := mapDataSourceFields(context.Background(), 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 cbd215c82..5decb0afa 100644
--- a/stackit/internal/services/cdn/distribution/resource.go
+++ b/stackit/internal/services/cdn/distribution/resource.go
@@ -62,6 +62,10 @@ var schemaDescriptions = map[string]string{
"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",
+ "config_backend_bucket_url": "The URL of the bucket (e.g. https://s3.example.com). Required if type is 'bucket'.",
+ "config_backend_region": "The region where the bucket is hosted. Required if type is 'bucket'.",
+ "config_backend_access_key": "The access key for the bucket. Required if type is 'bucket'.",
+ "config_backend_secret_key": "The secret key for the bucket. Required if type is 'bucket'.",
}
type Model struct {
@@ -92,6 +96,10 @@ type backend struct {
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
Geofencing *map[string][]*string `tfsdk:"geofencing"` // The geofencing is an object mapping multiple alternative origins to country codes.
+ BucketURL *string `tfsdk:"bucket_url"`
+ Region *string `tfsdk:"region"`
+ AccessKey *string `tfsdk:"access_key"`
+ SecretKey *string `tfsdk:"secret_key"`
}
var configTypes = map[string]attr.Type{
@@ -116,6 +124,10 @@ var backendTypes = map[string]attr.Type{
"origin_url": types.StringType,
"origin_request_headers": types.MapType{ElemType: types.StringType},
"geofencing": geofencingTypes,
+ "bucket_url": types.StringType,
+ "region": types.StringType,
+ "access_key": types.StringType,
+ "secret_key": types.StringType,
}
var domainTypes = map[string]attr.Type{
@@ -159,7 +171,7 @@ func (r *distributionResource) Metadata(_ context.Context, req resource.Metadata
}
func (r *distributionResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
- backendOptions := []string{"http"}
+ backendOptions := []string{"http", "bucket"}
resp.Schema = schema.Schema{
MarkdownDescription: features.AddBetaDescription("CDN distribution data source schema.", core.Resource),
Description: "CDN distribution data source schema.",
@@ -254,8 +266,17 @@ func (r *distributionResource) Schema(_ context.Context, _ resource.SchemaReques
Validators: []validator.String{stringvalidator.OneOf(backendOptions...)},
},
"origin_url": schema.StringAttribute{
- Required: true,
+ Optional: true,
Description: schemaDescriptions["config_backend_origin_url"],
+ Validators: []validator.String{
+ stringvalidator.ConflictsWith(
+ // If the origin_url field is populated, no bucket fields can be used
+ path.MatchRelative().AtParent().AtName("bucket_url"),
+ path.MatchRelative().AtParent().AtName("region"),
+ path.MatchRelative().AtParent().AtName("access_key"),
+ path.MatchRelative().AtParent().AtName("secret_key"),
+ ),
+ },
},
"origin_request_headers": schema.MapAttribute{
Optional: true,
@@ -272,6 +293,34 @@ func (r *distributionResource) Schema(_ context.Context, _ resource.SchemaReques
mapvalidator.SizeAtLeast(1),
},
},
+ "bucket_url": schema.StringAttribute{
+ Optional: true,
+ Description: schemaDescriptions["config_backend_bucket_url"],
+ Validators: []validator.String{
+ stringvalidator.AlsoRequires(path.MatchRelative().AtParent().AtName("region"), path.MatchRelative().AtParent().AtName("access_key"), path.MatchRelative().AtParent().AtName("secret_key")),
+ stringvalidator.ConflictsWith(
+ // If the origin_url is populated, bucket_url can not be used
+ path.MatchRelative().AtParent().AtName("origin_url"),
+ ),
+ },
+ },
+ "region": schema.StringAttribute{
+ Optional: true,
+ Description: schemaDescriptions["config_backend_region"],
+ Validators: []validator.String{stringvalidator.AlsoRequires((path.MatchRelative().AtParent().AtName("bucket_url")))},
+ },
+ "access_key": schema.StringAttribute{
+ Optional: true,
+ Sensitive: true,
+ Description: schemaDescriptions["config_backend_access_key"],
+ Validators: []validator.String{stringvalidator.AlsoRequires((path.MatchRelative().AtParent().AtName("bucket_url")))},
+ },
+ "secret_key": schema.StringAttribute{
+ Optional: true,
+ Sensitive: true,
+ Description: schemaDescriptions["config_backend_secret_key"],
+ Validators: []validator.String{stringvalidator.AlsoRequires((path.MatchRelative().AtParent().AtName("bucket_url")))},
+ },
},
},
"regions": schema.ListAttribute{
@@ -474,32 +523,46 @@ func (r *distributionResource) Update(ctx context.Context, req resource.UpdateRe
blockedCountries = &tempBlockedCountries
}
- geofencingPatch := map[string][]string{}
- if configModel.Backend.Geofencing != nil {
- gf := make(map[string][]string)
- for url, countries := range *configModel.Backend.Geofencing {
- countryStrings := make([]string, len(countries))
- for i, countryPtr := range countries {
- if countryPtr == nil {
- core.LogAndAddError(ctx, &resp.Diagnostics, "Update CDN distribution", fmt.Sprintf("Geofencing url %q has a null value", url))
- return
+ configPatchBackend := &cdn.ConfigPatchBackend{}
+
+ if configModel.Backend.Type == "http" {
+ geofencingPatch := map[string][]string{}
+ if configModel.Backend.Geofencing != nil {
+ gf := make(map[string][]string)
+ for url, countries := range *configModel.Backend.Geofencing {
+ countryStrings := make([]string, len(countries))
+ for i, countryPtr := range countries {
+ if countryPtr == nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Update CDN distribution", fmt.Sprintf("Geofencing url %q has a null value", url))
+ return
+ }
+ countryStrings[i] = *countryPtr
}
- countryStrings[i] = *countryPtr
+ gf[url] = countryStrings
}
- gf[url] = countryStrings
+ geofencingPatch = gf
+ }
+
+ configPatchBackend.HttpBackendPatch = &cdn.HttpBackendPatch{
+ OriginRequestHeaders: configModel.Backend.OriginRequestHeaders,
+ OriginUrl: &configModel.Backend.OriginURL,
+ Type: cdn.PtrString("http"),
+ Geofencing: &geofencingPatch,
+ }
+ } else if configModel.Backend.Type == "bucket" {
+ configPatchBackend.BucketBackendPatch = &cdn.BucketBackendPatch{
+ Type: cdn.PtrString("bucket"),
+ BucketUrl: configModel.Backend.BucketURL,
+ Region: configModel.Backend.Region,
+ Credentials: &cdn.BucketCredentials{
+ AccessKeyId: configModel.Backend.AccessKey,
+ SecretAccessKey: configModel.Backend.SecretKey,
+ },
}
- geofencingPatch = gf
}
configPatch := &cdn.ConfigPatch{
- Backend: &cdn.ConfigPatchBackend{
- HttpBackendPatch: &cdn.HttpBackendPatch{
- OriginRequestHeaders: configModel.Backend.OriginRequestHeaders,
- OriginUrl: &configModel.Backend.OriginURL,
- Type: &configModel.Backend.Type,
- Geofencing: &geofencingPatch, // Use the converted variable
- },
- },
+ Backend: configPatchBackend,
Regions: ®ions,
BlockedCountries: blockedCountries,
}
@@ -650,6 +713,18 @@ func mapFields(ctx context.Context, distribution *cdn.Distribution, model *Model
return core.DiagsToError(diags)
}
+ // Check if we have existing configuration to preerve secrets
+ var oldConfig distributionConfig
+ if !model.Config.IsNull() && !model.Config.IsUnknown() {
+ diags := model.Config.As(ctx, &oldConfig, basetypes.ObjectAsOptions{
+ UnhandledNullAsEmpty: true,
+ UnhandledUnknownAsEmpty: true,
+ })
+ if diags.HasError() {
+ return core.DiagsToError(diags)
+ }
+ }
+
// blockedCountries
var blockedCountries []attr.Value
if distribution.Config != nil && distribution.Config.BlockedCountries != nil {
@@ -665,41 +740,38 @@ func mapFields(ctx context.Context, distribution *cdn.Distribution, model *Model
// originRequestHeaders
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)
+ if distribution.Config.Backend.HttpBackend != nil {
+ 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)
+ }
}
}
// geofencing
- var oldConfig distributionConfig
oldGeofencingMap := make(map[string][]*string)
- if !model.Config.IsNull() {
- diags = model.Config.As(ctx, &oldConfig, basetypes.ObjectAsOptions{})
- if diags.HasError() {
- return core.DiagsToError(diags)
- }
- if oldConfig.Backend.Geofencing != nil {
- oldGeofencingMap = *oldConfig.Backend.Geofencing
- }
+ if oldConfig.Backend.Geofencing != nil {
+ oldGeofencingMap = *oldConfig.Backend.Geofencing
}
reconciledGeofencingData := make(map[string][]string)
- if geofencingAPI := distribution.Config.Backend.HttpBackend.Geofencing; geofencingAPI != nil && len(*geofencingAPI) > 0 {
- newGeofencingMap := *geofencingAPI
- for url, newCountries := range newGeofencingMap {
- oldCountriesPtrs := oldGeofencingMap[url]
+ if distribution.Config.Backend.HttpBackend != nil {
+ if geofencingAPI := distribution.Config.Backend.HttpBackend.Geofencing; geofencingAPI != nil && len(*geofencingAPI) > 0 {
+ newGeofencingMap := *geofencingAPI
+ for url, newCountries := range newGeofencingMap {
+ oldCountriesPtrs := oldGeofencingMap[url]
- oldCountries := utils.ConvertPointerSliceToStringSlice(oldCountriesPtrs)
+ oldCountries := utils.ConvertPointerSliceToStringSlice(oldCountriesPtrs)
- reconciledCountries := utils.ReconcileStringSlices(oldCountries, newCountries)
- reconciledGeofencingData[url] = reconciledCountries
+ reconciledCountries := utils.ReconcileStringSlices(oldCountries, newCountries)
+ reconciledGeofencingData[url] = reconciledCountries
+ }
}
}
@@ -721,14 +793,48 @@ func mapFields(ctx context.Context, distribution *cdn.Distribution, model *Model
}
geofencingVal = mappedGeofencing
}
+ // Map Backend Fields (HTTP or Bucket)
+ var backendValues map[string]attr.Value
+
+ if distribution.Config.Backend.HttpBackend != nil {
+ backendValues = map[string]attr.Value{
+ "type": types.StringValue("http"),
+ "origin_url": types.StringValue(*distribution.Config.Backend.HttpBackend.OriginUrl),
+ "origin_request_headers": originRequestHeaders,
+ "geofencing": geofencingVal,
+ // bucket fields must be null when using HTTP
+ "bucket_url": types.StringNull(),
+ "region": types.StringNull(),
+ "access_key": types.StringNull(),
+ "secret_key": types.StringNull(),
+ }
+ } else if distribution.Config.Backend.BucketBackend != nil {
+ // Preserve secrets from previos state beacuse API does not return them
+ accessKeyVal := types.StringNull()
+ secretKeyVal := types.StringNull()
+
+ if oldConfig.Backend.AccessKey != nil {
+ accessKeyVal = types.StringValue(*oldConfig.Backend.AccessKey)
+ }
+ if oldConfig.Backend.SecretKey != nil {
+ secretKeyVal = types.StringValue(*oldConfig.Backend.SecretKey)
+ }
+
+ backendValues = map[string]attr.Value{
+ "type": types.StringValue("bucket"),
+ "bucket_url": types.StringValue(*distribution.Config.Backend.BucketBackend.BucketUrl),
+ "region": types.StringValue(*distribution.Config.Backend.BucketBackend.Region),
+ "access_key": accessKeyVal,
+ "secret_key": secretKeyVal,
+ // HTTP field must be null when using Bucket
+ "origin_url": types.StringNull(),
+ "origin_request_headers": types.MapNull(types.StringType),
+ "geofencing": types.MapNull(geofencingTypes.ElemType),
+ }
+ }
// 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,
- "geofencing": geofencingVal,
- })
+ backend, diags := types.ObjectValue(backendTypes, backendValues)
if diags.HasError() {
return core.DiagsToError(diags)
}
@@ -810,10 +916,45 @@ func toCreatePayload(ctx context.Context, model *Model) (*cdn.CreateDistribution
if cfg.Optimizer != nil {
optimizer = cdn.NewOptimizer(cfg.Optimizer.GetEnabled())
}
+ var backend *cdn.CreateDistributionPayloadBackend
+ if cfg.Backend.HttpBackend != nil {
+ backend = &cdn.CreateDistributionPayloadBackend{
+ HttpBackendCreate: &cdn.HttpBackendCreate{
+ OriginUrl: cfg.Backend.HttpBackend.OriginUrl,
+ OriginRequestHeaders: cfg.Backend.HttpBackend.OriginRequestHeaders,
+ Geofencing: cfg.Backend.HttpBackend.Geofencing,
+ Type: cdn.PtrString("http"),
+ },
+ }
+ } else if cfg.Backend.BucketBackend != nil {
+ // We need to parse the model again to access the credentials,
+ // as convertConfig returns the SDK Config struct which hides them.
+ var rawConfig distributionConfig
+ diags := model.Config.As(ctx, &rawConfig, basetypes.ObjectAsOptions{
+ UnhandledNullAsEmpty: true,
+ UnhandledUnknownAsEmpty: true,
+ })
+ if diags.HasError() {
+ return nil, core.DiagsToError(diags)
+ }
+
+ backend = &cdn.CreateDistributionPayloadBackend{
+ BucketBackendCreate: &cdn.BucketBackendCreate{
+ Type: cdn.PtrString("bucket"),
+ BucketUrl: cfg.Backend.BucketBackend.BucketUrl,
+ Region: cfg.Backend.BucketBackend.Region,
+ Credentials: &cdn.BucketCredentials{
+ AccessKeyId: rawConfig.Backend.AccessKey,
+ SecretAccessKey: rawConfig.Backend.SecretKey,
+ },
+ },
+ }
+ }
payload := &cdn.CreateDistributionPayload{
IntentId: cdn.PtrString(uuid.NewString()),
Regions: cfg.Regions,
+ Backend: backend,
BlockedCountries: cfg.BlockedCountries,
Optimizer: optimizer,
}
@@ -830,8 +971,8 @@ func convertConfig(ctx context.Context, model *Model) (*cdn.Config, error) {
}
configModel := distributionConfig{}
diags := model.Config.As(ctx, &configModel, basetypes.ObjectAsOptions{
- UnhandledNullAsEmpty: false,
- UnhandledUnknownAsEmpty: false,
+ UnhandledNullAsEmpty: true,
+ UnhandledUnknownAsEmpty: true,
})
if diags.HasError() {
return nil, core.DiagsToError(diags)
@@ -878,27 +1019,34 @@ func convertConfig(ctx context.Context, model *Model) (*cdn.Config, error) {
}
}
- // originRequestHeaders
- originRequestHeaders := map[string]string{}
- if configModel.Backend.OriginRequestHeaders != nil {
- for k, v := range *configModel.Backend.OriginRequestHeaders {
- originRequestHeaders[k] = v
- }
- }
-
cdnConfig := &cdn.Config{
- Backend: &cdn.ConfigBackend{
- HttpBackend: &cdn.HttpBackend{
- OriginRequestHeaders: &originRequestHeaders,
- OriginUrl: &configModel.Backend.OriginURL,
- Type: &configModel.Backend.Type,
- Geofencing: &geofencing,
- },
- },
+ Backend: &cdn.ConfigBackend{},
Regions: ®ions,
BlockedCountries: &blockedCountries,
}
+ if configModel.Backend.Type == "http" {
+ originRequestHeaders := map[string]string{}
+ if configModel.Backend.OriginRequestHeaders != nil {
+ for k, v := range *configModel.Backend.OriginRequestHeaders {
+ originRequestHeaders[k] = v
+ }
+ }
+ cdnConfig.Backend.HttpBackend = &cdn.HttpBackend{
+ OriginRequestHeaders: &originRequestHeaders,
+ OriginUrl: &configModel.Backend.OriginURL,
+ Type: cdn.PtrString("http"),
+ Geofencing: &geofencing,
+ }
+
+ } else if configModel.Backend.Type == "bucket" {
+ cdnConfig.Backend.BucketBackend = &cdn.BucketBackend{
+ Type: cdn.PtrString("bucket"),
+ BucketUrl: configModel.Backend.BucketURL,
+ Region: configModel.Backend.Region,
+ }
+ }
+
if !utils.IsUndefined(configModel.Optimizer) {
var optimizerModel optimizerConfig
diags := configModel.Optimizer.As(ctx, &optimizerModel, basetypes.ObjectAsOptions{})
diff --git a/stackit/internal/services/cdn/distribution/resource_test.go b/stackit/internal/services/cdn/distribution/resource_test.go
index b4b6fd1c7..3dbb891dd 100644
--- a/stackit/internal/services/cdn/distribution/resource_test.go
+++ b/stackit/internal/services/cdn/distribution/resource_test.go
@@ -29,6 +29,10 @@ func TestToCreatePayload(t *testing.T) {
"origin_url": types.StringValue("https://www.mycoolapp.com"),
"origin_request_headers": originRequestHeaders,
"geofencing": geofencing,
+ "bucket_url": types.StringNull(),
+ "region": types.StringNull(),
+ "access_key": types.StringNull(),
+ "secret_key": types.StringNull(),
})
regions := []attr.Value{types.StringValue("EU"), types.StringValue("US")}
regionsFixture := types.ListValueMust(types.StringType, regions)
@@ -64,6 +68,14 @@ func TestToCreatePayload(t *testing.T) {
Expected: &cdn.CreateDistributionPayload{
Regions: &[]cdn.Region{"EU", "US"},
BlockedCountries: &[]string{"XX", "YY", "ZZ"},
+ Backend: &cdn.CreateDistributionPayloadBackend{
+ HttpBackendCreate: &cdn.HttpBackendCreate{
+ Geofencing: &map[string][]string{"https://de.mycoolapp.com": {"DE", "FR"}},
+ OriginRequestHeaders: &map[string]string{"testHeader0": "testHeaderValue0", "testHeader1": "testHeaderValue1"},
+ OriginUrl: cdn.PtrString("https://www.mycoolapp.com"),
+ Type: cdn.PtrString("http"),
+ },
+ },
},
IsValid: true,
},
@@ -80,6 +92,50 @@ func TestToCreatePayload(t *testing.T) {
Regions: &[]cdn.Region{"EU", "US"},
Optimizer: cdn.NewOptimizer(true),
BlockedCountries: &[]string{"XX", "YY", "ZZ"},
+ Backend: &cdn.CreateDistributionPayloadBackend{
+ HttpBackendCreate: &cdn.HttpBackendCreate{
+ Geofencing: &map[string][]string{"https://de.mycoolapp.com": {"DE", "FR"}},
+ OriginRequestHeaders: &map[string]string{"testHeader0": "testHeaderValue0", "testHeader1": "testHeaderValue1"},
+ OriginUrl: cdn.PtrString("https://www.mycoolapp.com"),
+ Type: cdn.PtrString("http"),
+ },
+ },
+ },
+ IsValid: true,
+ },
+ "happy_path_bucket": {
+ Input: modelFixture(func(m *Model) {
+ bucketBackend := types.ObjectValueMust(backendTypes, map[string]attr.Value{
+ "type": types.StringValue("bucket"),
+ "bucket_url": types.StringValue("https://s3.example.com"),
+ "region": types.StringValue("eu01"),
+ "access_key": types.StringValue("my-access"),
+ "secret_key": types.StringValue("my-secret"),
+ "origin_url": types.StringNull(),
+ "origin_request_headers": types.MapNull(types.StringType),
+ "geofencing": types.MapNull(geofencingTypes.ElemType),
+ })
+ m.Config = types.ObjectValueMust(configTypes, map[string]attr.Value{
+ "backend": bucketBackend,
+ "regions": regionsFixture, // reusing the existing one
+ "blocked_countries": blockedCountriesFixture,
+ "optimizer": types.ObjectNull(optimizerTypes),
+ })
+ }),
+ Expected: &cdn.CreateDistributionPayload{
+ Backend: &cdn.CreateDistributionPayloadBackend{
+ BucketBackendCreate: &cdn.BucketBackendCreate{
+ Type: cdn.PtrString("bucket"),
+ BucketUrl: cdn.PtrString("https://s3.example.com"),
+ Region: cdn.PtrString("eu01"),
+ Credentials: &cdn.BucketCredentials{
+ AccessKeyId: cdn.PtrString("my-access"),
+ SecretAccessKey: cdn.PtrString("my-secret"),
+ },
+ },
+ },
+ Regions: &[]cdn.Region{"EU", "US"},
+ BlockedCountries: &[]string{"XX", "YY", "ZZ"},
},
IsValid: true,
},
@@ -136,6 +192,10 @@ func TestConvertConfig(t *testing.T) {
"origin_url": types.StringValue("https://www.mycoolapp.com"),
"origin_request_headers": originRequestHeaders,
"geofencing": geofencing,
+ "bucket_url": types.StringNull(),
+ "region": types.StringNull(),
+ "access_key": types.StringNull(),
+ "secret_key": types.StringNull(),
})
regions := []attr.Value{types.StringValue("EU"), types.StringValue("US")}
regionsFixture := types.ListValueMust(types.StringType, regions)
@@ -214,6 +274,40 @@ func TestConvertConfig(t *testing.T) {
},
IsValid: true,
},
+ "happy_path_bucket": {
+ Input: modelFixture(func(m *Model) {
+ bucketBackend := types.ObjectValueMust(backendTypes, map[string]attr.Value{
+ "type": types.StringValue("bucket"),
+ "bucket_url": types.StringValue("https://s3.example.com"),
+ "region": types.StringValue("eu01"),
+ "access_key": types.StringValue("my-access"),
+ "secret_key": types.StringValue("my-secret"),
+ "origin_url": types.StringNull(),
+ "origin_request_headers": types.MapNull(types.StringType),
+ "geofencing": types.MapNull(geofencingTypes.ElemType),
+ })
+ m.Config = types.ObjectValueMust(configTypes, map[string]attr.Value{
+ "backend": bucketBackend,
+ "regions": regionsFixture,
+ "blocked_countries": blockedCountriesFixture,
+ "optimizer": types.ObjectNull(optimizerTypes),
+ })
+ }),
+ Expected: &cdn.Config{
+ Backend: &cdn.ConfigBackend{
+ BucketBackend: &cdn.BucketBackend{
+ Type: cdn.PtrString("bucket"),
+ BucketUrl: cdn.PtrString("https://s3.example.com"),
+ Region: cdn.PtrString("eu01"),
+ // Note: config does not return credentials
+
+ },
+ },
+ Regions: &[]cdn.Region{"EU", "US"},
+ BlockedCountries: &[]string{"XX", "YY", "ZZ"},
+ },
+ IsValid: true,
+ },
"sad_path_model_nil": {
Input: nil,
Expected: nil,
@@ -259,6 +353,10 @@ func TestMapFields(t *testing.T) {
"origin_url": types.StringValue("https://www.mycoolapp.com"),
"origin_request_headers": originRequestHeaders,
"geofencing": types.MapNull(geofencingTypes.ElemType),
+ "bucket_url": types.StringNull(),
+ "region": types.StringNull(),
+ "access_key": types.StringNull(),
+ "secret_key": types.StringNull(),
})
regions := []attr.Value{types.StringValue("EU"), types.StringValue("US")}
regionsFixture := types.ListValueMust(types.StringType, regions)
@@ -339,10 +437,28 @@ func TestMapFields(t *testing.T) {
}
return distribution
}
+ // define old state with the secrets
+ bucketBackendOld := types.ObjectValueMust(backendTypes, map[string]attr.Value{
+ "type": types.StringValue("bucket"),
+ "bucket_url": types.StringValue("https://s3.example.com"),
+ "region": types.StringValue("eu01"),
+ "access_key": types.StringValue("old-access"),
+ "secret_key": types.StringValue("old-secret"),
+ "origin_url": types.StringNull(),
+ "origin_request_headers": types.MapNull(types.StringType),
+ "geofencing": types.MapNull(geofencingTypes.ElemType),
+ })
+ configOld := types.ObjectValueMust(configTypes, map[string]attr.Value{
+ "backend": bucketBackendOld,
+ "regions": regionsFixture,
+ "blocked_countries": blockedCountriesFixture,
+ "optimizer": types.ObjectNull(optimizerTypes),
+ })
tests := map[string]struct {
- Input *cdn.Distribution
- Expected *Model
- IsValid bool
+ Input *cdn.Distribution
+ Expected *Model
+ InitialState *Model
+ IsValid bool
}{
"happy_path": {
Expected: expectedModel(),
@@ -372,6 +488,10 @@ func TestMapFields(t *testing.T) {
"origin_url": types.StringValue("https://www.mycoolapp.com"),
"origin_request_headers": originRequestHeaders,
"geofencing": geofencing,
+ "bucket_url": types.StringNull(),
+ "region": types.StringNull(),
+ "access_key": types.StringNull(),
+ "secret_key": types.StringNull(),
})
m.Config = types.ObjectValueMust(configTypes, map[string]attr.Value{
"backend": backendWithGeofencing,
@@ -427,6 +547,24 @@ func TestMapFields(t *testing.T) {
}),
IsValid: true,
},
+ "happy_path_bucket_restore_creds": {
+ Input: distributionFixture(func(d *cdn.Distribution) {
+ d.Config.Backend = &cdn.ConfigBackend{
+ BucketBackend: &cdn.BucketBackend{
+ Type: cdn.PtrString("bucket"),
+ BucketUrl: cdn.PtrString("https://s3.example.com"),
+ Region: cdn.PtrString("eu01"),
+ },
+ }
+ }),
+ InitialState: expectedModel(func(m *Model) {
+ m.Config = configOld
+ }),
+ Expected: expectedModel(func(m *Model) {
+ m.Config = configOld
+ }),
+ IsValid: true,
+ },
"sad_path_distribution_nil": {
Expected: nil,
Input: nil,
@@ -450,6 +588,12 @@ func TestMapFields(t *testing.T) {
for tn, tc := range tests {
t.Run(tn, func(t *testing.T) {
model := &Model{}
+ if tc.InitialState != nil {
+ model = tc.InitialState
+ } else {
+ model.Config = types.ObjectNull(configTypes)
+ }
+
err := mapFields(context.Background(), tc.Input, model)
if err != nil && tc.IsValid {
t.Fatalf("Error mapping fields: %v", err)