diff --git a/docs/data-sources/cdn_distribution.md b/docs/data-sources/cdn_distribution.md new file mode 100644 index 00000000..7d82a8a3 --- /dev/null +++ b/docs/data-sources/cdn_distribution.md @@ -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 + +### 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 + + +### 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 + + +### 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 + + + + +### 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 diff --git a/docs/index.md b/docs/index.md index 6e1573d4..a7653c04 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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 diff --git a/docs/resources/cdn_distribution.md b/docs/resources/cdn_distribution.md new file mode 100644 index 00000000..fed0d775 --- /dev/null +++ b/docs/resources/cdn_distribution.md @@ -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 + +### 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 + + +### 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 + + +### 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 + + + + +### 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 diff --git a/examples/data-sources/stackit_cdn_distribution/data-source.tf b/examples/data-sources/stackit_cdn_distribution/data-source.tf new file mode 100644 index 00000000..be24c0bc --- /dev/null +++ b/examples/data-sources/stackit_cdn_distribution/data-source.tf @@ -0,0 +1,5 @@ +data "stackit_cdn_distribution" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + distribution_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} + diff --git a/examples/resources/stackit_cdn_distribution/resource.tf b/examples/resources/stackit_cdn_distribution/resource.tf new file mode 100644 index 00000000..1c16ba4e --- /dev/null +++ b/examples/resources/stackit_cdn_distribution/resource.tf @@ -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"] + } +} diff --git a/go.mod b/go.mod index d1445693..a5db4d97 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index e3577dfb..22c26bd0 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/stackit/internal/core/core.go b/stackit/internal/core/core.go index f4502311..8b7d62c1 100644 --- a/stackit/internal/core/core.go +++ b/stackit/internal/core/core.go @@ -21,6 +21,7 @@ type ProviderData struct { DefaultRegion string ArgusCustomEndpoint string AuthorizationCustomEndpoint string + CdnCustomEndpoint string DnsCustomEndpoint string IaaSCustomEndpoint string LoadBalancerCustomEndpoint string diff --git a/stackit/internal/services/cdn/cdn_acc_test.go b/stackit/internal/services/cdn/cdn_acc_test.go new file mode 100644 index 00000000..c7a6541a --- /dev/null +++ b/stackit/internal/services/cdn/cdn_acc_test.go @@ -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 +} diff --git a/stackit/internal/services/cdn/distribution/datasource.go b/stackit/internal/services/cdn/distribution/datasource.go new file mode 100644 index 00000000..0a28a097 --- /dev/null +++ b/stackit/internal/services/cdn/distribution/datasource.go @@ -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...) +} diff --git a/stackit/internal/services/cdn/distribution/resource.go b/stackit/internal/services/cdn/distribution/resource.go new file mode 100644 index 00000000..1fe028f9 --- /dev/null +++ b/stackit/internal/services/cdn/distribution/resource.go @@ -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: ®ions, + }, + 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: ®ions, + }, nil +} diff --git a/stackit/internal/services/cdn/distribution/resource_test.go b/stackit/internal/services/cdn/distribution/resource_test.go new file mode 100644 index 00000000..ceecb5ca --- /dev/null +++ b/stackit/internal/services/cdn/distribution/resource_test.go @@ -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) + } + } + }) + } +} diff --git a/stackit/internal/testutil/testutil.go b/stackit/internal/testutil/testutil.go index 763d9f3a..66485e94 100644 --- a/stackit/internal/testutil/testutil.go +++ b/stackit/internal/testutil/testutil.go @@ -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 == "" { diff --git a/stackit/provider.go b/stackit/provider.go index 3d6b679a..4502199d 100644 --- a/stackit/provider.go +++ b/stackit/provider.go @@ -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,