From af17e1cc21d7bb846ff31adb5be2320d39941a6c Mon Sep 17 00:00:00 2001 From: Matheus Politano Date: Tue, 23 Sep 2025 09:30:14 +0200 Subject: [PATCH 1/7] add CDN geofencing --- docs/data-sources/cdn_distribution.md | 4 ++ docs/resources/cdn_distribution.md | 1 + .../stackit_cdn_distribution/resource.tf | 3 + stackit/internal/services/cdn/cdn_acc_test.go | 10 ++- .../services/cdn/distribution/datasource.go | 7 +++ .../services/cdn/distribution/resource.go | 61 ++++++++++++++++++- .../cdn/distribution/resource_test.go | 54 ++++++++++++++++ 7 files changed, 136 insertions(+), 4 deletions(-) diff --git a/docs/data-sources/cdn_distribution.md b/docs/data-sources/cdn_distribution.md index 84791109f..6e84fba08 100644 --- a/docs/data-sources/cdn_distribution.md +++ b/docs/data-sources/cdn_distribution.md @@ -56,6 +56,10 @@ Read-Only: ### Nested Schema for `config.backend` +Optional: + +- `geofencing` (Map of List of String) A map of URLs to a list of countries where content is allowed. + Read-Only: - `origin_request_headers` (Map of String) The configured origin request headers for the backend diff --git a/docs/resources/cdn_distribution.md b/docs/resources/cdn_distribution.md index 4f8de4c1b..42ef9d8b1 100644 --- a/docs/resources/cdn_distribution.md +++ b/docs/resources/cdn_distribution.md @@ -80,6 +80,7 @@ Required: Optional: +- `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 diff --git a/examples/resources/stackit_cdn_distribution/resource.tf b/examples/resources/stackit_cdn_distribution/resource.tf index 9356ca1e9..e69a7e619 100644 --- a/examples/resources/stackit_cdn_distribution/resource.tf +++ b/examples/resources/stackit_cdn_distribution/resource.tf @@ -4,6 +4,9 @@ resource "stackit_cdn_distribution" "example_distribution" { backend = { type = "http" origin_url = "https://mybackend.onstackit.cloud" + geofencing = { + "https://mybackend.onstackit.cloud" = ["DE"] + } } regions = ["EU", "US", "ASIA", "AF", "SA"] blocked_countries = ["DE", "AT", "CH"] diff --git a/stackit/internal/services/cdn/cdn_acc_test.go b/stackit/internal/services/cdn/cdn_acc_test.go index c2feec3e1..bb0100bd1 100644 --- a/stackit/internal/services/cdn/cdn_acc_test.go +++ b/stackit/internal/services/cdn/cdn_acc_test.go @@ -45,6 +45,9 @@ func configResources(regions string) string { backend = { type = "http" origin_url = "%s" + geofencing = { + "%s" = ["DE"] + } } regions = [%s] blocked_countries = [%s] @@ -70,7 +73,7 @@ func configResources(regions string) string { type = "CNAME" records = ["${stackit_cdn_distribution.distribution.domains[0].name}."] } - `, testutil.CdnProviderConfig(), testutil.ProjectId, instanceResource["config_backend_origin_url"], + `, testutil.CdnProviderConfig(), testutil.ProjectId, instanceResource["config_backend_origin_url"], instanceResource["config_backend_origin_url"], regions, instanceResource["blocked_countries"], testutil.ProjectId, instanceResource["dns_name"], testutil.ProjectId, instanceResource["custom_domain_prefix"]) } @@ -173,6 +176,11 @@ func TestAccCDNDistributionResource(t *testing.T) { resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.blocked_countries.#", "2"), resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.blocked_countries.0", "CU"), resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.blocked_countries.1", "AQ"), + resource.TestCheckResourceAttr( + "stackit_cdn_distribution.distribution", + fmt.Sprintf("config.backend.geofencing.%s.0", instanceResource["config_backend_origin_url"]), + "DE", + ), resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.optimizer.enabled", "true"), resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "project_id", testutil.ProjectId), resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "status", "ACTIVE"), diff --git a/stackit/internal/services/cdn/distribution/datasource.go b/stackit/internal/services/cdn/distribution/datasource.go index 590af1441..3a7ad4dd7 100644 --- a/stackit/internal/services/cdn/distribution/datasource.go +++ b/stackit/internal/services/cdn/distribution/datasource.go @@ -142,6 +142,13 @@ func (r *distributionDataSource) Schema(_ context.Context, _ datasource.SchemaRe Description: schemaDescriptions["config_backend_origin_request_headers"], ElementType: types.StringType, }, + "geofencing": schema.MapAttribute{ + Description: "A map of URLs to a list of countries where content is allowed.", + Optional: true, + ElementType: types.ListType{ + ElemType: types.StringType, + }, + }, }, }, "regions": schema.ListAttribute{ diff --git a/stackit/internal/services/cdn/distribution/resource.go b/stackit/internal/services/cdn/distribution/resource.go index 8c4125f75..6a31f9122 100644 --- a/stackit/internal/services/cdn/distribution/resource.go +++ b/stackit/internal/services/cdn/distribution/resource.go @@ -88,9 +88,10 @@ type optimizerConfig struct { } type backend struct { - Type string `tfsdk:"type"` // The type of the backend. Currently, only "http" backend is supported - OriginURL string `tfsdk:"origin_url"` // The origin URL of the backend - OriginRequestHeaders *map[string]string `tfsdk:"origin_request_headers"` // Request headers that should be added by the CDN distribution to incoming requests + Type string `tfsdk:"type"` // The type of the backend. Currently, only "http" backend is supported + OriginURL string `tfsdk:"origin_url"` // The origin URL of the backend + OriginRequestHeaders *map[string]string `tfsdk:"origin_request_headers"` // Request headers that should be added by the CDN distribution to incoming requests + Geofencing *map[string][]string `tfsdk:"geofencing"` // The geofencing is an object mapping multiple alternative origins to country codes. } var configTypes = map[string]attr.Type{ @@ -106,10 +107,15 @@ var optimizerTypes = map[string]attr.Type{ "enabled": types.BoolType, } +var geofencingTypes = types.MapType{ElemType: types.ListType{ + ElemType: types.StringType, +}} + var backendTypes = map[string]attr.Type{ "type": types.StringType, "origin_url": types.StringType, "origin_request_headers": types.MapType{ElemType: types.StringType}, + "geofencing": geofencingTypes, } var domainTypes = map[string]attr.Type{ @@ -256,6 +262,13 @@ func (r *distributionResource) Schema(_ context.Context, _ resource.SchemaReques Description: schemaDescriptions["config_backend_origin_request_headers"], ElementType: types.StringType, }, + "geofencing": schema.MapAttribute{ + Description: "A map of URLs to a list of countries where content is allowed.", + Optional: true, + ElementType: types.ListType{ + ElemType: types.StringType, + }, + }, }, }, "regions": schema.ListAttribute{ @@ -414,6 +427,7 @@ func (r *distributionResource) Update(ctx context.Context, req resource.UpdateRe OriginRequestHeaders: configModel.Backend.OriginRequestHeaders, OriginUrl: &configModel.Backend.OriginURL, Type: &configModel.Backend.Type, + Geofencing: configModel.Backend.Geofencing, }, }, Regions: ®ions, @@ -584,11 +598,34 @@ func mapFields(distribution *cdn.Distribution, model *Model) error { return core.DiagsToError(diags) } } + // geofencing + geofencingVal := types.MapNull(geofencingTypes.ElemType) + if geofencingAPI := distribution.Config.Backend.HttpBackend.Geofencing; geofencingAPI != nil && len(*geofencingAPI) > 0 { + geofencingMap := make(map[string]attr.Value) + for url, countries := range *geofencingAPI { + countryVals := []attr.Value{} + for _, country := range countries { + countryVals = append(countryVals, types.StringValue(country)) + } + listVal, diags := types.ListValue(types.StringType, countryVals) + if diags.HasError() { + return core.DiagsToError(diags) + } + geofencingMap[url] = listVal + } + + mappedGeofencing, diags := types.MapValue(geofencingTypes.ElemType, geofencingMap) + if diags.HasError() { + return core.DiagsToError(diags) + } + geofencingVal = mappedGeofencing + } // 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, }) if diags.HasError() { return core.DiagsToError(diags) @@ -678,6 +715,7 @@ func toCreatePayload(ctx context.Context, model *Model) (*cdn.CreateDistribution Regions: cfg.Regions, BlockedCountries: cfg.BlockedCountries, OriginRequestHeaders: cfg.Backend.HttpBackend.OriginRequestHeaders, + Geofencing: cfg.Backend.HttpBackend.Geofencing, Optimizer: optimizer, } @@ -722,6 +760,22 @@ func convertConfig(ctx context.Context, model *Model) (*cdn.Config, error) { } } + // geofencing + geofencing := map[string][]string{} + if configModel.Backend.Geofencing != nil { + for endpoint, countryCodes := range *configModel.Backend.Geofencing { + geofencingContry := make([]string, len(countryCodes)) + for i, countryCode := range countryCodes { + validatedBlockedCountry, err := validateCountryCode(countryCode) + if err != nil { + return nil, err + } + geofencingContry[i] = validatedBlockedCountry + } + geofencing[endpoint] = geofencingContry + } + } + // originRequestHeaders originRequestHeaders := map[string]string{} if configModel.Backend.OriginRequestHeaders != nil { @@ -736,6 +790,7 @@ func convertConfig(ctx context.Context, model *Model) (*cdn.Config, error) { OriginRequestHeaders: &originRequestHeaders, OriginUrl: &configModel.Backend.OriginURL, Type: &configModel.Backend.Type, + Geofencing: &geofencing, }, }, Regions: ®ions, diff --git a/stackit/internal/services/cdn/distribution/resource_test.go b/stackit/internal/services/cdn/distribution/resource_test.go index 13e55c7af..277acbde0 100644 --- a/stackit/internal/services/cdn/distribution/resource_test.go +++ b/stackit/internal/services/cdn/distribution/resource_test.go @@ -17,10 +17,18 @@ func TestToCreatePayload(t *testing.T) { "testHeader1": types.StringValue("testHeaderValue1"), } originRequestHeaders := types.MapValueMust(types.StringType, headers) + geofencingCountries := types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("DE"), + types.StringValue("FR"), + }) + geofencing := types.MapValueMust(geofencingTypes.ElemType, map[string]attr.Value{ + "https://de.mycoolapp.com": geofencingCountries, + }) backend := types.ObjectValueMust(backendTypes, map[string]attr.Value{ "type": types.StringValue("http"), "origin_url": types.StringValue("https://www.mycoolapp.com"), "origin_request_headers": originRequestHeaders, + "geofencing": geofencing, }) regions := []attr.Value{types.StringValue("EU"), types.StringValue("US")} regionsFixture := types.ListValueMust(types.StringType, regions) @@ -61,6 +69,9 @@ func TestToCreatePayload(t *testing.T) { OriginUrl: cdn.PtrString("https://www.mycoolapp.com"), Regions: &[]cdn.Region{"EU", "US"}, BlockedCountries: &[]string{"XX", "YY", "ZZ"}, + Geofencing: &map[string][]string{ + "https://de.mycoolapp.com": {"DE", "FR"}, + }, }, IsValid: true, }, @@ -82,6 +93,9 @@ func TestToCreatePayload(t *testing.T) { Regions: &[]cdn.Region{"EU", "US"}, Optimizer: cdn.NewOptimizer(true), BlockedCountries: &[]string{"XX", "YY", "ZZ"}, + Geofencing: &map[string][]string{ + "https://de.mycoolapp.com": {"DE", "FR"}, + }, }, IsValid: true, }, @@ -126,10 +140,18 @@ func TestConvertConfig(t *testing.T) { "testHeader1": types.StringValue("testHeaderValue1"), } originRequestHeaders := types.MapValueMust(types.StringType, headers) + geofencingCountries := types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("DE"), + types.StringValue("FR"), + }) + geofencing := types.MapValueMust(geofencingTypes.ElemType, map[string]attr.Value{ + "https://de.mycoolapp.com": geofencingCountries, + }) backend := types.ObjectValueMust(backendTypes, map[string]attr.Value{ "type": types.StringValue("http"), "origin_url": types.StringValue("https://www.mycoolapp.com"), "origin_request_headers": originRequestHeaders, + "geofencing": geofencing, }) regions := []attr.Value{types.StringValue("EU"), types.StringValue("US")} regionsFixture := types.ListValueMust(types.StringType, regions) @@ -169,6 +191,9 @@ func TestConvertConfig(t *testing.T) { }, OriginUrl: cdn.PtrString("https://www.mycoolapp.com"), Type: cdn.PtrString("http"), + Geofencing: &map[string][]string{ + "https://de.mycoolapp.com": {"DE", "FR"}, + }, }, }, Regions: &[]cdn.Region{"EU", "US"}, @@ -194,6 +219,9 @@ func TestConvertConfig(t *testing.T) { }, OriginUrl: cdn.PtrString("https://www.mycoolapp.com"), Type: cdn.PtrString("http"), + Geofencing: &map[string][]string{ + "https://de.mycoolapp.com": {"DE", "FR"}, + }, }, }, Regions: &[]cdn.Region{"EU", "US"}, @@ -246,11 +274,17 @@ func TestMapFields(t *testing.T) { "type": types.StringValue("http"), "origin_url": types.StringValue("https://www.mycoolapp.com"), "origin_request_headers": originRequestHeaders, + "geofencing": types.MapNull(geofencingTypes.ElemType), }) 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), }) @@ -347,6 +381,26 @@ func TestMapFields(t *testing.T) { }), IsValid: true, }, + "happy_path_with_geofencing": { + Expected: expectedModel(func(m *Model) { + backendWithGeofencing := types.ObjectValueMust(backendTypes, map[string]attr.Value{ + "type": types.StringValue("http"), + "origin_url": types.StringValue("https://www.mycoolapp.com"), + "origin_request_headers": originRequestHeaders, + "geofencing": geofencing, + }) + m.Config = types.ObjectValueMust(configTypes, 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") From b1815bd313b92358c80585a0ae16cf64e164a1c5 Mon Sep 17 00:00:00 2001 From: Politano <47066134+matheuspolitano@users.noreply.github.com> Date: Thu, 25 Sep 2025 09:30:11 +0200 Subject: [PATCH 2/7] Update stackit/internal/services/cdn/distribution/datasource.go Co-authored-by: Marcel Jacek <72880145+marceljk@users.noreply.github.com> --- 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 3a7ad4dd7..4e9c8a080 100644 --- a/stackit/internal/services/cdn/distribution/datasource.go +++ b/stackit/internal/services/cdn/distribution/datasource.go @@ -144,7 +144,7 @@ func (r *distributionDataSource) Schema(_ context.Context, _ datasource.SchemaRe }, "geofencing": schema.MapAttribute{ Description: "A map of URLs to a list of countries where content is allowed.", - Optional: true, + Computed: true, ElementType: types.ListType{ ElemType: types.StringType, }, From 52097533b8770b2b8a3fefe774ccb05562ec2150 Mon Sep 17 00:00:00 2001 From: Politano <47066134+matheuspolitano@users.noreply.github.com> Date: Thu, 25 Sep 2025 10:58:16 +0200 Subject: [PATCH 3/7] Update stackit/internal/services/cdn/distribution/resource.go Co-authored-by: Marcel Jacek <72880145+marceljk@users.noreply.github.com> --- stackit/internal/services/cdn/distribution/resource.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/stackit/internal/services/cdn/distribution/resource.go b/stackit/internal/services/cdn/distribution/resource.go index 6a31f9122..d247233c7 100644 --- a/stackit/internal/services/cdn/distribution/resource.go +++ b/stackit/internal/services/cdn/distribution/resource.go @@ -268,6 +268,9 @@ func (r *distributionResource) Schema(_ context.Context, _ resource.SchemaReques ElementType: types.ListType{ ElemType: types.StringType, }, + Validators: []validator.Map{ + mapvalidator.SizeAtLeast(1), + }, }, }, }, From d9311aacf8d2968ed8faa4259d679bc61381add7 Mon Sep 17 00:00:00 2001 From: Politano <47066134+matheuspolitano@users.noreply.github.com> Date: Thu, 25 Sep 2025 10:58:43 +0200 Subject: [PATCH 4/7] Update stackit/internal/services/cdn/distribution/resource.go Co-authored-by: Marcel Jacek <72880145+marceljk@users.noreply.github.com> --- stackit/internal/services/cdn/distribution/resource.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stackit/internal/services/cdn/distribution/resource.go b/stackit/internal/services/cdn/distribution/resource.go index d247233c7..8eaf55d6c 100644 --- a/stackit/internal/services/cdn/distribution/resource.go +++ b/stackit/internal/services/cdn/distribution/resource.go @@ -767,7 +767,7 @@ func convertConfig(ctx context.Context, model *Model) (*cdn.Config, error) { geofencing := map[string][]string{} if configModel.Backend.Geofencing != nil { for endpoint, countryCodes := range *configModel.Backend.Geofencing { - geofencingContry := make([]string, len(countryCodes)) + geofencingCountry := make([]string, len(countryCodes)) for i, countryCode := range countryCodes { validatedBlockedCountry, err := validateCountryCode(countryCode) if err != nil { From 090b1aa31aca1a6b906d9bc5c65c7c4a4ae857ae Mon Sep 17 00:00:00 2001 From: Politano <47066134+matheuspolitano@users.noreply.github.com> Date: Thu, 25 Sep 2025 10:58:55 +0200 Subject: [PATCH 5/7] Update stackit/internal/services/cdn/distribution/resource.go Co-authored-by: Marcel Jacek <72880145+marceljk@users.noreply.github.com> --- stackit/internal/services/cdn/distribution/resource.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stackit/internal/services/cdn/distribution/resource.go b/stackit/internal/services/cdn/distribution/resource.go index 8eaf55d6c..6e74b3966 100644 --- a/stackit/internal/services/cdn/distribution/resource.go +++ b/stackit/internal/services/cdn/distribution/resource.go @@ -91,7 +91,7 @@ type backend struct { Type string `tfsdk:"type"` // The type of the backend. Currently, only "http" backend is supported OriginURL string `tfsdk:"origin_url"` // The origin URL of the backend OriginRequestHeaders *map[string]string `tfsdk:"origin_request_headers"` // Request headers that should be added by the CDN distribution to incoming requests - Geofencing *map[string][]string `tfsdk:"geofencing"` // The geofencing is an object mapping multiple alternative origins to country codes. + Geofencing *map[string][]*string `tfsdk:"geofencing"` // The geofencing is an object mapping multiple alternative origins to country codes. } var configTypes = map[string]attr.Type{ From d18fa0abe79e7d615384a661183595eabc010d8f Mon Sep 17 00:00:00 2001 From: Matheus Politano Date: Fri, 26 Sep 2025 14:15:37 +0200 Subject: [PATCH 6/7] add ReconcileStringSlices in geofencing --- stackit/internal/services/cdn/cdn_acc_test.go | 36 ++++-- .../services/cdn/distribution/datasource.go | 2 +- .../services/cdn/distribution/resource.go | 105 +++++++++++++----- .../cdn/distribution/resource_test.go | 2 +- 4 files changed, 106 insertions(+), 39 deletions(-) diff --git a/stackit/internal/services/cdn/cdn_acc_test.go b/stackit/internal/services/cdn/cdn_acc_test.go index bb0100bd1..e5ad1634c 100644 --- a/stackit/internal/services/cdn/cdn_acc_test.go +++ b/stackit/internal/services/cdn/cdn_acc_test.go @@ -35,7 +35,13 @@ var instanceResource = map[string]string{ "dns_name": fmt.Sprintf("tf-acc-%s.stackit.gg", strings.Split(uuid.NewString(), "-")[0]), } -func configResources(regions string) string { +func configResources(regions string, geofencingCountries []string) string { + var quotedCountries []string + for _, country := range geofencingCountries { + quotedCountries = append(quotedCountries, fmt.Sprintf(`"%s"`, country)) + } + + geofencingList := strings.Join(quotedCountries, ",") return fmt.Sprintf(` %s @@ -46,7 +52,7 @@ func configResources(regions string) string { type = "http" origin_url = "%s" geofencing = { - "%s" = ["DE"] + "%s" = [%s] } } regions = [%s] @@ -73,12 +79,12 @@ func configResources(regions string) string { type = "CNAME" records = ["${stackit_cdn_distribution.distribution.domains[0].name}."] } - `, testutil.CdnProviderConfig(), testutil.ProjectId, instanceResource["config_backend_origin_url"], instanceResource["config_backend_origin_url"], + `, testutil.CdnProviderConfig(), testutil.ProjectId, instanceResource["config_backend_origin_url"], instanceResource["config_backend_origin_url"], geofencingList, regions, instanceResource["blocked_countries"], testutil.ProjectId, instanceResource["dns_name"], testutil.ProjectId, instanceResource["custom_domain_prefix"]) } -func configCustomDomainResources(regions, cert, key string) string { +func configCustomDomainResources(regions, cert, key string, geofencingCountries []string) string { return fmt.Sprintf(` %s @@ -91,10 +97,10 @@ func configCustomDomainResources(regions, cert, key string) string { private_key = %q } } -`, configResources(regions), cert, key) +`, configResources(regions, geofencingCountries), cert, key) } -func configDatasources(regions, cert, key string) string { +func configDatasources(regions, cert, key string, geofencingCountries []string) string { return fmt.Sprintf(` %s @@ -109,7 +115,7 @@ func configDatasources(regions, cert, key string) string { name = stackit_cdn_custom_domain.custom_domain.name } - `, configCustomDomainResources(regions, cert, key)) + `, configCustomDomainResources(regions, cert, key, geofencingCountries)) } func makeCertAndKey(t *testing.T, organization string) (cert, key []byte) { privateKey, err := rsa.GenerateKey(cryptoRand.Reader, 2048) @@ -152,6 +158,7 @@ func TestAccCDNDistributionResource(t *testing.T) { fullDomainName := fmt.Sprintf("%s.%s", instanceResource["custom_domain_prefix"], instanceResource["dns_name"]) organization := fmt.Sprintf("organization-%s", uuid.NewString()) cert, key := makeCertAndKey(t, organization) + gefencing := []string{"DE", "ES"} organization_updated := fmt.Sprintf("organization-updated-%s", uuid.NewString()) cert_updated, key_updated := makeCertAndKey(t, organization_updated) @@ -161,7 +168,7 @@ func TestAccCDNDistributionResource(t *testing.T) { Steps: []resource.TestStep{ // Distribution Create { - Config: configResources(instanceResource["config_regions"]), + Config: configResources(instanceResource["config_regions"], gefencing), Check: resource.ComposeAggregateTestCheckFunc( resource.TestCheckResourceAttrSet("stackit_cdn_distribution.distribution", "distribution_id"), resource.TestCheckResourceAttrSet("stackit_cdn_distribution.distribution", "created_at"), @@ -181,6 +188,11 @@ func TestAccCDNDistributionResource(t *testing.T) { fmt.Sprintf("config.backend.geofencing.%s.0", instanceResource["config_backend_origin_url"]), "DE", ), + resource.TestCheckResourceAttr( + "stackit_cdn_distribution.distribution", + fmt.Sprintf("config.backend.geofencing.%s.1", instanceResource["config_backend_origin_url"]), + "ES", + ), resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.optimizer.enabled", "true"), resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "project_id", testutil.ProjectId), resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "status", "ACTIVE"), @@ -188,7 +200,7 @@ func TestAccCDNDistributionResource(t *testing.T) { }, // Wait step, that confirms the CNAME record has "propagated" { - Config: configResources(instanceResource["config_regions"]), + Config: configResources(instanceResource["config_regions"], gefencing), Check: func(_ *terraform.State) error { _, err := blockUntilDomainResolves(fullDomainName) return err @@ -196,7 +208,7 @@ func TestAccCDNDistributionResource(t *testing.T) { }, // Custom Domain Create { - Config: configCustomDomainResources(instanceResource["config_regions"], string(cert), string(key)), + Config: configCustomDomainResources(instanceResource["config_regions"], string(cert), string(key), gefencing), Check: resource.ComposeAggregateTestCheckFunc( resource.TestCheckResourceAttr("stackit_cdn_custom_domain.custom_domain", "status", "ACTIVE"), resource.TestCheckResourceAttr("stackit_cdn_custom_domain.custom_domain", "name", fullDomainName), @@ -250,7 +262,7 @@ func TestAccCDNDistributionResource(t *testing.T) { }, // Data Source { - Config: configDatasources(instanceResource["config_regions"], string(cert), string(key)), + Config: configDatasources(instanceResource["config_regions"], string(cert), string(key), gefencing), Check: resource.ComposeAggregateTestCheckFunc( resource.TestCheckResourceAttrSet("data.stackit_cdn_distribution.distribution", "distribution_id"), resource.TestCheckResourceAttrSet("data.stackit_cdn_distribution.distribution", "created_at"), @@ -279,7 +291,7 @@ func TestAccCDNDistributionResource(t *testing.T) { }, // Update { - Config: configCustomDomainResources(instanceResource["config_regions_updated"], string(cert_updated), string(key_updated)), + Config: configCustomDomainResources(instanceResource["config_regions_updated"], string(cert_updated), string(key_updated), gefencing), Check: resource.ComposeAggregateTestCheckFunc( resource.TestCheckResourceAttrSet("stackit_cdn_distribution.distribution", "distribution_id"), resource.TestCheckResourceAttrSet("stackit_cdn_distribution.distribution", "created_at"), diff --git a/stackit/internal/services/cdn/distribution/datasource.go b/stackit/internal/services/cdn/distribution/datasource.go index 4e9c8a080..c111fcc9d 100644 --- a/stackit/internal/services/cdn/distribution/datasource.go +++ b/stackit/internal/services/cdn/distribution/datasource.go @@ -199,7 +199,7 @@ func (r *distributionDataSource) Read(ctx context.Context, req datasource.ReadRe resp.State.RemoveResource(ctx) return } - err = mapFields(distributionResp.Distribution, &model) + err = mapFields(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 diff --git a/stackit/internal/services/cdn/distribution/resource.go b/stackit/internal/services/cdn/distribution/resource.go index 6e74b3966..5aca53b15 100644 --- a/stackit/internal/services/cdn/distribution/resource.go +++ b/stackit/internal/services/cdn/distribution/resource.go @@ -8,10 +8,8 @@ import ( "strings" "time" - "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" - cdnUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/cdn/utils" - "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" "github.com/hashicorp/terraform-plugin-framework-validators/objectvalidator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/attr" @@ -28,8 +26,10 @@ import ( "github.com/stackitcloud/stackit-sdk-go/core/oapierror" "github.com/stackitcloud/stackit-sdk-go/services/cdn" "github.com/stackitcloud/stackit-sdk-go/services/cdn/wait" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features" + cdnUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/cdn/utils" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" ) @@ -88,9 +88,9 @@ type optimizerConfig struct { } type backend struct { - Type string `tfsdk:"type"` // The type of the backend. Currently, only "http" backend is supported - OriginURL string `tfsdk:"origin_url"` // The origin URL of the backend - OriginRequestHeaders *map[string]string `tfsdk:"origin_request_headers"` // Request headers that should be added by the CDN distribution to incoming requests + Type string `tfsdk:"type"` // The type of the backend. Currently, only "http" backend is supported + OriginURL string `tfsdk:"origin_url"` // The origin URL of the backend + OriginRequestHeaders *map[string]string `tfsdk:"origin_request_headers"` // Request headers that should be added by the CDN distribution to incoming requests Geofencing *map[string][]*string `tfsdk:"geofencing"` // The geofencing is an object mapping multiple alternative origins to country codes. } @@ -317,7 +317,7 @@ func (r *distributionResource) Create(ctx context.Context, req resource.CreateRe return } - err = mapFields(waitResp.Distribution, &model) + err = mapFields(ctx, waitResp.Distribution, &model) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating CDN distribution", fmt.Sprintf("Processing API payload: %v", err)) return @@ -357,7 +357,7 @@ func (r *distributionResource) Read(ctx context.Context, req resource.ReadReques core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading CDN distribution", fmt.Sprintf("Calling API: %v", err)) return } - err = mapFields(cdnResp.Distribution, &model) + err = mapFields(ctx, cdnResp.Distribution, &model) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading CDN ditribution", fmt.Sprintf("Processing API payload: %v", err)) return @@ -424,13 +424,28 @@ func (r *distributionResource) Update(ctx context.Context, req resource.UpdateRe blockedCountries = &tempBlockedCountries } + var 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 { + countryStrings[i] = *countryPtr + } + } + gf[url] = countryStrings + } + geofencingPatch = &gf + } + configPatch := &cdn.ConfigPatch{ Backend: &cdn.ConfigPatchBackend{ HttpBackendPatch: &cdn.HttpBackendPatch{ OriginRequestHeaders: configModel.Backend.OriginRequestHeaders, OriginUrl: &configModel.Backend.OriginURL, Type: &configModel.Backend.Type, - Geofencing: configModel.Backend.Geofencing, + Geofencing: geofencingPatch, // Use the converted variable }, }, Regions: ®ions, @@ -468,7 +483,7 @@ func (r *distributionResource) Update(ctx context.Context, req resource.UpdateRe return } - err = mapFields(waitResp.Distribution, &model) + err = mapFields(ctx, waitResp.Distribution, &model) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Update CDN distribution", fmt.Sprintf("Processing API payload: %v", err)) return @@ -517,7 +532,7 @@ func (r *distributionResource) ImportState(ctx context.Context, req resource.Imp tflog.Info(ctx, "CDN distribution state imported") } -func mapFields(distribution *cdn.Distribution, model *Model) error { +func mapFields(ctx context.Context, distribution *cdn.Distribution, model *Model) error { if distribution == nil { return fmt.Errorf("response input is nil") } @@ -601,28 +616,52 @@ func mapFields(distribution *cdn.Distribution, model *Model) error { return core.DiagsToError(diags) } } + // geofencing - geofencingVal := types.MapNull(geofencingTypes.ElemType) + 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 { - geofencingMap := make(map[string]attr.Value) - for url, countries := range *geofencingAPI { - countryVals := []attr.Value{} - for _, country := range countries { - countryVals = append(countryVals, types.StringValue(country)) - } - listVal, diags := types.ListValue(types.StringType, countryVals) + newGeofencingMap := *geofencingAPI + for url, newCountries := range newGeofencingMap { + oldCountriesPtrs := oldGeofencingMap[url] + + oldCountries := convertPointerSliceToStringSlice(oldCountriesPtrs) + + reconciledCountries := utils.ReconcileStringSlices(oldCountries, newCountries) + reconciledGeofencingData[url] = reconciledCountries + } + } + + geofencingVal := types.MapNull(geofencingTypes.ElemType) + if len(reconciledGeofencingData) > 0 { + geofencingMapElems := make(map[string]attr.Value) + for url, countries := range reconciledGeofencingData { + listVal, diags := types.ListValueFrom(ctx, types.StringType, countries) if diags.HasError() { return core.DiagsToError(diags) } - geofencingMap[url] = listVal + geofencingMapElems[url] = listVal } - mappedGeofencing, diags := types.MapValue(geofencingTypes.ElemType, geofencingMap) + var mappedGeofencing basetypes.MapValue + mappedGeofencing, diags = types.MapValue(geofencingTypes.ElemType, geofencingMapElems) if diags.HasError() { return core.DiagsToError(diags) } geofencingVal = mappedGeofencing } + // 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), @@ -699,6 +738,19 @@ func mapFields(distribution *cdn.Distribution, model *Model) error { return nil } +// convertPointerSliceToStringSlice safely converts a slice of string pointers to a slice of strings. +func convertPointerSliceToStringSlice(pointerSlice []*string) []string { + if pointerSlice == nil { + return nil // Or []string{} depending on how you want to handle it + } + stringSlice := make([]string, 0, len(pointerSlice)) + for _, strPtr := range pointerSlice { + if strPtr != nil { // Safely skip any nil pointers in the list + stringSlice = append(stringSlice, *strPtr) + } + } + return stringSlice +} func toCreatePayload(ctx context.Context, model *Model) (*cdn.CreateDistributionPayload, error) { if model == nil { return nil, fmt.Errorf("missing model") @@ -768,14 +820,17 @@ func convertConfig(ctx context.Context, model *Model) (*cdn.Config, error) { if configModel.Backend.Geofencing != nil { for endpoint, countryCodes := range *configModel.Backend.Geofencing { geofencingCountry := make([]string, len(countryCodes)) - for i, countryCode := range countryCodes { - validatedBlockedCountry, err := validateCountryCode(countryCode) + for i, countryCodePtr := range countryCodes { + if countryCodePtr == nil { + continue + } + validatedCountry, err := validateCountryCode(*countryCodePtr) if err != nil { return nil, err } - geofencingContry[i] = validatedBlockedCountry + geofencingCountry[i] = validatedCountry } - geofencing[endpoint] = geofencingContry + geofencing[endpoint] = geofencingCountry } } diff --git a/stackit/internal/services/cdn/distribution/resource_test.go b/stackit/internal/services/cdn/distribution/resource_test.go index 277acbde0..1a6394361 100644 --- a/stackit/internal/services/cdn/distribution/resource_test.go +++ b/stackit/internal/services/cdn/distribution/resource_test.go @@ -466,7 +466,7 @@ func TestMapFields(t *testing.T) { for tn, tc := range tests { t.Run(tn, func(t *testing.T) { model := &Model{} - err := mapFields(tc.Input, model) + err := mapFields(context.Background(), tc.Input, model) if err != nil && tc.IsValid { t.Fatalf("Error mapping fields: %v", err) } From c9980405bacdac9cb57b4933a62d5411b6987c2e Mon Sep 17 00:00:00 2001 From: Matheus Politano Date: Fri, 26 Sep 2025 14:23:05 +0200 Subject: [PATCH 7/7] check the data source geofencing in acc --- stackit/internal/services/cdn/cdn_acc_test.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/stackit/internal/services/cdn/cdn_acc_test.go b/stackit/internal/services/cdn/cdn_acc_test.go index e5ad1634c..805d45636 100644 --- a/stackit/internal/services/cdn/cdn_acc_test.go +++ b/stackit/internal/services/cdn/cdn_acc_test.go @@ -275,6 +275,16 @@ func TestAccCDNDistributionResource(t *testing.T) { resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "domains.0.type", "managed"), resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "domains.1.type", "custom"), resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "config.regions.#", "2"), + resource.TestCheckResourceAttr( + "data.stackit_cdn_distribution.distribution", + fmt.Sprintf("config.backend.geofencing.%s.0", instanceResource["config_backend_origin_url"]), + "DE", + ), + resource.TestCheckResourceAttr( + "data.stackit_cdn_distribution.distribution", + fmt.Sprintf("config.backend.geofencing.%s.1", instanceResource["config_backend_origin_url"]), + "ES", + ), resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "config.regions.0", "EU"), resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "config.regions.1", "US"), resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.blocked_countries.#", "2"),