chore: cleanup alpha branch

This commit is contained in:
Marcel S. Henselin 2025-12-17 16:14:25 +01:00
parent c07c81b091
commit df25ceffd4
374 changed files with 2 additions and 114477 deletions

View file

@ -1,50 +0,0 @@
package access_token_test
import (
_ "embed"
"regexp"
"testing"
"github.com/hashicorp/terraform-plugin-testing/config"
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
"github.com/hashicorp/terraform-plugin-testing/knownvalue"
"github.com/hashicorp/terraform-plugin-testing/statecheck"
"github.com/hashicorp/terraform-plugin-testing/tfjsonpath"
"github.com/hashicorp/terraform-plugin-testing/tfversion"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/testutil"
)
//go:embed testdata/ephemeral_resource.tf
var ephemeralResourceConfig string
var testConfigVars = config.Variables{
"default_region": config.StringVariable(testutil.Region),
}
func TestAccEphemeralAccessToken(t *testing.T) {
resource.Test(t, resource.TestCase{
TerraformVersionChecks: []tfversion.TerraformVersionCheck{
tfversion.SkipBelow(tfversion.Version1_10_0),
},
ProtoV6ProviderFactories: testutil.TestEphemeralAccProtoV6ProviderFactories,
Steps: []resource.TestStep{
{
Config: ephemeralResourceConfig,
ConfigVariables: testConfigVars,
ConfigStateChecks: []statecheck.StateCheck{
statecheck.ExpectKnownValue(
"echo.example",
tfjsonpath.New("data").AtMapKey("access_token"),
knownvalue.NotNull(),
),
// JWT access tokens start with "ey" because the first part is base64-encoded JSON that begins with "{".
statecheck.ExpectKnownValue(
"echo.example",
tfjsonpath.New("data").AtMapKey("access_token"),
knownvalue.StringRegexp(regexp.MustCompile(`^ey`)),
),
},
},
},
})
}

View file

@ -1,132 +0,0 @@
package access_token
import (
"context"
"fmt"
"github.com/hashicorp/terraform-plugin-framework/ephemeral"
"github.com/hashicorp/terraform-plugin-framework/ephemeral/schema"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/stackitcloud/stackit-sdk-go/core/auth"
"github.com/stackitcloud/stackit-sdk-go/core/clients"
"github.com/stackitcloud/stackit-sdk-go/core/config"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features"
)
var (
_ ephemeral.EphemeralResource = &accessTokenEphemeralResource{}
_ ephemeral.EphemeralResourceWithConfigure = &accessTokenEphemeralResource{}
)
func NewAccessTokenEphemeralResource() ephemeral.EphemeralResource {
return &accessTokenEphemeralResource{}
}
type accessTokenEphemeralResource struct {
keyAuthConfig config.Configuration
}
func (e *accessTokenEphemeralResource) Configure(ctx context.Context, req ephemeral.ConfigureRequest, resp *ephemeral.ConfigureResponse) {
ephemeralProviderData, ok := conversion.ParseEphemeralProviderData(ctx, req.ProviderData, &resp.Diagnostics)
if !ok {
return
}
features.CheckBetaResourcesEnabled(
ctx,
&ephemeralProviderData.ProviderData,
&resp.Diagnostics,
"stackit_access_token", "ephemeral_resource",
)
if resp.Diagnostics.HasError() {
return
}
e.keyAuthConfig = config.Configuration{
ServiceAccountKey: ephemeralProviderData.ServiceAccountKey,
ServiceAccountKeyPath: ephemeralProviderData.ServiceAccountKeyPath,
PrivateKeyPath: ephemeralProviderData.PrivateKey,
PrivateKey: ephemeralProviderData.PrivateKeyPath,
TokenCustomUrl: ephemeralProviderData.TokenCustomEndpoint,
}
}
type ephemeralTokenModel struct {
AccessToken types.String `tfsdk:"access_token"`
}
func (e *accessTokenEphemeralResource) Metadata(_ context.Context, req ephemeral.MetadataRequest, resp *ephemeral.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_access_token"
}
func (e *accessTokenEphemeralResource) Schema(_ context.Context, _ ephemeral.SchemaRequest, resp *ephemeral.SchemaResponse) {
description := features.AddBetaDescription(
fmt.Sprintf(
"%s\n\n%s",
"Ephemeral resource that generates a short-lived STACKIT access token (JWT) using a service account key. "+
"A new token is generated each time the resource is evaluated, and it remains consistent for the duration of a Terraform operation. "+
"If a private key is not explicitly provided, the provider attempts to extract it from the service account key instead. "+
"Access tokens generated from service account keys expire after 60 minutes.",
"~> Service account key credentials must be configured either in the STACKIT provider configuration or via environment variables (see example below). "+
"If any other authentication method is configured, this ephemeral resource will fail with an error.",
),
core.EphemeralResource,
)
resp.Schema = schema.Schema{
Description: description,
Attributes: map[string]schema.Attribute{
"access_token": schema.StringAttribute{
Description: "JWT access token for STACKIT API authentication.",
Computed: true,
Sensitive: true,
},
},
}
}
func (e *accessTokenEphemeralResource) Open(ctx context.Context, req ephemeral.OpenRequest, resp *ephemeral.OpenResponse) {
var model ephemeralTokenModel
resp.Diagnostics.Append(req.Config.Get(ctx, &model)...)
if resp.Diagnostics.HasError() {
return
}
accessToken, err := getAccessToken(&e.keyAuthConfig)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Access token generation failed", err.Error())
return
}
model.AccessToken = types.StringValue(accessToken)
resp.Diagnostics.Append(resp.Result.Set(ctx, model)...)
}
// getAccessToken initializes authentication using the provided config and returns an access token via the KeyFlow mechanism.
func getAccessToken(keyAuthConfig *config.Configuration) (string, error) {
roundTripper, err := auth.KeyAuth(keyAuthConfig)
if err != nil {
return "", fmt.Errorf(
"failed to initialize authentication: %w. "+
"Make sure service account credentials are configured either in the provider configuration or via environment variables",
err,
)
}
// Type assert to access token functionality
client, ok := roundTripper.(*clients.KeyFlow)
if !ok {
return "", fmt.Errorf("internal error: expected *clients.KeyFlow, but received a different implementation of http.RoundTripper")
}
// Retrieve the access token
accessToken, err := client.GetAccessToken()
if err != nil {
return "", fmt.Errorf("error obtaining access token: %w", err)
}
return accessToken, nil
}

View file

@ -1,253 +0,0 @@
package access_token
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
_ "embed"
"encoding/json"
"encoding/pem"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/stackitcloud/stackit-sdk-go/core/clients"
"github.com/stackitcloud/stackit-sdk-go/core/config"
)
//go:embed testdata/service_account.json
var testServiceAccountKey string
func startMockTokenServer() *httptest.Server {
handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
resp := clients.TokenResponseBody{
AccessToken: "mock_access_token",
RefreshToken: "mock_refresh_token",
TokenType: "Bearer",
ExpiresIn: int(time.Now().Add(time.Hour).Unix()),
Scope: "mock_scope",
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
})
return httptest.NewServer(handler)
}
func generatePrivateKey() (string, error) {
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return "", err
}
privateKeyPEM := &pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(privateKey),
}
return string(pem.EncodeToMemory(privateKeyPEM)), nil
}
func writeTempPEMFile(t *testing.T, pemContent string) string {
t.Helper()
tmpFile, err := os.CreateTemp("", "stackit_test_private_key_*.pem")
if err != nil {
t.Fatal(err)
}
if _, err := tmpFile.WriteString(pemContent); err != nil {
t.Fatal(err)
}
if err := tmpFile.Close(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_ = os.Remove(tmpFile.Name())
})
return tmpFile.Name()
}
func TestGetAccessToken(t *testing.T) {
mockServer := startMockTokenServer()
t.Cleanup(mockServer.Close)
privateKey, err := generatePrivateKey()
if err != nil {
t.Fatal(err)
}
tests := []struct {
description string
setupEnv func()
cleanupEnv func()
cfgFactory func() *config.Configuration
expectError bool
expected string
}{
{
description: "should return token when service account key passed by value",
cfgFactory: func() *config.Configuration {
return &config.Configuration{
ServiceAccountKey: testServiceAccountKey,
PrivateKey: privateKey,
TokenCustomUrl: mockServer.URL,
}
},
expectError: false,
expected: "mock_access_token",
},
{
description: "should return token when service account key is loaded from file path",
cfgFactory: func() *config.Configuration {
return &config.Configuration{
ServiceAccountKeyPath: "testdata/service_account.json",
PrivateKey: privateKey,
TokenCustomUrl: mockServer.URL,
}
},
expectError: false,
expected: "mock_access_token",
},
{
description: "should fail when private key is invalid",
cfgFactory: func() *config.Configuration {
return &config.Configuration{
ServiceAccountKey: "invalid-json",
PrivateKey: "invalid-PEM",
TokenCustomUrl: mockServer.URL,
}
},
expectError: true,
expected: "",
},
{
description: "should return token when service account key is set via env",
setupEnv: func() {
_ = os.Setenv("STACKIT_SERVICE_ACCOUNT_KEY", testServiceAccountKey)
},
cleanupEnv: func() {
_ = os.Unsetenv("STACKIT_SERVICE_ACCOUNT_KEY")
},
cfgFactory: func() *config.Configuration {
return &config.Configuration{
PrivateKey: privateKey,
TokenCustomUrl: mockServer.URL,
}
},
expectError: false,
expected: "mock_access_token",
},
{
description: "should return token when service account key path is set via env",
setupEnv: func() {
_ = os.Setenv("STACKIT_SERVICE_ACCOUNT_KEY_PATH", "testdata/service_account.json")
},
cleanupEnv: func() {
_ = os.Unsetenv("STACKIT_SERVICE_ACCOUNT_KEY_PATH")
},
cfgFactory: func() *config.Configuration {
return &config.Configuration{
PrivateKey: privateKey,
TokenCustomUrl: mockServer.URL,
}
},
expectError: false,
expected: "mock_access_token",
},
{
description: "should return token when private key is set via env",
setupEnv: func() {
_ = os.Setenv("STACKIT_PRIVATE_KEY", privateKey)
},
cleanupEnv: func() {
_ = os.Unsetenv("STACKIT_PRIVATE_KEY")
},
cfgFactory: func() *config.Configuration {
return &config.Configuration{
ServiceAccountKey: testServiceAccountKey,
TokenCustomUrl: mockServer.URL,
}
},
expectError: false,
expected: "mock_access_token",
},
{
description: "should return token when private key path is set via env",
setupEnv: func() {
// Write temp file and set env
tmpFile := writeTempPEMFile(t, privateKey)
_ = os.Setenv("STACKIT_PRIVATE_KEY_PATH", tmpFile)
},
cleanupEnv: func() {
_ = os.Unsetenv("STACKIT_PRIVATE_KEY_PATH")
},
cfgFactory: func() *config.Configuration {
return &config.Configuration{
ServiceAccountKey: testServiceAccountKey,
TokenCustomUrl: mockServer.URL,
}
},
expectError: false,
expected: "mock_access_token",
},
{
description: "should fail when no service account key or private key is set",
cfgFactory: func() *config.Configuration {
return &config.Configuration{
TokenCustomUrl: mockServer.URL,
}
},
expectError: true,
expected: "",
},
{
description: "should fail when no service account key or private key is set via env",
setupEnv: func() {
_ = os.Unsetenv("STACKIT_SERVICE_ACCOUNT_KEY")
_ = os.Unsetenv("STACKIT_SERVICE_ACCOUNT_KEY_PATH")
_ = os.Unsetenv("STACKIT_PRIVATE_KEY")
_ = os.Unsetenv("STACKIT_PRIVATE_KEY_PATH")
},
cleanupEnv: func() {
// Restore original environment variables
},
cfgFactory: func() *config.Configuration {
return &config.Configuration{
TokenCustomUrl: mockServer.URL,
}
},
expectError: true,
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
if tt.setupEnv != nil {
tt.setupEnv()
}
if tt.cleanupEnv != nil {
defer tt.cleanupEnv()
}
cfg := tt.cfgFactory()
token, err := getAccessToken(cfg)
if tt.expectError {
if err == nil {
t.Errorf("expected error but got none for test case '%s'", tt.description)
}
} else {
if err != nil {
t.Errorf("did not expect error but got: %v for test case '%s'", err, tt.description)
}
if token != tt.expected {
t.Errorf("expected token '%s', got '%s' for test case '%s'", tt.expected, token, tt.description)
}
}
})
}
}

View file

@ -1,15 +0,0 @@
variable "default_region" {}
provider "stackit" {
default_region = var.default_region
enable_beta_resources = true
}
ephemeral "stackit_access_token" "example" {}
provider "echo" {
data = ephemeral.stackit_access_token.example
}
resource "echo" "example" {
}

View file

@ -1,16 +0,0 @@
{
"id": "cad1592f-1fe6-4fd1-a6d6-ccef94b01697",
"publicKey": "-----BEGIN PUBLIC KEY-----\nABC\n-----END PUBLIC KEY-----",
"createdAt": "2025-11-25T15:19:30.689+00:00",
"keyType": "USER_MANAGED",
"keyOrigin": "GENERATED",
"keyAlgorithm": "RSA_2048",
"active": true,
"credentials": {
"kid": "cad1592f-1fe6-4fd1-a6d6-ccef94b01697",
"iss": "foo.bar@sa.stackit.cloud",
"sub": "cad1592f-1fe6-4fd1-a6d6-ccef94b01697",
"aud": "https://stackit-service-account-prod.apps.01.cf.eu01.stackit.cloud",
"privateKey": "-----BEGIN PRIVATE KEY-----\nABC\n-----END PRIVATE KEY-----"
}
}

View file

