From a69e3cb7b61d5076bf859362c577686af541b657 Mon Sep 17 00:00:00 2001 From: Matheus Politano Date: Mon, 2 Feb 2026 15:25:55 +0100 Subject: [PATCH 01/15] chore: add new field and add them in map field --- .../services/cdn/distribution/resource.go | 100 +++++++++++++++--- 1 file changed, 88 insertions(+), 12 deletions(-) diff --git a/stackit/internal/services/cdn/distribution/resource.go b/stackit/internal/services/cdn/distribution/resource.go index cbd215c82..5e3d8c0cc 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.", @@ -272,6 +284,27 @@ 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")), + }, + }, + "region": schema.StringAttribute{ + Optional: true, + Description: schemaDescriptions["config_backend_region"], + }, + "access_key": schema.StringAttribute{ + Optional: true, + Sensitive: true, + Description: schemaDescriptions["config_backend_access_key"], + }, + "secret_key": schema.StringAttribute{ + Optional: true, + Sensitive: true, + Description: schemaDescriptions["config_backend_secret_key"], + }, }, }, "regions": schema.ListAttribute{ @@ -650,6 +683,21 @@ 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) + } + } + if distribution.Config.Backend.BucketBackend != nil { + + } + // blockedCountries var blockedCountries []attr.Value if distribution.Config != nil && distribution.Config.BlockedCountries != nil { @@ -678,17 +726,10 @@ func mapFields(ctx context.Context, distribution *cdn.Distribution, model *Model } // 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 } - } reconciledGeofencingData := make(map[string][]string) if geofencingAPI := distribution.Config.Backend.HttpBackend.Geofencing; geofencingAPI != nil && len(*geofencingAPI) > 0 { @@ -721,14 +762,49 @@ func mapFields(ctx context.Context, distribution *cdn.Distribution, model *Model } geofencingVal = mappedGeofencing } + // Map Backend Fields (HTTP or Bucket) + var backendValues map[string]attr.Value - // 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), + 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.HttpBackend != 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) + secretKeyVal = types.StringValue(*oldConfig.Backend.SecretKey) + } + 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, backendValues) if diags.HasError() { return core.DiagsToError(diags) } From 877ff6a38fa50f18cbe0d70e9be1852cab709633 Mon Sep 17 00:00:00 2001 From: Matheus Politano Date: Mon, 2 Feb 2026 16:31:39 +0100 Subject: [PATCH 02/15] Add create payload --- .../services/cdn/distribution/resource.go | 86 ++++++++++++++----- 1 file changed, 64 insertions(+), 22 deletions(-) diff --git a/stackit/internal/services/cdn/distribution/resource.go b/stackit/internal/services/cdn/distribution/resource.go index 5e3d8c0cc..91ef859db 100644 --- a/stackit/internal/services/cdn/distribution/resource.go +++ b/stackit/internal/services/cdn/distribution/resource.go @@ -727,9 +727,9 @@ func mapFields(ctx context.Context, distribution *cdn.Distribution, model *Model // geofencing oldGeofencingMap := make(map[string][]*string) - 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 { @@ -768,9 +768,9 @@ func mapFields(ctx context.Context, distribution *cdn.Distribution, model *Model 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, + "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(), @@ -886,10 +886,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, } @@ -954,27 +989,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{}) From 6a1bb3abb0c849cf914d70cd8aa6ed18437e698a Mon Sep 17 00:00:00 2001 From: Matheus Politano Date: Mon, 2 Feb 2026 16:47:46 +0100 Subject: [PATCH 03/15] add update --- .../services/cdn/distribution/resource.go | 54 ++++++++++++------- 1 file changed, 34 insertions(+), 20 deletions(-) diff --git a/stackit/internal/services/cdn/distribution/resource.go b/stackit/internal/services/cdn/distribution/resource.go index 91ef859db..b8382fd3e 100644 --- a/stackit/internal/services/cdn/distribution/resource.go +++ b/stackit/internal/services/cdn/distribution/resource.go @@ -507,32 +507,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, } From 16285e79e08dffe430b07d20341bc4899f6ba4d5 Mon Sep 17 00:00:00 2001 From: Matheus Politano Date: Mon, 2 Feb 2026 17:09:53 +0100 Subject: [PATCH 04/15] add all changes in resource --- stackit/internal/services/cdn/distribution/resource.go | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/stackit/internal/services/cdn/distribution/resource.go b/stackit/internal/services/cdn/distribution/resource.go index b8382fd3e..24ac67db6 100644 --- a/stackit/internal/services/cdn/distribution/resource.go +++ b/stackit/internal/services/cdn/distribution/resource.go @@ -708,9 +708,6 @@ func mapFields(ctx context.Context, distribution *cdn.Distribution, model *Model return core.DiagsToError(diags) } } - if distribution.Config.Backend.BucketBackend != nil { - - } // blockedCountries var blockedCountries []attr.Value @@ -791,14 +788,13 @@ func mapFields(ctx context.Context, distribution *cdn.Distribution, model *Model "access_key": types.StringNull(), "secret_key": types.StringNull(), } - } else if distribution.Config.Backend.HttpBackend != nil { + } 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) - secretKeyVal = types.StringValue(*oldConfig.Backend.SecretKey) } if oldConfig.Backend.SecretKey != nil { secretKeyVal = types.StringValue(*oldConfig.Backend.SecretKey) From 26f3f8e1b47514544c056cc88655e3b6e175a274 Mon Sep 17 00:00:00 2001 From: Matheus Politano Date: Tue, 3 Feb 2026 16:58:58 +0100 Subject: [PATCH 05/15] add resource test --- .../services/cdn/distribution/resource.go | 40 ++--- .../cdn/distribution/resource_test.go | 150 +++++++++++++++++- 2 files changed, 169 insertions(+), 21 deletions(-) diff --git a/stackit/internal/services/cdn/distribution/resource.go b/stackit/internal/services/cdn/distribution/resource.go index 24ac67db6..6aa32ba39 100644 --- a/stackit/internal/services/cdn/distribution/resource.go +++ b/stackit/internal/services/cdn/distribution/resource.go @@ -724,15 +724,17 @@ 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) + } } } @@ -743,15 +745,17 @@ func mapFields(ctx context.Context, distribution *cdn.Distribution, model *Model } 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 + } } } @@ -951,8 +955,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) 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) From e3f736428cdfb233ef9998c67cb88a374f7f96d6 Mon Sep 17 00:00:00 2001 From: Matheus Politano Date: Tue, 3 Feb 2026 17:03:12 +0100 Subject: [PATCH 06/15] add bucket in datasource --- .../internal/services/cdn/distribution/datasource.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/stackit/internal/services/cdn/distribution/datasource.go b/stackit/internal/services/cdn/distribution/datasource.go index ce3f749c7..8bb9cce4e 100644 --- a/stackit/internal/services/cdn/distribution/datasource.go +++ b/stackit/internal/services/cdn/distribution/datasource.go @@ -56,7 +56,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 +149,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{ From 00aa5257df7b490eb75046cfaf3c5408b3e2a360 Mon Sep 17 00:00:00 2001 From: Matheus Politano Date: Tue, 3 Feb 2026 18:10:47 +0100 Subject: [PATCH 07/15] implement a new logic to blockUntilDomainResolves --- stackit/internal/services/cdn/cdn_acc_test.go | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/stackit/internal/services/cdn/cdn_acc_test.go b/stackit/internal/services/cdn/cdn_acc_test.go index 0dd031a5b..0987ad35c 100644 --- a/stackit/internal/services/cdn/cdn_acc_test.go +++ b/stackit/internal/services/cdn/cdn_acc_test.go @@ -376,9 +376,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 +407,7 @@ func blockUntilDomainResolves(domain string) (net.IP, error) { } return nil, fmt.Errorf("no IP for domain: %v", domain) } + return retry(recordCheckAttempts, recordCheckInterval, isReady) } From 1c250c01e541fdc95acb9f3f5e09da749df2e316 Mon Sep 17 00:00:00 2001 From: Matheus Politano Date: Tue, 3 Feb 2026 18:11:13 +0100 Subject: [PATCH 08/15] implement own mapDataSourceFields to data source --- .../services/cdn/distribution/datasource.go | 205 +++++++++++++++++- 1 file changed, 204 insertions(+), 1 deletion(-) diff --git a/stackit/internal/services/cdn/distribution/datasource.go b/stackit/internal/services/cdn/distribution/datasource.go index 8bb9cce4e..1baaeb603 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 } @@ -212,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 @@ -220,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 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 +} From 37d5712640323aebfd151588396f0dfba74b18a9 Mon Sep 17 00:00:00 2001 From: Matheus Politano Date: Tue, 3 Feb 2026 18:27:29 +0100 Subject: [PATCH 09/15] add new cdn acc test --- stackit/internal/services/cdn/cdn_acc_test.go | 106 ++++++++++++++++++ .../services/cdn/distribution/resource.go | 2 +- 2 files changed, 107 insertions(+), 1 deletion(-) diff --git a/stackit/internal/services/cdn/cdn_acc_test.go b/stackit/internal/services/cdn/cdn_acc_test.go index 0987ad35c..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 diff --git a/stackit/internal/services/cdn/distribution/resource.go b/stackit/internal/services/cdn/distribution/resource.go index 6aa32ba39..13405e58c 100644 --- a/stackit/internal/services/cdn/distribution/resource.go +++ b/stackit/internal/services/cdn/distribution/resource.go @@ -266,7 +266,7 @@ 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"], }, "origin_request_headers": schema.MapAttribute{ From 4e7bf7c038977ebfdd93853886a0a696c5c02894 Mon Sep 17 00:00:00 2001 From: Matheus Politano Date: Tue, 3 Feb 2026 18:39:58 +0100 Subject: [PATCH 10/15] generate docs --- docs/data-sources/cdn_distribution.md | 4 +++- docs/resources/cdn_distribution.md | 8 ++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) 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..3c6bd9d89 100644 --- a/docs/resources/cdn_distribution.md +++ b/docs/resources/cdn_distribution.md @@ -78,13 +78,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'. From 669b49547928ac016cd1c46bc6ff5240a117e886 Mon Sep 17 00:00:00 2001 From: Matheus Politano Date: Tue, 3 Feb 2026 18:42:54 +0100 Subject: [PATCH 11/15] add example --- .../stackit_cdn_distribution/resource.tf | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) 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 From 276913b30f07bc52a1dc02ab20403800bd994704 Mon Sep 17 00:00:00 2001 From: Matheus Politano Date: Wed, 4 Feb 2026 15:20:07 +0100 Subject: [PATCH 12/15] add more validations --- .../services/cdn/distribution/resource.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/stackit/internal/services/cdn/distribution/resource.go b/stackit/internal/services/cdn/distribution/resource.go index 13405e58c..5decb0afa 100644 --- a/stackit/internal/services/cdn/distribution/resource.go +++ b/stackit/internal/services/cdn/distribution/resource.go @@ -268,6 +268,15 @@ func (r *distributionResource) Schema(_ context.Context, _ resource.SchemaReques "origin_url": schema.StringAttribute{ 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, @@ -289,21 +298,28 @@ func (r *distributionResource) Schema(_ context.Context, _ resource.SchemaReques 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")))}, }, }, }, From 1a272ea1a700753d94514c1c28db55f79c1e3d21 Mon Sep 17 00:00:00 2001 From: Matheus Politano Date: Wed, 4 Feb 2026 15:20:22 +0100 Subject: [PATCH 13/15] create a test to new mapdatasource function --- .../cdn/distribution/datasource_test.go | 267 ++++++++++++++++++ 1 file changed, 267 insertions(+) create mode 100644 stackit/internal/services/cdn/distribution/datasource_test.go 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) + } + } + }) + } +} From 152e430151dd7afa823d2b567126d8fc416b0c14 Mon Sep 17 00:00:00 2001 From: Matheus Politano Date: Wed, 4 Feb 2026 15:21:22 +0100 Subject: [PATCH 14/15] improve doc --- stackit/internal/services/cdn/distribution/datasource.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stackit/internal/services/cdn/distribution/datasource.go b/stackit/internal/services/cdn/distribution/datasource.go index 1baaeb603..47185f1a2 100644 --- a/stackit/internal/services/cdn/distribution/datasource.go +++ b/stackit/internal/services/cdn/distribution/datasource.go @@ -244,7 +244,7 @@ func (r *distributionDataSource) Read(ctx context.Context, req datasource.ReadRe } // mapDataSourceFields is a specialized version of mapFields for the Data Source. -// It uses dataSourceConfigTypes (excludes secrets) and skips state restoration logic. +// 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") From ae4dd605a5d107dc7418fccb9edc9f0b493ecb89 Mon Sep 17 00:00:00 2001 From: Matheus Politano Date: Wed, 4 Feb 2026 15:25:31 +0100 Subject: [PATCH 15/15] update docs --- docs/resources/cdn_distribution.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docs/resources/cdn_distribution.md b/docs/resources/cdn_distribution.md index 3c6bd9d89..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