Skip to content

Commit bf9b225

Browse files
feat(cdn): add geoblocking (#906)
relates to STACKITCDN-841
1 parent 6f33262 commit bf9b225

File tree

9 files changed

+287
-44
lines changed

9 files changed

+287
-44
lines changed

docs/data-sources/cdn_distribution.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ data "stackit_cdn_distribution" "example" {
4343
<a id="nestedatt--config"></a>
4444
### Nested Schema for `config`
4545

46+
Optional:
47+
48+
- `blocked_countries` (List of String) The configured countries where distribution of content is blocked
49+
4650
Read-Only:
4751

4852
- `backend` (Attributes) The configured backend for the distribution (see [below for nested schema](#nestedatt--config--backend))

docs/guides/stackit_cdn_with_custom_domain.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ This guide outlines the process of creating a STACKIT CDN distribution and confi
2121
type = "http"
2222
origin_url = "mybackend.onstackit.cloud"
2323
}
24-
regions = ["EU", "US", "ASIA", "AF", "SA"]
24+
regions = ["EU", "US", "ASIA", "AF", "SA"]
25+
blocked_countries = ["DE", "AT", "CH"]
2526
}
2627
}
2728

docs/resources/cdn_distribution.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ resource "stackit_cdn_distribution" "example_distribution" {
2323
type = "http"
2424
origin_url = "mybackend.onstackit.cloud"
2525
}
26-
regions = ["EU", "US", "ASIA", "AF", "SA"]
26+
regions = ["EU", "US", "ASIA", "AF", "SA"]
27+
blocked_countries = ["DE", "AT", "CH"]
28+
2729
optimizer = {
2830
enabled = true
2931
}
@@ -59,6 +61,7 @@ Required:
5961

6062
Optional:
6163

64+
- `blocked_countries` (List of String) The configured countries where distribution of content is blocked
6265
- `optimizer` (Attributes) Configuration for the Image Optimizer. This is a paid feature that automatically optimizes images to reduce their file size for faster delivery, leading to improved website performance and a better user experience. (see [below for nested schema](#nestedatt--config--optimizer))
6366

6467
<a id="nestedatt--config--backend"></a>

examples/resources/stackit_cdn_distribution/resource.tf

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ resource "stackit_cdn_distribution" "example_distribution" {
55
type = "http"
66
origin_url = "mybackend.onstackit.cloud"
77
}
8-
regions = ["EU", "US", "ASIA", "AF", "SA"]
8+
regions = ["EU", "US", "ASIA", "AF", "SA"]
9+
blocked_countries = ["DE", "AT", "CH"]
10+
911
optimizer = {
1012
enabled = true
1113
}

stackit/internal/services/cdn/cdn_acc_test.go

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ var instanceResource = map[string]string{
2424
"config_backend_origin_url": "https://test-backend-1.cdn-dev.runs.onstackit.cloud",
2525
"config_regions": "\"EU\", \"US\"",
2626
"config_regions_updated": "\"EU\", \"US\", \"ASIA\"",
27+
"blocked_countries": "\"CU\", \"AQ\"", // Do NOT use DE or AT here, because the request might be blocked by bunny at the time of creation - don't lock yourself out
2728
"custom_domain_prefix": uuid.NewString(), // we use a different domain prefix each test run due to inconsistent upstream release of domains, which might impair consecutive test runs
2829
}
2930

@@ -38,7 +39,9 @@ func configResources(regions string) string {
3839
type = "http"
3940
origin_url = "%s"
4041
}
41-
regions = [%s]
42+
regions = [%s]
43+
blocked_countries = [%s]
44+
4245
optimizer = {
4346
enabled = true
4447
}
@@ -60,7 +63,9 @@ func configResources(regions string) string {
6063
type = "CNAME"
6164
records = ["${stackit_cdn_distribution.distribution.domains[0].name}."]
6265
}
63-
`, testutil.CdnProviderConfig(), testutil.ProjectId, instanceResource["config_backend_origin_url"], regions, testutil.ProjectId, testutil.ProjectId, instanceResource["custom_domain_prefix"])
66+
`, testutil.CdnProviderConfig(), testutil.ProjectId, instanceResource["config_backend_origin_url"],
67+
regions, instanceResource["blocked_countries"], testutil.ProjectId,
68+
testutil.ProjectId, instanceResource["custom_domain_prefix"])
6469
}
6570

6671
func configCustomDomainResources(regions string) string {
@@ -111,6 +116,9 @@ func TestAccCDNDistributionResource(t *testing.T) {
111116
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.regions.#", "2"),
112117
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.regions.0", "EU"),
113118
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.regions.1", "US"),
119+
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.blocked_countries.#", "2"),
120+
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.blocked_countries.0", "CU"),
121+
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.blocked_countries.1", "AQ"),
114122
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.optimizer.enabled", "true"),
115123
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "project_id", testutil.ProjectId),
116124
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "status", "ACTIVE"),
@@ -191,6 +199,9 @@ func TestAccCDNDistributionResource(t *testing.T) {
191199
resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "config.regions.#", "2"),
192200
resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "config.regions.0", "EU"),
193201
resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "config.regions.1", "US"),
202+
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.blocked_countries.#", "2"),
203+
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.blocked_countries.0", "CU"),
204+
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.blocked_countries.1", "AQ"),
194205
resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "config.optimizer.enabled", "true"),
195206
resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "project_id", testutil.ProjectId),
196207
resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "status", "ACTIVE"),
@@ -217,6 +228,9 @@ func TestAccCDNDistributionResource(t *testing.T) {
217228
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.regions.0", "EU"),
218229
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.regions.1", "US"),
219230
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.regions.2", "ASIA"),
231+
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.blocked_countries.#", "2"),
232+
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.blocked_countries.0", "CU"),
233+
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.blocked_countries.1", "AQ"),
220234
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.optimizer.enabled", "true"),
221235
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "project_id", testutil.ProjectId),
222236
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "status", "ACTIVE"),