@ -1,407 +0,0 @@
package cdn_test
import (
"context"
cryptoRand "crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"net"
"strings"
"testing"
"time"
"github.com/google/uuid"
"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\"",
"blocked_countries": "\"CU\", \"AQ\"", // Do NOT use DE or AT here, because the request might be blocked by bunny at the time of creation - don't lock yourself out
"custom_domain_prefix": uuid.NewString(), // we use a different domain prefix each test run due to inconsistent upstream release of domains, which might impair consecutive test runs
"dns_name": fmt.Sprintf("tf-acc-%s.stackit.gg", strings.Split(uuid.NewString(), "-")[0]),
}
func configResources(regions string, geofencingCountries []string) string {
var quotedCountries []string
for _, country := range geofencingCountries {
quotedCountries = append(quotedCountries, fmt.Sprintf(`%q`, country))
}
geofencingList := strings.Join(quotedCountries, ",")
return fmt.Sprintf(`
%s
resource "stackit_cdn_distribution" "distribution" {
project_id = "%s"
config = {
backend = {
type = "http"
origin_url = "%s"
geofencing = {
"%s" = [%s]
}
}
regions = [%s]
blocked_countries = [%s]
optimizer = {
enabled = true
}
}
}
resource "stackit_dns_zone" "dns_zone" {
project_id = "%s"
name = "cdn_acc_test_zone"
dns_name = "%s"
contact_email = "aa@bb.cc"
type = "primary"
default_ttl = 3600
}
resource "stackit_dns_record_set" "dns_record" {
project_id = "%s"
zone_id = stackit_dns_zone.dns_zone.zone_id
name = "%s"
type = "CNAME"
records = ["${stackit_cdn_distribution.distribution.domains[0].name}."]
}
`, testutil.CdnProviderConfig(), testutil.ProjectId, instanceResource["config_backend_origin_url"], instanceResource["config_backend_origin_url"], geofencingList,
regions, instanceResource["blocked_countries"], testutil.ProjectId, instanceResource["dns_name"],
testutil.ProjectId, instanceResource["custom_domain_prefix"])
}
func configCustomDomainResources(regions, cert, key string, geofencingCountries []string) string {
return fmt.Sprintf(`
%s
resource "stackit_cdn_custom_domain" "custom_domain" {
project_id = stackit_cdn_distribution.distribution.project_id
distribution_id = stackit_cdn_distribution.distribution.distribution_id
name = "${stackit_dns_record_set.dns_record.name}.${stackit_dns_zone.dns_zone.dns_name}"
certificate = {
certificate = %q
private_key = %q
}
}
`, configResources(regions, geofencingCountries), cert, key)
}
func configDatasources(regions, cert, key string, geofencingCountries []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
}
data "stackit_cdn_custom_domain" "custom_domain" {
project_id = stackit_cdn_custom_domain.custom_domain.project_id
distribution_id = stackit_cdn_custom_domain.custom_domain.distribution_id
name = stackit_cdn_custom_domain.custom_domain.name
}
`, configCustomDomainResources(regions, cert, key, geofencingCountries))
}
func makeCertAndKey(t *testing.T, organization string) (cert, key []byte) {
privateKey, err := rsa.GenerateKey(cryptoRand.Reader, 2048)
if err != nil {
t.Fatalf("failed to generate key: %s", err.Error())
}
template := x509.Certificate{
SerialNumber: big.NewInt(1),
Issuer: pkix.Name{CommonName: organization},
Subject: pkix.Name{
Organization: []string{organization},
},
NotBefore: time.Now(),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
cert, err = x509.CreateCertificate(
cryptoRand.Reader,
&template,
&template,
&privateKey.PublicKey,
privateKey,
)
if err != nil {
t.Fatalf("failed to generate cert: %s", err.Error())
}
return pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: cert,
}), pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(privateKey),
})
}
func TestAccCDNDistributionResource(t *testing.T) {
fullDomainName := fmt.Sprintf("%s.%s", instanceResource["custom_domain_prefix"], instanceResource["dns_name"])
organization := fmt.Sprintf("organization-%s", uuid.NewString())
cert, key := makeCertAndKey(t, organization)
geofencing := []string{"DE", "ES"}
organization_updated := fmt.Sprintf("organization-updated-%s", uuid.NewString())
cert_updated, key_updated := makeCertAndKey(t, organization_updated)
resource.Test(t, resource.TestCase{
ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories,
CheckDestroy: testAccCheckCDNDistributionDestroy,
Steps: []resource.TestStep{
// Distribution Create
{
Config: configResources(instanceResource["config_regions"], geofencing),
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.type", "managed"),
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", "config.blocked_countries.#", "2"),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.blocked_countries.0", "CU"),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.blocked_countries.1", "AQ"),
resource.TestCheckResourceAttr(
"stackit_cdn_distribution.distribution",
fmt.Sprintf("config.backend.geofencing.%s.0", instanceResource["config_backend_origin_url"]),
"DE",
),
resource.TestCheckResourceAttr(
"stackit_cdn_distribution.distribution",
fmt.Sprintf("config.backend.geofencing.%s.1", instanceResource["config_backend_origin_url"]),
"ES",
),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.optimizer.enabled", "true"),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "project_id", testutil.ProjectId),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "status", "ACTIVE"),
),
},
// Wait step, that confirms the CNAME record has "propagated"
{
Config: configResources(instanceResource["config_regions"], geofencing),
Check: func(_ *terraform.State) error {
_, err := blockUntilDomainResolves(fullDomainName)
return err
},
},
// Custom Domain Create
{
Config: configCustomDomainResources(instanceResource["config_regions"], string(cert), string(key), geofencing),
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttr("stackit_cdn_custom_domain.custom_domain", "status", "ACTIVE"),
resource.TestCheckResourceAttr("stackit_cdn_custom_domain.custom_domain", "name", fullDomainName),
resource.TestCheckResourceAttrPair("stackit_cdn_distribution.distribution", "distribution_id", "stackit_cdn_custom_domain.custom_domain", "distribution_id"),
resource.TestCheckResourceAttrPair("stackit_cdn_distribution.distribution", "project_id", "stackit_cdn_custom_domain.custom_domain", "project_id"),
),
},
// 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,
ImportStateVerifyIgnore: []string{"domains"}, // we added a domain in the meantime...
},
{
ResourceName: "stackit_cdn_custom_domain.custom_domain",
ImportStateIdFunc: func(s *terraform.State) (string, error) {
r, ok := s.RootModule().Resources["stackit_cdn_custom_domain.custom_domain"]
if !ok {
return "", fmt.Errorf("couldn't find resource stackit_cdn_custom_domain.custom_domain")
}
distributionId, ok := r.Primary.Attributes["distribution_id"]
if !ok {
return "", fmt.Errorf("couldn't find attribute distribution_id")
}
name, ok := r.Primary.Attributes["name"]
if !ok {
return "", fmt.Errorf("couldn't find attribute name")
}
return fmt.Sprintf("%s,%s,%s", testutil.ProjectId, distributionId, name), nil
},
ImportState: true,
ImportStateVerify: true,
ImportStateVerifyIgnore: []string{
"certificate.certificate",
"certificate.private_key",
},
},
// Data Source
{
Config: configDatasources(instanceResource["config_regions"], string(cert), string(key), geofencing),
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttrSet("data.stackit_cdn_distribution.distribution", "distribution_id"),
resource.TestCheckResourceAttrSet("data.stackit_cdn_distribution.distribution", "created_at"),
resource.TestCheckResourceAttrSet("data.stackit_cdn_distribution.distribution", "updated_at"),
resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "domains.#", "2"),
resource.TestCheckResourceAttrSet("data.stackit_cdn_distribution.distribution", "domains.0.name"),
resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "domains.1.name", fullDomainName),
resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "domains.0.status", "ACTIVE"),
resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "domains.1.status", "ACTIVE"),
resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "domains.0.type", "managed"),
resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "domains.1.type", "custom"),
resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "config.regions.#", "2"),
resource.TestCheckResourceAttr(
"data.stackit_cdn_distribution.distribution",
fmt.Sprintf("config.backend.geofencing.%s.0", instanceResource["config_backend_origin_url"]),
"DE",
),
resource.TestCheckResourceAttr(
"data.stackit_cdn_distribution.distribution",
fmt.Sprintf("config.backend.geofencing.%s.1", instanceResource["config_backend_origin_url"]),
"ES",
),
resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "config.regions.0", "EU"),
resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "config.regions.1", "US"),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.blocked_countries.#", "2"),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.blocked_countries.0", "CU"),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.blocked_countries.1", "AQ"),
resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "config.optimizer.enabled", "true"),
resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "project_id", testutil.ProjectId),
resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "status", "ACTIVE"),
resource.TestCheckResourceAttr("data.stackit_cdn_custom_domain.custom_domain", "status", "ACTIVE"),
resource.TestCheckResourceAttr("data.stackit_cdn_custom_domain.custom_domain", "certificate.version", "1"),
resource.TestCheckResourceAttr("data.stackit_cdn_custom_domain.custom_domain", "name", fullDomainName),
resource.TestCheckResourceAttrPair("stackit_cdn_distribution.distribution", "distribution_id", "stackit_cdn_custom_domain.custom_domain", "distribution_id"),
),
},
// Update
{
Config: configCustomDomainResources(instanceResource["config_regions_updated"], string(cert_updated), string(key_updated), geofencing),
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.#", "2"),
resource.TestCheckResourceAttrSet("stackit_cdn_distribution.distribution", "domains.0.name"),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "domains.1.name", fullDomainName),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "domains.0.status", "ACTIVE"),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "domains.1.status", "ACTIVE"),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "domains.0.type", "managed"),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "domains.1.type", "custom"),
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", "config.blocked_countries.#", "2"),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.blocked_countries.0", "CU"),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.blocked_countries.1", "AQ"),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.optimizer.enabled", "true"),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "project_id", testutil.ProjectId),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "status", "ACTIVE"),
resource.TestCheckResourceAttr("stackit_cdn_custom_domain.custom_domain", "status", "ACTIVE"),
resource.TestCheckResourceAttr("stackit_cdn_custom_domain.custom_domain", "certificate.version", "2"),
resource.TestCheckResourceAttr("stackit_cdn_custom_domain.custom_domain", "name", fullDomainName),
resource.TestCheckResourceAttrPair("stackit_cdn_distribution.distribution", "distribution_id", "stackit_cdn_custom_domain.custom_domain", "distribution_id"),
resource.TestCheckResourceAttrPair("stackit_cdn_distribution.distribution", "project_id", "stackit_cdn_custom_domain.custom_domain", "project_id"),
),
},
},
})
}
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
}
const (
recordCheckInterval time.Duration = 3 * time.Second
recordCheckAttempts = 100 // wait up to 5 minutes for record to be come available (normally takes less than 2 minutes)
)
func blockUntilDomainResolves(domain string) (net.IP, error) {
// wait until it becomes ready
isReady := func() (net.IP, error) {
ips, err := net.LookupIP(domain)
if err != nil {
return nil, fmt.Errorf("error looking up IP for domain %s: %w", domain, err)
}
for _, ip := range ips {
if ip.String() != "<nil>" {
return ip, nil
}
}
return nil, fmt.Errorf("no IP for domain: %v", domain)
}
return retry(recordCheckAttempts, recordCheckInterval, isReady)
}
func retry[T any](attempts int, sleep time.Duration, f func() (T, error)) (T, error) {
var zero T
var errOuter error
for i := 0; i < attempts; i++ {
dist, err := f()
if err == nil {
return dist, nil
}
errOuter = err
time.Sleep(sleep)
}
return zero, fmt.Errorf("retry timed out, last error: %w", errOuter)
}

View file

@ -1,236 +0,0 @@
package cdn
import (
"context"
"errors"
"fmt"
"net/http"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
cdnUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/cdn/utils"
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/datasource"
"github.com/hashicorp/terraform-plugin-framework/datasource/schema"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/stackitcloud/stackit-sdk-go/core/oapierror"
"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/validate"
)
// Ensure the implementation satisfies the expected interfaces.
var (
_ datasource.DataSource = &customDomainDataSource{}
_ datasource.DataSourceWithConfigure = &customDomainDataSource{}
)
var certificateDataSourceTypes = map[string]attr.Type{
"version": types.Int64Type,
}
type customDomainDataSource struct {
client *cdn.APIClient
}
func NewCustomDomainDataSource() datasource.DataSource {
return &customDomainDataSource{}
}
type customDomainDataSourceModel struct {
ID types.String `tfsdk:"id"`
DistributionId types.String `tfsdk:"distribution_id"`
ProjectId types.String `tfsdk:"project_id"`
Name types.String `tfsdk:"name"`
Status types.String `tfsdk:"status"`
Errors types.List `tfsdk:"errors"`
Certificate types.Object `tfsdk:"certificate"`
}
func (d *customDomainDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
providerData, ok := conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics)
if !ok {
return
}
features.CheckBetaResourcesEnabled(ctx, &providerData, &resp.Diagnostics, "stackit_cdn_custom_domain", core.Datasource)
if resp.Diagnostics.HasError() {
return
}
apiClient := cdnUtils.ConfigureClient(ctx, &providerData, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}
d.client = apiClient
tflog.Info(ctx, "CDN client configured")
}
func (r *customDomainDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_cdn_custom_domain"
}
func (r *customDomainDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: features.AddBetaDescription("CDN distribution data source schema.", core.Datasource),
Description: "CDN distribution data source schema.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: customDomainSchemaDescriptions["id"],
Computed: true,
},
"name": schema.StringAttribute{
Description: customDomainSchemaDescriptions["name"],
Required: true,
},
"distribution_id": schema.StringAttribute{
Description: customDomainSchemaDescriptions["distribution_id"],
Required: true,
Validators: []validator.String{validate.UUID()},
},
"project_id": schema.StringAttribute{
Description: customDomainSchemaDescriptions["project_id"],
Required: true,
},
"status": schema.StringAttribute{
Computed: true,
Description: customDomainSchemaDescriptions["status"],
},
"errors": schema.ListAttribute{
ElementType: types.StringType,
Computed: true,
Description: customDomainSchemaDescriptions["errors"],
},
"certificate": schema.SingleNestedAttribute{
Description: certificateSchemaDescriptions["main"],
Optional: true,
Attributes: map[string]schema.Attribute{
"version": schema.Int64Attribute{
Description: certificateSchemaDescriptions["version"],
Computed: true,
},
},
},
},
}
}
func (r *customDomainDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform
var model customDomainDataSourceModel // Use the new data source model
diags := req.Config.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
ctx = core.InitProviderContext(ctx)
projectId := model.ProjectId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
distributionId := model.DistributionId.ValueString()
ctx = tflog.SetField(ctx, "distribution_id", distributionId)
name := model.Name.ValueString()
ctx = tflog.SetField(ctx, "name", name)
customDomainResp, err := r.client.GetCustomDomain(ctx, projectId, distributionId, name).Execute()
if err != nil {
var oapiErr *oapierror.GenericOpenAPIError
if errors.As(err, &oapiErr) {
if oapiErr.StatusCode == http.StatusNotFound {
resp.State.RemoveResource(ctx)
return
}
}
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading CDN custom domain", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
// Call the new data source mapping function
err = mapCustomDomainDataSourceFields(customDomainResp, &model, projectId, distributionId)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading CDN custom domain", 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 custom domain read")
}
func mapCustomDomainDataSourceFields(customDomainResponse *cdn.GetCustomDomainResponse, model *customDomainDataSourceModel, projectId, distributionId string) error {
if customDomainResponse == nil {
return fmt.Errorf("response input is nil")
}
if model == nil {
return fmt.Errorf("model input is nil")
}
if customDomainResponse.CustomDomain == nil {
return fmt.Errorf("CustomDomain is missing in response")
}
if customDomainResponse.CustomDomain.Name == nil {
return fmt.Errorf("name is missing in response")
}
if customDomainResponse.CustomDomain.Status == nil {
return fmt.Errorf("status missing in response")
}
normalizedCert, err := normalizeCertificate(customDomainResponse.Certificate)
if err != nil {
return fmt.Errorf("Certificate error in normalizer: %w", err)
}
// If the certificate is managed, the certificate block in the state should be null.
if normalizedCert.Type == "managed" {
model.Certificate = types.ObjectNull(certificateDataSourceTypes)
} else {
// For custom certificates, we only care about the version.
version := types.Int64Null()
if normalizedCert.Version != nil {
version = types.Int64Value(*normalizedCert.Version)
}
certificateObj, diags := types.ObjectValue(certificateDataSourceTypes, map[string]attr.Value{
"version": version,
})
if diags.HasError() {
return fmt.Errorf("failed to map certificate: %w", core.DiagsToError(diags))
}
model.Certificate = certificateObj
}
model.ID = types.StringValue(fmt.Sprintf("%s,%s,%s", projectId, distributionId, *customDomainResponse.CustomDomain.Name))
model.Status = types.StringValue(string(*customDomainResponse.CustomDomain.Status))
customDomainErrors := []attr.Value{}
if customDomainResponse.CustomDomain.Errors != nil {
for _, e := range *customDomainResponse.CustomDomain.Errors {
if e.En == nil {
return fmt.Errorf("error description missing")
}
customDomainErrors = append(customDomainErrors, types.StringValue(*e.En))
}
}
modelErrors, diags := types.ListValue(types.StringType, customDomainErrors)
if diags.HasError() {
return core.DiagsToError(diags)
}
model.Errors = modelErrors
// Also map the fields back to the model from the config
model.ProjectId = types.StringValue(projectId)
model.DistributionId = types.StringValue(distributionId)
model.Name = types.StringValue(*customDomainResponse.CustomDomain.Name)
return nil
}

View file

@ -1,137 +0,0 @@
package cdn
import (
"testing"
"github.com/google/go-cmp/cmp"
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/stackitcloud/stackit-sdk-go/services/cdn"
)
func TestMapDataSourceFields(t *testing.T) {
emtpyErrorsList := types.ListValueMust(types.StringType, []attr.Value{})
// Expected certificate object when a custom certificate is returned
certAttributes := map[string]attr.Value{
"version": types.Int64Value(3),
}
certificateObj, _ := types.ObjectValue(certificateDataSourceTypes, certAttributes)
// Helper to create expected model instances
expectedModel := func(mods ...func(*customDomainDataSourceModel)) *customDomainDataSourceModel {
model := &customDomainDataSourceModel{
ID: types.StringValue("test-project-id,test-distribution-id,https://testdomain.com"),
DistributionId: types.StringValue("test-distribution-id"),
ProjectId: types.StringValue("test-project-id"),
Name: types.StringValue("https://testdomain.com"),
Status: types.StringValue("ACTIVE"),
Errors: emtpyErrorsList,
Certificate: types.ObjectUnknown(certificateDataSourceTypes),
}
for _, mod := range mods {
mod(model)
}
return model
}
// API response fixtures for custom and managed certificates
customType := "custom"
customVersion := int64(3)
getRespCustom := cdn.GetCustomDomainResponseGetCertificateAttributeType(&cdn.GetCustomDomainResponseCertificate{
GetCustomDomainCustomCertificate: &cdn.GetCustomDomainCustomCertificate{
Type: &customType,
Version: &customVersion,
},
})
managedType := "managed"
getRespManaged := cdn.GetCustomDomainResponseGetCertificateAttributeType(&cdn.GetCustomDomainResponseCertificate{
GetCustomDomainManagedCertificate: &cdn.GetCustomDomainManagedCertificate{
Type: &managedType,
},
})
// Helper to create API response fixtures
customDomainFixture := func(mods ...func(*cdn.GetCustomDomainResponse)) *cdn.GetCustomDomainResponse {
distribution := &cdn.CustomDomain{
Errors: &[]cdn.StatusError{},
Name: cdn.PtrString("https://testdomain.com"),
Status: cdn.DOMAINSTATUS_ACTIVE.Ptr(),
}
customDomainResponse := &cdn.GetCustomDomainResponse{
CustomDomain: distribution,
Certificate: getRespCustom,
}
for _, mod := range mods {
mod(customDomainResponse)
}
return customDomainResponse
}
// Test cases
tests := map[string]struct {
Input *cdn.GetCustomDomainResponse
Expected *customDomainDataSourceModel
IsValid bool
}{
"happy_path_custom_cert": {
Expected: expectedModel(func(m *customDomainDataSourceModel) {
m.Certificate = certificateObj
}),
Input: customDomainFixture(),
IsValid: true,
},
"happy_path_managed_cert": {
Expected: expectedModel(func(m *customDomainDataSourceModel) {
m.Certificate = types.ObjectNull(certificateDataSourceTypes)
}),
Input: customDomainFixture(func(gcdr *cdn.GetCustomDomainResponse) {
gcdr.Certificate = getRespManaged
}),
IsValid: true,
},
"happy_path_status_error": {
Expected: expectedModel(func(m *customDomainDataSourceModel) {
m.Status = types.StringValue("ERROR")
m.Certificate = certificateObj
}),
Input: customDomainFixture(func(d *cdn.GetCustomDomainResponse) {
d.CustomDomain.Status = cdn.DOMAINSTATUS_ERROR.Ptr()
}),
IsValid: true,
},
"sad_path_response_nil": {
Expected: expectedModel(),
Input: nil,
IsValid: false,
},
"sad_path_name_missing": {
Expected: expectedModel(),
Input: customDomainFixture(func(d *cdn.GetCustomDomainResponse) {
d.CustomDomain.Name = nil
}),
IsValid: false,
},
}
for tn, tc := range tests {
t.Run(tn, func(t *testing.T) {
model := &customDomainDataSourceModel{}
err := mapCustomDomainDataSourceFields(tc.Input, model, "test-project-id", "test-distribution-id")
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(tc.Expected, model)
if diff != "" {
t.Fatalf("Mapped model not as expected (-want +got):\n%s", diff)
}
}
})
}
}

View file

@ -1,534 +0,0 @@
package cdn
import (
"context"
"encoding/base64"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
cdnUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/cdn/utils"
"github.com/google/uuid"
"github.com/hashicorp/terraform-plugin-framework/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/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 = &customDomainResource{}
_ resource.ResourceWithConfigure = &customDomainResource{}
_ resource.ResourceWithImportState = &customDomainResource{}
)
var certificateSchemaDescriptions = map[string]string{
"main": "The TLS certificate for the custom domain. If omitted, a managed certificate will be used. If the block is specified, a custom certificate is used.",
"certificate": "The PEM-encoded TLS certificate. Required for custom certificates.",
"private_key": "The PEM-encoded private key for the certificate. Required for custom certificates. The certificate will be updated if this field is changed.",
"version": "A version identifier for the certificate. Required for custom certificates. The certificate will be updated if this field is changed.",
}
var certificateTypes = map[string]attr.Type{
"version": types.Int64Type,
"certificate": types.StringType,
"private_key": types.StringType,
}
var customDomainSchemaDescriptions = 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",
"errors": "List of distribution errors",
}
type CertificateModel struct {
Certificate types.String `tfsdk:"certificate"`
PrivateKey types.String `tfsdk:"private_key"`
Version types.Int64 `tfsdk:"version"`
}
type CustomDomainModel 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
Name types.String `tfsdk:"name"` // The custom domain
Status types.String `tfsdk:"status"` // The status of the cdn distribution
Errors types.List `tfsdk:"errors"` // Any errors that the distribution has
Certificate types.Object `tfsdk:"certificate"` // the certificate of the custom domain
}
type customDomainResource struct {
client *cdn.APIClient
}
func NewCustomDomainResource() resource.Resource {
return &customDomainResource{}
}
type Certificate struct {
Type string
Version *int64
}
func (r *customDomainResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
providerData, ok := conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics)
if !ok {
return
}
features.CheckBetaResourcesEnabled(ctx, &providerData, &resp.Diagnostics, "stackit_cdn_custom_domain", "resource")
if resp.Diagnostics.HasError() {
return
}
apiClient := cdnUtils.ConfigureClient(ctx, &providerData, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}
r.client = apiClient
tflog.Info(ctx, "CDN client configured")
}
func (r *customDomainResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_cdn_custom_domain"
}
func (r *customDomainResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: features.AddBetaDescription("CDN distribution data source schema.", core.Resource),
Description: "CDN distribution data source schema.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: customDomainSchemaDescriptions["id"],
Computed: true,
},
"name": schema.StringAttribute{
Description: customDomainSchemaDescriptions["name"],
Required: true,
Optional: false,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"distribution_id": schema.StringAttribute{
Description: customDomainSchemaDescriptions["distribution_id"],
Required: true,
Optional: false,
Validators: []validator.String{validate.UUID()},
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"project_id": schema.StringAttribute{
Description: customDomainSchemaDescriptions["project_id"],
Required: true,
Optional: false,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"certificate": schema.SingleNestedAttribute{
Description: certificateSchemaDescriptions["main"],
Optional: true,
Attributes: map[string]schema.Attribute{
"certificate": schema.StringAttribute{
Description: certificateSchemaDescriptions["certificate"],
Optional: true,
Sensitive: true,
},
"private_key": schema.StringAttribute{
Description: certificateSchemaDescriptions["private_key"],
Optional: true,
Sensitive: true,
},
"version": schema.Int64Attribute{
Description: certificateSchemaDescriptions["version"],
Computed: true,
},
},
},
"status": schema.StringAttribute{
Computed: true,
Description: customDomainSchemaDescriptions["status"],
},
"errors": schema.ListAttribute{
ElementType: types.StringType,
Computed: true,
Description: customDomainSchemaDescriptions["errors"],
},
},
}
}
func (r *customDomainResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform
var model CustomDomainModel
diags := req.Plan.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
ctx = core.InitProviderContext(ctx)
projectId := model.ProjectId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
distributionId := model.DistributionId.ValueString()
ctx = tflog.SetField(ctx, "distribution_id", distributionId)
name := model.Name.ValueString()
ctx = tflog.SetField(ctx, "name", name)
certificate, err := toCertificatePayload(ctx, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating CDN custom domain", fmt.Sprintf("Creating API payload: %v", err))
return
}
payload := cdn.PutCustomDomainPayload{
IntentId: cdn.PtrString(uuid.NewString()),
Certificate: certificate,
}
_, err = r.client.PutCustomDomain(ctx, projectId, distributionId, name).PutCustomDomainPayload(payload).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating CDN custom domain", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
_, err = wait.CreateCDNCustomDomainWaitHandler(ctx, r.client, projectId, distributionId, name).SetTimeout(5 * time.Minute).WaitWithContext(ctx)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating CDN custom domain", fmt.Sprintf("Waiting for create: %v", err))
return
}
respCustomDomain, err := r.client.GetCustomDomainExecute(ctx, projectId, distributionId, name)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating CDN custom domain", fmt.Sprintf("Calling API: %v", err))
return
}
err = mapCustomDomainResourceFields(respCustomDomain, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating CDN custom domain", 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 custom domain created")
}
func (r *customDomainResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform
var model CustomDomainModel
diags := req.State.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
ctx = core.InitProviderContext(ctx)
projectId := model.ProjectId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
distributionId := model.DistributionId.ValueString()
ctx = tflog.SetField(ctx, "distribution_id", distributionId)
name := model.Name.ValueString()
ctx = tflog.SetField(ctx, "name", name)
customDomainResp, err := r.client.GetCustomDomain(ctx, projectId, distributionId, name).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 custom domain", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
err = mapCustomDomainResourceFields(customDomainResp, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading CDN custom domain", 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 custom domain read")
}
func (r *customDomainResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform
var model CustomDomainModel
diags := req.Plan.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
ctx = core.InitProviderContext(ctx)
projectId := model.ProjectId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
distributionId := model.DistributionId.ValueString()
ctx = tflog.SetField(ctx, "distribution_id", distributionId)
name := model.Name.ValueString()
ctx = tflog.SetField(ctx, "name", name)
certificate, err := toCertificatePayload(ctx, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating CDN custom domain", fmt.Sprintf("Creating API payload: %v", err))
return
}
payload := cdn.PutCustomDomainPayload{
IntentId: cdn.PtrString(uuid.NewString()),
Certificate: certificate,
}
_, err = r.client.PutCustomDomain(ctx, projectId, distributionId, name).PutCustomDomainPayload(payload).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating CDN custom domain certificate", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
_, err = wait.CreateCDNCustomDomainWaitHandler(ctx, r.client, projectId, distributionId, name).SetTimeout(5 * time.Minute).WaitWithContext(ctx)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating CDN custom domain certificate", fmt.Sprintf("Waiting for update: %v", err))
return
}
respCustomDomain, err := r.client.GetCustomDomainExecute(ctx, projectId, distributionId, name)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating CDN custom domain certificate", fmt.Sprintf("Calling API to read final state: %v", err))
return
}
err = mapCustomDomainResourceFields(respCustomDomain, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating CDN custom domain certificate", 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 custom domain certificate updated")
}
func (r *customDomainResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform
var model CustomDomainModel
diags := req.State.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
ctx = core.InitProviderContext(ctx)
projectId := model.ProjectId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
distributionId := model.DistributionId.ValueString()
ctx = tflog.SetField(ctx, "distribution_id", distributionId)
name := model.Name.ValueString()
ctx = tflog.SetField(ctx, "name", name)
_, err := r.client.DeleteCustomDomain(ctx, projectId, distributionId, name).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Delete CDN custom domain", fmt.Sprintf("Delete custom domain: %v", err))
}
ctx = core.LogResponse(ctx)
_, err = wait.DeleteCDNCustomDomainWaitHandler(ctx, r.client, projectId, distributionId, name).WaitWithContext(ctx)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Delete CDN custom domain", fmt.Sprintf("Waiting for deletion: %v", err))
return
}
tflog.Info(ctx, "CDN custom domain deleted")
}
func (r *customDomainResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
idParts := strings.Split(req.ID, core.Separator)
if len(idParts) != 3 || idParts[0] == "" || idParts[1] == "" || idParts[2] == "" {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error importing CDN custom domain", fmt.Sprintf("Expected import identifier on the format: [project_id]%q[distribution_id]%q[custom_domain_name], got %q", core.Separator, 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])...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("name"), idParts[2])...)
tflog.Info(ctx, "CDN custom domain state imported")
}
func normalizeCertificate(certInput cdn.GetCustomDomainResponseGetCertificateAttributeType) (Certificate, error) {
var customCert *cdn.GetCustomDomainCustomCertificate
var managedCert *cdn.GetCustomDomainManagedCertificate
if certInput == nil {
return Certificate{}, errors.New("input of type GetCustomDomainResponseCertificate is nil")
}
customCert = certInput.GetCustomDomainCustomCertificate
managedCert = certInput.GetCustomDomainManagedCertificate
// Now we process the extracted certificates
if customCert != nil && customCert.Type != nil && customCert.Version != nil {
return Certificate{
Type: *customCert.Type,
Version: customCert.Version, // Converts from *int64 to int
}, nil
}
if managedCert != nil && managedCert.Type != nil {
// The version will be the zero value for int (0), as requested
return Certificate{
Type: *managedCert.Type,
}, nil
}
return Certificate{}, errors.New("certificate structure is empty, neither custom nor managed is set")
}
// toCertificatePayload constructs the certificate part of the payload for the API request.
// It defaults to a managed certificate if the certificate block is omitted, otherwise it creates a custom certificate.
func toCertificatePayload(ctx context.Context, model *CustomDomainModel) (*cdn.PutCustomDomainPayloadCertificate, error) {
// If the certificate block is not specified, default to a managed certificate.
if model.Certificate.IsNull() {
managedCert := cdn.NewPutCustomDomainManagedCertificate("managed")
certPayload := cdn.PutCustomDomainManagedCertificateAsPutCustomDomainPayloadCertificate(managedCert)
return &certPayload, nil
}
var certModel CertificateModel
// Unpack the Terraform object into the temporary struct.
respDiags := model.Certificate.As(ctx, &certModel, basetypes.ObjectAsOptions{})
if respDiags.HasError() {
return nil, fmt.Errorf("invalid certificate or private key: %w", core.DiagsToError(respDiags))
}
if utils.IsUndefined(certModel.Certificate) || utils.IsUndefined(certModel.PrivateKey) {
return nil, fmt.Errorf(`"certificate" and "private_key" must be set`)
}
certStr := base64.StdEncoding.EncodeToString([]byte(certModel.Certificate.ValueString()))
keyStr := base64.StdEncoding.EncodeToString([]byte(certModel.PrivateKey.ValueString()))
if certStr == "" || keyStr == "" {
return nil, errors.New("invalid certificate or private key. Please check if the string of the public certificate and private key in PEM format")
}
customCert := cdn.NewPutCustomDomainCustomCertificate(
certStr,
keyStr,
"custom",
)
certPayload := cdn.PutCustomDomainCustomCertificateAsPutCustomDomainPayloadCertificate(customCert)
return &certPayload, nil
}
func mapCustomDomainResourceFields(customDomainResponse *cdn.GetCustomDomainResponse, model *CustomDomainModel) error {
if customDomainResponse == nil {
return fmt.Errorf("response input is nil")
}
if model == nil {
return fmt.Errorf("model input is nil")
}
if customDomainResponse.CustomDomain == nil {
return fmt.Errorf("CustomDomain is missing in response")
}
if customDomainResponse.CustomDomain.Name == nil {
return fmt.Errorf("name is missing in response")
}
if customDomainResponse.CustomDomain.Status == nil {
return fmt.Errorf("status missing in response")
}
normalizedCert, err := normalizeCertificate(customDomainResponse.Certificate)
if err != nil {
return fmt.Errorf("Certificate error in normalizer: %w", err)
}
// If the certificate is managed, the certificate block in the state should be null.
if normalizedCert.Type == "managed" {
model.Certificate = types.ObjectNull(certificateTypes)
} else {
// If the certificate is custom, we need to preserve the user-configured
// certificate and private key from the plan/state, and only update the computed version.
certAttributes := map[string]attr.Value{
"certificate": types.StringNull(), // Default to null
"private_key": types.StringNull(), // Default to null
"version": types.Int64Null(),
}
// Get existing values from the model's certificate object if it exists
if !model.Certificate.IsNull() {
existingAttrs := model.Certificate.Attributes()
if val, ok := existingAttrs["certificate"]; ok {
certAttributes["certificate"] = val
}
if val, ok := existingAttrs["private_key"]; ok {
certAttributes["private_key"] = val
}
}
// Set the computed version from the API response
if normalizedCert.Version != nil {
certAttributes["version"] = types.Int64Value(*normalizedCert.Version)
}
certificateObj, diags := types.ObjectValue(certificateTypes, certAttributes)
if diags.HasError() {
return fmt.Errorf("failed to map certificate: %w", core.DiagsToError(diags))
}
model.Certificate = certificateObj
}
model.ID = utils.BuildInternalTerraformId(model.ProjectId.ValueString(), model.DistributionId.ValueString(), *customDomainResponse.CustomDomain.Name)
model.Status = types.StringValue(string(*customDomainResponse.CustomDomain.Status))
customDomainErrors := []attr.Value{}
if customDomainResponse.CustomDomain.Errors != nil {
for _, e := range *customDomainResponse.CustomDomain.Errors {
if e.En == nil {
return fmt.Errorf("error description missing")
}
customDomainErrors = append(customDomainErrors, types.StringValue(*e.En))
}
}
modelErrors, diags := types.ListValue(types.StringType, customDomainErrors)
if diags.HasError() {
return core.DiagsToError(diags)
}
model.Errors = modelErrors
return nil
}

View file

@ -1,308 +0,0 @@
package cdn
import (
"context"
cryptoRand "crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/pem"
"fmt"
"math/big"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/google/uuid"
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
"github.com/stackitcloud/stackit-sdk-go/services/cdn"
)
func TestMapFields(t *testing.T) {
// Redefine certificateTypes locally for testing, matching the updated schema
certificateTypes := map[string]attr.Type{
"version": types.Int64Type,
"certificate": types.StringType,
"private_key": types.StringType,
}
const dummyCert = "dummy-cert-pem"
const dummyKey = "dummy-key-pem"
emtpyErrorsList := types.ListValueMust(types.StringType, []attr.Value{})
// Expected object when a custom certificate is returned
certAttributes := map[string]attr.Value{
"version": types.Int64Value(3),
"certificate": types.StringValue(dummyCert),
"private_key": types.StringValue(dummyKey),
}
certificateObj, _ := types.ObjectValue(certificateTypes, certAttributes)
expectedModel := func(mods ...func(*CustomDomainModel)) *CustomDomainModel {
model := &CustomDomainModel{
ID: types.StringValue("test-project-id,test-distribution-id,https://testdomain.com"),
DistributionId: types.StringValue("test-distribution-id"),
ProjectId: types.StringValue("test-project-id"),
Status: types.StringValue("ACTIVE"),
Errors: emtpyErrorsList,
Certificate: types.ObjectUnknown(certificateTypes),
}
for _, mod := range mods {
mod(model)
}
return model
}
customType := "custom"
customVersion := int64(3)
getRespCustom := cdn.GetCustomDomainResponseGetCertificateAttributeType(&cdn.GetCustomDomainResponseCertificate{
GetCustomDomainCustomCertificate: &cdn.GetCustomDomainCustomCertificate{
Type: &customType,
Version: &customVersion,
},
})
managedType := "managed"
getRespManaged := cdn.GetCustomDomainResponseGetCertificateAttributeType(&cdn.GetCustomDomainResponseCertificate{
GetCustomDomainManagedCertificate: &cdn.GetCustomDomainManagedCertificate{
Type: &managedType,
},
})
customDomainFixture := func(mods ...func(*cdn.GetCustomDomainResponse)) *cdn.GetCustomDomainResponse {
distribution := &cdn.CustomDomain{
Errors: &[]cdn.StatusError{},
Name: cdn.PtrString("https://testdomain.com"),
Status: cdn.DOMAINSTATUS_ACTIVE.Ptr(),
}
customDomainResponse := &cdn.GetCustomDomainResponse{
CustomDomain: distribution,
Certificate: getRespCustom,
}
for _, mod := range mods {
mod(customDomainResponse)
}
return customDomainResponse
}
tests := map[string]struct {
Input *cdn.GetCustomDomainResponse
Certificate interface{}
Expected *CustomDomainModel
InitialModel *CustomDomainModel
IsValid bool
SkipInitialNil bool
}{
"happy_path_custom_cert": {
Expected: expectedModel(func(m *CustomDomainModel) {
m.Certificate = certificateObj
}),
Input: customDomainFixture(),
IsValid: true,
InitialModel: expectedModel(func(m *CustomDomainModel) {
m.Certificate = basetypes.NewObjectValueMust(certificateTypes, map[string]attr.Value{
"certificate": types.StringValue(dummyCert),
"private_key": types.StringValue(dummyKey),
"version": types.Int64Null(),
})
}),
},
"happy_path_managed_cert": {
Expected: expectedModel(func(m *CustomDomainModel) {
m.Certificate = types.ObjectNull(certificateTypes)
}),
Input: customDomainFixture(func(gcdr *cdn.GetCustomDomainResponse) {
gcdr.Certificate = getRespManaged
}),
IsValid: true,
InitialModel: expectedModel(func(m *CustomDomainModel) { m.Certificate = types.ObjectNull(certificateTypes) }),
},
"happy_path_status_error": {
Expected: expectedModel(func(m *CustomDomainModel) {
m.Status = types.StringValue("ERROR")
m.Certificate = certificateObj
}),
Input: customDomainFixture(func(d *cdn.GetCustomDomainResponse) {
d.CustomDomain.Status = cdn.DOMAINSTATUS_ERROR.Ptr()
}),
IsValid: true,
InitialModel: expectedModel(func(m *CustomDomainModel) {
m.Certificate = basetypes.NewObjectValueMust(certificateTypes, map[string]attr.Value{
"certificate": types.StringValue(dummyCert),
"private_key": types.StringValue(dummyKey),
"version": types.Int64Null(),
})
}),
},
"sad_path_custom_domain_nil": {
Expected: expectedModel(),
Input: nil,
IsValid: false,
InitialModel: &CustomDomainModel{},
},
"sad_path_name_missing": {
Expected: expectedModel(),
Input: customDomainFixture(func(d *cdn.GetCustomDomainResponse) {
d.CustomDomain.Name = nil
}),
IsValid: false,
InitialModel: &CustomDomainModel{},
},
}
for tn, tc := range tests {
t.Run(tn, func(t *testing.T) {
model := tc.InitialModel
model.DistributionId = tc.Expected.DistributionId
model.ProjectId = tc.Expected.ProjectId
err := mapCustomDomainResourceFields(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(tc.Expected, model)
if diff != "" {
t.Fatalf("Mapped model not as expected (-want +got):\n%s", diff)
}
}
})
}
}
func makeCertAndKey(t *testing.T, organization string) (cert, key []byte) {
privateKey, err := rsa.GenerateKey(cryptoRand.Reader, 2048)
if err != nil {
t.Fatalf("failed to generate key: %s", err.Error())
}
template := x509.Certificate{
SerialNumber: big.NewInt(1),
Issuer: pkix.Name{CommonName: organization},
Subject: pkix.Name{
Organization: []string{organization},
},
NotBefore: time.Now(),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
cert, err = x509.CreateCertificate(
cryptoRand.Reader,
&template,
&template,
&privateKey.PublicKey,
privateKey,
)
if err != nil {
t.Fatalf("failed to generate cert: %s", err.Error())
}
return pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: cert,
}), pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(privateKey),
})
}
func TestToCertificatePayload(t *testing.T) {
organization := fmt.Sprintf("organization-%s", uuid.NewString())
cert, key := makeCertAndKey(t, organization)
certPEM := string(cert)
keyPEM := string(key)
certBase64 := base64.StdEncoding.EncodeToString(cert)
keyBase64 := base64.StdEncoding.EncodeToString(key)
tests := map[string]struct {
model *CustomDomainModel
expectedPayload *cdn.PutCustomDomainPayloadCertificate
expectErr bool
expectedErrMsg string
}{
"success_managed_when_certificate_block_is_nil": {
model: &CustomDomainModel{
Certificate: types.ObjectNull(certificateTypes),
},
expectedPayload: &cdn.PutCustomDomainPayloadCertificate{
PutCustomDomainManagedCertificate: cdn.NewPutCustomDomainManagedCertificate("managed"),
},
expectErr: false,
},
"success_custom_certificate": {
model: &CustomDomainModel{
Certificate: basetypes.NewObjectValueMust(
certificateTypes,
map[string]attr.Value{
"version": types.Int64Null(),
"certificate": types.StringValue(certPEM),
"private_key": types.StringValue(keyPEM),
},
),
},
expectedPayload: &cdn.PutCustomDomainPayloadCertificate{
PutCustomDomainCustomCertificate: cdn.NewPutCustomDomainCustomCertificate(certBase64, keyBase64, "custom"),
},
expectErr: false,
},
"fail_custom_missing_cert_value": {
model: &CustomDomainModel{
Certificate: basetypes.NewObjectValueMust(
certificateTypes,
map[string]attr.Value{
"version": types.Int64Null(),
"certificate": types.StringValue(""), // Empty certificate
"private_key": types.StringValue(keyPEM),
},
),
},
expectErr: true,
expectedErrMsg: "invalid certificate or private key. Please check if the string of the public certificate and private key in PEM format",
},
"success_managed_when_certificate_attributes_are_nil": {
model: &CustomDomainModel{
Certificate: basetypes.NewObjectValueMust(
certificateTypes,
map[string]attr.Value{
"version": types.Int64Null(),
"certificate": types.StringNull(),
"private_key": types.StringNull(),
},
),
},
expectErr: true,
expectedErrMsg: `"certificate" and "private_key" must be set`,
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
payload, err := toCertificatePayload(context.Background(), tt.model)
if tt.expectErr {
if err == nil {
t.Fatalf("expected err, but got none")
}
if err.Error() != tt.expectedErrMsg {
t.Fatalf("expected err '%s', got '%s'", tt.expectedErrMsg, err.Error())
}
return // Test ends here for failing cases
}
if err != nil {
t.Fatalf("did not expect err, but got: %s", err.Error())
}
if diff := cmp.Diff(tt.expectedPayload, payload); diff != "" {
t.Errorf("payload mismatch (-want +got):\n%s", diff)
}
})
}
}

View file

@ -1,214 +0,0 @@
package cdn
import (
"context"
"fmt"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
cdnUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/cdn/utils"
"github.com/hashicorp/terraform-plugin-framework/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/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) {
providerData, ok := conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics)
if !ok {
return
}
features.CheckBetaResourcesEnabled(ctx, &providerData, &resp.Diagnostics, "stackit_cdn_distribution", "datasource")
if resp.Diagnostics.HasError() {
return
}
apiClient := cdnUtils.ConfigureClient(ctx, &providerData, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
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) {
backendOptions := []string{"http"}
resp.Schema = schema.Schema{
MarkdownDescription: features.AddBetaDescription("CDN distribution data source schema.", core.Datasource),
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"] + utils.FormatPossibleValues(backendOptions...),
},
"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,
},
"geofencing": schema.MapAttribute{
Description: "A map of URLs to a list of countries where content is allowed.",
Computed: true,
ElementType: types.ListType{
ElemType: types.StringType,
},
},
},
},
"regions": schema.ListAttribute{
Computed: true,
Description: schemaDescriptions["config_regions"],
ElementType: types.StringType,
},
"blocked_countries": schema.ListAttribute{
Optional: true,
Description: schemaDescriptions["config_blocked_countries"],
ElementType: types.StringType,
},
"optimizer": schema.SingleNestedAttribute{
Description: schemaDescriptions["config_optimizer"],
Computed: true,
Attributes: map[string]schema.Attribute{
"enabled": schema.BoolAttribute{
Computed: true,
},
},
},
},
},
},
}
}
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
}
ctx = core.InitProviderContext(ctx)
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
}
ctx = core.LogResponse(ctx)
err = mapFields(ctx, distributionResp.Distribution, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading CDN distribution", fmt.Sprintf("Error processing API response: %v", err))
return
}
diags = resp.State.Set(ctx, &model)
resp.Diagnostics.Append(diags...)
}

View file

@ -1,940 +0,0 @@
package cdn
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/google/uuid"
"github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator"
"github.com/hashicorp/terraform-plugin-framework-validators/objectvalidator"
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/diag"
"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/oapierror"
"github.com/stackitcloud/stackit-sdk-go/services/cdn"
"github.com/stackitcloud/stackit-sdk-go/services/cdn/wait"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features"
cdnUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/cdn/utils"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate"
)
// 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_optimizer": "Configuration for the Image Optimizer. This is a paid feature that automatically optimizes images to reduce their file size for faster delivery, leading to improved website performance and a better user experience.",
"config_backend_origin_url": "The configured backend type for the distribution",
"config_backend_origin_request_headers": "The configured origin request headers for the backend",
"config_blocked_countries": "The configured countries where distribution of content is blocked",
"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
BlockedCountries *[]string `tfsdk:"blocked_countries"` // The countries for which content will be blocked
Optimizer types.Object `tfsdk:"optimizer"` // The optimizer configuration
}
type optimizerConfig struct {
Enabled types.Bool `tfsdk:"enabled"`
}
type backend struct {
Type string `tfsdk:"type"` // The type of the backend. Currently, only "http" backend is supported
OriginURL string `tfsdk:"origin_url"` // The origin URL of the backend
OriginRequestHeaders *map[string]string `tfsdk:"origin_request_headers"` // Request headers that should be added by the CDN distribution to incoming requests
Geofencing *map[string][]*string `tfsdk:"geofencing"` // The geofencing is an object mapping multiple alternative origins to country codes.
}
var configTypes = map[string]attr.Type{
"backend": types.ObjectType{AttrTypes: backendTypes},
"regions": types.ListType{ElemType: types.StringType},
"blocked_countries": types.ListType{ElemType: types.StringType},
"optimizer": types.ObjectType{
AttrTypes: optimizerTypes,
},
}
var optimizerTypes = map[string]attr.Type{
"enabled": types.BoolType,
}
var geofencingTypes = types.MapType{ElemType: types.ListType{
ElemType: types.StringType,
}}
var backendTypes = map[string]attr.Type{
"type": types.StringType,
"origin_url": types.StringType,
"origin_request_headers": types.MapType{ElemType: types.StringType},
"geofencing": geofencingTypes,
}
var domainTypes = map[string]attr.Type{
"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) {
var ok bool
r.providerData, ok = conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics)
if !ok {
return
}
features.CheckBetaResourcesEnabled(ctx, &r.providerData, &resp.Diagnostics, "stackit_cdn_distribution", "resource")
if resp.Diagnostics.HasError() {
return
}
apiClient := cdnUtils.ConfigureClient(ctx, &r.providerData, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
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) {
backendOptions := []string{"http"}
resp.Schema = schema.Schema{
MarkdownDescription: features.AddBetaDescription("CDN distribution data source schema.", core.Resource),
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["distribution_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{
"optimizer": schema.SingleNestedAttribute{
Description: schemaDescriptions["config_optimizer"],
Optional: true,
Computed: true,
Attributes: map[string]schema.Attribute{
"enabled": schema.BoolAttribute{
Optional: true,
Computed: true,
},
},
Validators: []validator.Object{
objectvalidator.AlsoRequires(path.MatchRelative().AtName("enabled")),
},
},
"backend": schema.SingleNestedAttribute{
Required: true,
Description: schemaDescriptions["config_backend"],
Attributes: map[string]schema.Attribute{
"type": schema.StringAttribute{
Required: true,
Description: schemaDescriptions["config_backend_type"] + utils.FormatPossibleValues(backendOptions...),
Validators: []validator.String{stringvalidator.OneOf(backendOptions...)},
},
"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,
},
"geofencing": schema.MapAttribute{
Description: "A map of URLs to a list of countries where content is allowed.",
Optional: true,
ElementType: types.ListType{
ElemType: types.StringType,
},
Validators: []validator.Map{
mapvalidator.SizeAtLeast(1),
},
},
},
},
"regions": schema.ListAttribute{
Required: true,
Description: schemaDescriptions["config_regions"],
ElementType: types.StringType,
},
"blocked_countries": schema.ListAttribute{
Optional: true,
Description: schemaDescriptions["config_blocked_countries"],
ElementType: types.StringType,
},
},
},
},
}
}
func (r *distributionResource) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) {
var model Model
resp.Diagnostics.Append(req.Config.Get(ctx, &model)...)
if resp.Diagnostics.HasError() {
return
}
if !utils.IsUndefined(model.Config) {
var config distributionConfig
if !model.Config.IsNull() {
diags := model.Config.As(ctx, &config, basetypes.ObjectAsOptions{})
if diags.HasError() {
return
}
if geofencing := config.Backend.Geofencing; geofencing != nil {
for url, region := range *geofencing {
if region == nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Invalid geofencing config", fmt.Sprintf("The list of countries for URL %q must not be null.", url))
continue
}
if len(region) == 0 {
core.LogAndAddError(ctx, &resp.Diagnostics, "Invalid geofencing config", fmt.Sprintf("The list of countries for URL %q must not be empty.", url))
continue
}
for i, countryPtr := range region {
if countryPtr == nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Invalid geofencing config", fmt.Sprintf("Found a null value in the country list for URL %q at index %d.", url, i))
break
}
}
}
}
}
}
}
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
}
ctx = core.InitProviderContext(ctx)
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
}
ctx = core.LogResponse(ctx)
waitResp, err := wait.CreateDistributionPoolWaitHandler(ctx, r.client, projectId, *createResp.Distribution.Id).SetTimeout(5 * time.Minute).WaitWithContext(ctx)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating CDN distribution", fmt.Sprintf("Waiting for create: %v", err))
return
}
err = mapFields(ctx, waitResp.Distribution, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating CDN distribution", fmt.Sprintf("Processing API payload: %v", err))
return
}
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
}
ctx = core.InitProviderContext(ctx)
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
}
ctx = core.LogResponse(ctx)
err = mapFields(ctx, cdnResp.Distribution, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading CDN ditribution", fmt.Sprintf("Processing API payload: %v", err))
return
}
// 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
}
ctx = core.InitProviderContext(ctx)
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)
}
// blockedCountries
// Use a pointer to a slice to distinguish between an empty list (unblock all) and nil (no change).
var blockedCountries *[]string
if configModel.BlockedCountries != nil {
// Use a temporary slice
tempBlockedCountries := []string{}
for _, blockedCountry := range *configModel.BlockedCountries {
validatedBlockedCountry, err := validateCountryCode(blockedCountry)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Update CDN distribution", fmt.Sprintf("Blocked countries: %v", err))
return
}
tempBlockedCountries = append(tempBlockedCountries, validatedBlockedCountry)
}
// Point to the populated slice
blockedCountries = &tempBlockedCountries
}
geofencingPatch := map[string][]string{}
if configModel.Backend.Geofencing != nil {
gf := make(map[string][]string)
for url, countries := range *configModel.Backend.Geofencing {
countryStrings := make([]string, len(countries))
for i, countryPtr := range countries {
if countryPtr == nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Update CDN distribution", fmt.Sprintf("Geofencing url %q has a null value", url))
return
}
countryStrings[i] = *countryPtr
}
gf[url] = countryStrings
}
geofencingPatch = gf
}
configPatch := &cdn.ConfigPatch{
Backend: &cdn.ConfigPatchBackend{
HttpBackendPatch: &cdn.HttpBackendPatch{
OriginRequestHeaders: configModel.Backend.OriginRequestHeaders,
OriginUrl: &configModel.Backend.OriginURL,
Type: &configModel.Backend.Type,
Geofencing: &geofencingPatch, // Use the converted variable
},
},
Regions: &regions,
BlockedCountries: blockedCountries,
}
if !utils.IsUndefined(configModel.Optimizer) {
var optimizerModel optimizerConfig
diags = configModel.Optimizer.As(ctx, &optimizerModel, basetypes.ObjectAsOptions{})
if diags.HasError() {
core.LogAndAddError(ctx, &resp.Diagnostics, "Update CDN distribution", "Error mapping optimizer config")
return
}
optimizer := cdn.NewOptimizerPatch()
if !utils.IsUndefined(optimizerModel.Enabled) {
optimizer.SetEnabled(optimizerModel.Enabled.ValueBool())
}
configPatch.Optimizer = optimizer
}
_, err := r.client.PatchDistribution(ctx, projectId, distributionId).PatchDistributionPayload(cdn.PatchDistributionPayload{
Config: configPatch,
IntentId: cdn.PtrString(uuid.NewString()),
}).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Update CDN distribution", fmt.Sprintf("Patch distribution: %v", err))
return
}
ctx = core.LogResponse(ctx)
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(ctx, 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
}
ctx = core.LogResponse(ctx)
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))
}
ctx = core.LogResponse(ctx)
_, 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(ctx context.Context, distribution *cdn.Distribution, model *Model) error {
if distribution == nil {
return fmt.Errorf("response input is nil")
}
if model == nil {
return fmt.Errorf("model input is nil")
}
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")
}
model.ID = utils.BuildInternalTerraformId(*distribution.ProjectId, *distribution.Id)
model.DistributionId = types.StringValue(*distribution.Id)
model.ProjectId = types.StringValue(*distribution.ProjectId)
model.Status = types.StringValue(string(distribution.GetStatus()))
model.CreatedAt = types.StringValue(distribution.CreatedAt.String())
model.UpdatedAt = types.StringValue(distribution.UpdatedAt.String())
// distributionErrors
distributionErrors := []attr.Value{}
if distribution.Errors != nil {
for _, e := range *distribution.Errors {
distributionErrors = append(distributionErrors, types.StringValue(*e.En))
}
}
modelErrors, diags := types.ListValue(types.StringType, distributionErrors)
if diags.HasError() {
return core.DiagsToError(diags)
}
model.Errors = modelErrors
// regions
regions := []attr.Value{}
for _, r := range *distribution.Config.Regions {
regions = append(regions, types.StringValue(string(r)))
}
modelRegions, diags := types.ListValue(types.StringType, regions)
if diags.HasError() {
return core.DiagsToError(diags)
}
// blockedCountries
var blockedCountries []attr.Value
if distribution.Config != nil && distribution.Config.BlockedCountries != nil {
for _, c := range *distribution.Config.BlockedCountries {
blockedCountries = append(blockedCountries, types.StringValue(string(c)))
}
}
modelBlockedCountries, diags := types.ListValue(types.StringType, blockedCountries)
if diags.HasError() {
return core.DiagsToError(diags)
}
// originRequestHeaders
originRequestHeaders := types.MapNull(types.StringType)
if origHeaders := distribution.Config.Backend.HttpBackend.OriginRequestHeaders; origHeaders != nil && len(*origHeaders) > 0 {
headers := map[string]attr.Value{}
for k, v := range *origHeaders {
headers[k] = types.StringValue(v)
}
mappedHeaders, diags := types.MapValue(types.StringType, headers)
originRequestHeaders = mappedHeaders
if diags.HasError() {
return core.DiagsToError(diags)
}
}
// geofencing
var oldConfig distributionConfig
oldGeofencingMap := make(map[string][]*string)
if !model.Config.IsNull() {
diags = model.Config.As(ctx, &oldConfig, basetypes.ObjectAsOptions{})
if diags.HasError() {
return core.DiagsToError(diags)
}
if oldConfig.Backend.Geofencing != nil {
oldGeofencingMap = *oldConfig.Backend.Geofencing
}
}
reconciledGeofencingData := make(map[string][]string)
if geofencingAPI := distribution.Config.Backend.HttpBackend.Geofencing; geofencingAPI != nil && len(*geofencingAPI) > 0 {
newGeofencingMap := *geofencingAPI
for url, newCountries := range newGeofencingMap {
oldCountriesPtrs := oldGeofencingMap[url]
oldCountries := utils.ConvertPointerSliceToStringSlice(oldCountriesPtrs)
reconciledCountries := utils.ReconcileStringSlices(oldCountries, newCountries)
reconciledGeofencingData[url] = reconciledCountries
}
}
geofencingVal := types.MapNull(geofencingTypes.ElemType)
if len(reconciledGeofencingData) > 0 {
geofencingMapElems := make(map[string]attr.Value)
for url, countries := range reconciledGeofencingData {
listVal, diags := types.ListValueFrom(ctx, types.StringType, countries)
if diags.HasError() {
return core.DiagsToError(diags)
}
geofencingMapElems[url] = listVal
}
var mappedGeofencing basetypes.MapValue
mappedGeofencing, diags = types.MapValue(geofencingTypes.ElemType, geofencingMapElems)
if diags.HasError() {
return core.DiagsToError(diags)
}
geofencingVal = mappedGeofencing
}
// note that httpbackend is hardcoded here as long as it is the only available backend
backend, diags := types.ObjectValue(backendTypes, map[string]attr.Value{
"type": types.StringValue(*distribution.Config.Backend.HttpBackend.Type),
"origin_url": types.StringValue(*distribution.Config.Backend.HttpBackend.OriginUrl),
"origin_request_headers": originRequestHeaders,
"geofencing": geofencingVal,
})
if diags.HasError() {
return core.DiagsToError(diags)
}
optimizerVal := types.ObjectNull(optimizerTypes)
if o := distribution.Config.Optimizer; o != nil {
optimizerEnabled, ok := o.GetEnabledOk()
if ok {
var diags diag.Diagnostics
optimizerVal, diags = types.ObjectValue(optimizerTypes, map[string]attr.Value{
"enabled": types.BoolValue(optimizerEnabled),
})
if diags.HasError() {
return core.DiagsToError(diags)
}
}
}
cfg, diags := types.ObjectValue(configTypes, map[string]attr.Value{
"backend": backend,
"regions": modelRegions,
"blocked_countries": modelBlockedCountries,
"optimizer": optimizerVal,
})
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(string(*d.Type)),
"errors": modelDomainErrors,
})
if diags.HasError() {
return core.DiagsToError(diags)
}
domains = append(domains, modelDomain)
}
}
modelDomains, diags := types.ListValue(types.ObjectType{AttrTypes: domainTypes}, domains)
if diags.HasError() {
return core.DiagsToError(diags)
}
model.Domains = modelDomains
return nil
}
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
}
var optimizer *cdn.Optimizer
if cfg.Optimizer != nil {
optimizer = cdn.NewOptimizer(cfg.Optimizer.GetEnabled())
}
payload := &cdn.CreateDistributionPayload{
IntentId: cdn.PtrString(uuid.NewString()),
OriginUrl: cfg.Backend.HttpBackend.OriginUrl,
Regions: cfg.Regions,
BlockedCountries: cfg.BlockedCountries,
OriginRequestHeaders: cfg.Backend.HttpBackend.OriginRequestHeaders,
Geofencing: cfg.Backend.HttpBackend.Geofencing,
Optimizer: optimizer,
}
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
regions := []cdn.Region{}
for _, r := range *configModel.Regions {
regionEnum, err := cdn.NewRegionFromValue(r)
if err != nil {
return nil, err
}
regions = append(regions, *regionEnum)
}
// blockedCountries
var blockedCountries []string
if configModel.BlockedCountries != nil {
for _, blockedCountry := range *configModel.BlockedCountries {
validatedBlockedCountry, err := validateCountryCode(blockedCountry)
if err != nil {
return nil, err
}
blockedCountries = append(blockedCountries, validatedBlockedCountry)
}
}
// geofencing
geofencing := map[string][]string{}
if configModel.Backend.Geofencing != nil {
for endpoint, countryCodes := range *configModel.Backend.Geofencing {
geofencingCountry := make([]string, len(countryCodes))
for i, countryCodePtr := range countryCodes {
if countryCodePtr == nil {
return nil, fmt.Errorf("geofencing url %q has a null value", endpoint)
}
validatedCountry, err := validateCountryCode(*countryCodePtr)
if err != nil {
return nil, err
}
geofencingCountry[i] = validatedCountry
}
geofencing[endpoint] = geofencingCountry
}
}
// originRequestHeaders
originRequestHeaders := map[string]string{}
if configModel.Backend.OriginRequestHeaders != nil {
for k, v := range *configModel.Backend.OriginRequestHeaders {
originRequestHeaders[k] = v
}
}
cdnConfig := &cdn.Config{
Backend: &cdn.ConfigBackend{
HttpBackend: &cdn.HttpBackend{
OriginRequestHeaders: &originRequestHeaders,
OriginUrl: &configModel.Backend.OriginURL,
Type: &configModel.Backend.Type,
Geofencing: &geofencing,
},
},
Regions: &regions,
BlockedCountries: &blockedCountries,
}
if !utils.IsUndefined(configModel.Optimizer) {
var optimizerModel optimizerConfig
diags := configModel.Optimizer.As(ctx, &optimizerModel, basetypes.ObjectAsOptions{})
if diags.HasError() {
return nil, core.DiagsToError(diags)
}
if !utils.IsUndefined(optimizerModel.Enabled) {
cdnConfig.Optimizer = cdn.NewOptimizer(optimizerModel.Enabled.ValueBool())
}
}
return cdnConfig, nil
}
// validateCountryCode checks for a valid country user input. This is just a quick check
// since the API already does a more thorough check.
func validateCountryCode(country string) (string, error) {
if len(country) != 2 {
return "", errors.New("country code must be exactly 2 characters long")
}
upperCountry := strings.ToUpper(country)
// Check if both characters are alphabetical letters within the ASCII range A-Z.
// Yes, we could use the unicode package, but we are only targeting ASCII letters specifically, so
// let's omit this dependency.
char1 := upperCountry[0]
char2 := upperCountry[1]
if !((char1 >= 'A' && char1 <= 'Z') && (char2 >= 'A' && char2 <= 'Z')) {
return "", fmt.Errorf("country code '%s' must consist of two alphabetical letters (A-Z or a-z)", country)
}
return upperCountry, nil
}

View file

@ -1,589 +0,0 @@
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)
geofencingCountries := types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("DE"),
types.StringValue("FR"),
})
geofencing := types.MapValueMust(geofencingTypes.ElemType, map[string]attr.Value{
"https://de.mycoolapp.com": geofencingCountries,
})
backend := types.ObjectValueMust(backendTypes, map[string]attr.Value{
"type": types.StringValue("http"),
"origin_url": types.StringValue("https://www.mycoolapp.com"),
"origin_request_headers": originRequestHeaders,
"geofencing": geofencing,
})
regions := []attr.Value{types.StringValue("EU"), types.StringValue("US")}
regionsFixture := types.ListValueMust(types.StringType, regions)
blockedCountries := []attr.Value{types.StringValue("XX"), types.StringValue("YY"), types.StringValue("ZZ")}
blockedCountriesFixture := types.ListValueMust(types.StringType, blockedCountries)
optimizer := types.ObjectValueMust(optimizerTypes, map[string]attr.Value{
"enabled": types.BoolValue(true),
})
config := types.ObjectValueMust(configTypes, map[string]attr.Value{
"backend": backend,
"regions": regionsFixture,
"blocked_countries": blockedCountriesFixture,
"optimizer": types.ObjectNull(optimizerTypes),
})
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"},
BlockedCountries: &[]string{"XX", "YY", "ZZ"},
Geofencing: &map[string][]string{
"https://de.mycoolapp.com": {"DE", "FR"},
},
},
IsValid: true,
},
"happy_path_with_optimizer": {
Input: modelFixture(func(m *Model) {
m.Config = types.ObjectValueMust(configTypes, map[string]attr.Value{
"backend": backend,
"regions": regionsFixture,
"optimizer": optimizer,
"blocked_countries": blockedCountriesFixture,
})
}),
Expected: &cdn.CreateDistributionPayload{
OriginRequestHeaders: &map[string]string{
"testHeader0": "testHeaderValue0",
"testHeader1": "testHeaderValue1",
},
OriginUrl: cdn.PtrString("https://www.mycoolapp.com"),
Regions: &[]cdn.Region{"EU", "US"},
Optimizer: cdn.NewOptimizer(true),
BlockedCountries: &[]string{"XX", "YY", "ZZ"},
Geofencing: &map[string][]string{
"https://de.mycoolapp.com": {"DE", "FR"},
},
},
IsValid: true,
},
"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)
geofencingCountries := types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("DE"),
types.StringValue("FR"),
})
geofencing := types.MapValueMust(geofencingTypes.ElemType, map[string]attr.Value{
"https://de.mycoolapp.com": geofencingCountries,
})
backend := types.ObjectValueMust(backendTypes, map[string]attr.Value{
"type": types.StringValue("http"),
"origin_url": types.StringValue("https://www.mycoolapp.com"),
"origin_request_headers": originRequestHeaders,
"geofencing": geofencing,
})
regions := []attr.Value{types.StringValue("EU"), types.StringValue("US")}
regionsFixture := types.ListValueMust(types.StringType, regions)
blockedCountries := []attr.Value{types.StringValue("XX"), types.StringValue("YY"), types.StringValue("ZZ")}
blockedCountriesFixture := types.ListValueMust(types.StringType, blockedCountries)
optimizer := types.ObjectValueMust(optimizerTypes, map[string]attr.Value{"enabled": types.BoolValue(true)})
config := types.ObjectValueMust(configTypes, map[string]attr.Value{
"backend": backend,
"regions": regionsFixture,
"optimizer": types.ObjectNull(optimizerTypes),
"blocked_countries": blockedCountriesFixture,
})
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"),
Geofencing: &map[string][]string{
"https://de.mycoolapp.com": {"DE", "FR"},
},
},
},
Regions: &[]cdn.Region{"EU", "US"},
BlockedCountries: &[]string{"XX", "YY", "ZZ"},
},
IsValid: true,
},
"happy_path_with_optimizer": {
Input: modelFixture(func(m *Model) {
m.Config = types.ObjectValueMust(configTypes, map[string]attr.Value{
"backend": backend,
"regions": regionsFixture,
"optimizer": optimizer,
"blocked_countries": blockedCountriesFixture,
})
}),
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"),
Geofencing: &map[string][]string{
"https://de.mycoolapp.com": {"DE", "FR"},
},
},
},
Regions: &[]cdn.Region{"EU", "US"},
Optimizer: cdn.NewOptimizer(true),
BlockedCountries: &[]string{"XX", "YY", "ZZ"},
},
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,
"geofencing": types.MapNull(geofencingTypes.ElemType),
})
regions := []attr.Value{types.StringValue("EU"), types.StringValue("US")}
regionsFixture := types.ListValueMust(types.StringType, regions)
blockedCountries := []attr.Value{types.StringValue("XX"), types.StringValue("YY"), types.StringValue("ZZ")}
blockedCountriesFixture := types.ListValueMust(types.StringType, blockedCountries)
geofencingCountries := types.ListValueMust(types.StringType, []attr.Value{types.StringValue("DE"), types.StringValue("BR")})
geofencing := types.MapValueMust(geofencingTypes.ElemType, map[string]attr.Value{
"test/": geofencingCountries,
})
geofencingInput := map[string][]string{"test/": {"DE", "BR"}}
optimizer := types.ObjectValueMust(optimizerTypes, map[string]attr.Value{
"enabled": types.BoolValue(true),
})
config := types.ObjectValueMust(configTypes, map[string]attr.Value{
"backend": backend,
"regions": regionsFixture,
"blocked_countries": blockedCountriesFixture,
"optimizer": types.ObjectNull(optimizerTypes),
})
emtpyErrorsList := types.ListValueMust(types.StringType, []attr.Value{})
managedDomain := types.ObjectValueMust(domainTypes, map[string]attr.Value{
"name": types.StringValue("test.stackit-cdn.com"),
"status": types.StringValue("ACTIVE"),
"type": types.StringValue("managed"),
"errors": types.ListValueMust(types.StringType, []attr.Value{}),
})
domains := types.ListValueMust(types.ObjectType{AttrTypes: domainTypes}, []attr.Value{managedDomain})
expectedModel := func(mods ...func(*Model)) *Model {
model := &Model{
ID: types.StringValue("test-project-id,test-distribution-id"),
DistributionId: types.StringValue("test-distribution-id"),
ProjectId: types.StringValue("test-project-id"),
Config: config,
Status: types.StringValue("ACTIVE"),
CreatedAt: types.StringValue(createdAt.String()),
UpdatedAt: types.StringValue(updatedAt.String()),
Errors: emtpyErrorsList,
Domains: domains,
}
for _, mod := range mods {
mod(model)
}
return model
}
distributionFixture := func(mods ...func(*cdn.Distribution)) *cdn.Distribution {
distribution := &cdn.Distribution{
Config: &cdn.Config{
Backend: &cdn.ConfigBackend{
HttpBackend: &cdn.HttpBackend{
OriginRequestHeaders: &map[string]string{
"testHeader0": "testHeaderValue0",
"testHeader1": "testHeaderValue1",
},
OriginUrl: cdn.PtrString("https://www.mycoolapp.com"),
Type: cdn.PtrString("http"),
},
},
Regions: &[]cdn.Region{"EU", "US"},
BlockedCountries: &[]string{"XX", "YY", "ZZ"},
Optimizer: nil,
},
CreatedAt: &createdAt,
Domains: &[]cdn.Domain{
{
Name: cdn.PtrString("test.stackit-cdn.com"),
Status: cdn.DOMAINSTATUS_ACTIVE.Ptr(),
Type: cdn.DOMAINTYPE_MANAGED.Ptr(),
},
},
Id: cdn.PtrString("test-distribution-id"),
ProjectId: cdn.PtrString("test-project-id"),
Status: cdn.DISTRIBUTIONSTATUS_ACTIVE.Ptr(),
UpdatedAt: &updatedAt,
}
for _, mod := range mods {
mod(distribution)
}
return distribution
}
tests := map[string]struct {
Input *cdn.Distribution
Expected *Model
IsValid bool
}{
"happy_path": {
Expected: expectedModel(),
Input: distributionFixture(),
IsValid: true,
},
"happy_path_with_optimizer": {
Expected: expectedModel(func(m *Model) {
m.Config = types.ObjectValueMust(configTypes, map[string]attr.Value{
"backend": backend,
"regions": regionsFixture,
"optimizer": optimizer,
"blocked_countries": blockedCountriesFixture,
})
}),
Input: distributionFixture(func(d *cdn.Distribution) {
d.Config.Optimizer = &cdn.Optimizer{
Enabled: cdn.PtrBool(true),
}
}),
IsValid: true,
},
"happy_path_with_geofencing": {
Expected: expectedModel(func(m *Model) {
backendWithGeofencing := types.ObjectValueMust(backendTypes, map[string]attr.Value{
"type": types.StringValue("http"),
"origin_url": types.StringValue("https://www.mycoolapp.com"),
"origin_request_headers": originRequestHeaders,
"geofencing": geofencing,
})
m.Config = types.ObjectValueMust(configTypes, map[string]attr.Value{
"backend": backendWithGeofencing,
"regions": regionsFixture,
"optimizer": types.ObjectNull(optimizerTypes),
"blocked_countries": blockedCountriesFixture,
})
}),
Input: distributionFixture(func(d *cdn.Distribution) {
d.Config.Backend.HttpBackend.Geofencing = &geofencingInput
}),
IsValid: true,
},
"happy_path_status_error": {
Expected: expectedModel(func(m *Model) {
m.Status = types.StringValue("ERROR")
}),
Input: distributionFixture(func(d *cdn.Distribution) {
d.Status = cdn.DISTRIBUTIONSTATUS_ERROR.Ptr()
}),
IsValid: true,
},
"happy_path_custom_domain": {
Expected: expectedModel(func(m *Model) {
managedDomain := types.ObjectValueMust(domainTypes, map[string]attr.Value{
"name": types.StringValue("test.stackit-cdn.com"),
"status": types.StringValue("ACTIVE"),
"type": types.StringValue("managed"),
"errors": types.ListValueMust(types.StringType, []attr.Value{}),
})
customDomain := types.ObjectValueMust(domainTypes, map[string]attr.Value{
"name": types.StringValue("mycoolapp.info"),
"status": types.StringValue("ACTIVE"),
"type": types.StringValue("custom"),
"errors": types.ListValueMust(types.StringType, []attr.Value{}),
})
domains := types.ListValueMust(types.ObjectType{AttrTypes: domainTypes}, []attr.Value{managedDomain, customDomain})
m.Domains = domains
}),
Input: distributionFixture(func(d *cdn.Distribution) {
d.Domains = &[]cdn.Domain{
{
Name: cdn.PtrString("test.stackit-cdn.com"),
Status: cdn.DOMAINSTATUS_ACTIVE.Ptr(),
Type: cdn.DOMAINTYPE_MANAGED.Ptr(),
},
{
Name: cdn.PtrString("mycoolapp.info"),
Status: cdn.DOMAINSTATUS_ACTIVE.Ptr(),
Type: cdn.DOMAINTYPE_CUSTOM.Ptr(),
},
}
}),
IsValid: true,
},
"sad_path_distribution_nil": {
Expected: nil,
Input: nil,
IsValid: false,
},
"sad_path_project_id_missing": {
Expected: expectedModel(),
Input: distributionFixture(func(d *cdn.Distribution) {
d.ProjectId = nil
}),
IsValid: false,
},
"sad_path_distribution_id_missing": {
Expected: expectedModel(),
Input: distributionFixture(func(d *cdn.Distribution) {
d.Id = nil
}),
IsValid: false,
},
}
for tn, tc := range tests {
t.Run(tn, func(t *testing.T) {
model := &Model{}
err := mapFields(context.Background(), tc.Input, model)
if err != nil && tc.IsValid {
t.Fatalf("Error mapping fields: %v", err)
}
if err == nil && !tc.IsValid {
t.Fatalf("Should have failed")
}
if tc.IsValid {
diff := cmp.Diff(model, tc.Expected)
if diff != "" {
t.Fatalf("Create Payload not as expected: %s", diff)
}
}
})
}
}
// TestValidateCountryCode tests the validateCountryCode function with a variety of inputs.
func TestValidateCountryCode(t *testing.T) {
testCases := []struct {
name string
inputCountry string
wantOutput string
expectError bool
expectedError string
}{
// Happy Path
{
name: "Valid lowercase",
inputCountry: "us",
wantOutput: "US",
expectError: false,
},
{
name: "Valid uppercase",
inputCountry: "DE",
wantOutput: "DE",
expectError: false,
},
{
name: "Valid mixed case",
inputCountry: "cA",
wantOutput: "CA",
expectError: false,
},
{
name: "Valid country code FR",
inputCountry: "fr",
wantOutput: "FR",
expectError: false,
},
// Error Scenarios
{
name: "Invalid length - too short",
inputCountry: "a",
wantOutput: "",
expectError: true,
expectedError: "country code must be exactly 2 characters long",
},
{
name: "Invalid length - too long",
inputCountry: "USA",
wantOutput: "",
expectError: true,
expectedError: "country code must be exactly 2 characters long",
},
{
name: "Invalid characters - contains number",
inputCountry: "U1",
wantOutput: "",
expectError: true,
expectedError: "country code 'U1' must consist of two alphabetical letters (A-Z or a-z)",
},
{
name: "Invalid characters - contains symbol",
inputCountry: "D!",
wantOutput: "",
expectError: true,
expectedError: "country code 'D!' must consist of two alphabetical letters (A-Z or a-z)",
},
{
name: "Invalid characters - both are numbers",
inputCountry: "42",
wantOutput: "",
expectError: true,
expectedError: "country code '42' must consist of two alphabetical letters (A-Z or a-z)",
},
{
name: "Empty string",
inputCountry: "",
wantOutput: "",
expectError: true,
expectedError: "country code must be exactly 2 characters long",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
gotOutput, err := validateCountryCode(tc.inputCountry)
if tc.expectError {
if err == nil {
t.Errorf("expected an error for input '%s', but got none", tc.inputCountry)
} else if err.Error() != tc.expectedError {
t.Errorf("for input '%s', expected error '%s', but got '%s'", tc.inputCountry, tc.expectedError, err.Error())
}
if gotOutput != "" {
t.Errorf("expected empty string on error, but got '%s'", gotOutput)
}
} else {
if err != nil {
t.Errorf("did not expect an error for input '%s', but got: %v", tc.inputCountry, err)
}
if gotOutput != tc.wantOutput {
t.Errorf("for input '%s', expected output '%s', but got '%s'", tc.inputCountry, tc.wantOutput, gotOutput)
}
}
})
}
}

View file

@ -1,29 +0,0 @@
package utils
import (
"context"
"fmt"
"github.com/hashicorp/terraform-plugin-framework/diag"
"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/utils"
)
func ConfigureClient(ctx context.Context, providerData *core.ProviderData, diags *diag.Diagnostics) *cdn.APIClient {
apiClientConfigOptions := []config.ConfigurationOption{
config.WithCustomAuth(providerData.RoundTripper),
utils.UserAgentConfigOption(providerData.Version),
}
if providerData.CdnCustomEndpoint != "" {
apiClientConfigOptions = append(apiClientConfigOptions, config.WithEndpoint(providerData.CdnCustomEndpoint))
}
apiClient, err := cdn.NewAPIClient(apiClientConfigOptions...)
if err != nil {
core.LogAndAddError(ctx, diags, "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 nil
}
return apiClient
}

View file

@ -1,93 +0,0 @@
package utils
import (
"context"
"os"
"reflect"
"testing"
"github.com/hashicorp/terraform-plugin-framework/diag"
sdkClients "github.com/stackitcloud/stackit-sdk-go/core/clients"
"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/utils"
)
const (
testVersion = "1.2.3"
testCustomEndpoint = "https://cdn-custom-endpoint.api.stackit.cloud"
)
func TestConfigureClient(t *testing.T) {
/* mock authentication by setting service account token env variable */
os.Clearenv()
err := os.Setenv(sdkClients.ServiceAccountToken, "mock-val")
if err != nil {
t.Errorf("error setting env variable: %v", err)
}
type args struct {
providerData *core.ProviderData
}
tests := []struct {
name string
args args
wantErr bool
expected *cdn.APIClient
}{
{
name: "default endpoint",
args: args{
providerData: &core.ProviderData{
Version: testVersion,
},
},
expected: func() *cdn.APIClient {
apiClient, err := cdn.NewAPIClient(
utils.UserAgentConfigOption(testVersion),
)
if err != nil {
t.Errorf("error configuring client: %v", err)
}
return apiClient
}(),
wantErr: false,
},
{
name: "custom endpoint",
args: args{
providerData: &core.ProviderData{
Version: testVersion,
CdnCustomEndpoint: testCustomEndpoint,
},
},
expected: func() *cdn.APIClient {
apiClient, err := cdn.NewAPIClient(
utils.UserAgentConfigOption(testVersion),
config.WithEndpoint(testCustomEndpoint),
)
if err != nil {
t.Errorf("error configuring client: %v", err)
}
return apiClient
}(),
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
diags := diag.Diagnostics{}
actual := ConfigureClient(ctx, tt.args.providerData, &diags)
if diags.HasError() != tt.wantErr {
t.Errorf("ConfigureClient() error = %v, want %v", diags.HasError(), tt.wantErr)
}
if !reflect.DeepEqual(actual, tt.expected) {
t.Errorf("ConfigureClient() = %v, want %v", actual, tt.expected)
}
})
}
}

View file

@ -1,541 +0,0 @@
package dns_test
import (
"context"
_ "embed"
"fmt"
"maps"
"regexp"
"strings"
"testing"
"github.com/hashicorp/terraform-plugin-testing/config"
"github.com/hashicorp/terraform-plugin-testing/helper/acctest"
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
"github.com/hashicorp/terraform-plugin-testing/terraform"
core_config "github.com/stackitcloud/stackit-sdk-go/core/config"
"github.com/stackitcloud/stackit-sdk-go/core/utils"
"github.com/stackitcloud/stackit-sdk-go/services/dns"
"github.com/stackitcloud/stackit-sdk-go/services/dns/wait"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/testutil"
)
var (
//go:embed testdata/resource-min.tf
resourceMinConfig string
//go:embed testdata/resource-max.tf
resourceMaxConfig string
)
var testConfigVarsMin = config.Variables{
"project_id": config.StringVariable(testutil.ProjectId),
"name": config.StringVariable("tf-acc-" + acctest.RandStringFromCharSet(8, acctest.CharSetAlpha)),
"dns_name": config.StringVariable("tf-acc-" + acctest.RandStringFromCharSet(8, acctest.CharSetAlpha) + ".example.home"),
"record_name": config.StringVariable("tf-acc-" + acctest.RandStringFromCharSet(8, acctest.CharSetAlpha)),
"record_record1": config.StringVariable("1.2.3.4"),
"record_type": config.StringVariable("A"),
}
var testConfigVarsMax = config.Variables{
"project_id": config.StringVariable(testutil.ProjectId),
"name": config.StringVariable("tf-acc-" + acctest.RandStringFromCharSet(8, acctest.CharSetAlpha)),
"dns_name": config.StringVariable("tf-acc-" + acctest.RandStringFromCharSet(8, acctest.CharSetAlpha) + ".example.home"),
"acl": config.StringVariable("0.0.0.0/0"),
"active": config.BoolVariable(true),
"contact_email": config.StringVariable("contact@example.com"),
"default_ttl": config.IntegerVariable(3600),
"description": config.StringVariable("a test description"),
"expire_time": config.IntegerVariable(1 * 24 * 60 * 60),
"is_reverse_zone": config.BoolVariable(false),
// "negative_cache": config.IntegerVariable(128),
"primaries": config.ListVariable(config.StringVariable("1.1.1.1")),
"refresh_time": config.IntegerVariable(3600),
"retry_time": config.IntegerVariable(600),
"type": config.StringVariable("primary"),
"record_name": config.StringVariable("tf-acc-" + acctest.RandStringFromCharSet(8, acctest.CharSetAlpha)),
"record_record1": config.StringVariable("1.2.3.4"),
"record_active": config.BoolVariable(true),
"record_comment": config.StringVariable("a test comment"),
"record_ttl": config.IntegerVariable(3600),
"record_type": config.StringVariable("A"),
}
func configVarsInvalid(vars config.Variables) config.Variables {
tempConfig := maps.Clone(vars)
tempConfig["dns_name"] = config.StringVariable("foo")
return tempConfig
}
func configVarsMinUpdated() config.Variables {
tempConfig := maps.Clone(testConfigVarsMin)
tempConfig["record_record1"] = config.StringVariable("1.2.3.5")
return tempConfig
}
func configVarsMaxUpdated() config.Variables {
tempConfig := maps.Clone(testConfigVarsMax)
tempConfig["record_record1"] = config.StringVariable("1.2.3.5")
return tempConfig
}
func TestAccDnsMinResource(t *testing.T) {
resource.ParallelTest(t, resource.TestCase{
ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories,
CheckDestroy: testAccCheckDnsDestroy,
Steps: []resource.TestStep{
// Creation fail
{
Config: resourceMinConfig,
ConfigVariables: configVarsInvalid(testConfigVarsMin),
ExpectError: regexp.MustCompile(`not a valid dns name. Need at least two levels`),
},
// creation
{
Config: resourceMinConfig,
ConfigVariables: testConfigVarsMin,
Check: resource.ComposeAggregateTestCheckFunc(
// Zone data
resource.TestCheckResourceAttr("stackit_dns_zone.zone", "project_id", testutil.ProjectId),
resource.TestCheckResourceAttrSet("stackit_dns_zone.zone", "zone_id"),
resource.TestCheckResourceAttrSet("stackit_dns_zone.zone", "state"),
resource.TestCheckResourceAttrSet("stackit_dns_zone.zone", "primary_name_server"),
resource.TestCheckResourceAttrSet("stackit_dns_zone.zone", "serial_number"),
resource.TestCheckResourceAttrSet("stackit_dns_zone.zone", "state"),
resource.TestCheckResourceAttrSet("stackit_dns_zone.zone", "visibility"),
resource.TestCheckResourceAttrSet("stackit_dns_zone.zone", "zone_id"),
// Record set data
resource.TestCheckResourceAttrPair(
"stackit_dns_record_set.record_set", "project_id",
"stackit_dns_zone.zone", "project_id",
),
resource.TestCheckResourceAttrPair(
"stackit_dns_record_set.record_set", "zone_id",
"stackit_dns_zone.zone", "zone_id",
),
resource.TestCheckResourceAttrSet("stackit_dns_record_set.record_set", "record_set_id"),
resource.TestCheckResourceAttrSet("stackit_dns_record_set.record_set", "name"),
resource.TestCheckResourceAttr("stackit_dns_record_set.record_set", "records.#", "1"),
resource.TestCheckResourceAttr("stackit_dns_record_set.record_set", "records.0", testutil.ConvertConfigVariable(testConfigVarsMin["record_record1"])),
resource.TestCheckResourceAttr("stackit_dns_record_set.record_set", "type", testutil.ConvertConfigVariable(testConfigVarsMin["record_type"])),
resource.TestCheckResourceAttrSet("stackit_dns_record_set.record_set", "fqdn"),
resource.TestCheckResourceAttrSet("stackit_dns_record_set.record_set", "state"),
),
},
// Data sources
{
Config: resourceMinConfig,
ConfigVariables: testConfigVarsMin,
Check: resource.ComposeAggregateTestCheckFunc(
// Zone data by zone_id
resource.TestCheckResourceAttr("data.stackit_dns_zone.zone", "project_id", testutil.ProjectId),
resource.TestCheckResourceAttrPair(
"stackit_dns_zone.zone", "zone_id",
"data.stackit_dns_zone.zone", "zone_id",
),
resource.TestCheckResourceAttrPair(
"data.stackit_dns_record_set.record_set", "zone_id",
"data.stackit_dns_zone.zone", "zone_id",
),
resource.TestCheckResourceAttrPair(
"data.stackit_dns_record_set.record_set", "project_id",
"data.stackit_dns_zone.zone", "project_id",
),
resource.TestCheckResourceAttrPair(
"stackit_dns_record_set.record_set", "project_id",
"data.stackit_dns_record_set.record_set", "project_id",
),
// Zone data by dns_name
resource.TestCheckResourceAttr("data.stackit_dns_zone.zone_name", "project_id", testutil.ProjectId),
resource.TestCheckResourceAttrPair(
"stackit_dns_zone.zone", "zone_id",
"data.stackit_dns_zone.zone_name", "zone_id",
),
resource.TestCheckResourceAttrPair(
"data.stackit_dns_record_set.record_set", "zone_id",
"data.stackit_dns_zone.zone_name", "zone_id",
),
resource.TestCheckResourceAttrPair(
"data.stackit_dns_record_set.record_set", "project_id",
"data.stackit_dns_zone.zone_name", "project_id",
),
// Record set data
resource.TestCheckResourceAttrSet("data.stackit_dns_record_set.record_set", "record_set_id"),
resource.TestCheckResourceAttrSet("data.stackit_dns_record_set.record_set", "name"),
resource.TestCheckResourceAttr("data.stackit_dns_record_set.record_set", "records.#", "1"),
resource.TestCheckResourceAttr("data.stackit_dns_record_set.record_set", "records.0", testutil.ConvertConfigVariable(testConfigVarsMin["record_record1"])),
resource.TestCheckResourceAttr("data.stackit_dns_record_set.record_set", "type", testutil.ConvertConfigVariable(testConfigVarsMin["record_type"])),
),
},
// Import
{
ConfigVariables: testConfigVarsMin,
ResourceName: "stackit_dns_zone.zone",
ImportStateIdFunc: func(s *terraform.State) (string, error) {
r, ok := s.RootModule().Resources["stackit_dns_zone.zone"]
if !ok {
return "", fmt.Errorf("couldn't find resource stackit_dns_zone.recozonerd_set")
}
zoneId, ok := r.Primary.Attributes["zone_id"]
if !ok {
return "", fmt.Errorf("couldn't find attribute zone_id")
}
return fmt.Sprintf("%s,%s", testutil.ProjectId, zoneId), nil
},
ImportState: true,
ImportStateVerify: true,
},
{
ConfigVariables: testConfigVarsMin,
ResourceName: "stackit_dns_record_set.record_set",
ImportStateIdFunc: func(s *terraform.State) (string, error) {
r, ok := s.RootModule().Resources["stackit_dns_record_set.record_set"]
if !ok {
return "", fmt.Errorf("couldn't find resource stackit_dns_record_set.record_set")
}
zoneId, ok := r.Primary.Attributes["zone_id"]
if !ok {
return "", fmt.Errorf("couldn't find attribute zone_id")
}
recordSetId, ok := r.Primary.Attributes["record_set_id"]
if !ok {
return "", fmt.Errorf("couldn't find attribute record_set_id")
}
return fmt.Sprintf("%s,%s,%s", testutil.ProjectId, zoneId, recordSetId), nil
},
ImportState: true,
ImportStateVerify: true,
// Will be different because of the name vs fqdn problem, but the value is already tested in the datasource acc test
ImportStateVerifyIgnore: []string{"name"},
},
// Update. The zone ttl should not be updated according to the DNS API.
{
Config: resourceMinConfig,
ConfigVariables: configVarsMinUpdated(),
Check: resource.ComposeAggregateTestCheckFunc(
// Zone data
resource.TestCheckResourceAttr("stackit_dns_zone.zone", "project_id", testutil.ProjectId),
resource.TestCheckResourceAttrSet("stackit_dns_zone.zone", "zone_id"),
resource.TestCheckResourceAttrSet("stackit_dns_zone.zone", "state"),
resource.TestCheckResourceAttrSet("stackit_dns_zone.zone", "primary_name_server"),
resource.TestCheckResourceAttrSet("stackit_dns_zone.zone", "serial_number"),
resource.TestCheckResourceAttrSet("stackit_dns_zone.zone", "state"),
resource.TestCheckResourceAttrSet("stackit_dns_zone.zone", "visibility"),
resource.TestCheckResourceAttrSet("stackit_dns_zone.zone", "zone_id"),
// Record set data
resource.TestCheckResourceAttrPair(
"stackit_dns_record_set.record_set", "project_id",
"stackit_dns_zone.zone", "project_id",
),
resource.TestCheckResourceAttrPair(
"stackit_dns_record_set.record_set", "zone_id",
"stackit_dns_zone.zone", "zone_id",
),
resource.TestCheckResourceAttrSet("stackit_dns_record_set.record_set", "record_set_id"),
resource.TestCheckResourceAttr("stackit_dns_record_set.record_set", "name", testutil.ConvertConfigVariable(testConfigVarsMin["record_name"])),
resource.TestCheckResourceAttr("stackit_dns_record_set.record_set", "records.#", "1"),
resource.TestCheckResourceAttr("stackit_dns_record_set.record_set", "records.0", testutil.ConvertConfigVariable(configVarsMinUpdated()["record_record1"])),
resource.TestCheckResourceAttr("stackit_dns_record_set.record_set", "type", testutil.ConvertConfigVariable(testConfigVarsMin["record_type"])),
resource.TestCheckResourceAttrSet("stackit_dns_record_set.record_set", "fqdn"),
resource.TestCheckResourceAttrSet("stackit_dns_record_set.record_set", "state")),
},
// Deletion is done by the framework implicitly
},
})
}
func TestAccDnsMaxResource(t *testing.T) {
resource.ParallelTest(t, resource.TestCase{
ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories,
CheckDestroy: testAccCheckDnsDestroy,
Steps: []resource.TestStep{
// Creation
{
Config: resourceMaxConfig,
ConfigVariables: testConfigVarsMax,
Check: resource.ComposeAggregateTestCheckFunc(
// Zone data
resource.TestCheckResourceAttr("stackit_dns_zone.zone", "project_id", testutil.ProjectId),
resource.TestCheckResourceAttrSet("stackit_dns_zone.zone", "zone_id"),
resource.TestCheckResourceAttrSet("stackit_dns_zone.zone", "state"),
// Record set data
resource.TestCheckResourceAttrPair(
"stackit_dns_record_set.record_set", "project_id",
"stackit_dns_zone.zone", "project_id",
),
resource.TestCheckResourceAttrPair(
"stackit_dns_record_set.record_set", "zone_id",
"stackit_dns_zone.zone", "zone_id",
),
resource.TestCheckResourceAttr("stackit_dns_zone.zone", "acl", testutil.ConvertConfigVariable(testConfigVarsMax["acl"])),
resource.TestCheckResourceAttr("stackit_dns_zone.zone", "active", testutil.ConvertConfigVariable(testConfigVarsMax["active"])),
resource.TestCheckResourceAttr("stackit_dns_zone.zone", "contact_email", testutil.ConvertConfigVariable(testConfigVarsMax["contact_email"])),
resource.TestCheckResourceAttr("stackit_dns_zone.zone", "default_ttl", testutil.ConvertConfigVariable(testConfigVarsMax["default_ttl"])),
resource.TestCheckResourceAttr("stackit_dns_zone.zone", "description", testutil.ConvertConfigVariable(testConfigVarsMax["description"])),
resource.TestCheckResourceAttr("stackit_dns_zone.zone", "expire_time", testutil.ConvertConfigVariable(testConfigVarsMax["expire_time"])),
resource.TestCheckResourceAttr("stackit_dns_zone.zone", "is_reverse_zone", testutil.ConvertConfigVariable(testConfigVarsMax["is_reverse_zone"])),
// resource.TestCheckResourceAttrSet("stackit_dns_zone.zone", "negative_cache"),
resource.TestCheckResourceAttr("stackit_dns_zone.zone", "primaries.#", "1"),
resource.TestCheckResourceAttrSet("stackit_dns_zone.zone", "primaries.0"),
resource.TestCheckResourceAttr("stackit_dns_zone.zone", "refresh_time", testutil.ConvertConfigVariable(testConfigVarsMax["refresh_time"])),
resource.TestCheckResourceAttr("stackit_dns_zone.zone", "retry_time", testutil.ConvertConfigVariable(testConfigVarsMax["retry_time"])),
resource.TestCheckResourceAttr("stackit_dns_zone.zone", "type", testutil.ConvertConfigVariable(testConfigVarsMax["type"])),
resource.TestCheckResourceAttrSet("stackit_dns_zone.zone", "primary_name_server"),
resource.TestCheckResourceAttrSet("stackit_dns_zone.zone", "serial_number"),
resource.TestCheckResourceAttrSet("stackit_dns_zone.zone", "state"),
resource.TestCheckResourceAttrSet("stackit_dns_zone.zone", "visibility"),
resource.TestCheckResourceAttrSet("stackit_dns_zone.zone", "zone_id"),
resource.TestCheckResourceAttrSet("stackit_dns_record_set.record_set", "record_set_id"),
resource.TestCheckResourceAttr("stackit_dns_record_set.record_set", "name", testutil.ConvertConfigVariable(testConfigVarsMax["record_name"])),
resource.TestCheckResourceAttr("stackit_dns_record_set.record_set", "records.#", "1"),
resource.TestCheckResourceAttr("stackit_dns_record_set.record_set", "records.0", testutil.ConvertConfigVariable(testConfigVarsMax["record_record1"])),
resource.TestCheckResourceAttr("stackit_dns_record_set.record_set", "active", testutil.ConvertConfigVariable(testConfigVarsMax["record_active"])),
resource.TestCheckResourceAttr("stackit_dns_record_set.record_set", "comment", testutil.ConvertConfigVariable(testConfigVarsMax["record_comment"])),
resource.TestCheckResourceAttr("stackit_dns_record_set.record_set", "ttl", testutil.ConvertConfigVariable(testConfigVarsMax["record_ttl"])),
resource.TestCheckResourceAttr("stackit_dns_record_set.record_set", "type", testutil.ConvertConfigVariable(testConfigVarsMax["record_type"])),
resource.TestCheckResourceAttrSet("stackit_dns_record_set.record_set", "fqdn"),
resource.TestCheckResourceAttrSet("stackit_dns_record_set.record_set", "state"),
),
},
// Data sources
{
Config: resourceMaxConfig,
ConfigVariables: testConfigVarsMax,
Check: resource.ComposeAggregateTestCheckFunc(
// Zone data by zone_id
resource.TestCheckResourceAttr("data.stackit_dns_zone.zone", "project_id", testutil.ProjectId),
resource.TestCheckResourceAttrPair(
"stackit_dns_zone.zone", "zone_id",
"data.stackit_dns_zone.zone", "zone_id",
),
resource.TestCheckResourceAttrPair(
"data.stackit_dns_record_set.record_set", "zone_id",
"data.stackit_dns_zone.zone", "zone_id",
),
resource.TestCheckResourceAttrPair(
"data.stackit_dns_record_set.record_set", "project_id",
"data.stackit_dns_zone.zone", "project_id",
),
resource.TestCheckResourceAttrPair(
"data.stackit_dns_record_set.record_set", "project_id",
"stackit_dns_record_set.record_set", "project_id",
),
resource.TestCheckResourceAttr("data.stackit_dns_zone.zone", "acl", testutil.ConvertConfigVariable(testConfigVarsMax["acl"])),
resource.TestCheckResourceAttr("data.stackit_dns_zone.zone", "active", testutil.ConvertConfigVariable(testConfigVarsMax["active"])),
resource.TestCheckResourceAttr("data.stackit_dns_zone.zone", "contact_email", testutil.ConvertConfigVariable(testConfigVarsMax["contact_email"])),
resource.TestCheckResourceAttr("data.stackit_dns_zone.zone", "default_ttl", testutil.ConvertConfigVariable(testConfigVarsMax["default_ttl"])),
resource.TestCheckResourceAttr("data.stackit_dns_zone.zone", "description", testutil.ConvertConfigVariable(testConfigVarsMax["description"])),
resource.TestCheckResourceAttr("data.stackit_dns_zone.zone", "expire_time", testutil.ConvertConfigVariable(testConfigVarsMax["expire_time"])),
resource.TestCheckResourceAttr("data.stackit_dns_zone.zone", "is_reverse_zone", testutil.ConvertConfigVariable(testConfigVarsMax["is_reverse_zone"])),
resource.TestCheckResourceAttr("data.stackit_dns_zone.zone", "primaries.#", "1"),
resource.TestCheckResourceAttrSet("data.stackit_dns_zone.zone", "primaries.0"),
resource.TestCheckResourceAttr("data.stackit_dns_zone.zone", "refresh_time", testutil.ConvertConfigVariable(testConfigVarsMax["refresh_time"])),
resource.TestCheckResourceAttr("data.stackit_dns_zone.zone", "retry_time", testutil.ConvertConfigVariable(testConfigVarsMax["retry_time"])),
resource.TestCheckResourceAttr("data.stackit_dns_zone.zone", "type", testutil.ConvertConfigVariable(testConfigVarsMax["type"])),
resource.TestCheckResourceAttr("data.stackit_dns_zone.zone", "dns_name", testutil.ConvertConfigVariable(testConfigVarsMax["dns_name"])),
resource.TestCheckResourceAttr("data.stackit_dns_zone.zone", "name", testutil.ConvertConfigVariable(testConfigVarsMax["name"])),
// resource.TestCheckResourceAttrSet("data.stackit_dns_zone.zone", "negative_cache"),
resource.TestCheckResourceAttrSet("data.stackit_dns_zone.zone", "serial_number"),
resource.TestCheckResourceAttrSet("data.stackit_dns_zone.zone", "state"),
resource.TestCheckResourceAttrSet("data.stackit_dns_zone.zone", "visibility"),
// Zone data by dns_name
resource.TestCheckResourceAttr("data.stackit_dns_zone.zone_name", "project_id", testutil.ProjectId),
resource.TestCheckResourceAttrPair(
"stackit_dns_zone.zone", "zone_id",
"data.stackit_dns_zone.zone_name", "zone_id",
),
resource.TestCheckResourceAttrPair(
"data.stackit_dns_record_set.record_set", "zone_id",
"data.stackit_dns_zone.zone_name", "zone_id",
),
resource.TestCheckResourceAttrPair(
"data.stackit_dns_record_set.record_set", "project_id",
"data.stackit_dns_zone.zone_name", "project_id",
),
resource.TestCheckResourceAttr("data.stackit_dns_zone.zone_name", "acl", testutil.ConvertConfigVariable(testConfigVarsMax["acl"])),
resource.TestCheckResourceAttr("data.stackit_dns_zone.zone_name", "active", testutil.ConvertConfigVariable(testConfigVarsMax["active"])),
resource.TestCheckResourceAttr("data.stackit_dns_zone.zone_name", "contact_email", testutil.ConvertConfigVariable(testConfigVarsMax["contact_email"])),
resource.TestCheckResourceAttr("data.stackit_dns_zone.zone_name", "default_ttl", testutil.ConvertConfigVariable(testConfigVarsMax["default_ttl"])),
resource.TestCheckResourceAttr("data.stackit_dns_zone.zone_name", "description", testutil.ConvertConfigVariable(testConfigVarsMax["description"])),
resource.TestCheckResourceAttr("data.stackit_dns_zone.zone_name", "expire_time", testutil.ConvertConfigVariable(testConfigVarsMax["expire_time"])),
resource.TestCheckResourceAttr("data.stackit_dns_zone.zone_name", "is_reverse_zone", testutil.ConvertConfigVariable(testConfigVarsMax["is_reverse_zone"])),
resource.TestCheckResourceAttr("data.stackit_dns_zone.zone_name", "primaries.#", "1"),
resource.TestCheckResourceAttrSet("data.stackit_dns_zone.zone_name", "primaries.0"),
resource.TestCheckResourceAttr("data.stackit_dns_zone.zone_name", "refresh_time", testutil.ConvertConfigVariable(testConfigVarsMax["refresh_time"])),
resource.TestCheckResourceAttr("data.stackit_dns_zone.zone_name", "retry_time", testutil.ConvertConfigVariable(testConfigVarsMax["retry_time"])),
resource.TestCheckResourceAttr("data.stackit_dns_zone.zone_name", "type", testutil.ConvertConfigVariable(testConfigVarsMax["type"])),
resource.TestCheckResourceAttr("data.stackit_dns_zone.zone_name", "dns_name", testutil.ConvertConfigVariable(testConfigVarsMax["dns_name"])),
resource.TestCheckResourceAttr("data.stackit_dns_zone.zone_name", "name", testutil.ConvertConfigVariable(testConfigVarsMax["name"])),
// resource.TestCheckResourceAttrSet("data.stackit_dns_zone.zone_name", "negative_cache"),
resource.TestCheckResourceAttrSet("data.stackit_dns_zone.zone_name", "serial_number"),
resource.TestCheckResourceAttrSet("data.stackit_dns_zone.zone_name", "state"),
resource.TestCheckResourceAttrSet("data.stackit_dns_zone.zone_name", "visibility"),
// Record set data
resource.TestCheckResourceAttrSet("data.stackit_dns_record_set.record_set", "record_set_id"),
resource.TestCheckResourceAttrSet("data.stackit_dns_record_set.record_set", "name"),
resource.TestCheckResourceAttr("data.stackit_dns_record_set.record_set", "active", testutil.ConvertConfigVariable(testConfigVarsMax["active"])),
resource.TestCheckResourceAttrSet("data.stackit_dns_record_set.record_set", "fqdn"),
resource.TestCheckResourceAttrSet("data.stackit_dns_record_set.record_set", "state"),
resource.TestCheckResourceAttr("data.stackit_dns_record_set.record_set", "records.#", "1"),
resource.TestCheckResourceAttr("data.stackit_dns_record_set.record_set", "records.0", testutil.ConvertConfigVariable(testConfigVarsMax["record_record1"])),
resource.TestCheckResourceAttr("data.stackit_dns_record_set.record_set", "active", testutil.ConvertConfigVariable(testConfigVarsMax["record_active"])),
resource.TestCheckResourceAttr("data.stackit_dns_record_set.record_set", "comment", testutil.ConvertConfigVariable(testConfigVarsMax["record_comment"])),
resource.TestCheckResourceAttr("data.stackit_dns_record_set.record_set", "ttl", testutil.ConvertConfigVariable(testConfigVarsMax["record_ttl"])),
resource.TestCheckResourceAttr("data.stackit_dns_record_set.record_set", "type", testutil.ConvertConfigVariable(testConfigVarsMax["record_type"])),
),
},
// Import
{
ConfigVariables: testConfigVarsMax,
ResourceName: "stackit_dns_zone.zone",
ImportStateIdFunc: func(s *terraform.State) (string, error) {
r, ok := s.RootModule().Resources["stackit_dns_zone.zone"]
if !ok {
return "", fmt.Errorf("couldn't find resource stackit_dns_zone.record_set")
}
zoneId, ok := r.Primary.Attributes["zone_id"]
if !ok {
return "", fmt.Errorf("couldn't find attribute zone_id")
}
return fmt.Sprintf("%s,%s", testutil.ProjectId, zoneId), nil
},
ImportState: true,
ImportStateVerify: true,
},
{
ConfigVariables: testConfigVarsMax,
ResourceName: "stackit_dns_record_set.record_set",
ImportStateIdFunc: func(s *terraform.State) (string, error) {
r, ok := s.RootModule().Resources["stackit_dns_record_set.record_set"]
if !ok {
return "", fmt.Errorf("couldn't find resource stackit_dns_record_set.record_set")
}
zoneId, ok := r.Primary.Attributes["zone_id"]
if !ok {
return "", fmt.Errorf("couldn't find attribute zone_id")
}
recordSetId, ok := r.Primary.Attributes["record_set_id"]
if !ok {
return "", fmt.Errorf("couldn't find attribute record_set_id")
}
return fmt.Sprintf("%s,%s,%s", testutil.ProjectId, zoneId, recordSetId), nil
},
ImportState: true,
ImportStateVerify: true,
// Will be different because of the name vs fqdn problem, but the value is already tested in the datasource acc test
ImportStateVerifyIgnore: []string{"name"},
},
// Update. The zone ttl should not be updated according to the DNS API.
{
Config: resourceMaxConfig,
ConfigVariables: configVarsMaxUpdated(),
Check: resource.ComposeAggregateTestCheckFunc(
// Zone data
resource.TestCheckResourceAttr("stackit_dns_zone.zone", "project_id", testutil.ProjectId),
resource.TestCheckResourceAttrSet("stackit_dns_zone.zone", "zone_id"),
resource.TestCheckResourceAttrSet("stackit_dns_zone.zone", "state"),
resource.TestCheckResourceAttr("stackit_dns_zone.zone", "acl", testutil.ConvertConfigVariable(testConfigVarsMax["acl"])),
resource.TestCheckResourceAttr("stackit_dns_zone.zone", "active", testutil.ConvertConfigVariable(testConfigVarsMax["active"])),
resource.TestCheckResourceAttr("stackit_dns_zone.zone", "contact_email", testutil.ConvertConfigVariable(testConfigVarsMax["contact_email"])),
resource.TestCheckResourceAttr("stackit_dns_zone.zone", "default_ttl", testutil.ConvertConfigVariable(testConfigVarsMax["default_ttl"])),
resource.TestCheckResourceAttr("stackit_dns_zone.zone", "description", testutil.ConvertConfigVariable(testConfigVarsMax["description"])),
resource.TestCheckResourceAttr("stackit_dns_zone.zone", "expire_time", testutil.ConvertConfigVariable(testConfigVarsMax["expire_time"])),
resource.TestCheckResourceAttr("stackit_dns_zone.zone", "is_reverse_zone", testutil.ConvertConfigVariable(testConfigVarsMax["is_reverse_zone"])),
// resource.TestCheckResourceAttr("stackit_dns_zone.zone", "negative_cache", testutil.ConvertConfigVariable(testConfigVarsMax["negative_cache"])),
resource.TestCheckResourceAttr("stackit_dns_zone.zone", "primaries.#", "1"),
resource.TestCheckResourceAttrSet("stackit_dns_zone.zone", "primaries.0"),
resource.TestCheckResourceAttr("stackit_dns_zone.zone", "refresh_time", testutil.ConvertConfigVariable(testConfigVarsMax["refresh_time"])),
resource.TestCheckResourceAttr("stackit_dns_zone.zone", "retry_time", testutil.ConvertConfigVariable(testConfigVarsMax["retry_time"])),
resource.TestCheckResourceAttr("stackit_dns_zone.zone", "type", testutil.ConvertConfigVariable(testConfigVarsMax["type"])),
resource.TestCheckResourceAttrSet("stackit_dns_zone.zone", "primary_name_server"),
resource.TestCheckResourceAttrSet("stackit_dns_zone.zone", "serial_number"),
resource.TestCheckResourceAttrSet("stackit_dns_zone.zone", "state"),
resource.TestCheckResourceAttrSet("stackit_dns_zone.zone", "visibility"),
resource.TestCheckResourceAttrSet("stackit_dns_zone.zone", "zone_id"),
// Record set data
resource.TestCheckResourceAttrPair(
"stackit_dns_record_set.record_set", "project_id",
"stackit_dns_zone.zone", "project_id",
),
resource.TestCheckResourceAttrPair(
"stackit_dns_record_set.record_set", "zone_id",
"stackit_dns_zone.zone", "zone_id",
),
resource.TestCheckResourceAttrSet("stackit_dns_record_set.record_set", "record_set_id"),
resource.TestCheckResourceAttr("stackit_dns_record_set.record_set", "name", testutil.ConvertConfigVariable(testConfigVarsMax["record_name"])),
resource.TestCheckResourceAttr("stackit_dns_record_set.record_set", "records.#", "1"),
resource.TestCheckResourceAttr("stackit_dns_record_set.record_set", "records.0", testutil.ConvertConfigVariable(configVarsMaxUpdated()["record_record1"])),
resource.TestCheckResourceAttr("stackit_dns_record_set.record_set", "active", testutil.ConvertConfigVariable(testConfigVarsMax["record_active"])),
resource.TestCheckResourceAttr("stackit_dns_record_set.record_set", "comment", testutil.ConvertConfigVariable(testConfigVarsMax["record_comment"])),
resource.TestCheckResourceAttr("stackit_dns_record_set.record_set", "ttl", testutil.ConvertConfigVariable(testConfigVarsMax["record_ttl"])),
resource.TestCheckResourceAttr("stackit_dns_record_set.record_set", "type", testutil.ConvertConfigVariable(testConfigVarsMax["record_type"])),
resource.TestCheckResourceAttrSet("stackit_dns_record_set.record_set", "fqdn"),
resource.TestCheckResourceAttrSet("stackit_dns_record_set.record_set", "state")),
},
// Deletion is done by the framework implicitly
},
})
}
func testAccCheckDnsDestroy(s *terraform.State) error {
ctx := context.Background()
var client *dns.APIClient
var err error
if testutil.DnsCustomEndpoint == "" {
client, err = dns.NewAPIClient()
} else {
client, err = dns.NewAPIClient(
core_config.WithEndpoint(testutil.DnsCustomEndpoint),
)
}
if err != nil {
return fmt.Errorf("creating client: %w", err)
}
zonesToDestroy := []string{}
for _, rs := range s.RootModule().Resources {
if rs.Type != "stackit_dns_zone" {
continue
}
// zone terraform ID: "[projectId],[zoneId]"
zoneId := strings.Split(rs.Primary.ID, core.Separator)[1]
zonesToDestroy = append(zonesToDestroy, zoneId)
}
zonesResp, err := client.ListZones(ctx, testutil.ProjectId).ActiveEq(true).Execute()
if err != nil {
return fmt.Errorf("getting zonesResp: %w", err)
}
zones := *zonesResp.Zones
for i := range zones {
id := *zones[i].Id
if utils.Contains(zonesToDestroy, id) {
_, err := client.DeleteZoneExecute(ctx, testutil.ProjectId, id)
if err != nil {
return fmt.Errorf("destroying zone %s during CheckDestroy: %w", *zones[i].Id, err)
}
_, err = wait.DeleteZoneWaitHandler(ctx, client, testutil.ProjectId, id).WaitWithContext(ctx)
if err != nil {
return fmt.Errorf("destroying zone %s during CheckDestroy: waiting for deletion %w", *zones[i].Id, err)
}
}
}
return nil
}

View file

@ -1,183 +0,0 @@
package dns
import (
"context"
"fmt"
"net/http"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
dnsUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/dns/utils"
"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/services/dns"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate"
)
// Ensure the implementation satisfies the expected interfaces.
var (
_ datasource.DataSource = &recordSetDataSource{}
)
// NewRecordSetDataSource NewZoneDataSource is a helper function to simplify the provider implementation.
func NewRecordSetDataSource() datasource.DataSource {
return &recordSetDataSource{}
}
// recordSetDataSource is the data source implementation.
type recordSetDataSource struct {
client *dns.APIClient
}
// Metadata returns the data source type name.
func (d *recordSetDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_dns_record_set"
}
// Configure adds the provider configured client to the data source.
func (d *recordSetDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
providerData, ok := conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics)
if !ok {
return
}
apiClient := dnsUtils.ConfigureClient(ctx, &providerData, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}
d.client = apiClient
tflog.Info(ctx, "DNS record set client configured")
}
// Schema defines the schema for the data source.
func (d *recordSetDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
resp.Schema = schema.Schema{
Description: "DNS Record Set Resource schema.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: "Terraform's internal data source. ID. It is structured as \"`project_id`,`zone_id`,`record_set_id`\".",
Computed: true,
},
"project_id": schema.StringAttribute{
Description: "STACKIT project ID to which the dns record set is associated.",
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"zone_id": schema.StringAttribute{
Description: "The zone ID to which is dns record set is associated.",
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"record_set_id": schema.StringAttribute{
Description: "The rr set id.",
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"name": schema.StringAttribute{
Description: "Name of the record which should be a valid domain according to rfc1035 Section 2.3.4. E.g. `example.com`",
Computed: true,
},
"fqdn": schema.StringAttribute{
Description: "Fully qualified domain name (FQDN) of the record set.",
Computed: true,
},
"records": schema.ListAttribute{
Description: "Records.",
Computed: true,
ElementType: types.StringType,
},
"ttl": schema.Int64Attribute{
Description: "Time to live. E.g. 3600",
Computed: true,
},
"type": schema.StringAttribute{
Description: "The record set type. E.g. `A` or `CNAME`",
Computed: true,
},
"active": schema.BoolAttribute{
Description: "Specifies if the record set is active or not.",
Computed: true,
},
"comment": schema.StringAttribute{
Description: "Comment.",
Computed: true,
},
"error": schema.StringAttribute{
Description: "Error shows error in case create/update/delete failed.",
Computed: true,
},
"state": schema.StringAttribute{
Description: "Record set state.",
Computed: true,
},
},
}
}
// Read refreshes the Terraform state with the latest data.
func (d *recordSetDataSource) 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
}
ctx = core.InitProviderContext(ctx)
projectId := model.ProjectId.ValueString()
zoneId := model.ZoneId.ValueString()
recordSetId := model.RecordSetId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "zone_id", zoneId)
ctx = tflog.SetField(ctx, "record_set_id", recordSetId)
recordSetResp, err := d.client.GetRecordSet(ctx, projectId, zoneId, recordSetId).Execute()
if err != nil {
utils.LogError(
ctx,
&resp.Diagnostics,
err,
"Reading record set",
fmt.Sprintf("The record set %q or zone %q does not exist in project %q.", recordSetId, zoneId, projectId),
map[int]string{
http.StatusForbidden: fmt.Sprintf("Project with ID %q not found or forbidden access", projectId),
},
)
resp.State.RemoveResource(ctx)
return
}
ctx = core.LogResponse(ctx)
if recordSetResp != nil && recordSetResp.Rrset.State != nil && *recordSetResp.Rrset.State == dns.RECORDSETSTATE_DELETE_SUCCEEDED {
resp.State.RemoveResource(ctx)
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading record set", "Record set was deleted successfully")
return
}
err = mapFields(ctx, recordSetResp, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading record set", 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, "DNS record set read")
}

View file

@ -1,515 +0,0 @@
package dns
import (
"context"
"fmt"
"strings"
"github.com/hashicorp/terraform-plugin-framework-validators/int64validator"
"github.com/hashicorp/terraform-plugin-framework-validators/listvalidator"
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault"
"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-log/tflog"
"github.com/stackitcloud/stackit-sdk-go/services/dns"
"github.com/stackitcloud/stackit-sdk-go/services/dns/wait"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
dnsUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/dns/utils"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate"
)
// Ensure the implementation satisfies the expected interfaces.
var (
_ resource.Resource = &recordSetResource{}
_ resource.ResourceWithConfigure = &recordSetResource{}
_ resource.ResourceWithImportState = &recordSetResource{}
)
type Model struct {
Id types.String `tfsdk:"id"` // needed by TF
RecordSetId types.String `tfsdk:"record_set_id"`
ZoneId types.String `tfsdk:"zone_id"`
ProjectId types.String `tfsdk:"project_id"`
Active types.Bool `tfsdk:"active"`
Comment types.String `tfsdk:"comment"`
Name types.String `tfsdk:"name"`
Records types.List `tfsdk:"records"`
TTL types.Int64 `tfsdk:"ttl"`
Type types.String `tfsdk:"type"`
Error types.String `tfsdk:"error"`
State types.String `tfsdk:"state"`
FQDN types.String `tfsdk:"fqdn"`
}
// NewRecordSetResource is a helper function to simplify the provider implementation.
func NewRecordSetResource() resource.Resource {
return &recordSetResource{}
}
// recordSetResource is the resource implementation.
type recordSetResource struct {
client *dns.APIClient
}
// Metadata returns the resource type name.
func (r *recordSetResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_dns_record_set"
}
// Configure adds the provider configured client to the resource.
func (r *recordSetResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
providerData, ok := conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics)
if !ok {
return
}
apiClient := dnsUtils.ConfigureClient(ctx, &providerData, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}
r.client = apiClient
tflog.Info(ctx, "DNS record set client configured")
}
// Schema defines the schema for the resource.
func (r *recordSetResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Description: "DNS Record Set Resource schema.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: "Terraform's internal resource ID. It is structured as \"`project_id`,`zone_id`,`record_set_id`\".",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"project_id": schema.StringAttribute{
Description: "STACKIT project ID to which the dns record set is associated.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"zone_id": schema.StringAttribute{
Description: "The zone ID to which is dns record set is associated.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"record_set_id": schema.StringAttribute{
Description: "The rr set id.",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"name": schema.StringAttribute{
Description: "Name of the record which should be a valid domain according to rfc1035 Section 2.3.4. E.g. `example.com`",
Required: true,
Validators: []validator.String{
stringvalidator.LengthAtLeast(1),
},
},
"fqdn": schema.StringAttribute{
Description: "Fully qualified domain name (FQDN) of the record set.",
Computed: true,
},
"records": schema.ListAttribute{
Description: "Records.",
ElementType: types.StringType,
Required: true,
Validators: []validator.List{
listvalidator.SizeAtLeast(1),
listvalidator.UniqueValues(),
listvalidator.ValueStringsAre(validate.RecordSet()),
},
},
"ttl": schema.Int64Attribute{
Description: "Time to live. E.g. 3600",
Optional: true,
Computed: true,
Validators: []validator.Int64{
int64validator.AtLeast(60),
int64validator.AtMost(99999999),
},
},
"type": schema.StringAttribute{
Description: "The record set type. E.g. `A` or `CNAME`",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
stringplanmodifier.RequiresReplace(),
},
},
"active": schema.BoolAttribute{
Description: "Specifies if the record set is active or not. Defaults to `true`",
Optional: true,
Computed: true,
Default: booldefault.StaticBool(true),
},
"comment": schema.StringAttribute{
Description: "Comment.",
Optional: true,
Computed: true,
Validators: []validator.String{
stringvalidator.LengthAtMost(255),
},
},
"error": schema.StringAttribute{
Description: "Error shows error in case create/update/delete failed.",
Computed: true,
Validators: []validator.String{
stringvalidator.LengthAtMost(2000),
},
},
"state": schema.StringAttribute{
Description: "Record set state.",
Computed: true,
},
},
}
}
// Create creates the resource and sets the initial Terraform state.
func (r *recordSetResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve values from plan
var model Model
diags := req.Plan.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
ctx = core.InitProviderContext(ctx)
projectId := model.ProjectId.ValueString()
zoneId := model.ZoneId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "zone_id", zoneId)
// Generate API request body from model
payload, err := toCreatePayload(&model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating record set", fmt.Sprintf("Creating API payload: %v", err))
return
}
// Create new recordset
recordSetResp, err := r.client.CreateRecordSet(ctx, projectId, zoneId).CreateRecordSetPayload(*payload).Execute()
if err != nil || recordSetResp.Rrset == nil || recordSetResp.Rrset.Id == nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating record set", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
// Write id attributes to state before polling via the wait handler - just in case anything goes wrong during the wait handler
utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{
"project_id": projectId,
"zone_id": zoneId,
"record_set_id": *recordSetResp.Rrset.Id,
})
if resp.Diagnostics.HasError() {
return
}
waitResp, err := wait.CreateRecordSetWaitHandler(ctx, r.client, projectId, zoneId, *recordSetResp.Rrset.Id).WaitWithContext(ctx)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating record set", fmt.Sprintf("Instance creation waiting: %v", err))
return
}
// Map response body to schema
err = mapFields(ctx, waitResp, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating record set", fmt.Sprintf("Processing API payload: %v", err))
return
}
// Set state to fully populated data
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "DNS record set created")
}
// Read refreshes the Terraform state with the latest data.
func (r *recordSetResource) 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
}
ctx = core.InitProviderContext(ctx)
projectId := model.ProjectId.ValueString()
zoneId := model.ZoneId.ValueString()
recordSetId := model.RecordSetId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "zone_id", zoneId)
ctx = tflog.SetField(ctx, "record_set_id", recordSetId)
recordSetResp, err := r.client.GetRecordSet(ctx, projectId, zoneId, recordSetId).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading record set", fmt.Sprintf("Calling API: %v", err))
return
}
if recordSetResp != nil && recordSetResp.Rrset.State != nil && *recordSetResp.Rrset.State == dns.RECORDSETSTATE_DELETE_SUCCEEDED {
resp.State.RemoveResource(ctx)
return
}
ctx = core.LogResponse(ctx)
// Map response body to schema
err = mapFields(ctx, recordSetResp, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading record set", 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, "DNS record set read")
}
// Update updates the resource and sets the updated Terraform state on success.
func (r *recordSetResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve values from plan
var model Model
diags := req.Plan.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
ctx = core.InitProviderContext(ctx)
projectId := model.ProjectId.ValueString()
zoneId := model.ZoneId.ValueString()
recordSetId := model.RecordSetId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "zone_id", zoneId)
ctx = tflog.SetField(ctx, "record_set_id", recordSetId)
// Generate API request body from model
payload, err := toUpdatePayload(&model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating record set", fmt.Sprintf("Creating API payload: %v", err))
return
}
// Update recordset
_, err = r.client.PartialUpdateRecordSet(ctx, projectId, zoneId, recordSetId).PartialUpdateRecordSetPayload(*payload).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating record set", err.Error())
return
}
ctx = core.LogResponse(ctx)
waitResp, err := wait.PartialUpdateRecordSetWaitHandler(ctx, r.client, projectId, zoneId, recordSetId).WaitWithContext(ctx)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating record set", fmt.Sprintf("Instance update waiting: %v", err))
return
}
err = mapFields(ctx, waitResp, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating record set", 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, "DNS record set updated")
}
// Delete deletes the resource and removes the Terraform state on success.
func (r *recordSetResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve values from plan
var model Model
diags := req.State.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
ctx = core.InitProviderContext(ctx)
projectId := model.ProjectId.ValueString()
zoneId := model.ZoneId.ValueString()
recordSetId := model.RecordSetId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "zone_id", zoneId)
ctx = tflog.SetField(ctx, "record_set_id", recordSetId)
// Delete existing record set
_, err := r.client.DeleteRecordSet(ctx, projectId, zoneId, recordSetId).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting record set", fmt.Sprintf("Calling API: %v", err))
}
ctx = core.LogResponse(ctx)
_, err = wait.DeleteRecordSetWaitHandler(ctx, r.client, projectId, zoneId, recordSetId).WaitWithContext(ctx)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting record set", fmt.Sprintf("Instance deletion waiting: %v", err))
return
}
tflog.Info(ctx, "DNS record set deleted")
}
// ImportState imports a resource into the Terraform state on success.
// The expected format of the resource import identifier is: project_id,zone_id,record_set_id
func (r *recordSetResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
idParts := strings.Split(req.ID, core.Separator)
if len(idParts) != 3 || idParts[0] == "" || idParts[1] == "" || idParts[2] == "" {
core.LogAndAddError(ctx, &resp.Diagnostics,
"Error importing record set",
fmt.Sprintf("Expected import identifier with format [project_id],[zone_id],[record_set_id], got %q", req.ID),
)
return
}
utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]interface{}{
"project_id": idParts[0],
"zone_id": idParts[1],
"record_set_id": idParts[2],
})
tflog.Info(ctx, "DNS record set state imported")
}
func mapFields(ctx context.Context, recordSetResp *dns.RecordSetResponse, model *Model) error {
if recordSetResp == nil || recordSetResp.Rrset == nil {
return fmt.Errorf("response input is nil")
}
if model == nil {
return fmt.Errorf("model input is nil")
}
recordSet := recordSetResp.Rrset
var recordSetId string
if model.RecordSetId.ValueString() != "" {
recordSetId = model.RecordSetId.ValueString()
} else if recordSet.Id != nil {
recordSetId = *recordSet.Id
} else {
return fmt.Errorf("record set id not present")
}
if recordSet.Records == nil {
model.Records = types.ListNull(types.StringType)
} else {
respRecords := []string{}
for _, record := range *recordSet.Records {
respRecords = append(respRecords, *record.Content)
}
modelRecords, err := utils.ListValuetoStringSlice(model.Records)
if err != nil {
return err
}
reconciledRecords := utils.ReconcileStringSlices(modelRecords, respRecords)
recordsTF, diags := types.ListValueFrom(ctx, types.StringType, reconciledRecords)
if diags.HasError() {
return fmt.Errorf("failed to map records: %w", core.DiagsToError(diags))
}
model.Records = recordsTF
}
model.Id = utils.BuildInternalTerraformId(
model.ProjectId.ValueString(), model.ZoneId.ValueString(), recordSetId,
)
model.RecordSetId = types.StringPointerValue(recordSet.Id)
model.Active = types.BoolPointerValue(recordSet.Active)
model.Comment = types.StringPointerValue(recordSet.Comment)
model.Error = types.StringPointerValue(recordSet.Error)
if model.Name.IsNull() || model.Name.IsUnknown() {
model.Name = types.StringPointerValue(recordSet.Name)
}
model.FQDN = types.StringPointerValue(recordSet.Name)
model.State = types.StringValue(string(recordSet.GetState()))
model.TTL = types.Int64PointerValue(recordSet.Ttl)
model.Type = types.StringValue(string(recordSet.GetType()))
return nil
}
func toCreatePayload(model *Model) (*dns.CreateRecordSetPayload, error) {
if model == nil {
return nil, fmt.Errorf("nil model")
}
records := []dns.RecordPayload{}
for i, record := range model.Records.Elements() {
recordString, ok := record.(types.String)
if !ok {
return nil, fmt.Errorf("expected record at index %d to be of type %T, got %T", i, types.String{}, record)
}
records = append(records, dns.RecordPayload{
Content: conversion.StringValueToPointer(recordString),
})
}
return &dns.CreateRecordSetPayload{
Comment: conversion.StringValueToPointer(model.Comment),
Name: conversion.StringValueToPointer(model.Name),
Records: &records,
Ttl: conversion.Int64ValueToPointer(model.TTL),
Type: dns.CreateRecordSetPayloadGetTypeAttributeType(conversion.StringValueToPointer(model.Type)),
}, nil
}
func toUpdatePayload(model *Model) (*dns.PartialUpdateRecordSetPayload, error) {
if model == nil {
return nil, fmt.Errorf("nil model")
}
records := []dns.RecordPayload{}
for i, record := range model.Records.Elements() {
recordString, ok := record.(types.String)
if !ok {
return nil, fmt.Errorf("expected record at index %d to be of type %T, got %T", i, types.String{}, record)
}
records = append(records, dns.RecordPayload{
Content: conversion.StringValueToPointer(recordString),
})
}
return &dns.PartialUpdateRecordSetPayload{
Comment: conversion.StringValueToPointer(model.Comment),
Name: conversion.StringValueToPointer(model.Name),
Records: &records,
Ttl: conversion.Int64ValueToPointer(model.TTL),
}, nil
}

View file

@ -1,375 +0,0 @@
package dns
import (
"context"
"testing"
"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/core/utils"
"github.com/stackitcloud/stackit-sdk-go/services/dns"
)
func TestMapFields(t *testing.T) {
tests := []struct {
description string
state Model
input *dns.RecordSetResponse
expected Model
isValid bool
}{
{
"default_values",
Model{
ProjectId: types.StringValue("pid"),
ZoneId: types.StringValue("zid"),
},
&dns.RecordSetResponse{
Rrset: &dns.RecordSet{
Id: utils.Ptr("rid"),
},
},
Model{
Id: types.StringValue("pid,zid,rid"),
RecordSetId: types.StringValue("rid"),
ZoneId: types.StringValue("zid"),
ProjectId: types.StringValue("pid"),
Active: types.BoolNull(),
Comment: types.StringNull(),
Error: types.StringNull(),
Name: types.StringNull(),
FQDN: types.StringNull(),
Records: types.ListNull(types.StringType),
State: types.StringValue(""),
TTL: types.Int64Null(),
Type: types.StringValue(""),
},
true,
},
{
"simple_values",
Model{
ProjectId: types.StringValue("pid"),
ZoneId: types.StringValue("zid"),
},
&dns.RecordSetResponse{
Rrset: &dns.RecordSet{
Id: utils.Ptr("rid"),
Active: utils.Ptr(true),
Comment: utils.Ptr("comment"),
Error: utils.Ptr("error"),
Name: utils.Ptr("name"),
Records: &[]dns.Record{
{Content: utils.Ptr("record_1")},
{Content: utils.Ptr("record_2")},
},
State: dns.RECORDSETSTATE_CREATING.Ptr(),
Ttl: utils.Ptr(int64(1)),
Type: dns.RECORDSETTYPE_A.Ptr(),
},
},
Model{
Id: types.StringValue("pid,zid,rid"),
RecordSetId: types.StringValue("rid"),
ZoneId: types.StringValue("zid"),
ProjectId: types.StringValue("pid"),
Active: types.BoolValue(true),
Comment: types.StringValue("comment"),
Error: types.StringValue("error"),
Name: types.StringValue("name"),
FQDN: types.StringValue("name"),
Records: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("record_1"),
types.StringValue("record_2"),
}),
State: types.StringValue(string(dns.RECORDSETSTATE_CREATING)),
TTL: types.Int64Value(1),
Type: types.StringValue(string(dns.RECORDSETTYPE_A)),
},
true,
},
{
"unordered_records",
Model{
ProjectId: types.StringValue("pid"),
ZoneId: types.StringValue("zid"),
Records: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("record_2"),
types.StringValue("record_1"),
}),
},
&dns.RecordSetResponse{
Rrset: &dns.RecordSet{
Id: utils.Ptr("rid"),
Active: utils.Ptr(true),
Comment: utils.Ptr("comment"),
Error: utils.Ptr("error"),
Name: utils.Ptr("name"),
Records: &[]dns.Record{
{Content: utils.Ptr("record_1")},
{Content: utils.Ptr("record_2")},
},
State: dns.RECORDSETSTATE_CREATING.Ptr(),
Ttl: utils.Ptr(int64(1)),
Type: dns.RECORDSETTYPE_A.Ptr(),
},
},
Model{
Id: types.StringValue("pid,zid,rid"),
RecordSetId: types.StringValue("rid"),
ZoneId: types.StringValue("zid"),
ProjectId: types.StringValue("pid"),
Active: types.BoolValue(true),
Comment: types.StringValue("comment"),
Error: types.StringValue("error"),
Name: types.StringValue("name"),
FQDN: types.StringValue("name"),
Records: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("record_2"),
types.StringValue("record_1"),
}),
State: types.StringValue(string(dns.RECORDSETSTATE_CREATING)),
TTL: types.Int64Value(1),
Type: types.StringValue(string(dns.RECORDSETTYPE_A)),
},
true,
},
{
"null_fields_and_int_conversions",
Model{
ProjectId: types.StringValue("pid"),
ZoneId: types.StringValue("zid"),
Name: types.StringValue("other-name"),
},
&dns.RecordSetResponse{
Rrset: &dns.RecordSet{
Id: utils.Ptr("rid"),
Active: nil,
Comment: nil,
Error: nil,
Name: utils.Ptr("name"),
Records: nil,
State: dns.RECORDSETSTATE_CREATING.Ptr(),
Ttl: utils.Ptr(int64(2123456789)),
Type: dns.RECORDSETTYPE_A.Ptr(),
},
},
Model{
Id: types.StringValue("pid,zid,rid"),
RecordSetId: types.StringValue("rid"),
ZoneId: types.StringValue("zid"),
ProjectId: types.StringValue("pid"),
Active: types.BoolNull(),
Comment: types.StringNull(),
Error: types.StringNull(),
Name: types.StringValue("other-name"),
FQDN: types.StringValue("name"),
Records: types.ListNull(types.StringType),
State: types.StringValue(string(dns.RECORDSETSTATE_CREATING)),
TTL: types.Int64Value(2123456789),
Type: types.StringValue(string(dns.RECORDSETTYPE_A)),
},
true,
},
{
"nil_response",
Model{
ProjectId: types.StringValue("pid"),
ZoneId: types.StringValue("zid"),
},
nil,
Model{},
false,
},
{
"no_resource_id",
Model{
ProjectId: types.StringValue("pid"),
ZoneId: types.StringValue("zid"),
},
&dns.RecordSetResponse{},
Model{},
false,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
err := mapFields(context.Background(), tt.input, &tt.state)
if !tt.isValid && err == nil {
t.Fatalf("Should have failed")
}
if tt.isValid && err != nil {
t.Fatalf("Should not have failed: %v", err)
}
if tt.isValid {
diff := cmp.Diff(tt.state, tt.expected)
if diff != "" {
t.Fatalf("Data does not match: %s", diff)
}
}
})
}
}
func TestToCreatePayload(t *testing.T) {
tests := []struct {
description string
input *Model
expected *dns.CreateRecordSetPayload
isValid bool
}{
{
"default values",
&Model{},
&dns.CreateRecordSetPayload{
Records: &[]dns.RecordPayload{},
},
true,
},
{
"simple_values",
&Model{
Comment: types.StringValue("comment"),
Name: types.StringValue("name"),
Records: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("record_1"),
types.StringValue("record_2"),
}),
TTL: types.Int64Value(1),
Type: types.StringValue(string(dns.RECORDSETTYPE_A)),
},
&dns.CreateRecordSetPayload{
Comment: utils.Ptr("comment"),
Name: utils.Ptr("name"),
Records: &[]dns.RecordPayload{
{Content: utils.Ptr("record_1")},
{Content: utils.Ptr("record_2")},
},
Ttl: utils.Ptr(int64(1)),
Type: dns.CREATERECORDSETPAYLOADTYPE_A.Ptr(),
},
true,
},
{
"null_fields_and_int_conversions",
&Model{
Comment: types.StringNull(),
Name: types.StringValue(""),
Records: types.ListValueMust(types.StringType, nil),
TTL: types.Int64Value(2123456789),
Type: types.StringValue(string(dns.RECORDSETTYPE_A)),
},
&dns.CreateRecordSetPayload{
Comment: nil,
Name: utils.Ptr(""),
Records: &[]dns.RecordPayload{},
Ttl: utils.Ptr(int64(2123456789)),
Type: dns.CREATERECORDSETPAYLOADTYPE_A.Ptr(),
},
true,
},
{
"nil_model",
nil,
nil,
false,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
output, err := toCreatePayload(tt.input)
if !tt.isValid && err == nil {
t.Fatalf("Should have failed")
}
if tt.isValid && err != nil {
t.Fatalf("Should not have failed: %v", err)
}
if tt.isValid {
diff := cmp.Diff(output, tt.expected)
if diff != "" {
t.Fatalf("Data does not match: %s", diff)
}
}
})
}
}
func TestToUpdatePayload(t *testing.T) {
tests := []struct {
description string
input *Model
expected *dns.PartialUpdateRecordSetPayload
isValid bool
}{
{
"default_values",
&Model{},
&dns.PartialUpdateRecordSetPayload{
Records: &[]dns.RecordPayload{},
},
true,
},
{
"simple_values",
&Model{
Comment: types.StringValue("comment"),
Name: types.StringValue("name"),
Records: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("record_1"),
types.StringValue("record_2"),
}),
TTL: types.Int64Value(1),
},
&dns.PartialUpdateRecordSetPayload{
Comment: utils.Ptr("comment"),
Name: utils.Ptr("name"),
Records: &[]dns.RecordPayload{
{Content: utils.Ptr("record_1")},
{Content: utils.Ptr("record_2")},
},
Ttl: utils.Ptr(int64(1)),
},
true,
},
{
"null_fields_and_int_conversions",
&Model{
Comment: types.StringNull(),
Name: types.StringValue(""),
Records: types.ListValueMust(types.StringType, nil),
TTL: types.Int64Value(2123456789),
},
&dns.PartialUpdateRecordSetPayload{
Comment: nil,
Name: utils.Ptr(""),
Records: &[]dns.RecordPayload{},
Ttl: utils.Ptr(int64(2123456789)),
},
true,
},
{
"nil_model",
nil,
nil,
false,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
output, err := toUpdatePayload(tt.input)
if !tt.isValid && err == nil {
t.Fatalf("Should have failed")
}
if tt.isValid && err != nil {
t.Fatalf("Should not have failed: %v", err)
}
if tt.isValid {
diff := cmp.Diff(output, tt.expected)
if diff != "" {
t.Fatalf("Data does not match: %s", diff)
}
}
})
}
}

View file

@ -1,74 +0,0 @@
variable "project_id" {}
variable "name" {}
variable "dns_name" {}
variable "acl" {}
variable "active" {}
variable "contact_email" {}
variable "default_ttl" {}
variable "description" {}
variable "expire_time" {}
variable "is_reverse_zone" {}
# variable "negative_cache" {}
variable "primaries" {}
variable "refresh_time" {}
variable "retry_time" {}
variable "type" {}
variable "record_name" {}
variable "record_record1" {}
variable "record_active" {}
variable "record_comment" {}
variable "record_ttl" {}
variable "record_type" {}
resource "stackit_dns_zone" "zone" {
project_id = var.project_id
name = var.name
dns_name = var.dns_name
acl = var.acl
active = var.active
contact_email = var.contact_email
default_ttl = var.default_ttl
description = var.description
expire_time = var.expire_time
is_reverse_zone = var.is_reverse_zone
# negative_cache = var.negative_cache
primaries = var.primaries
refresh_time = var.refresh_time
retry_time = var.retry_time
type = var.type
}
resource "stackit_dns_record_set" "record_set" {
project_id = var.project_id
zone_id = stackit_dns_zone.zone.zone_id
name = var.record_name
records = [
var.record_record1
]
active = var.record_active
comment = var.record_comment
ttl = var.record_ttl
type = var.record_type
}
data "stackit_dns_zone" "zone" {
project_id = var.project_id
zone_id = stackit_dns_zone.zone.zone_id
}
data "stackit_dns_zone" "zone_name" {
project_id = var.project_id
dns_name = stackit_dns_zone.zone.dns_name
}
data "stackit_dns_record_set" "record_set" {
project_id = var.project_id
zone_id = stackit_dns_zone.zone.zone_id
record_set_id = stackit_dns_record_set.record_set.record_set_id
}

View file

@ -1,41 +0,0 @@
variable "project_id" {}
variable "name" {}
variable "dns_name" {}
variable "record_name" {}
variable "record_record1" {}
variable "record_type" {}
resource "stackit_dns_zone" "zone" {
project_id = var.project_id
name = var.name
dns_name = var.dns_name
}
resource "stackit_dns_record_set" "record_set" {
project_id = var.project_id
zone_id = stackit_dns_zone.zone.zone_id
name = var.record_name
records = [
var.record_record1
]
type = var.record_type
}
data "stackit_dns_zone" "zone" {
project_id = var.project_id
zone_id = stackit_dns_zone.zone.zone_id
}
data "stackit_dns_zone" "zone_name" {
project_id = var.project_id
dns_name = stackit_dns_zone.zone.dns_name
}
data "stackit_dns_record_set" "record_set" {
project_id = var.project_id
zone_id = stackit_dns_zone.zone.zone_id
record_set_id = stackit_dns_record_set.record_set.record_set_id
}