Feature: CDN distribution resource and data source (#786)

* add datasource

* finish data source

* implement resource

* add unit tests

* add examples

* acceptance test

* review comments

* review comments 2

---------

Co-authored-by: Malte Ehrlen <malte.ehrlen@freiheit.com>
This commit is contained in:
Malte Ehrlen 2025-04-29 16:59:07 +03:00 committed by GitHub
parent 3c20b7743f
commit 855d3040ef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1520 additions and 0 deletions

View file

@ -0,0 +1,70 @@
---
# generated by https://github.com/hashicorp/terraform-plugin-docs
page_title: "stackit_cdn_distribution Data Source - stackit"
subcategory: ""
description: |-
CDN distribution data source schema.
~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our guide https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources for how to opt-in to use beta resources.
---
# stackit_cdn_distribution (Data Source)
CDN distribution data source schema.
~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our [guide](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources) for how to opt-in to use beta resources.
## Example Usage
```terraform
data "stackit_cdn_distribution" "example" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
distribution_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
```
<!-- schema generated by tfplugindocs -->
## Schema
### Required
- `distribution_id` (String) STACKIT project ID associated with the distribution
- `project_id` (String) STACKIT project ID associated with the distribution
### Read-Only
- `config` (Attributes) The distribution configuration (see [below for nested schema](#nestedatt--config))
- `created_at` (String) Time when the distribution was created
- `domains` (Attributes List) List of configured domains for the distribution (see [below for nested schema](#nestedatt--domains))
- `errors` (List of String) List of distribution errors
- `id` (String) Terraform's internal resource identifier. It is structured as "`project_id`,`distribution_id`".
- `status` (String) Status of the distribution
- `updated_at` (String) Time when the distribution was last updated
<a id="nestedatt--config"></a>
### Nested Schema for `config`
Read-Only:
- `backend` (Attributes) The configured backend for the distribution (see [below for nested schema](#nestedatt--config--backend))
- `regions` (List of String) The configured regions where content will be hosted
<a id="nestedatt--config--backend"></a>
### Nested Schema for `config.backend`
Read-Only:
- `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
<a id="nestedatt--domains"></a>
### Nested Schema for `domains`
Read-Only:
- `errors` (List of String) List of domain errors
- `name` (String) The name of the domain
- `status` (String) The status of the domain
- `type` (String) 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

View file

@ -153,6 +153,7 @@ Note: AWS specific checks must be skipped as they do not work on STACKIT. For de
- `argus_custom_endpoint` (String, Deprecated) Custom endpoint for the Argus service
- `authorization_custom_endpoint` (String) Custom endpoint for the Membership service
- `cdn_custom_endpoint` (String) Custom endpoint for the CDN service
- `credentials_path` (String) Path of JSON from where the credentials are read. Takes precedence over the env var `STACKIT_CREDENTIALS_PATH`. Default value is `~/.stackit/credentials.json`.
- `default_region` (String) Region will be used as the default location for regional services. Not all services require a region, some are global
- `dns_custom_endpoint` (String) Custom endpoint for the DNS service

View file

@ -0,0 +1,80 @@
---
# generated by https://github.com/hashicorp/terraform-plugin-docs
page_title: "stackit_cdn_distribution Resource - stackit"
subcategory: ""
description: |-
CDN distribution data source schema.
~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our guide https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources for how to opt-in to use beta resources.
---
# stackit_cdn_distribution (Resource)
CDN distribution data source schema.
~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our [guide](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources) for how to opt-in to use beta resources.
## Example Usage
```terraform
# Create a CDN distribution
resource "stackit_cdn_distribution" "example_distribution" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
config = {
backend = {
type = "http"
origin_url = "mybackend.onstackit.cloud"
}
regions = ["EN", "US", "ASIA", "AF", "SA"]
}
}
```
<!-- schema generated by tfplugindocs -->
## Schema
### Required
- `config` (Attributes) The distribution configuration (see [below for nested schema](#nestedatt--config))
- `project_id` (String) STACKIT project ID associated with the distribution
### Read-Only
- `created_at` (String) Time when the distribution was created
- `distribution_id` (String) STACKIT project ID associated with the distribution
- `domains` (Attributes List) List of configured domains for the distribution (see [below for nested schema](#nestedatt--domains))
- `errors` (List of String) List of distribution errors
- `id` (String) Terraform's internal resource identifier. It is structured as "`project_id`,`distribution_id`".
- `status` (String) Status of the distribution
- `updated_at` (String) Time when the distribution was last updated
<a id="nestedatt--config"></a>
### Nested Schema for `config`
Required:
- `backend` (Attributes) The configured backend for the distribution (see [below for nested schema](#nestedatt--config--backend))
- `regions` (List of String) The configured regions where content will be hosted
<a id="nestedatt--config--backend"></a>
### Nested Schema for `config.backend`
Required:
- `origin_url` (String) The configured backend type for the distribution
- `type` (String) The configured backend type
Optional:
- `origin_request_headers` (Map of String) The configured origin request headers for the backend
<a id="nestedatt--domains"></a>
### Nested Schema for `domains`
Read-Only:
- `errors` (List of String) List of domain errors
- `name` (String) The name of the domain
- `status` (String) The status of the domain
- `type` (String) 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

View file

@ -0,0 +1,5 @@
data "stackit_cdn_distribution" "example" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
distribution_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}

View file

@ -0,0 +1,11 @@
# Create a CDN distribution
resource "stackit_cdn_distribution" "example_distribution" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
config = {
backend = {
type = "http"
origin_url = "mybackend.onstackit.cloud"
}
regions = ["EN", "US", "ASIA", "AF", "SA"]
}
}

1
go.mod
View file

@ -12,6 +12,7 @@ require (
github.com/hashicorp/terraform-plugin-log v0.9.0
github.com/hashicorp/terraform-plugin-testing v1.12.0
github.com/stackitcloud/stackit-sdk-go/core v0.17.1
github.com/stackitcloud/stackit-sdk-go/services/cdn v0.3.0
github.com/stackitcloud/stackit-sdk-go/services/dns v0.13.1
github.com/stackitcloud/stackit-sdk-go/services/iaas v0.22.0
github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v1.0.1

2
go.sum
View file

@ -154,6 +154,8 @@ github.com/stackitcloud/stackit-sdk-go/core v0.17.1 h1:TTrVoB1lERd/qfWzpe6HpwCJS
github.com/stackitcloud/stackit-sdk-go/core v0.17.1/go.mod h1:8KIw3czdNJ9sdil9QQimxjR6vHjeINFrRv0iZ67wfn0=
github.com/stackitcloud/stackit-sdk-go/services/authorization v0.6.1 h1:2lq6SG8qOgPOx2OIA5Bca8mwRSlect3Yljk57bXqd5I=
github.com/stackitcloud/stackit-sdk-go/services/authorization v0.6.1/go.mod h1:in9kC4GIBU5DpzXKFDL7RDl0fKyvN/RUIc7YbyWYEUA=
github.com/stackitcloud/stackit-sdk-go/services/cdn v0.3.0 h1:l3COE8uny+AVkHW7MElzEGdriy+QzhpRhYgLkYJlnLU=
github.com/stackitcloud/stackit-sdk-go/services/cdn v0.3.0/go.mod h1:O5esCqh35n0ERX7/Sqpf09ZRDWckhHUuflJFbUvx9QM=
github.com/stackitcloud/stackit-sdk-go/services/dns v0.13.1 h1:W5zQhg/nA2RVSkUtRjsGcJMdYlOicoE5gBGE9zMT9Eo=
github.com/stackitcloud/stackit-sdk-go/services/dns v0.13.1/go.mod h1:+i7jQpfgj/OuZNVZ9A9aUHdVUR/j2SfICLeHbtNn+5c=
github.com/stackitcloud/stackit-sdk-go/services/iaas v0.22.0 h1:xaNory8kBIsBG7PJnBfPP1cERc+ERqjebxmEmEOvRHU=

View file

@ -21,6 +21,7 @@ type ProviderData struct {
DefaultRegion string
ArgusCustomEndpoint string
AuthorizationCustomEndpoint string
CdnCustomEndpoint string
DnsCustomEndpoint string
IaaSCustomEndpoint string
LoadBalancerCustomEndpoint string

View file

@ -0,0 +1,167 @@
package cdn_test
import (
"context"
"fmt"
"strings"
"testing"
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
"github.com/hashicorp/terraform-plugin-testing/terraform"
"github.com/stackitcloud/stackit-sdk-go/core/config"
"github.com/stackitcloud/stackit-sdk-go/services/cdn"
"github.com/stackitcloud/stackit-sdk-go/services/cdn/wait"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/testutil"
)
var instanceResource = map[string]string{
"project_id": testutil.ProjectId,
"config_backend_type": "http",
"config_backend_origin_url": "https://test-backend-1.cdn-dev.runs.onstackit.cloud",
"config_regions": "\"EU\", \"US\"",
"config_regions_updated": "\"EU\", \"US\", \"ASIA\"",
}
func configResources(regions string) string {
return fmt.Sprintf(`
%s
resource "stackit_cdn_distribution" "distribution" {
project_id = "%s"
config = {
backend = {
type = "http"
origin_url = "%s"
}
regions = [%s]
}
}
`, testutil.CdnProviderConfig(), testutil.ProjectId, instanceResource["config_backend_origin_url"], regions)
}
func configDatasources(regions string) string {
return fmt.Sprintf(`
%s
data "stackit_cdn_distribution" "distribution" {
project_id = stackit_cdn_distribution.distribution.project_id
distribution_id = stackit_cdn_distribution.distribution.distribution_id
}
`, configResources(regions))
}
func TestAccCDNDistributionResource(t *testing.T) {
resource.Test(t, resource.TestCase{
ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories,
CheckDestroy: testAccCheckCDNDistributionDestroy,
Steps: []resource.TestStep{
// Create
{
Config: configResources(instanceResource["config_regions"]),
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttrSet("stackit_cdn_distribution.distribution", "distribution_id"),
resource.TestCheckResourceAttrSet("stackit_cdn_distribution.distribution", "created_at"),
resource.TestCheckResourceAttrSet("stackit_cdn_distribution.distribution", "updated_at"),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "domains.#", "1"),
resource.TestCheckResourceAttrSet("stackit_cdn_distribution.distribution", "domains.0.name"),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "domains.0.status", "ACTIVE"),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.regions.#", "2"),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.regions.0", "EU"),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.regions.1", "US"),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "project_id", testutil.ProjectId),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "status", "ACTIVE"),
),
},
// Import
{
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,
},
// Data Source
{
Config: configDatasources(instanceResource["config_regions"]),
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttrSet("stackit_cdn_distribution.distribution", "distribution_id"),
resource.TestCheckResourceAttrSet("stackit_cdn_distribution.distribution", "created_at"),
resource.TestCheckResourceAttrSet("stackit_cdn_distribution.distribution", "updated_at"),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "domains.#", "1"),
resource.TestCheckResourceAttrSet("stackit_cdn_distribution.distribution", "domains.0.name"),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "domains.0.status", "ACTIVE"),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.regions.#", "2"),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.regions.0", "EU"),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.regions.1", "US"),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "project_id", testutil.ProjectId),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "status", "ACTIVE"),
),
},
// Update
{
Config: configResources(instanceResource["config_regions_updated"]),
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttrSet("stackit_cdn_distribution.distribution", "distribution_id"),
resource.TestCheckResourceAttrSet("stackit_cdn_distribution.distribution", "created_at"),
resource.TestCheckResourceAttrSet("stackit_cdn_distribution.distribution", "updated_at"),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "domains.#", "1"),
resource.TestCheckResourceAttrSet("stackit_cdn_distribution.distribution", "domains.0.name"),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "domains.0.status", "ACTIVE"),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.regions.#", "3"),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.regions.0", "EU"),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.regions.1", "US"),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.regions.2", "ASIA"),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "project_id", testutil.ProjectId),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "status", "ACTIVE"),
),
},
},
})
}
func testAccCheckCDNDistributionDestroy(s *terraform.State) error {
ctx := context.Background()
var client *cdn.APIClient
var err error
if testutil.MongoDBFlexCustomEndpoint == "" {
client, err = cdn.NewAPIClient()
} else {
client, err = cdn.NewAPIClient(
config.WithEndpoint(testutil.MongoDBFlexCustomEndpoint),
)
}
if err != nil {
return fmt.Errorf("creating client: %w", err)
}
distributionsToDestroy := []string{}
for _, rs := range s.RootModule().Resources {
if rs.Type != "stackit_mongodbflex_instance" {
continue
}
distributionId := strings.Split(rs.Primary.ID, core.Separator)[1]
distributionsToDestroy = append(distributionsToDestroy, distributionId)
}
for _, dist := range distributionsToDestroy {
_, err := client.DeleteDistribution(ctx, testutil.ProjectId, dist).Execute()
if err != nil {
return fmt.Errorf("destroying CDN distribution %s during CheckDestroy: %w", dist, err)
}
_, err = wait.DeleteDistributionWaitHandler(ctx, client, testutil.ProjectId, dist).WaitWithContext(ctx)
if err != nil {
return fmt.Errorf("destroying CDN distribution %s during CheckDestroy: waiting for deletion %w", dist, err)
}
}
return nil
}

View file

@ -0,0 +1,203 @@
package cdn
import (
"context"
"fmt"
"github.com/hashicorp/terraform-plugin-framework/datasource"
"github.com/hashicorp/terraform-plugin-framework/datasource/schema"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/stackitcloud/stackit-sdk-go/core/config"
"github.com/stackitcloud/stackit-sdk-go/services/cdn"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate"
)
type distributionDataSource struct {
client *cdn.APIClient
}
// Ensure the implementation satisfies the expected interfaces.
var (
_ datasource.DataSource = &distributionDataSource{}
)
func NewDistributionDataSource() datasource.DataSource {
return &distributionDataSource{}
}
func (d *distributionDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
if req.ProviderData == nil {
return
}
providerData, ok := req.ProviderData.(core.ProviderData)
if !ok {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Expected configure type stackit.ProviderData, got %T", req.ProviderData))
return
}
features.CheckBetaResourcesEnabled(ctx, &providerData, &resp.Diagnostics, "stackit_cdn_distribution", "datasource")
if resp.Diagnostics.HasError() {
return
}
var apiClient *cdn.APIClient
var err error
if providerData.CdnCustomEndpoint != "" {
apiClient, err = cdn.NewAPIClient(
config.WithCustomAuth(providerData.RoundTripper),
config.WithEndpoint(providerData.CdnCustomEndpoint),
)
} else {
apiClient, err = cdn.NewAPIClient(
config.WithCustomAuth(providerData.RoundTripper),
)
}
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Configuring client: %v. This is an error related to the provider configuration, not to the resource configuration", err))
return
}
d.client = apiClient
tflog.Info(ctx, "Service Account client configured")
}
func (r *distributionDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_cdn_distribution"
}
func (r *distributionDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: features.AddBetaDescription("CDN distribution data source schema."),
Description: "CDN distribution data source schema.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: schemaDescriptions["id"],
Computed: true,
},
"distribution_id": schema.StringAttribute{
Description: schemaDescriptions["project_id"],
Required: true,
Validators: []validator.String{
validate.UUID(),
},
},
"project_id": schema.StringAttribute{
Description: schemaDescriptions["project_id"],
Required: true,
Validators: []validator.String{
validate.UUID(),
},
},
"status": schema.StringAttribute{
Computed: true,
Description: schemaDescriptions["status"],
},
"created_at": schema.StringAttribute{
Computed: true,
Description: schemaDescriptions["created_at"],
},
"updated_at": schema.StringAttribute{
Computed: true,
Description: schemaDescriptions["updated_at"],
},
"errors": schema.ListAttribute{
ElementType: types.StringType,
Computed: true,
Description: schemaDescriptions["errors"],
},
"domains": schema.ListNestedAttribute{
Computed: true,
Description: schemaDescriptions["domains"],
NestedObject: schema.NestedAttributeObject{
Attributes: map[string]schema.Attribute{
"name": schema.StringAttribute{
Computed: true,
Description: schemaDescriptions["domain_name"],
},
"status": schema.StringAttribute{
Computed: true,
Description: schemaDescriptions["domain_status"],
},
"type": schema.StringAttribute{
Computed: true,
Description: schemaDescriptions["domain_type"],
},
"errors": schema.ListAttribute{
Computed: true,
Description: schemaDescriptions["domain_errors"],
ElementType: types.StringType,
},
},
},
},
"config": schema.SingleNestedAttribute{
Computed: true,
Description: schemaDescriptions["config"],
Attributes: map[string]schema.Attribute{
"backend": schema.SingleNestedAttribute{
Computed: true,
Description: schemaDescriptions["config_backend"],
Attributes: map[string]schema.Attribute{
"type": schema.StringAttribute{
Computed: true,
Description: schemaDescriptions["config_backend_type"],
},
"origin_url": schema.StringAttribute{
Computed: true,
Description: schemaDescriptions["config_backend_origin_url"],
},
"origin_request_headers": schema.MapAttribute{
Computed: true,
Description: schemaDescriptions["config_backend_origin_request_headers"],
ElementType: types.StringType,
},
},
},
"regions": schema.ListAttribute{
Computed: true,
Description: schemaDescriptions["config_regions"],
ElementType: types.StringType,
}},
},
},
}
}
func (r *distributionDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform
var model Model
diags := req.Config.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
distributionId := model.DistributionId.ValueString()
distributionResp, err := r.client.GetDistributionExecute(ctx, projectId, distributionId)
if err != nil {
utils.LogError(
ctx,
&resp.Diagnostics,
err,
"Reading CDN distribution",
fmt.Sprintf("Unable to access CDN distribution %q.", distributionId),
map[int]string{},
)
resp.State.RemoveResource(ctx)
return
}
err = mapFields(distributionResp.Distribution, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading CDN distribution", fmt.Sprintf("Error processing API response: %v", err))
return
}
diags = resp.State.Set(ctx, &model)
resp.Diagnostics.Append(diags...)
}

View file

@ -0,0 +1,613 @@
package cdn
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"github.com/google/uuid"
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/stackitcloud/stackit-sdk-go/core/config"
"github.com/stackitcloud/stackit-sdk-go/core/oapierror"
"github.com/stackitcloud/stackit-sdk-go/services/cdn"
"github.com/stackitcloud/stackit-sdk-go/services/cdn/wait"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate"
)
// Ensure the implementation satisfies the expected interfaces.
var (
_ resource.Resource = &distributionResource{}
_ resource.ResourceWithConfigure = &distributionResource{}
_ resource.ResourceWithImportState = &distributionResource{}
)
var schemaDescriptions = map[string]string{
"id": "Terraform's internal resource identifier. It is structured as \"`project_id`,`distribution_id`\".",
"distribution_id": "CDN distribution ID",
"project_id": "STACKIT project ID associated with the distribution",
"status": "Status of the distribution",
"created_at": "Time when the distribution was created",
"updated_at": "Time when the distribution was last updated",
"errors": "List of distribution errors",
"domains": "List of configured domains for the distribution",
"config": "The distribution configuration",
"config_backend": "The configured backend for the distribution",
"config_regions": "The configured regions where content will be hosted",
"config_backend_type": "The configured backend type",
"config_backend_origin_url": "The configured backend type for the distribution",
"config_backend_origin_request_headers": "The configured origin request headers for the backend",
"domain_name": "The name of the domain",
"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",
}
type Model struct {
ID types.String `tfsdk:"id"` // Required by Terraform
DistributionId types.String `tfsdk:"distribution_id"` // DistributionID associated with the cdn distribution
ProjectId types.String `tfsdk:"project_id"` // ProjectId associated with the cdn distribution
Status types.String `tfsdk:"status"` // The status of the cdn distribution
CreatedAt types.String `tfsdk:"created_at"` // When the distribution was created
UpdatedAt types.String `tfsdk:"updated_at"` // When the distribution was last updated
Errors types.List `tfsdk:"errors"` // Any errors that the distribution has
Domains types.List `tfsdk:"domains"` // The domains associated with the distribution
Config types.Object `tfsdk:"config"` // the configuration of the distribution
}
type distributionConfig struct {
Backend backend `tfsdk:"backend"` // The backend associated with the distribution
Regions *[]string `tfsdk:"regions"` // The regions in which data will be cached
}
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
}
var configTypes = map[string]attr.Type{
"backend": types.ObjectType{AttrTypes: backendTypes},
"regions": 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},
}
var domainTypes = map[string]attr.Type{
"name": types.StringType,
"status": types.StringType,
"type": types.StringType,
"errors": types.ListType{ElemType: types.StringType},
}
type distributionResource struct {
client *cdn.APIClient
providerData core.ProviderData
}
func NewDistributionResource() resource.Resource {
return &distributionResource{}
}
func (r *distributionResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
if req.ProviderData == nil {
return
}
var ok bool
if r.providerData, ok = req.ProviderData.(core.ProviderData); !ok {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Expected configure type stackit.ProviderData, got %T", req.ProviderData))
return
}
features.CheckBetaResourcesEnabled(ctx, &r.providerData, &resp.Diagnostics, "stackit_cdn_distribution", "resource")
var apiClient *cdn.APIClient
var err error
if r.providerData.CdnCustomEndpoint != "" {
ctx = tflog.SetField(ctx, "cdn_custom_endpoint", r.providerData.CdnCustomEndpoint)
apiClient, err = cdn.NewAPIClient(
config.WithCustomAuth(r.providerData.RoundTripper),
config.WithEndpoint(r.providerData.CdnCustomEndpoint),
)
} else {
apiClient, err = cdn.NewAPIClient(
config.WithCustomAuth(r.providerData.RoundTripper),
)
}
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Configuring client: %v. This is an error related to the provider configuration, not to the resource configuration", err))
return
}
r.client = apiClient
tflog.Info(ctx, "CDN client configured")
}
func (r *distributionResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_cdn_distribution"
}
func (r *distributionResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: features.AddBetaDescription("CDN distribution data source schema."),
Description: "CDN distribution data source schema.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: schemaDescriptions["id"],
Computed: true,
},
"distribution_id": schema.StringAttribute{
Description: schemaDescriptions["project_id"],
Computed: true,
Validators: []validator.String{validate.UUID()},
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"project_id": schema.StringAttribute{
Description: schemaDescriptions["project_id"],
Required: true,
Optional: false,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"status": schema.StringAttribute{
Computed: true,
Description: schemaDescriptions["status"],
},
"created_at": schema.StringAttribute{
Computed: true,
Description: schemaDescriptions["created_at"],
},
"updated_at": schema.StringAttribute{
Computed: true,
Description: schemaDescriptions["updated_at"],
},
"errors": schema.ListAttribute{
ElementType: types.StringType,
Computed: true,
Description: schemaDescriptions["errors"],
},
"domains": schema.ListNestedAttribute{
Computed: true,
Description: schemaDescriptions["domains"],
NestedObject: schema.NestedAttributeObject{
Attributes: map[string]schema.Attribute{
"name": schema.StringAttribute{
Computed: true,
Description: schemaDescriptions["domain_name"],
},
"status": schema.StringAttribute{
Computed: true,
Description: schemaDescriptions["domain_status"],
},
"type": schema.StringAttribute{
Computed: true,
Description: schemaDescriptions["domain_type"],
},
"errors": schema.ListAttribute{
Computed: true,
Description: schemaDescriptions["domain_errors"],
ElementType: types.StringType,
},
},
},
},
"config": schema.SingleNestedAttribute{
Required: true,
Description: schemaDescriptions["config"],
Attributes: map[string]schema.Attribute{
"backend": schema.SingleNestedAttribute{
Required: true,
Description: schemaDescriptions["config_backend"],
Attributes: map[string]schema.Attribute{
"type": schema.StringAttribute{
Required: true,
Description: schemaDescriptions["config_backend_type"],
},
"origin_url": schema.StringAttribute{
Required: true,
Description: schemaDescriptions["config_backend_origin_url"],
},
"origin_request_headers": schema.MapAttribute{
Optional: true,
Description: schemaDescriptions["config_backend_origin_request_headers"],
ElementType: types.StringType,
},
},
},
"regions": schema.ListAttribute{
Required: true,
Description: schemaDescriptions["config_regions"],
ElementType: types.StringType,
}},
},
},
}
}
func (r *distributionResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform
var model Model
diags := req.Plan.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
payload, err := toCreatePayload(ctx, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating CDN distribution", fmt.Sprintf("Creating API payload: %v", err))
return
}
createResp, err := r.client.CreateDistribution(ctx, projectId).CreateDistributionPayload(*payload).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating CDN distribution", fmt.Sprintf("Calling API: %v", err))
return
}
waitResp, err := wait.CreateDistributionPoolWaitHandler(ctx, r.client, projectId, *createResp.Distribution.Id).WaitWithContext(ctx)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating CDN distribution", fmt.Sprintf("Waiting for create: %v", err))
return
}
err = mapFields(waitResp.Distribution, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating CDN distribution", fmt.Sprintf("Processing API payload: %v", err))
return
}
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "CDN distribution created")
}
func (r *distributionResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform
var model Model
diags := req.State.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
distributionId := model.DistributionId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "distribution_id", distributionId)
cdnResp, err := r.client.GetDistribution(ctx, projectId, distributionId).Execute()
if err != nil {
var oapiErr *oapierror.GenericOpenAPIError
// n.b. err is caught here if of type *oapierror.GenericOpenAPIError, which the stackit SDK client returns
if errors.As(err, &oapiErr) {
if oapiErr.StatusCode == http.StatusNotFound {
resp.State.RemoveResource(ctx)
return
}
}
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading CDN distribution", fmt.Sprintf("Calling API: %v", err))
return
}
err = mapFields(cdnResp.Distribution, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading CDN ditribution", fmt.Sprintf("Processing API payload: %v", err))
return
}
// Set refreshed state
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "CDN distribution read")
}
func (r *distributionResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform
var model Model
diags := req.Plan.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
distributionId := model.DistributionId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "distribution_id", distributionId)
configModel := distributionConfig{}
diags = model.Config.As(ctx, &configModel, basetypes.ObjectAsOptions{
UnhandledNullAsEmpty: false,
UnhandledUnknownAsEmpty: false,
})
if diags.HasError() {
core.LogAndAddError(ctx, &resp.Diagnostics, "Update CDN distribution", "Error mapping config")
return
}
regions := []cdn.Region{}
for _, r := range *configModel.Regions {
regionEnum, err := cdn.NewRegionFromValue(r)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Update CDN distribution", fmt.Sprintf("Map regions: %v", err))
return
}
regions = append(regions, *regionEnum)
}
_, err := r.client.PatchDistribution(ctx, projectId, distributionId).PatchDistributionPayload(cdn.PatchDistributionPayload{
Config: &cdn.ConfigPatch{
Backend: &cdn.ConfigPatchBackend{
HttpBackendPatch: &cdn.HttpBackendPatch{
OriginRequestHeaders: configModel.Backend.OriginRequestHeaders,
OriginUrl: &configModel.Backend.OriginURL,
Type: &configModel.Backend.Type,
},
},
Regions: &regions,
},
IntentId: cdn.PtrString(uuid.NewString()),
}).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Update CDN distribution", fmt.Sprintf("Patch distribution: %v", err))
}
waitResp, err := wait.UpdateDistributionWaitHandler(ctx, r.client, projectId, distributionId).WaitWithContext(ctx)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Update CDN distribution", fmt.Sprintf("Waiting for update: %v", err))
return
}
err = mapFields(waitResp.Distribution, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Update CDN distribution", fmt.Sprintf("Processing API payload: %v", err))
return
}
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "CDN distribution updated")
}
func (r *distributionResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform
var model Model
diags := req.State.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
distributionId := model.DistributionId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "distribution_id", distributionId)
_, err := r.client.DeleteDistribution(ctx, projectId, distributionId).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Delete CDN distribution", fmt.Sprintf("Delete distribution: %v", err))
}
_, err = wait.DeleteDistributionWaitHandler(ctx, r.client, projectId, distributionId).WaitWithContext(ctx)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Delete CDN distribution", fmt.Sprintf("Waiting for deletion: %v", err))
return
}
tflog.Info(ctx, "CDN distribution deleted")
}
func (r *distributionResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
idParts := strings.Split(req.ID, core.Separator)
if len(idParts) != 2 || idParts[0] == "" || idParts[1] == "" {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error importing CDN distribution", fmt.Sprintf("Expected import identifier on the format: [project_id]%q[distribution_id], got %q", core.Separator, req.ID))
}
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), idParts[0])...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("distribution_id"), idParts[1])...)
tflog.Info(ctx, "CDN distribution state imported")
}
func mapFields(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")
}
if distribution.ProjectId == nil {
return fmt.Errorf("Project ID not present")
}
if distribution.Id == nil {
return fmt.Errorf("CDN distribution ID not present")
}
if distribution.CreatedAt == nil {
return fmt.Errorf("CreatedAt missing in response")
}
if distribution.UpdatedAt == nil {
return fmt.Errorf("UpdatedAt missing in response")
}
if distribution.Status == nil {
return fmt.Errorf("Status missing in response")
}
id := *distribution.ProjectId + core.Separator + *distribution.Id
model.ID = types.StringValue(id)
model.DistributionId = types.StringValue(*distribution.Id)
model.ProjectId = types.StringValue(*distribution.ProjectId)
model.Status = types.StringValue(*distribution.Status)
model.CreatedAt = types.StringValue(distribution.CreatedAt.String())
model.UpdatedAt = types.StringValue(distribution.UpdatedAt.String())
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 := []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)
}
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)
}
}
// 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,
})
if diags.HasError() {
return core.DiagsToError(diags)
}
cfg, diags := types.ObjectValue(configTypes, map[string]attr.Value{
"backend": backend,
"regions": modelRegions,
})
if diags.HasError() {
return core.DiagsToError(diags)
}
model.Config = cfg
domains := []attr.Value{}
if distribution.Domains != nil {
for _, d := range *distribution.Domains {
domainErrors := []attr.Value{}
if d.Errors != nil {
for _, e := range *d.Errors {
if e.En == nil {
return fmt.Errorf("error description missing")
}
domainErrors = append(domainErrors, types.StringValue(*e.En))
}
}
modelDomainErrors, diags := types.ListValue(types.StringType, domainErrors)
if diags.HasError() {
return core.DiagsToError(diags)
}
if d.Name == nil || d.Status == nil || d.Type == nil {
return fmt.Errorf("domain entry incomplete")
}
modelDomain, diags := types.ObjectValue(domainTypes, map[string]attr.Value{
"name": types.StringValue(*d.Name),
"status": types.StringValue(string(*d.Status)),
"type": types.StringValue(*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
}
func toCreatePayload(ctx context.Context, model *Model) (*cdn.CreateDistributionPayload, error) {
if model == nil {
return nil, fmt.Errorf("missing model")
}
cfg, err := convertConfig(ctx, model)
if err != nil {
return nil, err
}
payload := &cdn.CreateDistributionPayload{
IntentId: cdn.PtrString(uuid.NewString()),
OriginUrl: cfg.Backend.HttpBackend.OriginUrl,
Regions: cfg.Regions,
OriginRequestHeaders: cfg.Backend.HttpBackend.OriginRequestHeaders,
}
return payload, nil
}
func convertConfig(ctx context.Context, model *Model) (*cdn.Config, error) {
if model == nil {
return nil, errors.New("model cannot be nil")
}
if model.Config.IsNull() || model.Config.IsUnknown() {
return nil, errors.New("config cannot be nil or unknown")
}
configModel := distributionConfig{}
diags := model.Config.As(ctx, &configModel, basetypes.ObjectAsOptions{
UnhandledNullAsEmpty: false,
UnhandledUnknownAsEmpty: false,
})
if diags.HasError() {
return nil, core.DiagsToError(diags)
}
regions := []cdn.Region{}
for _, r := range *configModel.Regions {
regionEnum, err := cdn.NewRegionFromValue(r)
if err != nil {
return nil, err
}
regions = append(regions, *regionEnum)
}
originRequestHeaders := map[string]string{}
if configModel.Backend.OriginRequestHeaders != nil {
for k, v := range *configModel.Backend.OriginRequestHeaders {
originRequestHeaders[k] = v
}
}
return &cdn.Config{
Backend: &cdn.ConfigBackend{
HttpBackend: &cdn.HttpBackend{
OriginRequestHeaders: &originRequestHeaders,
OriginUrl: &configModel.Backend.OriginURL,
Type: &configModel.Backend.Type,
},
},
Regions: &regions,
}, nil
}

View file

@ -0,0 +1,342 @@
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 TestToCreatePayload(t *testing.T) {
headers := map[string]attr.Value{
"testHeader0": types.StringValue("testHeaderValue0"),
"testHeader1": types.StringValue("testHeaderValue1"),
}
originRequestHeaders := types.MapValueMust(types.StringType, headers)
backend := types.ObjectValueMust(backendTypes, map[string]attr.Value{
"type": types.StringValue("http"),
"origin_url": types.StringValue("https://www.mycoolapp.com"),
"origin_request_headers": originRequestHeaders,
})
regions := []attr.Value{types.StringValue("EU"), types.StringValue("US")}
regionsFixture := types.ListValueMust(types.StringType, regions)
config := types.ObjectValueMust(configTypes, map[string]attr.Value{
"backend": backend,
"regions": regionsFixture,
})
modelFixture := func(mods ...func(*Model)) *Model {
model := &Model{
DistributionId: types.StringValue("test-distribution-id"),
ProjectId: types.StringValue("test-project-id"),
Config: config,
}
for _, mod := range mods {
mod(model)
}
return model
}
tests := map[string]struct {
Input *Model
Expected *cdn.CreateDistributionPayload
IsValid bool
}{
"happy_path": {
Input: modelFixture(),
Expected: &cdn.CreateDistributionPayload{
OriginRequestHeaders: &map[string]string{
"testHeader0": "testHeaderValue0",
"testHeader1": "testHeaderValue1",
},
OriginUrl: cdn.PtrString("https://www.mycoolapp.com"),
Regions: &[]cdn.Region{"EU", "US"},
},
IsValid: true,
},
"sad_path_model_nil": {
Input: nil,
Expected: nil,
IsValid: false,
},
"sad_path_config_error": {
Input: modelFixture(func(m *Model) {
m.Config = types.ObjectNull(configTypes)
}),
Expected: nil,
IsValid: false,
},
}
for tn, tc := range tests {
t.Run(tn, func(t *testing.T) {
res, err := toCreatePayload(context.Background(), tc.Input)
if err != nil && tc.IsValid {
t.Fatalf("Error converting model to create payload: %v", err)
}
if err == nil && !tc.IsValid {
t.Fatalf("Should have failed")
}
if tc.IsValid {
// set generated ID before diffing
tc.Expected.IntentId = res.IntentId
diff := cmp.Diff(res, tc.Expected)
if diff != "" {
t.Fatalf("Create Payload not as expected: %s", diff)
}
}
})
}
}
func TestConvertConfig(t *testing.T) {
headers := map[string]attr.Value{
"testHeader0": types.StringValue("testHeaderValue0"),
"testHeader1": types.StringValue("testHeaderValue1"),
}
originRequestHeaders := types.MapValueMust(types.StringType, headers)
backend := types.ObjectValueMust(backendTypes, map[string]attr.Value{
"type": types.StringValue("http"),
"origin_url": types.StringValue("https://www.mycoolapp.com"),
"origin_request_headers": originRequestHeaders,
})
regions := []attr.Value{types.StringValue("EU"), types.StringValue("US")}
regionsFixture := types.ListValueMust(types.StringType, regions)
config := types.ObjectValueMust(configTypes, map[string]attr.Value{
"backend": backend,
"regions": regionsFixture,
})
modelFixture := func(mods ...func(*Model)) *Model {
model := &Model{
DistributionId: types.StringValue("test-distribution-id"),
ProjectId: types.StringValue("test-project-id"),
Config: config,
}
for _, mod := range mods {
mod(model)
}
return model
}
tests := map[string]struct {
Input *Model
Expected *cdn.Config
IsValid bool
}{
"happy_path": {
Input: modelFixture(),
Expected: &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"},
},
IsValid: true,
},
"sad_path_model_nil": {
Input: nil,
Expected: nil,
IsValid: false,
},
"sad_path_config_error": {
Input: modelFixture(func(m *Model) {
m.Config = types.ObjectNull(configTypes)
}),
Expected: nil,
IsValid: false,
},
}
for tn, tc := range tests {
t.Run(tn, func(t *testing.T) {
res, err := convertConfig(context.Background(), tc.Input)
if err != nil && tc.IsValid {
t.Fatalf("Error converting model to create payload: %v", err)
}
if err == nil && !tc.IsValid {
t.Fatalf("Should have failed")
}
if tc.IsValid {
diff := cmp.Diff(res, tc.Expected)
if diff != "" {
t.Fatalf("Create Payload not as expected: %s", diff)
}
}
})
}
}
func TestMapFields(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(backendTypes, map[string]attr.Value{
"type": types.StringValue("http"),
"origin_url": types.StringValue("https://www.mycoolapp.com"),
"origin_request_headers": originRequestHeaders,
})
regions := []attr.Value{types.StringValue("EU"), types.StringValue("US")}
regionsFixture := types.ListValueMust(types.StringType, regions)
config := types.ObjectValueMust(configTypes, map[string]attr.Value{
"backend": backend,
"regions": regionsFixture,
})
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"},
},
CreatedAt: &createdAt,
Domains: &[]cdn.Domain{
{
Name: cdn.PtrString("test.stackit-cdn.com"),
Status: cdn.DOMAINSTATUS_ACTIVE.Ptr(),
Type: cdn.PtrString("managed"),
},
},
Id: cdn.PtrString("test-distribution-id"),
ProjectId: cdn.PtrString("test-project-id"),
Status: cdn.PtrString("ACTIVE"),
UpdatedAt: &updatedAt,
}
for _, mod := range mods {
mod(distribution)
}
return distribution
}
tests := map[string]struct {
Input *cdn.Distribution
Expected *Model
IsValid bool
}{
"happy_path": {
Expected: expectedModel(),
Input: distributionFixture(),
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.PtrString("ERROR")
}),
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.PtrString("managed"),
},
{
Name: cdn.PtrString("mycoolapp.info"),
Status: cdn.DOMAINSTATUS_ACTIVE.Ptr(),
Type: cdn.PtrString("custom"),
},
}
}),
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 := mapFields(tc.Input, model)
if err != nil && tc.IsValid {
t.Fatalf("Error mapping fields: %v", err)
}
if err == nil && !tc.IsValid {
t.Fatalf("Should have failed")
}
if tc.IsValid {
diff := cmp.Diff(model, tc.Expected)
if diff != "" {
t.Fatalf("Create Payload not as expected: %s", diff)
}
}
})
}
}

View file

@ -51,6 +51,7 @@ var (
TestImageLocalFilePath = getenv("TF_ACC_TEST_IMAGE_LOCAL_FILE_PATH", "default")
ArgusCustomEndpoint = os.Getenv("TF_ACC_ARGUS_CUSTOM_ENDPOINT")
CdnCustomEndpoint = os.Getenv("TF_ACC_CDN_CUSTOM_ENDPOINT")
DnsCustomEndpoint = os.Getenv("TF_ACC_DNS_CUSTOM_ENDPOINT")
IaaSCustomEndpoint = os.Getenv("TF_ACC_IAAS_CUSTOM_ENDPOINT")
LoadBalancerCustomEndpoint = os.Getenv("TF_ACC_LOADBALANCER_CUSTOM_ENDPOINT")
@ -105,6 +106,17 @@ func ObservabilityProviderConfig() string {
ObservabilityCustomEndpoint,
)
}
func CdnProviderConfig() string {
if CdnCustomEndpoint == "" {
return `provider "stackit" {}`
}
return fmt.Sprintf(`
provider "stackit" {
cdn_custom_endpoint = "%s"
}`,
CdnCustomEndpoint,
)
}
func DnsProviderConfig() string {
if DnsCustomEndpoint == "" {

View file

@ -14,6 +14,7 @@ import (
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features"
roleAssignements "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/authorization/roleassignments"
cdn "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/cdn/distribution"
dnsRecordSet "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/dns/recordset"
dnsZone "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/dns/zone"
iaasAffinityGroup "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/affinitygroup"
@ -114,6 +115,7 @@ type providerModel struct {
Region types.String `tfsdk:"region"`
DefaultRegion types.String `tfsdk:"default_region"`
ArgusCustomEndpoint types.String `tfsdk:"argus_custom_endpoint"`
CdnCustomEndpoint types.String `tfsdk:"cdn_custom_endpoint"`
DNSCustomEndpoint types.String `tfsdk:"dns_custom_endpoint"`
IaaSCustomEndpoint types.String `tfsdk:"iaas_custom_endpoint"`
PostgresFlexCustomEndpoint types.String `tfsdk:"postgresflex_custom_endpoint"`
@ -154,6 +156,7 @@ func (p *Provider) Schema(_ context.Context, _ provider.SchemaRequest, resp *pro
"region": "Region will be used as the default location for regional services. Not all services require a region, some are global",
"default_region": "Region will be used as the default location for regional services. Not all services require a region, some are global",
"argus_custom_endpoint": "Custom endpoint for the Argus service",
"cdn_custom_endpoint": "Custom endpoint for the CDN service",
"dns_custom_endpoint": "Custom endpoint for the DNS service",
"iaas_custom_endpoint": "Custom endpoint for the IaaS service",
"mongodbflex_custom_endpoint": "Custom endpoint for the MongoDB Flex service",
@ -232,6 +235,10 @@ func (p *Provider) Schema(_ context.Context, _ provider.SchemaRequest, resp *pro
Description: descriptions["argus_custom_endpoint"],
DeprecationMessage: "Argus service has been deprecated and integration will be removed after February 26th 2025. Please use `observability_custom_endpoint` and `observability` resources instead, which offer the exact same functionality.",
},
"cdn_custom_endpoint": schema.StringAttribute{
Optional: true,
Description: descriptions["cdn_custom_endpoint"],
},
"dns_custom_endpoint": schema.StringAttribute{
Optional: true,
Description: descriptions["dns_custom_endpoint"],
@ -373,6 +380,9 @@ func (p *Provider) Configure(ctx context.Context, req provider.ConfigureRequest,
} else if !(providerConfig.Region.IsUnknown() || providerConfig.Region.IsNull()) { // nolint:staticcheck // preliminary handling of deprecated attribute
providerData.Region = providerConfig.Region.ValueString() // nolint:staticcheck // preliminary handling of deprecated attribute
}
if !(providerConfig.CdnCustomEndpoint.IsUnknown() || providerConfig.CdnCustomEndpoint.IsNull()) {
providerData.CdnCustomEndpoint = providerConfig.CdnCustomEndpoint.ValueString()
}
if !(providerConfig.DNSCustomEndpoint.IsUnknown() || providerConfig.DNSCustomEndpoint.IsNull()) {
providerData.DnsCustomEndpoint = providerConfig.DNSCustomEndpoint.ValueString()
}
@ -465,6 +475,7 @@ func (p *Provider) Configure(ctx context.Context, req provider.ConfigureRequest,
func (p *Provider) DataSources(_ context.Context) []func() datasource.DataSource {
return []func() datasource.DataSource{
alertGroup.NewAlertGroupDataSource,
cdn.NewDistributionDataSource,
dnsZone.NewZoneDataSource,
dnsRecordSet.NewRecordSetDataSource,
iaasAffinityGroup.NewAffinityGroupDatasource,
@ -520,6 +531,7 @@ func (p *Provider) DataSources(_ context.Context) []func() datasource.DataSource
func (p *Provider) Resources(_ context.Context) []func() resource.Resource {
resources := []func() resource.Resource{
alertGroup.NewAlertGroupResource,
cdn.NewDistributionResource,
dnsZone.NewZoneResource,
dnsRecordSet.NewRecordSetResource,
iaasAffinityGroup.NewAffinityGroupResource,