stackit/internal/services/cdn/distribution/datasource.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,11 @@ func (r *distributionDataSource) Schema(_ context.Context, _ datasource.SchemaRe
149149
Description: schemaDescriptions["config_regions"],
150150
ElementType: types.StringType,
151151
},
152+
"blocked_countries": schema.ListAttribute{
153+
Optional: true,
154+
Description: schemaDescriptions["config_blocked_countries"],
155+
ElementType: types.StringType,
156+
},
152157
"optimizer": schema.SingleNestedAttribute{
153158
Description: schemaDescriptions["config_optimizer"],
154159
Computed: true,

stackit/internal/services/cdn/distribution/resource.go

Lines changed: 101 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ var schemaDescriptions = map[string]string{
5757
"config_optimizer": "Configuration for the Image Optimizer. This is a paid feature that automatically optimizes images to reduce their file size for faster delivery, leading to improved website performance and a better user experience.",
5858
"config_backend_origin_url": "The configured backend type for the distribution",
5959
"config_backend_origin_request_headers": "The configured origin request headers for the backend",
60+
"config_blocked_countries": "The configured countries where distribution of content is blocked",
6061
"domain_name": "The name of the domain",
6162
"domain_status": "The status of the domain",
6263
"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",
@@ -76,22 +77,26 @@ type Model struct {
7677
}
7778

7879
type distributionConfig struct {
79-
Backend backend `tfsdk:"backend"` // The backend associated with the distribution
80-
Regions *[]string `tfsdk:"regions"` // The regions in which data will be cached
81-
Optimizer types.Object `tfsdk:"optimizer"` // The optimizer configuration
80+
Backend backend `tfsdk:"backend"` // The backend associated with the distribution
81+
Regions *[]string `tfsdk:"regions"` // The regions in which data will be cached
82+
BlockedCountries *[]string `tfsdk:"blocked_countries"` // The countries for which content will be blocked
83+
Optimizer types.Object `tfsdk:"optimizer"` // The optimizer configuration
8284
}
85+
8386
type optimizerConfig struct {
8487
Enabled types.Bool `tfsdk:"enabled"`
8588
}
89+
8690
type backend struct {
8791
Type string `tfsdk:"type"` // The type of the backend. Currently, only "http" backend is supported
8892
OriginURL string `tfsdk:"origin_url"` // The origin URL of the backend
8993
OriginRequestHeaders *map[string]string `tfsdk:"origin_request_headers"` // Request headers that should be added by the CDN distribution to incoming requests
9094
}
9195

9296
var configTypes = map[string]attr.Type{
93-
"backend": types.ObjectType{AttrTypes: backendTypes},
94-
"regions": types.ListType{ElemType: types.StringType},
97+
"backend": types.ObjectType{AttrTypes: backendTypes},
98+
"regions": types.ListType{ElemType: types.StringType},
99+
"blocked_countries": types.ListType{ElemType: types.StringType},
95100
"optimizer": types.ObjectType{
96101
AttrTypes: optimizerTypes,
97102
},
@@ -258,6 +263,11 @@ func (r *distributionResource) Schema(_ context.Context, _ resource.SchemaReques
258263
Description: schemaDescriptions["config_regions"],
259264
ElementType: types.StringType,
260265
},
266+
"blocked_countries": schema.ListAttribute{
267+
Optional: true,
268+
Description: schemaDescriptions["config_blocked_countries"],
269+
ElementType: types.StringType,
270+
},
261271
},
262272
},
263273
},
@@ -378,6 +388,26 @@ func (r *distributionResource) Update(ctx context.Context, req resource.UpdateRe
378388
regions = append(regions, *regionEnum)
379389
}
380390

391+
// blockedCountries
392+
// Use a pointer to a slice to distinguish between an empty list (unblock all) and nil (no change).
393+
var blockedCountries *[]string
394+
if configModel.BlockedCountries != nil {
395+
// Use a temporary slice
396+
tempBlockedCountries := []string{}
397+
398+
for _, blockedCountry := range *configModel.BlockedCountries {
399+
validatedBlockedCountry, err := validateCountryCode(blockedCountry)
400+
if err != nil {
401+
core.LogAndAddError(ctx, &resp.Diagnostics, "Update CDN distribution", fmt.Sprintf("Blocked countries: %v", err))
402+
return
403+
}
404+
tempBlockedCountries = append(tempBlockedCountries, validatedBlockedCountry)
405+
}
406+
407+
// Point to the populated slice
408+
blockedCountries = &tempBlockedCountries
409+
}
410+
381411
configPatch := &cdn.ConfigPatch{
382412
Backend: &cdn.ConfigPatchBackend{
383413
HttpBackendPatch: &cdn.HttpBackendPatch{
@@ -386,7 +416,8 @@ func (r *distributionResource) Update(ctx context.Context, req resource.UpdateRe
386416
Type: &configModel.Backend.Type,
387417
},
388418
},
389-
Regions: &regions,
419+
Regions: &regions,
420+
BlockedCountries: blockedCountries,
390421
}
391422

392423
if !utils.IsUndefined(configModel.Optimizer) {
@@ -411,7 +442,9 @@ func (r *distributionResource) Update(ctx context.Context, req resource.UpdateRe
411442
}).Execute()
412443
if err != nil {
413444
core.LogAndAddError(ctx, &resp.Diagnostics, "Update CDN distribution", fmt.Sprintf("Patch distribution: %v", err))
445+
return
414446
}
447+
415448
waitResp, err := wait.UpdateDistributionWaitHandler(ctx, r.client, projectId, distributionId).WaitWithContext(ctx)
416449
if err != nil {
417450
core.LogAndAddError(ctx, &resp.Diagnostics, "Update CDN distribution", fmt.Sprintf("Waiting for update: %v", err))
@@ -423,6 +456,7 @@ func (r *distributionResource) Update(ctx context.Context, req resource.UpdateRe
423456
core.LogAndAddError(ctx, &resp.Diagnostics, "Update CDN distribution", fmt.Sprintf("Processing API payload: %v", err))
424457
return
425458
}
459+
426460
diags = resp.State.Set(ctx, model)
427461
resp.Diagnostics.Append(diags...)
428462
if resp.Diagnostics.HasError() {
@@ -501,6 +535,7 @@ func mapFields(distribution *cdn.Distribution, model *Model) error {
501535
model.CreatedAt = types.StringValue(distribution.CreatedAt.String())
502536
model.UpdatedAt = types.StringValue(distribution.UpdatedAt.String())
503537

538+
// distributionErrors
504539
distributionErrors := []attr.Value{}
505540
if distribution.Errors != nil {
506541
for _, e := range *distribution.Errors {
@@ -513,6 +548,7 @@ func mapFields(distribution *cdn.Distribution, model *Model) error {
513548
}
514549
model.Errors = modelErrors
515550

551+
// regions
516552
regions := []attr.Value{}
517553
for _, r := range *distribution.Config.Regions {
518554
regions = append(regions, types.StringValue(string(r)))
@@ -521,6 +557,21 @@ func mapFields(distribution *cdn.Distribution, model *Model) error {
521557
if diags.HasError() {
522558
return core.DiagsToError(diags)
523559
}
560+
561+
// blockedCountries
562+
var blockedCountries []attr.Value
563+
if distribution.Config != nil && distribution.Config.BlockedCountries != nil {
564+
for _, c := range *distribution.Config.BlockedCountries {
565+
blockedCountries = append(blockedCountries, types.StringValue(string(c)))
566+
}
567+
}
568+
569+
modelBlockedCountries, diags := types.ListValue(types.StringType, blockedCountries)
570+
if diags.HasError() {
571+
return core.DiagsToError(diags)
572+
}
573+
574+
// originRequestHeaders
524575
originRequestHeaders := types.MapNull(types.StringType)
525576
if origHeaders := distribution.Config.Backend.HttpBackend.OriginRequestHeaders; origHeaders != nil && len(*origHeaders) > 0 {
526577
headers := map[string]attr.Value{}
@@ -557,9 +608,10 @@ func mapFields(distribution *cdn.Distribution, model *Model) error {
557608
}
558609
}
559610
cfg, diags := types.ObjectValue(configTypes, map[string]attr.Value{
560-
"backend": backend,
561-
"regions": modelRegions,
562-
"optimizer": optimizerVal,
611+
"backend": backend,
612+
"regions": modelRegions,
613+
"blocked_countries": modelBlockedCountries,
614+
"optimizer": optimizerVal,
563615
})
564616
if diags.HasError() {
565617
return core.DiagsToError(diags)
@@ -624,6 +676,7 @@ func toCreatePayload(ctx context.Context, model *Model) (*cdn.CreateDistribution
624676
IntentId: cdn.PtrString(uuid.NewString()),
625677
OriginUrl: cfg.Backend.HttpBackend.OriginUrl,
626678
Regions: cfg.Regions,
679+
BlockedCountries: cfg.BlockedCountries,
627680
OriginRequestHeaders: cfg.Backend.HttpBackend.OriginRequestHeaders,
628681
Optimizer: optimizer,
629682
}
@@ -646,6 +699,8 @@ func convertConfig(ctx context.Context, model *Model) (*cdn.Config, error) {
646699
if diags.HasError() {
647700
return nil, core.DiagsToError(diags)
648701
}
702+
703+
// regions
649704
regions := []cdn.Region{}
650705
for _, r := range *configModel.Regions {
651706
regionEnum, err := cdn.NewRegionFromValue(r)
@@ -655,6 +710,19 @@ func convertConfig(ctx context.Context, model *Model) (*cdn.Config, error) {
655710
regions = append(regions, *regionEnum)
656711
}
657712

713+
// blockedCountries
714+
var blockedCountries []string
715+
if configModel.BlockedCountries != nil {
716+
for _, blockedCountry := range *configModel.BlockedCountries {
717+
validatedBlockedCountry, err := validateCountryCode(blockedCountry)
718+
if err != nil {
719+
return nil, err
720+
}
721+
blockedCountries = append(blockedCountries, validatedBlockedCountry)
722+
}
723+
}
724+
725+
// originRequestHeaders
658726
originRequestHeaders := map[string]string{}
659727
if configModel.Backend.OriginRequestHeaders != nil {
660728
for k, v := range *configModel.Backend.OriginRequestHeaders {
@@ -670,7 +738,8 @@ func convertConfig(ctx context.Context, model *Model) (*cdn.Config, error) {
670738
Type: &configModel.Backend.Type,
671739
},
672740
},
673-
Regions: &regions,
741+
Regions: &regions,
742+
BlockedCountries: &blockedCountries,
674743
}
675744

676745
if !utils.IsUndefined(configModel.Optimizer) {
@@ -687,3 +756,25 @@ func convertConfig(ctx context.Context, model *Model) (*cdn.Config, error) {
687756

688757
return cdnConfig, nil
689758
}
759+
760+
// validateCountryCode checks for a valid country user input. This is just a quick check
761+
// since the API already does a more thorough check.
762+
func validateCountryCode(country string) (string, error) {
763+
if len(country) != 2 {
764+
return "", errors.New("country code must be exactly 2 characters long")
765+
}
766+
767+
upperCountry := strings.ToUpper(country)
768+
769+
// Check if both characters are alphabetical letters within the ASCII range A-Z.
770+
// Yes, we could use the unicode package, but we are only targeting ASCII letters specifically, so
771+
// let's omit this dependency.
772+
char1 := upperCountry[0]
773+
char2 := upperCountry[1]
774+
775+
if !((char1 >= 'A' && char1 <= 'Z') && (char2 >= 'A' && char2 <= 'Z')) {
776+
return "", fmt.Errorf("country code '%s' must consist of two alphabetical letters (A-Z or a-z)", country)
777+
}
778+
779+
return upperCountry, nil
780+
}

0 commit comments

Comments
 (0)