chore: cleanup alpha branch

This commit is contained in:
Marcel_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
}

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/dns"
"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) *dns.APIClient {
apiClientConfigOptions := []config.ConfigurationOption{
config.WithCustomAuth(providerData.RoundTripper),
utils.UserAgentConfigOption(providerData.Version),
}
if providerData.DnsCustomEndpoint != "" {
apiClientConfigOptions = append(apiClientConfigOptions, config.WithEndpoint(providerData.DnsCustomEndpoint))
}
apiClient, err := dns.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/dns"
"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://dns-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 *dns.APIClient
}{
{
name: "default endpoint",
args: args{
providerData: &core.ProviderData{
Version: testVersion,
},
},
expected: func() *dns.APIClient {
apiClient, err := dns.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,
DnsCustomEndpoint: testCustomEndpoint,
},
},
expected: func() *dns.APIClient {
apiClient, err := dns.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,268 +0,0 @@
package dns
import (
"context"
"fmt"
"net/http"
"github.com/hashicorp/terraform-plugin-framework-validators/datasourcevalidator"
"github.com/hashicorp/terraform-plugin-framework/path"
"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 = &zoneDataSource{}
)
// NewZoneDataSource is a helper function to simplify the provider implementation.
func NewZoneDataSource() datasource.DataSource {
return &zoneDataSource{}
}
// zoneDataSource is the data source implementation.
type zoneDataSource struct {
client *dns.APIClient
}
// Metadata returns the data source type name.
func (d *zoneDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_dns_zone"
}
// ConfigValidators validates the resource configuration
func (d *zoneDataSource) ConfigValidators(_ context.Context) []datasource.ConfigValidator {
return []datasource.ConfigValidator{
datasourcevalidator.ExactlyOneOf(
path.MatchRoot("zone_id"),
path.MatchRoot("dns_name"),
),
}
}
func (d *zoneDataSource) 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 zone client configured")
}
// Schema defines the schema for the data source.
func (d *zoneDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
resp.Schema = schema.Schema{
Description: "DNS Zone 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`\".",
Computed: true,
},
"project_id": schema.StringAttribute{
Description: "STACKIT project ID to which the dns zone is associated.",
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"zone_id": schema.StringAttribute{
Description: "The zone ID.",
Optional: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"name": schema.StringAttribute{
Description: "The user given name of the zone.",
Computed: true,
},
"dns_name": schema.StringAttribute{
Description: "The zone name. E.g. `example.com`",
Optional: true,
},
"description": schema.StringAttribute{
Description: "Description of the zone.",
Computed: true,
},
"acl": schema.StringAttribute{
Description: "The access control list.",
Computed: true,
},
"active": schema.BoolAttribute{
Description: "",
Computed: true,
},
"contact_email": schema.StringAttribute{
Description: "A contact e-mail for the zone.",
Computed: true,
},
"default_ttl": schema.Int64Attribute{
Description: "Default time to live.",
Computed: true,
},
"expire_time": schema.Int64Attribute{
Description: "Expire time.",
Computed: true,
},
"is_reverse_zone": schema.BoolAttribute{
Description: "Specifies, if the zone is a reverse zone or not.",
Computed: true,
},
"negative_cache": schema.Int64Attribute{
Description: "Negative caching.",
Computed: true,
},
"primary_name_server": schema.StringAttribute{
Description: "Primary name server. FQDN.",
Computed: true,
},
"primaries": schema.ListAttribute{
Description: `Primary name server for secondary zone.`,
Computed: true,
ElementType: types.StringType,
},
"record_count": schema.Int64Attribute{
Description: "Record count how many records are in the zone.",
Computed: true,
},
"refresh_time": schema.Int64Attribute{
Description: "Refresh time.",
Computed: true,
},
"retry_time": schema.Int64Attribute{
Description: "Retry time.",
Computed: true,
},
"serial_number": schema.Int64Attribute{
Description: "Serial number.",
Computed: true,
},
"type": schema.StringAttribute{
Description: "Zone type.",
Computed: true,
},
"visibility": schema.StringAttribute{
Description: "Visibility of the zone.",
Computed: true,
},
"state": schema.StringAttribute{
Description: "Zone state.",
Computed: true,
},
},
}
}
// Read refreshes the Terraform state with the latest data.
func (d *zoneDataSource) 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()
dnsName := model.DnsName.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "zone_id", zoneId)
ctx = tflog.SetField(ctx, "dns_name", dnsName)
var zoneResp *dns.ZoneResponse
var err error
if zoneId != "" {
zoneResp, err = d.client.GetZone(ctx, projectId, zoneId).Execute()
if err != nil {
utils.LogError(
ctx,
&resp.Diagnostics,
err,
"Reading zone",
fmt.Sprintf("Zone with ID %q does not exist in project %q.", 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)
} else {
listZoneResp, err := d.client.ListZones(ctx, projectId).
DnsNameEq(dnsName).
ActiveEq(true).
Execute()
if err != nil {
utils.LogError(
ctx,
&resp.Diagnostics,
err,
"Reading zone",
fmt.Sprintf("Zone with DNS name %q does not exist in project %q.", dnsName, 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 *listZoneResp.TotalItems != 1 {
utils.LogError(
ctx,
&resp.Diagnostics,
fmt.Errorf("zone with DNS name %q does not exist in project %q", dnsName, projectId),
"Reading zone",
fmt.Sprintf("Zone with DNS name %q does not exist in project %q.", dnsName, projectId),
nil,
)
resp.State.RemoveResource(ctx)
return
}
zones := *listZoneResp.Zones
zoneResp = dns.NewZoneResponse(zones[0])
}
if zoneResp != nil && zoneResp.Zone.State != nil && *zoneResp.Zone.State == dns.ZONESTATE_DELETE_SUCCEEDED {
resp.State.RemoveResource(ctx)
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading zone", "Zone was deleted successfully")
return
}
err = mapFields(ctx, zoneResp, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading zone", 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 zone read")
}

View file

@ -1,603 +0,0 @@
package dns
import (
"context"
"fmt"
"math"
"strings"
dnsUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/dns/utils"
"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/listplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault"
"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"
"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 = &zoneResource{}
_ resource.ResourceWithConfigure = &zoneResource{}
_ resource.ResourceWithImportState = &zoneResource{}
)
type Model struct {
Id types.String `tfsdk:"id"` // needed by TF
ZoneId types.String `tfsdk:"zone_id"`
ProjectId types.String `tfsdk:"project_id"`
Name types.String `tfsdk:"name"`
DnsName types.String `tfsdk:"dns_name"`
Description types.String `tfsdk:"description"`
Acl types.String `tfsdk:"acl"`
Active types.Bool `tfsdk:"active"`
ContactEmail types.String `tfsdk:"contact_email"`
DefaultTTL types.Int64 `tfsdk:"default_ttl"`
ExpireTime types.Int64 `tfsdk:"expire_time"`
IsReverseZone types.Bool `tfsdk:"is_reverse_zone"`
NegativeCache types.Int64 `tfsdk:"negative_cache"`
PrimaryNameServer types.String `tfsdk:"primary_name_server"`
Primaries types.List `tfsdk:"primaries"`
RecordCount types.Int64 `tfsdk:"record_count"`
RefreshTime types.Int64 `tfsdk:"refresh_time"`
RetryTime types.Int64 `tfsdk:"retry_time"`
SerialNumber types.Int64 `tfsdk:"serial_number"`
Type types.String `tfsdk:"type"`
Visibility types.String `tfsdk:"visibility"`
State types.String `tfsdk:"state"`
}
// NewZoneResource is a helper function to simplify the provider implementation.
func NewZoneResource() resource.Resource {
return &zoneResource{}
}
// zoneResource is the resource implementation.
type zoneResource struct {
client *dns.APIClient
}
// Metadata returns the resource type name.
func (r *zoneResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_dns_zone"
}
// Configure adds the provider configured client to the resource.
func (r *zoneResource) 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 zone client configured")
}
// Schema defines the schema for the resource.
func (r *zoneResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
primaryOptions := []string{"primary", "secondary"}
resp.Schema = schema.Schema{
Description: "DNS Zone resource schema.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: "Terraform's internal resource ID. It is structured as \"`project_id`,`zone_id`\".",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"project_id": schema.StringAttribute{
Description: "STACKIT project ID to which the dns zone is associated.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"zone_id": schema.StringAttribute{
Description: "The zone ID.",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"name": schema.StringAttribute{
Description: "The user given name of the zone.",
Required: true,
Validators: []validator.String{
stringvalidator.LengthAtLeast(1),
stringvalidator.LengthAtMost(63),
},
},
"dns_name": schema.StringAttribute{
Description: "The zone name. E.g. `example.com`",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
stringvalidator.LengthAtLeast(1),
stringvalidator.LengthAtMost(253),
},
},
"description": schema.StringAttribute{
Description: "Description of the zone.",
Optional: true,
Computed: true,
Validators: []validator.String{
stringvalidator.LengthAtMost(1024),
},
},
"acl": schema.StringAttribute{
Description: "The access control list. E.g. `0.0.0.0/0,::/0`",
Optional: true,
Computed: true,
Validators: []validator.String{
stringvalidator.LengthAtMost(2000),
},
},
"active": schema.BoolAttribute{
Description: "",
Optional: true,
Computed: true,
},
"contact_email": schema.StringAttribute{
Description: "A contact e-mail for the zone.",
Optional: true,
Computed: true,
Validators: []validator.String{
stringvalidator.LengthAtMost(255),
},
},
"default_ttl": schema.Int64Attribute{
Description: "Default time to live. E.g. 3600.",
Optional: true,
Computed: true,
Validators: []validator.Int64{
int64validator.Between(60, 99999999),
},
},
"expire_time": schema.Int64Attribute{
Description: "Expire time. E.g. 1209600.",
Optional: true,
Computed: true,
Validators: []validator.Int64{
int64validator.Between(60, 99999999),
},
},
"is_reverse_zone": schema.BoolAttribute{
Description: "Specifies, if the zone is a reverse zone or not. Defaults to `false`",
Optional: true,
Computed: true,
Default: booldefault.StaticBool(false),
},
"negative_cache": schema.Int64Attribute{
Description: "Negative caching. E.g. 60",
Optional: true,
Computed: true,
Validators: []validator.Int64{
int64validator.Between(60, 99999999),
},
},
"primaries": schema.ListAttribute{
Description: `Primary name server for secondary zone. E.g. ["1.2.3.4"]`,
Optional: true,
Computed: true,
ElementType: types.StringType,
PlanModifiers: []planmodifier.List{
listplanmodifier.RequiresReplace(),
listplanmodifier.UseStateForUnknown(),
},
Validators: []validator.List{
listvalidator.SizeAtMost(10),
},
},
"refresh_time": schema.Int64Attribute{
Description: "Refresh time. E.g. 3600",
Optional: true,
Computed: true,
Validators: []validator.Int64{
int64validator.Between(60, 99999999),
},
},
"retry_time": schema.Int64Attribute{
Description: "Retry time. E.g. 600",
Optional: true,
Computed: true,
Validators: []validator.Int64{
int64validator.Between(60, 99999999),
},
},
"type": schema.StringAttribute{
Description: "Zone type. Defaults to `primary`. " + utils.FormatPossibleValues(primaryOptions...),
Optional: true,
Computed: true,
Default: stringdefault.StaticString("primary"),
Validators: []validator.String{
stringvalidator.OneOf(primaryOptions...),
},
},
"primary_name_server": schema.StringAttribute{
Description: "Primary name server. FQDN.",
Computed: true,
Validators: []validator.String{
stringvalidator.LengthAtLeast(1),
stringvalidator.LengthAtMost(253),
},
},
"serial_number": schema.Int64Attribute{
Description: "Serial number. E.g. `2022111400`.",
Computed: true,
Validators: []validator.Int64{
int64validator.AtLeast(0),
int64validator.AtMost(math.MaxInt32 - 1),
},
},
"visibility": schema.StringAttribute{
Description: "Visibility of the zone. E.g. `public`.",
Computed: true,
},
"record_count": schema.Int64Attribute{
Description: "Record count how many records are in the zone.",
Computed: true,
},
"state": schema.StringAttribute{
Description: "Zone state. E.g. `CREATE_SUCCEEDED`.",
Computed: true,
},
},
}
}
// Create creates the resource and sets the initial Terraform state.
func (r *zoneResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve values from plan
var model Model
resp.Diagnostics.Append(req.Plan.Get(ctx, &model)...)
if resp.Diagnostics.HasError() {
return
}
ctx = core.InitProviderContext(ctx)
projectId := model.ProjectId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
// Generate API request body from model
payload, err := toCreatePayload(&model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating zone", fmt.Sprintf("Creating API payload: %v", err))
return
}
// Create new zone
createResp, err := r.client.CreateZone(ctx, projectId).CreateZonePayload(*payload).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating zone", 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
zoneId := *createResp.Zone.Id
utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]interface{}{
"project_id": projectId,
"zone_id": zoneId,
})
if resp.Diagnostics.HasError() {
return
}
waitResp, err := wait.CreateZoneWaitHandler(ctx, r.client, projectId, zoneId).WaitWithContext(ctx)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating zone", fmt.Sprintf("Zone 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 zone", fmt.Sprintf("Processing API payload: %v", err))
return
}
// Set state to fully populated data
resp.Diagnostics.Append(resp.State.Set(ctx, model)...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "DNS zone created")
}
// Read refreshes the Terraform state with the latest data.
func (r *zoneResource) 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()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "zone_id", zoneId)
zoneResp, err := r.client.GetZone(ctx, projectId, zoneId).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading zone", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
if zoneResp != nil && zoneResp.Zone.State != nil && *zoneResp.Zone.State == dns.ZONESTATE_DELETE_SUCCEEDED {
resp.State.RemoveResource(ctx)
return
}
// Map response body to schema
err = mapFields(ctx, zoneResp, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading zone", 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 zone read")
}
// Update updates the resource and sets the updated Terraform state on success.
func (r *zoneResource) 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()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "zone_id", zoneId)
// Generate API request body from model
payload, err := toUpdatePayload(&model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating zone", fmt.Sprintf("Creating API payload: %v", err))
return
}
// Update existing zone
_, err = r.client.PartialUpdateZone(ctx, projectId, zoneId).PartialUpdateZonePayload(*payload).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating zone", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
waitResp, err := wait.PartialUpdateZoneWaitHandler(ctx, r.client, projectId, zoneId).WaitWithContext(ctx)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating zone", fmt.Sprintf("Zone update waiting: %v", err))
return
}
err = mapFields(ctx, waitResp, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating zone", 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 zone updated")
}
// Delete deletes the resource and removes the Terraform state on success.
func (r *zoneResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve values from state
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()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "zone_id", zoneId)
// Delete existing zone
_, err := r.client.DeleteZone(ctx, projectId, zoneId).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting zone", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
_, err = wait.DeleteZoneWaitHandler(ctx, r.client, projectId, zoneId).WaitWithContext(ctx)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting zone", fmt.Sprintf("Zone deletion waiting: %v", err))
return
}
tflog.Info(ctx, "DNS zone deleted")
}
// ImportState imports a resource into the Terraform state on success.
// The expected format of the resource import identifier is: project_id,zone_id
func (r *zoneResource) 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 zone",
fmt.Sprintf("Expected import identifier with format: [project_id],[zone_id] Got: %q", req.ID),
)
return
}
utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]interface{}{
"project_id": idParts[0],
"zone_id": idParts[1],
})
tflog.Info(ctx, "DNS zone state imported")
}
func mapFields(ctx context.Context, zoneResp *dns.ZoneResponse, model *Model) error {
if zoneResp == nil || zoneResp.Zone == nil {
return fmt.Errorf("response input is nil")
}
if model == nil {
return fmt.Errorf("model input is nil")
}
z := zoneResp.Zone
var rc *int64
if z.RecordCount != nil {
recordCount64 := int64(*z.RecordCount)
rc = &recordCount64
} else {
rc = nil
}
var zoneId string
if model.ZoneId.ValueString() != "" {
zoneId = model.ZoneId.ValueString()
} else if z.Id != nil {
zoneId = *z.Id
} else {
return fmt.Errorf("zone id not present")
}
model.Id = utils.BuildInternalTerraformId(model.ProjectId.ValueString(), zoneId)
if z.Primaries == nil {
model.Primaries = types.ListNull(types.StringType)
} else {
respPrimaries := *z.Primaries
modelPrimaries, err := utils.ListValuetoStringSlice(model.Primaries)
if err != nil {
return err
}
reconciledPrimaries := utils.ReconcileStringSlices(modelPrimaries, respPrimaries)
primariesTF, diags := types.ListValueFrom(ctx, types.StringType, reconciledPrimaries)
if diags.HasError() {
return fmt.Errorf("failed to map zone primaries: %w", core.DiagsToError(diags))
}
model.Primaries = primariesTF
}
model.ZoneId = types.StringValue(zoneId)
model.Description = types.StringPointerValue(z.Description)
model.Acl = types.StringPointerValue(z.Acl)
model.Active = types.BoolPointerValue(z.Active)
model.ContactEmail = types.StringPointerValue(z.ContactEmail)
model.DefaultTTL = types.Int64PointerValue(z.DefaultTTL)
model.DnsName = types.StringPointerValue(z.DnsName)
model.ExpireTime = types.Int64PointerValue(z.ExpireTime)
model.IsReverseZone = types.BoolPointerValue(z.IsReverseZone)
model.Name = types.StringPointerValue(z.Name)
model.NegativeCache = types.Int64PointerValue(z.NegativeCache)
model.PrimaryNameServer = types.StringPointerValue(z.PrimaryNameServer)
model.RecordCount = types.Int64PointerValue(rc)
model.RefreshTime = types.Int64PointerValue(z.RefreshTime)
model.RetryTime = types.Int64PointerValue(z.RetryTime)
model.SerialNumber = types.Int64PointerValue(z.SerialNumber)
model.State = types.StringValue(string(z.GetState()))
model.Type = types.StringValue(string(z.GetType()))
model.Visibility = types.StringValue(string(z.GetVisibility()))
return nil
}
func toCreatePayload(model *Model) (*dns.CreateZonePayload, error) {
if model == nil {
return nil, fmt.Errorf("nil model")
}
modelPrimaries := []string{}
for _, primary := range model.Primaries.Elements() {
primaryString, ok := primary.(types.String)
if !ok {
return nil, fmt.Errorf("type assertion failed")
}
modelPrimaries = append(modelPrimaries, primaryString.ValueString())
}
return &dns.CreateZonePayload{
Name: conversion.StringValueToPointer(model.Name),
DnsName: conversion.StringValueToPointer(model.DnsName),
ContactEmail: conversion.StringValueToPointer(model.ContactEmail),
Description: conversion.StringValueToPointer(model.Description),
Acl: conversion.StringValueToPointer(model.Acl),
Type: dns.CreateZonePayloadGetTypeAttributeType(conversion.StringValueToPointer(model.Type)),
DefaultTTL: conversion.Int64ValueToPointer(model.DefaultTTL),
ExpireTime: conversion.Int64ValueToPointer(model.ExpireTime),
RefreshTime: conversion.Int64ValueToPointer(model.RefreshTime),
RetryTime: conversion.Int64ValueToPointer(model.RetryTime),
NegativeCache: conversion.Int64ValueToPointer(model.NegativeCache),
IsReverseZone: conversion.BoolValueToPointer(model.IsReverseZone),
Primaries: &modelPrimaries,
}, nil
}
func toUpdatePayload(model *Model) (*dns.PartialUpdateZonePayload, error) {
if model == nil {
return nil, fmt.Errorf("nil model")
}
return &dns.PartialUpdateZonePayload{
Name: conversion.StringValueToPointer(model.Name),
ContactEmail: conversion.StringValueToPointer(model.ContactEmail),
Description: conversion.StringValueToPointer(model.Description),
Acl: conversion.StringValueToPointer(model.Acl),
DefaultTTL: conversion.Int64ValueToPointer(model.DefaultTTL),
ExpireTime: conversion.Int64ValueToPointer(model.ExpireTime),
RefreshTime: conversion.Int64ValueToPointer(model.RefreshTime),
RetryTime: conversion.Int64ValueToPointer(model.RetryTime),
NegativeCache: conversion.Int64ValueToPointer(model.NegativeCache),
Primaries: nil, // API returns error if this field is set, even if nothing changes
}, nil
}

View file

@ -1,437 +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.ZoneResponse
expected Model
isValid bool
}{
{
"default_ok",
Model{
ProjectId: types.StringValue("pid"),
ZoneId: types.StringValue("zid"),
},
&dns.ZoneResponse{
Zone: &dns.Zone{
Id: utils.Ptr("zid"),
},
},
Model{
Id: types.StringValue("pid,zid"),
ProjectId: types.StringValue("pid"),
ZoneId: types.StringValue("zid"),
Name: types.StringNull(),
DnsName: types.StringNull(),
Acl: types.StringNull(),
DefaultTTL: types.Int64Null(),
ExpireTime: types.Int64Null(),
RefreshTime: types.Int64Null(),
RetryTime: types.Int64Null(),
SerialNumber: types.Int64Null(),
NegativeCache: types.Int64Null(),
Type: types.StringValue(""),
State: types.StringValue(""),
PrimaryNameServer: types.StringNull(),
Primaries: types.ListNull(types.StringType),
Visibility: types.StringValue(""),
},
true,
},
{
"values_ok",
Model{
ProjectId: types.StringValue("pid"),
ZoneId: types.StringValue("zid"),
},
&dns.ZoneResponse{
Zone: &dns.Zone{
Id: utils.Ptr("zid"),
Name: utils.Ptr("name"),
DnsName: utils.Ptr("dnsname"),
Acl: utils.Ptr("acl"),
Active: utils.Ptr(false),
CreationStarted: utils.Ptr("bar"),
CreationFinished: utils.Ptr("foo"),
DefaultTTL: utils.Ptr(int64(1)),
ExpireTime: utils.Ptr(int64(2)),
RefreshTime: utils.Ptr(int64(3)),
RetryTime: utils.Ptr(int64(4)),
SerialNumber: utils.Ptr(int64(5)),
NegativeCache: utils.Ptr(int64(6)),
State: dns.ZONESTATE_CREATING.Ptr(),
Type: dns.ZONETYPE_PRIMARY.Ptr(),
Primaries: &[]string{"primary"},
PrimaryNameServer: utils.Ptr("pns"),
UpdateStarted: utils.Ptr("ufoo"),
UpdateFinished: utils.Ptr("ubar"),
Visibility: dns.ZONEVISIBILITY_PUBLIC.Ptr(),
Error: utils.Ptr("error"),
ContactEmail: utils.Ptr("a@b.cd"),
Description: utils.Ptr("description"),
IsReverseZone: utils.Ptr(false),
RecordCount: utils.Ptr(int64(3)),
},
},
Model{
Id: types.StringValue("pid,zid"),
ProjectId: types.StringValue("pid"),
ZoneId: types.StringValue("zid"),
Name: types.StringValue("name"),
DnsName: types.StringValue("dnsname"),
Acl: types.StringValue("acl"),
Active: types.BoolValue(false),
DefaultTTL: types.Int64Value(1),
ExpireTime: types.Int64Value(2),
RefreshTime: types.Int64Value(3),
RetryTime: types.Int64Value(4),
SerialNumber: types.Int64Value(5),
NegativeCache: types.Int64Value(6),
Type: types.StringValue(string(dns.ZONETYPE_PRIMARY)),
State: types.StringValue(string(dns.ZONESTATE_CREATING)),
PrimaryNameServer: types.StringValue("pns"),
Primaries: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("primary"),
}),
Visibility: types.StringValue(string(dns.ZONEVISIBILITY_PUBLIC)),
ContactEmail: types.StringValue("a@b.cd"),
Description: types.StringValue("description"),
IsReverseZone: types.BoolValue(false),
RecordCount: types.Int64Value(3),
},
true,
},
{
"primaries_unordered",
Model{
ProjectId: types.StringValue("pid"),
ZoneId: types.StringValue("zid"),
Primaries: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("primary2"),
types.StringValue("primary1"),
}),
},
&dns.ZoneResponse{
Zone: &dns.Zone{
Id: utils.Ptr("zid"),
Name: utils.Ptr("name"),
DnsName: utils.Ptr("dnsname"),
Acl: utils.Ptr("acl"),
Active: utils.Ptr(false),
CreationStarted: utils.Ptr("bar"),
CreationFinished: utils.Ptr("foo"),
DefaultTTL: utils.Ptr(int64(1)),
ExpireTime: utils.Ptr(int64(2)),
RefreshTime: utils.Ptr(int64(3)),
RetryTime: utils.Ptr(int64(4)),
SerialNumber: utils.Ptr(int64(5)),
NegativeCache: utils.Ptr(int64(6)),
State: dns.ZONESTATE_CREATING.Ptr(),
Type: dns.ZONETYPE_PRIMARY.Ptr(),
Primaries: &[]string{
"primary1",
"primary2",
},
PrimaryNameServer: utils.Ptr("pns"),
UpdateStarted: utils.Ptr("ufoo"),
UpdateFinished: utils.Ptr("ubar"),
Visibility: dns.ZONEVISIBILITY_PUBLIC.Ptr(),
Error: utils.Ptr("error"),
ContactEmail: utils.Ptr("a@b.cd"),
Description: utils.Ptr("description"),
IsReverseZone: utils.Ptr(false),
RecordCount: utils.Ptr(int64(3)),
},
},
Model{
Id: types.StringValue("pid,zid"),
ProjectId: types.StringValue("pid"),
ZoneId: types.StringValue("zid"),
Name: types.StringValue("name"),
DnsName: types.StringValue("dnsname"),
Acl: types.StringValue("acl"),
Active: types.BoolValue(false),
DefaultTTL: types.Int64Value(1),
ExpireTime: types.Int64Value(2),
RefreshTime: types.Int64Value(3),
RetryTime: types.Int64Value(4),
SerialNumber: types.Int64Value(5),
NegativeCache: types.Int64Value(6),
Type: types.StringValue(string(dns.ZONETYPE_PRIMARY)),
State: types.StringValue(string(dns.ZONESTATE_CREATING)),
PrimaryNameServer: types.StringValue("pns"),
Primaries: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("primary2"),
types.StringValue("primary1"),
}),
Visibility: types.StringValue(string(dns.ZONEVISIBILITY_PUBLIC)),
ContactEmail: types.StringValue("a@b.cd"),
Description: types.StringValue("description"),
IsReverseZone: types.BoolValue(false),
RecordCount: types.Int64Value(3),
},
true,
},
{
"nullable_fields_and_int_conversions_ok",
Model{
Id: types.StringValue("pid,zid"),
ProjectId: types.StringValue("pid"),
ZoneId: types.StringValue("zid"),
},
&dns.ZoneResponse{
Zone: &dns.Zone{
Id: utils.Ptr("zid"),
Name: utils.Ptr("name"),
DnsName: utils.Ptr("dnsname"),
Acl: utils.Ptr("acl"),
Active: nil,
CreationStarted: utils.Ptr("bar"),
CreationFinished: utils.Ptr("foo"),
DefaultTTL: utils.Ptr(int64(2123456789)),
ExpireTime: utils.Ptr(int64(-2)),
RefreshTime: utils.Ptr(int64(3)),
RetryTime: utils.Ptr(int64(4)),
SerialNumber: utils.Ptr(int64(5)),
NegativeCache: utils.Ptr(int64(0)),
State: dns.ZONESTATE_CREATING.Ptr(),
Type: dns.ZONETYPE_PRIMARY.Ptr(),
Primaries: nil,
PrimaryNameServer: utils.Ptr("pns"),
UpdateStarted: utils.Ptr("ufoo"),
UpdateFinished: utils.Ptr("ubar"),
Visibility: dns.ZONEVISIBILITY_PUBLIC.Ptr(),
ContactEmail: nil,
Description: nil,
IsReverseZone: nil,
RecordCount: utils.Ptr(int64(-2123456789)),
},
},
Model{
Id: types.StringValue("pid,zid"),
ProjectId: types.StringValue("pid"),
ZoneId: types.StringValue("zid"),
Name: types.StringValue("name"),
DnsName: types.StringValue("dnsname"),
Acl: types.StringValue("acl"),
Active: types.BoolNull(),
DefaultTTL: types.Int64Value(2123456789),
ExpireTime: types.Int64Value(-2),
RefreshTime: types.Int64Value(3),
RetryTime: types.Int64Value(4),
SerialNumber: types.Int64Value(5),
NegativeCache: types.Int64Value(0),
Type: types.StringValue(string(dns.ZONETYPE_PRIMARY)),
Primaries: types.ListNull(types.StringType),
State: types.StringValue(string(dns.ZONESTATE_CREATING)),
PrimaryNameServer: types.StringValue("pns"),
Visibility: types.StringValue(string(dns.ZONEVISIBILITY_PUBLIC)),
ContactEmail: types.StringNull(),
Description: types.StringNull(),
IsReverseZone: types.BoolNull(),
RecordCount: types.Int64Value(-2123456789),
},
true,
},
{
"response_nil_fail",
Model{},
nil,
Model{},
false,
},
{
"no_resource_id",
Model{
ProjectId: types.StringValue("pid"),
ZoneId: types.StringValue("zid"),
},
&dns.ZoneResponse{},
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.CreateZonePayload
isValid bool
}{
{
"default_ok",
&Model{
Name: types.StringValue("Name"),
DnsName: types.StringValue("DnsName"),
},
&dns.CreateZonePayload{
Name: utils.Ptr("Name"),
DnsName: utils.Ptr("DnsName"),
Primaries: &[]string{},
},
true,
},
{
"mapping_with_conversions_ok",
&Model{
Name: types.StringValue("Name"),
DnsName: types.StringValue("DnsName"),
Acl: types.StringValue("Acl"),
Description: types.StringValue("Description"),
Type: types.StringValue(string(dns.CREATEZONEPAYLOADTYPE_PRIMARY)),
ContactEmail: types.StringValue("ContactEmail"),
RetryTime: types.Int64Value(3),
RefreshTime: types.Int64Value(4),
ExpireTime: types.Int64Value(5),
DefaultTTL: types.Int64Value(4534534),
NegativeCache: types.Int64Value(-4534534),
Primaries: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("primary"),
}),
IsReverseZone: types.BoolValue(true),
},
&dns.CreateZonePayload{
Name: utils.Ptr("Name"),
DnsName: utils.Ptr("DnsName"),
Acl: utils.Ptr("Acl"),
Description: utils.Ptr("Description"),
Type: dns.CREATEZONEPAYLOADTYPE_PRIMARY.Ptr(),
ContactEmail: utils.Ptr("ContactEmail"),
Primaries: &[]string{"primary"},
RetryTime: utils.Ptr(int64(3)),
RefreshTime: utils.Ptr(int64(4)),
ExpireTime: utils.Ptr(int64(5)),
DefaultTTL: utils.Ptr(int64(4534534)),
NegativeCache: utils.Ptr(int64(-4534534)),
IsReverseZone: utils.Ptr(true),
},
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 TestToPayloadUpdate(t *testing.T) {
tests := []struct {
description string
input *Model
expected *dns.PartialUpdateZonePayload
isValid bool
}{
{
"single_field_change_ok",
&Model{
Name: types.StringValue("Name"),
},
&dns.PartialUpdateZonePayload{
Name: utils.Ptr("Name"),
},
true,
},
{
"mapping_with_conversions_ok",
&Model{
Name: types.StringValue("Name"),
DnsName: types.StringValue("DnsName"),
Acl: types.StringValue("Acl"),
Active: types.BoolValue(true),
Description: types.StringValue("Description"),
Type: types.StringValue(string(dns.ZONETYPE_PRIMARY)),
ContactEmail: types.StringValue("ContactEmail"),
PrimaryNameServer: types.StringValue("PrimaryNameServer"),
Primaries: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("Primary"),
}),
RetryTime: types.Int64Value(3),
RefreshTime: types.Int64Value(4),
ExpireTime: types.Int64Value(5),
DefaultTTL: types.Int64Value(4534534),
NegativeCache: types.Int64Value(-4534534),
IsReverseZone: types.BoolValue(true),
},
&dns.PartialUpdateZonePayload{
Name: utils.Ptr("Name"),
Acl: utils.Ptr("Acl"),
Description: utils.Ptr("Description"),
ContactEmail: utils.Ptr("ContactEmail"),
RetryTime: utils.Ptr(int64(3)),
RefreshTime: utils.Ptr(int64(4)),
ExpireTime: utils.Ptr(int64(5)),
DefaultTTL: utils.Ptr(int64(4534534)),
NegativeCache: utils.Ptr(int64(-4534534)),
},
true,
},
}
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,342 +0,0 @@
package git
import (
"context"
_ "embed"
"fmt"
"maps"
"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"
stackitSdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config"
"github.com/stackitcloud/stackit-sdk-go/core/utils"
"github.com/stackitcloud/stackit-sdk-go/services/git"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/testutil"
)
//go:embed testdata/resource-min.tf
var resourceMin string
//go:embed testdata/resource-max.tf
var resourceMax string
var nameMin = fmt.Sprintf("git-min-%s-instance", acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum))
var nameMinUpdated = fmt.Sprintf("git-min-%s-instance", acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum))
var nameMax = fmt.Sprintf("git-max-%s-instance", acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum))
var nameMaxUpdated = fmt.Sprintf("git-max-%s-instance", acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum))
var aclUpdated = "192.168.1.0/32"
var testConfigVarsMin = config.Variables{
"project_id": config.StringVariable(testutil.ProjectId),
"name": config.StringVariable(nameMin),
}
var testConfigVarsMax = config.Variables{
"project_id": config.StringVariable(testutil.ProjectId),
"name": config.StringVariable(nameMax),
"acl": config.StringVariable("192.168.0.0/16"),
"flavor": config.StringVariable("git-100"),
}
func testConfigVarsMinUpdated() config.Variables {
tempConfig := make(config.Variables, len(testConfigVarsMin))
maps.Copy(tempConfig, testConfigVarsMin)
// update git instance to a new name
// should trigger creating a new instance
tempConfig["name"] = config.StringVariable(nameMinUpdated)
return tempConfig
}
func testConfigVarsMaxUpdated() config.Variables {
tempConfig := make(config.Variables, len(testConfigVarsMax))
maps.Copy(tempConfig, testConfigVarsMax)
// update git instance to a new name
// should trigger creating a new instance
tempConfig["name"] = config.StringVariable(nameMaxUpdated)
tempConfig["acl"] = config.StringVariable(aclUpdated)
return tempConfig
}
func TestAccGitMin(t *testing.T) {
resource.Test(t, resource.TestCase{
ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories,
CheckDestroy: testAccCheckGitInstanceDestroy,
Steps: []resource.TestStep{
// Creation
{
ConfigVariables: testConfigVarsMin,
Config: testutil.GitProviderConfig() + resourceMin,
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttr("stackit_git.git", "project_id", testutil.ConvertConfigVariable(testConfigVarsMin["project_id"])),
resource.TestCheckResourceAttr("stackit_git.git", "name", testutil.ConvertConfigVariable(testConfigVarsMin["name"])),
resource.TestCheckResourceAttrSet("stackit_git.git", "url"),
resource.TestCheckResourceAttrSet("stackit_git.git", "version"),
resource.TestCheckResourceAttrSet("stackit_git.git", "instance_id"),
resource.TestCheckResourceAttrSet("stackit_git.git", "created"),
resource.TestCheckResourceAttrSet("stackit_git.git", "consumed_object_storage"),
resource.TestCheckResourceAttrSet("stackit_git.git", "consumed_disk"),
resource.TestCheckResourceAttrSet("stackit_git.git", "flavor"),
),
},
// Data source
{
ConfigVariables: testConfigVarsMin,
Config: fmt.Sprintf(`
%s
data "stackit_git" "git" {
project_id = stackit_git.git.project_id
instance_id = stackit_git.git.instance_id
}
`, testutil.GitProviderConfig()+resourceMin,
),
Check: resource.ComposeAggregateTestCheckFunc(
// Instance
resource.TestCheckResourceAttr("data.stackit_git.git", "project_id", testutil.ConvertConfigVariable(testConfigVarsMin["project_id"])),
resource.TestCheckResourceAttrPair(
"stackit_git.git", "project_id",
"data.stackit_git.git", "project_id",
),
resource.TestCheckResourceAttrPair(
"stackit_git.git", "instance_id",
"data.stackit_git.git", "instance_id",
),
resource.TestCheckResourceAttrPair(
"stackit_git.git", "name",
"data.stackit_git.git", "name",
),
resource.TestCheckResourceAttrPair(
"stackit_git.git", "url",
"data.stackit_git.git", "url",
),
resource.TestCheckResourceAttrPair(
"stackit_git.git", "version",
"data.stackit_git.git", "version",
),
resource.TestCheckResourceAttrPair(
"stackit_git.git", "created",
"data.stackit_git.git", "created",
),
resource.TestCheckResourceAttrPair(
"stackit_git.git", "consumed_object_storage",
"data.stackit_git.git", "consumed_object_storage",
),
resource.TestCheckResourceAttrPair(
"stackit_git.git", "consumed_disk",
"data.stackit_git.git", "consumed_disk",
),
resource.TestCheckResourceAttrPair(
"stackit_git.git", "flavor",
"data.stackit_git.git", "flavor",
),
),
},
// Import
{
ConfigVariables: testConfigVarsMin,
ResourceName: "stackit_git.git",
ImportStateIdFunc: func(s *terraform.State) (string, error) {
r, ok := s.RootModule().Resources["stackit_git.git"]
if !ok {
return "", fmt.Errorf("couldn't find resource stackit_git.git")
}
instanceId, ok := r.Primary.Attributes["instance_id"]
if !ok {
return "", fmt.Errorf("couldn't find attribute instance_id")
}
return fmt.Sprintf("%s,%s", testutil.ProjectId, instanceId), nil
},
ImportState: true,
ImportStateVerify: true,
},
// Update
{
ConfigVariables: testConfigVarsMinUpdated(),
Config: testutil.GitProviderConfig() + resourceMin,
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttr("stackit_git.git", "project_id", testutil.ConvertConfigVariable(testConfigVarsMinUpdated()["project_id"])),
resource.TestCheckResourceAttr("stackit_git.git", "name", testutil.ConvertConfigVariable(testConfigVarsMinUpdated()["name"])),
resource.TestCheckResourceAttrSet("stackit_git.git", "url"),
resource.TestCheckResourceAttrSet("stackit_git.git", "version"),
resource.TestCheckResourceAttrSet("stackit_git.git", "instance_id"),
resource.TestCheckResourceAttrSet("stackit_git.git", "created"),
resource.TestCheckResourceAttrSet("stackit_git.git", "consumed_object_storage"),
resource.TestCheckResourceAttrSet("stackit_git.git", "consumed_disk"),
resource.TestCheckResourceAttrSet("stackit_git.git", "flavor"),
),
},
// Deletion is done by the framework implicitly
},
})
}
func TestAccGitMax(t *testing.T) {
resource.Test(t, resource.TestCase{
ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories,
CheckDestroy: testAccCheckGitInstanceDestroy,
Steps: []resource.TestStep{
// Creation
{
ConfigVariables: testConfigVarsMax,
Config: testutil.GitProviderConfig() + resourceMax,
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttr("stackit_git.git", "project_id", testutil.ConvertConfigVariable(testConfigVarsMax["project_id"])),
resource.TestCheckResourceAttr("stackit_git.git", "name", testutil.ConvertConfigVariable(testConfigVarsMax["name"])),
resource.TestCheckResourceAttr("stackit_git.git", "flavor", testutil.ConvertConfigVariable(testConfigVarsMax["flavor"])),
resource.TestCheckResourceAttr("stackit_git.git", "acl.0", testutil.ConvertConfigVariable(testConfigVarsMax["acl"])),
resource.TestCheckResourceAttrSet("stackit_git.git", "url"),
resource.TestCheckResourceAttrSet("stackit_git.git", "version"),
resource.TestCheckResourceAttrSet("stackit_git.git", "instance_id"),
resource.TestCheckResourceAttrSet("stackit_git.git", "created"),
resource.TestCheckResourceAttrSet("stackit_git.git", "consumed_object_storage"),
resource.TestCheckResourceAttrSet("stackit_git.git", "consumed_disk"),
),
},
// Data source
{
ConfigVariables: testConfigVarsMax,
Config: fmt.Sprintf(`
%s
data "stackit_git" "git" {
project_id = stackit_git.git.project_id
instance_id = stackit_git.git.instance_id
}
`, testutil.GitProviderConfig()+resourceMax,
),
Check: resource.ComposeAggregateTestCheckFunc(
// Instance
resource.TestCheckResourceAttr("data.stackit_git.git", "project_id", testutil.ConvertConfigVariable(testConfigVarsMax["project_id"])),
resource.TestCheckResourceAttrPair(
"stackit_git.git", "project_id",
"data.stackit_git.git", "project_id",
),
resource.TestCheckResourceAttrPair(
"stackit_git.git", "instance_id",
"data.stackit_git.git", "instance_id",
),
resource.TestCheckResourceAttrPair(
"stackit_git.git", "name",
"data.stackit_git.git", "name",
),
resource.TestCheckResourceAttrPair(
"stackit_git.git", "url",
"data.stackit_git.git", "url",
),
resource.TestCheckResourceAttrPair(
"stackit_git.git", "version",
"data.stackit_git.git", "version",
),
resource.TestCheckResourceAttrPair(
"stackit_git.git", "created",
"data.stackit_git.git", "created",
),
resource.TestCheckResourceAttrPair(
"stackit_git.git", "consumed_object_storage",
"data.stackit_git.git", "consumed_object_storage",
),
resource.TestCheckResourceAttrPair(
"stackit_git.git", "consumed_disk",
"data.stackit_git.git", "consumed_disk",
),
resource.TestCheckResourceAttrPair(
"stackit_git.git", "flavor",
"data.stackit_git.git", "flavor",
),
resource.TestCheckResourceAttrPair(
"stackit_git.git", "acl",
"data.stackit_git.git", "acl",
),
),
},
// Import
{
ConfigVariables: testConfigVarsMax,
ResourceName: "stackit_git.git",
ImportStateIdFunc: func(s *terraform.State) (string, error) {
r, ok := s.RootModule().Resources["stackit_git.git"]
if !ok {
return "", fmt.Errorf("couldn't find resource stackit_git.git")
}
instanceId, ok := r.Primary.Attributes["instance_id"]
if !ok {
return "", fmt.Errorf("couldn't find attribute instance_id")
}
return fmt.Sprintf("%s,%s", testutil.ProjectId, instanceId), nil
},
ImportState: true,
ImportStateVerify: true,
},
// Update
{
ConfigVariables: testConfigVarsMaxUpdated(),
Config: testutil.GitProviderConfig() + resourceMax,
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttr("stackit_git.git", "project_id", testutil.ConvertConfigVariable(testConfigVarsMaxUpdated()["project_id"])),
resource.TestCheckResourceAttr("stackit_git.git", "name", testutil.ConvertConfigVariable(testConfigVarsMaxUpdated()["name"])),
resource.TestCheckResourceAttr("stackit_git.git", "flavor", testutil.ConvertConfigVariable(testConfigVarsMaxUpdated()["flavor"])),
resource.TestCheckResourceAttr("stackit_git.git", "acl.0", testutil.ConvertConfigVariable(testConfigVarsMaxUpdated()["acl"])),
resource.TestCheckResourceAttrSet("stackit_git.git", "url"),
resource.TestCheckResourceAttrSet("stackit_git.git", "version"),
resource.TestCheckResourceAttrSet("stackit_git.git", "instance_id"),
resource.TestCheckResourceAttrSet("stackit_git.git", "created"),
resource.TestCheckResourceAttrSet("stackit_git.git", "consumed_object_storage"),
resource.TestCheckResourceAttrSet("stackit_git.git", "consumed_disk"),
),
},
// Deletion is done by the framework implicitly
},
})
}
func testAccCheckGitInstanceDestroy(s *terraform.State) error {
ctx := context.Background()
var client *git.APIClient
var err error
if testutil.GitCustomEndpoint == "" {
client, err = git.NewAPIClient()
} else {
client, err = git.NewAPIClient(
stackitSdkConfig.WithEndpoint(testutil.GitCustomEndpoint),
)
}
if err != nil {
return fmt.Errorf("creating client: %w", err)
}
var instancesToDestroy []string
for _, rs := range s.RootModule().Resources {
if rs.Type != "stackit_git" {
continue
}
instanceId := strings.Split(rs.Primary.ID, core.Separator)[1]
instancesToDestroy = append(instancesToDestroy, instanceId)
}
instancesResp, err := client.ListInstances(ctx, testutil.ProjectId).Execute()
if err != nil {
return fmt.Errorf("getting git instances: %w", err)
}
gitInstances := *instancesResp.Instances
for i := range gitInstances {
if gitInstances[i].Id == nil {
continue
}
if utils.Contains(instancesToDestroy, *gitInstances[i].Id) {
err := client.DeleteInstance(ctx, testutil.ProjectId, *gitInstances[i].Id).Execute()
if err != nil {
return fmt.Errorf("destroying git instance %s during CheckDestroy: %w", *gitInstances[i].Id, err)
}
}
}
return nil
}

View file

@ -1,166 +0,0 @@
package instance
import (
"context"
"errors"
"fmt"
"net/http"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
gitUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/git/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/core/oapierror"
"github.com/stackitcloud/stackit-sdk-go/services/git"
"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 = &gitDataSource{}
)
// NewGitDataSource creates a new instance of the gitDataSource.
func NewGitDataSource() datasource.DataSource {
return &gitDataSource{}
}
// gitDataSource is the datasource implementation.
type gitDataSource struct {
client *git.APIClient
}
// Configure sets up the API client for the git instance resource.
func (g *gitDataSource) 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_git", "datasource")
if resp.Diagnostics.HasError() {
return
}
apiClient := gitUtils.ConfigureClient(ctx, &providerData, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}
g.client = apiClient
tflog.Info(ctx, "git client configured")
}
// Metadata provides metadata for the git datasource.
func (g *gitDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_git"
}
// Schema defines the schema for the git data source.
func (g *gitDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: features.AddBetaDescription("Git Instance datasource schema.", core.Datasource),
Description: "Git Instance datasource schema.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: descriptions["id"],
Computed: true,
},
"project_id": schema.StringAttribute{
Description: descriptions["project_id"],
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"instance_id": schema.StringAttribute{
Description: descriptions["instance_id"],
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"acl": schema.ListAttribute{
Description: descriptions["acl"],
Computed: true,
ElementType: types.StringType,
},
"consumed_disk": schema.StringAttribute{
Description: descriptions["consumed_disk"],
Computed: true,
},
"consumed_object_storage": schema.StringAttribute{
Description: descriptions["consumed_object_storage"],
Computed: true,
},
"created": schema.StringAttribute{
Description: descriptions["created"],
Computed: true,
},
"flavor": schema.StringAttribute{
Description: descriptions["flavor"],
Computed: true,
},
"name": schema.StringAttribute{
Description: descriptions["name"],
Computed: true,
},
"url": schema.StringAttribute{
Description: descriptions["url"],
Computed: true,
},
"version": schema.StringAttribute{
Description: descriptions["version"],
Computed: true,
},
},
}
}
func (g *gitDataSource) 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)
// Extract the project ID and instance id of the model
projectId := model.ProjectId.ValueString()
instanceId := model.InstanceId.ValueString()
// Read the current git instance via id
gitInstanceResp, err := g.client.GetInstance(ctx, projectId, instanceId).Execute()
if err != nil {
var oapiErr *oapierror.GenericOpenAPIError
ok := errors.As(err, &oapiErr)
if ok && oapiErr.StatusCode == http.StatusNotFound {
resp.State.RemoveResource(ctx)
return
}
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading git instance", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
err = mapFields(ctx, gitInstanceResp, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading git instance", fmt.Sprintf("Processing API response: %v", err))
return
}
// Set the updated state.
diags = resp.State.Set(ctx, &model)
resp.Diagnostics.Append(diags...)
tflog.Info(ctx, fmt.Sprintf("read git instance %s", instanceId))
}

View file

@ -1,425 +0,0 @@
package instance
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
"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/listplanmodifier"
"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/core/oapierror"
"github.com/stackitcloud/stackit-sdk-go/services/git"
"github.com/stackitcloud/stackit-sdk-go/services/git/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"
gitUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/git/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 = &gitResource{}
_ resource.ResourceWithConfigure = &gitResource{}
_ resource.ResourceWithImportState = &gitResource{}
)
// Model represents the schema for the git resource.
type Model struct {
Id types.String `tfsdk:"id"` // Required by Terraform
ACL types.List `tfsdk:"acl"`
ConsumedDisk types.String `tfsdk:"consumed_disk"`
ConsumedObjectStorage types.String `tfsdk:"consumed_object_storage"`
Created types.String `tfsdk:"created"`
Flavor types.String `tfsdk:"flavor"`
InstanceId types.String `tfsdk:"instance_id"`
Name types.String `tfsdk:"name"`
ProjectId types.String `tfsdk:"project_id"`
Url types.String `tfsdk:"url"`
Version types.String `tfsdk:"version"`
}
// NewGitResource is a helper function to create a new git resource instance.
func NewGitResource() resource.Resource {
return &gitResource{}
}
// gitResource implements the resource interface for git instances.
type gitResource struct {
client *git.APIClient
}
// descriptions for the attributes in the Schema
var descriptions = map[string]string{
"id": "Terraform's internal resource ID, structured as \"`project_id`,`instance_id`\".",
"acl": "Restricted ACL for instance access.",
"consumed_disk": "How many bytes of disk space is consumed.",
"consumed_object_storage": "How many bytes of Object Storage is consumed.",
"created": "Instance creation timestamp in RFC3339 format.",
"flavor": "Instance flavor. If not provided, defaults to git-100. For a list of available flavors, refer to our API documentation: `https://docs.api.stackit.cloud/documentation/git/version/v1beta`",
"instance_id": "ID linked to the git instance.",
"name": "Unique name linked to the git instance.",
"project_id": "STACKIT project ID to which the git instance is associated.",
"url": "Url linked to the git instance.",
"version": "Version linked to the git instance.",
}
// Configure sets up the API client for the git instance resource.
func (g *gitResource) 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_git", "resource")
if resp.Diagnostics.HasError() {
return
}
apiClient := gitUtils.ConfigureClient(ctx, &providerData, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}
g.client = apiClient
tflog.Info(ctx, "git client configured")
}
// Metadata sets the resource type name for the git instance resource.
func (g *gitResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_git"
}
// Schema defines the schema for the resource.
func (g *gitResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: fmt.Sprintf(
"%s %s",
features.AddBetaDescription("Git Instance resource schema.", core.Resource),
"This resource currently does not support updates. Changing the ACLs, flavor, or name will trigger resource recreation. Update functionality will be added soon. In the meantime, please proceed with caution. To update these attributes, please open a support ticket.",
),
Description: "Git Instance resource schema.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: descriptions["id"],
Computed: true,
},
"project_id": schema.StringAttribute{
Description: descriptions["project_id"],
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"instance_id": schema.StringAttribute{
Description: descriptions["instance_id"],
Computed: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"acl": schema.ListAttribute{
Description: descriptions["acl"],
PlanModifiers: []planmodifier.List{
listplanmodifier.RequiresReplace(),
},
ElementType: types.StringType,
Optional: true,
Computed: true,
},
"consumed_disk": schema.StringAttribute{
Description: descriptions["consumed_disk"],
Computed: true,
},
"consumed_object_storage": schema.StringAttribute{
Description: descriptions["consumed_object_storage"],
Computed: true,
},
"created": schema.StringAttribute{
Description: descriptions["created"],
Computed: true,
},
"flavor": schema.StringAttribute{
Description: descriptions["flavor"],
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Optional: true,
Computed: true,
},
"name": schema.StringAttribute{
Description: descriptions["name"],
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
stringvalidator.LengthBetween(5, 32),
},
},
"url": schema.StringAttribute{
Description: descriptions["url"],
Computed: true,
},
"version": schema.StringAttribute{
Description: descriptions["version"],
Computed: true,
},
},
}
}
// Create creates the resource and sets the initial Terraform state for the git instance.
func (g *gitResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve the planned values for the resource.
var model Model
diags := req.Plan.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
ctx = core.InitProviderContext(ctx)
// Set logging context with the project ID and instance ID.
projectId := model.ProjectId.ValueString()
instanceName := model.Name.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "instance_name", instanceName)
payload, diags := toCreatePayload(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
// Create the new git instance via the API client.
gitInstanceResp, err := g.client.CreateInstance(ctx, projectId).
CreateInstancePayload(payload).
Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating git instance", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
gitInstanceId := *gitInstanceResp.Id
_, err = wait.CreateGitInstanceWaitHandler(ctx, g.client, projectId, gitInstanceId).WaitWithContext(ctx)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating git instance", fmt.Sprintf("Git instance creation waiting: %v", err))
return
}
err = mapFields(ctx, gitInstanceResp, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating git instance", fmt.Sprintf("Mapping fields: %v", err))
return
}
// Set the state with fully populated data.
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "Git Instance created")
}
// Read refreshes the Terraform state with the latest git instance data.
func (g *gitResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve the current state of the resource.
var model Model
diags := req.State.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
ctx = core.InitProviderContext(ctx)
// Extract the project ID and instance id of the model
projectId := model.ProjectId.ValueString()
instanceId := model.InstanceId.ValueString()
// Read the current git instance via id
gitInstanceResp, err := g.client.GetInstance(ctx, projectId, instanceId).Execute()
if err != nil {
var oapiErr *oapierror.GenericOpenAPIError
ok := errors.As(err, &oapiErr)
if ok && oapiErr.StatusCode == http.StatusNotFound {
resp.State.RemoveResource(ctx)
return
}
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading git instance", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
err = mapFields(ctx, gitInstanceResp, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading git instance", fmt.Sprintf("Processing API response: %v", err))
return
}
// Set the updated state.
diags = resp.State.Set(ctx, &model)
resp.Diagnostics.Append(diags...)
tflog.Info(ctx, fmt.Sprintf("read git instance %s", instanceId))
}
// Update attempts to update the resource. In this case, git instances cannot be updated.
// Note: This method is intentionally left without update logic because changes
// to 'project_id' or 'name' require the resource to be entirely replaced.
// As a result, the Update function is redundant since any modifications will
// automatically trigger a resource recreation through Terraform's built-in
// lifecycle management.
func (g *gitResource) Update(ctx context.Context, _ resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform
// git instances cannot be updated, so we log an error.
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating git instance", "Git Instance can't be updated")
}
// Delete deletes the git instance and removes it from the Terraform state on success.
func (g *gitResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve current state of the resource.
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()
instanceId := model.InstanceId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "instance_id", instanceId)
// Call API to delete the existing git instance.
err := g.client.DeleteInstance(ctx, projectId, instanceId).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting git instance", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
_, err = wait.DeleteGitInstanceWaitHandler(ctx, g.client, projectId, instanceId).WaitWithContext(ctx)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error waiting for instance deletion", fmt.Sprintf("Instance deletion waiting: %v", err))
return
}
tflog.Info(ctx, "Git instance deleted")
}
// ImportState imports a resource into the Terraform state on success.
// The expected format of the resource import identifier is: project_id,instance_id
func (g *gitResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
// Split the import identifier to extract project ID and email.
idParts := strings.Split(req.ID, core.Separator)
// Ensure the import identifier format is correct.
if len(idParts) != 2 || idParts[0] == "" || idParts[1] == "" {
core.LogAndAddError(ctx, &resp.Diagnostics,
"Error importing git instance",
fmt.Sprintf("Expected import identifier with format: [project_id],[instance_id] Got: %q", req.ID),
)
return
}
projectId := idParts[0]
instanceId := idParts[1]
// Set the project ID and instance ID attributes in the state.
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), projectId)...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("instance_id"), instanceId)...)
tflog.Info(ctx, "Git instance state imported")
}
// mapFields maps a Git response to the model.
func mapFields(ctx context.Context, resp *git.Instance, model *Model) error {
if resp == nil {
return fmt.Errorf("response input is nil")
}
if model == nil {
return fmt.Errorf("model input is nil")
}
if resp.Id == nil {
return fmt.Errorf("git instance id not present")
}
aclList := types.ListNull(types.StringType)
var diags diag.Diagnostics
if resp.Acl != nil && len(*resp.Acl) > 0 {
aclList, diags = types.ListValueFrom(ctx, types.StringType, resp.Acl)
if diags.HasError() {
return fmt.Errorf("mapping ACL: %w", core.DiagsToError(diags))
}
}
model.Created = types.StringNull()
if resp.Created != nil && resp.Created.String() != "" {
model.Created = types.StringValue(resp.Created.String())
}
// Build the ID by combining the project ID and instance id and assign the model's fields.
model.Id = utils.BuildInternalTerraformId(model.ProjectId.ValueString(), *resp.Id)
model.ACL = aclList
model.ConsumedDisk = types.StringPointerValue(resp.ConsumedDisk)
model.ConsumedObjectStorage = types.StringPointerValue(resp.ConsumedObjectStorage)
model.Flavor = types.StringPointerValue(resp.Flavor)
model.InstanceId = types.StringPointerValue(resp.Id)
model.Name = types.StringPointerValue(resp.Name)
model.Url = types.StringPointerValue(resp.Url)
model.Version = types.StringPointerValue(resp.Version)
return nil
}
// toCreatePayload creates the payload to create a git instance
func toCreatePayload(ctx context.Context, model *Model) (git.CreateInstancePayload, diag.Diagnostics) {
diags := diag.Diagnostics{}
if model == nil {
return git.CreateInstancePayload{}, diags
}
payload := git.CreateInstancePayload{
Name: model.Name.ValueStringPointer(),
}
if !(model.ACL.IsNull() || model.ACL.IsUnknown()) {
var acl []string
aclDiags := model.ACL.ElementsAs(ctx, &acl, false)
diags.Append(aclDiags...)
if !aclDiags.HasError() {
payload.Acl = &acl
}
}
if !(model.Flavor.IsNull() || model.Flavor.IsUnknown()) {
payload.Flavor = git.CreateInstancePayloadGetFlavorAttributeType(model.Flavor.ValueStringPointer())
}
return payload, diags
}

View file

@ -1,225 +0,0 @@
package instance
import (
"context"
"fmt"
"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/stackitcloud/stackit-sdk-go/core/utils"
"github.com/stackitcloud/stackit-sdk-go/services/git"
)
var (
testInstanceId = uuid.New().String()
testProjectId = uuid.New().String()
)
func TestMapFields(t *testing.T) {
createdTime, err := time.Parse("2006-01-02 15:04:05 -0700 MST", "2025-01-01 00:00:00 +0000 UTC")
if err != nil {
t.Fatalf("failed to parse test time: %v", err)
}
tests := []struct {
description string
input *git.Instance
expected *Model
isValid bool
}{
{
description: "minimal_input_name_only",
input: &git.Instance{
Id: utils.Ptr(testInstanceId),
Name: utils.Ptr("git-min-instance"),
},
expected: &Model{
Id: types.StringValue(fmt.Sprintf("%s,%s", testProjectId, testInstanceId)),
ProjectId: types.StringValue(testProjectId),
InstanceId: types.StringValue(testInstanceId),
Name: types.StringValue("git-min-instance"),
ACL: types.ListNull(types.StringType),
Flavor: types.StringNull(),
Url: types.StringNull(),
Version: types.StringNull(),
Created: types.StringNull(),
ConsumedDisk: types.StringNull(),
ConsumedObjectStorage: types.StringNull(),
},
isValid: true,
},
{
description: "full_input_with_acl_and_flavor",
input: &git.Instance{
Acl: &[]string{"192.168.0.0/24"},
ConsumedDisk: utils.Ptr("1.00 GB"),
ConsumedObjectStorage: utils.Ptr("2.00 GB"),
Created: &createdTime,
Flavor: utils.Ptr("git-100"),
Id: utils.Ptr(testInstanceId),
Name: utils.Ptr("git-full-instance"),
Url: utils.Ptr("https://git-full-instance.git.onstackit.cloud"),
Version: utils.Ptr("v1.9.1"),
},
expected: &Model{
Id: types.StringValue(fmt.Sprintf("%s,%s", testProjectId, testInstanceId)),
ProjectId: types.StringValue(testProjectId),
InstanceId: types.StringValue(testInstanceId),
Name: types.StringValue("git-full-instance"),
ACL: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("192.168.0.0/24")}),
Flavor: types.StringValue("git-100"),
Url: types.StringValue("https://git-full-instance.git.onstackit.cloud"),
Version: types.StringValue("v1.9.1"),
Created: types.StringValue("2025-01-01 00:00:00 +0000 UTC"),
ConsumedDisk: types.StringValue("1.00 GB"),
ConsumedObjectStorage: types.StringValue("2.00 GB"),
},
isValid: true,
},
{
description: "empty_acls",
input: &git.Instance{
Id: utils.Ptr(testInstanceId),
Name: utils.Ptr("git-empty-acl"),
Acl: &[]string{},
},
expected: &Model{
Id: types.StringValue(fmt.Sprintf("%s,%s", testProjectId, testInstanceId)),
ProjectId: types.StringValue(testProjectId),
InstanceId: types.StringValue(testInstanceId),
Name: types.StringValue("git-empty-acl"),
ACL: types.ListNull(types.StringType),
Flavor: types.StringNull(),
Url: types.StringNull(),
Version: types.StringNull(),
Created: types.StringNull(),
ConsumedDisk: types.StringNull(),
ConsumedObjectStorage: types.StringNull(),
},
isValid: true,
},
{
description: "nil_instance",
input: nil,
expected: nil,
isValid: false,
},
{
description: "empty_instance",
input: &git.Instance{},
expected: nil,
isValid: false,
},
{
description: "missing_id",
input: &git.Instance{
Name: utils.Ptr("git-missing-id"),
},
expected: nil,
isValid: false,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
state := &Model{}
if tt.expected != nil {
state.ProjectId = tt.expected.ProjectId
}
err := mapFields(context.Background(), tt.input, state)
if tt.isValid && err != nil {
t.Fatalf("expected success, got error: %v", err)
}
if !tt.isValid && err == nil {
t.Fatalf("expected error, got nil")
}
if tt.isValid {
if diff := cmp.Diff(tt.expected, state); diff != "" {
t.Errorf("unexpected diff (-want +got):\n%s", diff)
}
}
})
}
}
func TestToCreatePayload(t *testing.T) {
tests := []struct {
description string
input *Model
expected git.CreateInstancePayload
expectError bool
}{
{
description: "default values",
input: &Model{
Name: types.StringValue("example-instance"),
Flavor: types.StringNull(),
ACL: types.ListNull(types.StringType),
},
expected: git.CreateInstancePayload{
Name: utils.Ptr("example-instance"),
},
expectError: false,
},
{
description: "simple values with ACL and Flavor",
input: &Model{
Name: types.StringValue("my-instance"),
Flavor: types.StringValue("git-100"),
ACL: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("10.0.0.1"),
types.StringValue("10.0.0.2"),
}),
},
expected: git.CreateInstancePayload{
Name: utils.Ptr("my-instance"),
Flavor: git.CREATEINSTANCEPAYLOADFLAVOR__100.Ptr(),
Acl: &[]string{"10.0.0.1", "10.0.0.2"},
},
expectError: false,
},
{
description: "empty ACL still valid",
input: &Model{
Name: types.StringValue("my-instance"),
Flavor: types.StringValue("git-100"),
ACL: types.ListValueMust(types.StringType, []attr.Value{}),
},
expected: git.CreateInstancePayload{
Name: utils.Ptr("my-instance"),
Flavor: git.CREATEINSTANCEPAYLOADFLAVOR__100.Ptr(),
Acl: &[]string{},
},
expectError: false,
},
{
description: "nil input model",
input: nil,
expected: git.CreateInstancePayload{},
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
output, diags := toCreatePayload(context.Background(), tt.input)
if tt.expectError && !diags.HasError() {
t.Fatalf("expected diagnostics error but got none")
}
if !tt.expectError && diags.HasError() {
t.Fatalf("unexpected diagnostics error: %v", diags)
}
if diff := cmp.Diff(tt.expected, output); diff != "" {
t.Fatalf("unexpected payload (-want +got):\n%s", diff)
}
})
}
}

View file

@ -1,14 +0,0 @@
variable "project_id" {}
variable "name" {}
variable "acl" {}
variable "flavor" {}
resource "stackit_git" "git" {
project_id = var.project_id
name = var.name
acl = [
var.acl
]
flavor = var.flavor
}

View file

@ -1,8 +0,0 @@
variable "project_id" {}
variable "name" {}
resource "stackit_git" "git" {
project_id = var.project_id
name = var.name
}

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/git"
"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) *git.APIClient {
apiClientConfigOptions := []config.ConfigurationOption{
config.WithCustomAuth(providerData.RoundTripper),
utils.UserAgentConfigOption(providerData.Version),
}
if providerData.GitCustomEndpoint != "" {
apiClientConfigOptions = append(apiClientConfigOptions, config.WithEndpoint(providerData.GitCustomEndpoint))
}
apiClient, err := git.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/git"
"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://git-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 *git.APIClient
}{
{
name: "default endpoint",
args: args{
providerData: &core.ProviderData{
Version: testVersion,
},
},
expected: func() *git.APIClient {
apiClient, err := git.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,
GitCustomEndpoint: testCustomEndpoint,
},
},
expected: func() *git.APIClient {
apiClient, err := git.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,41 +0,0 @@
package affinitygroup
const exampleUsageWithServer = `
### Usage with server` + "\n" +
"```terraform" + `
resource "stackit_affinity_group" "affinity-group" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
name = "example-key-pair"
policy = "soft-affinity"
}
resource "stackit_server" "example-server" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
name = "example-server"
boot_volume = {
size = 64
source_type = "image"
source_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
affinity_group = stackit_affinity_group.affinity-group.affinity_group_id
availability_zone = "eu01-1"
machine_type = "g2i.1"
}
` + "\n```"
const policies = `
### Policies
* ` + "`hard-affinity`" + `- All servers launched in this group will be hosted on the same compute node.
* ` + "`hard-anti-affinity`" + `- All servers launched in this group will be
hosted on different compute nodes.
* ` + "`soft-affinity`" + `- All servers launched in this group will be hosted
on the same compute node if possible, but if not possible they still will be scheduled instead of failure.
* ` + "`soft-anti-affinity`" + `- All servers launched in this group will be hosted on different compute nodes if possible,
but if not possible they still will be scheduled instead of failure.
`

View file

@ -1,165 +0,0 @@
package affinitygroup
import (
"context"
"fmt"
"net/http"
"regexp"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
iaasUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/utils"
"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"
"github.com/hashicorp/terraform-plugin-framework-validators/listvalidator"
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
"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/iaas"
)
var (
_ datasource.DataSource = &affinityGroupDatasource{}
_ datasource.DataSourceWithConfigure = &affinityGroupDatasource{}
)
func NewAffinityGroupDatasource() datasource.DataSource {
return &affinityGroupDatasource{}
}
type affinityGroupDatasource struct {
client *iaas.APIClient
providerData core.ProviderData
}
func (d *affinityGroupDatasource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
var ok bool
d.providerData, ok = conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics)
if !ok {
return
}
apiClient := iaasUtils.ConfigureClient(ctx, &d.providerData, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}
d.client = apiClient
tflog.Info(ctx, "iaas client configured")
}
func (d *affinityGroupDatasource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_affinity_group"
}
func (d *affinityGroupDatasource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
descriptionMain := "Affinity Group schema. Must have a `region` specified in the provider configuration."
resp.Schema = schema.Schema{
Description: descriptionMain,
MarkdownDescription: descriptionMain,
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: "Terraform's internal resource identifier. It is structured as \"`project_id`,`region`,`affinity_group_id`\".",
Computed: true,
},
"project_id": schema.StringAttribute{
Description: "STACKIT Project ID to which the affinity group is associated.",
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"region": schema.StringAttribute{
Description: "The resource region. If not defined, the provider region is used.",
// the region cannot be found, so it has to be passed
Optional: true,
},
"affinity_group_id": schema.StringAttribute{
Description: "The affinity group ID.",
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"name": schema.StringAttribute{
Description: "The name of the affinity group.",
Computed: true,
Validators: []validator.String{
stringvalidator.LengthAtLeast(1),
stringvalidator.LengthAtMost(63),
stringvalidator.RegexMatches(
regexp.MustCompile(`^[A-Za-z0-9]+((-|_|\s|\.)[A-Za-z0-9]+)*$`),
"must match expression"),
},
},
"policy": schema.StringAttribute{
Description: "The policy of the affinity group.",
Computed: true,
},
"members": schema.ListAttribute{
Description: descriptionMain,
Computed: true,
ElementType: types.StringType,
Validators: []validator.List{
listvalidator.ValueStringsAre(
validate.UUID(),
),
},
},
},
}
}
func (d *affinityGroupDatasource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform
var model Model
diags := req.Config.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
region := d.providerData.GetRegionWithOverride(model.Region)
affinityGroupId := model.AffinityGroupId.ValueString()
ctx = core.InitProviderContext(ctx)
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "region", region)
ctx = tflog.SetField(ctx, "affinity_group_id", affinityGroupId)
affinityGroupResp, err := d.client.GetAffinityGroupExecute(ctx, projectId, region, affinityGroupId)
if err != nil {
utils.LogError(
ctx,
&resp.Diagnostics,
err,
"Reading affinity group",
fmt.Sprintf("Affinity group with ID %q does not exist in project %q.", affinityGroupId, 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)
err = mapFields(ctx, affinityGroupResp, &model, region)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading affinity group", fmt.Sprintf("Processing API payload: %v", err))
}
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "Affinity group read")
}

View file

@ -1,388 +0,0 @@
package affinitygroup
import (
"context"
"fmt"
"net/http"
"regexp"
"strings"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
iaasUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/utils"
"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/validate"
"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/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/core/oapierror"
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
)
var (
_ resource.Resource = &affinityGroupResource{}
_ resource.ResourceWithConfigure = &affinityGroupResource{}
_ resource.ResourceWithImportState = &affinityGroupResource{}
_ resource.ResourceWithModifyPlan = &affinityGroupResource{}
)
// Model is the provider's internal model
type Model struct {
Id types.String `tfsdk:"id"`
ProjectId types.String `tfsdk:"project_id"`
Region types.String `tfsdk:"region"`
AffinityGroupId types.String `tfsdk:"affinity_group_id"`
Name types.String `tfsdk:"name"`
Policy types.String `tfsdk:"policy"`
Members types.List `tfsdk:"members"`
}
func NewAffinityGroupResource() resource.Resource {
return &affinityGroupResource{}
}
// affinityGroupResource is the resource implementation.
type affinityGroupResource struct {
client *iaas.APIClient
providerData core.ProviderData
}
// Metadata returns the resource type name.
func (r *affinityGroupResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_affinity_group"
}
// ModifyPlan implements resource.ResourceWithModifyPlan.
// Use the modifier to set the effective region in the current plan.
func (r *affinityGroupResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { // nolint:gocritic // function signature required by Terraform
var configModel Model
// skip initial empty configuration to avoid follow-up errors
if req.Config.Raw.IsNull() {
return
}
resp.Diagnostics.Append(req.Config.Get(ctx, &configModel)...)
if resp.Diagnostics.HasError() {
return
}
var planModel Model
resp.Diagnostics.Append(req.Plan.Get(ctx, &planModel)...)
if resp.Diagnostics.HasError() {
return
}
utils.AdaptRegion(ctx, configModel.Region, &planModel.Region, r.providerData.GetRegion(), resp)
if resp.Diagnostics.HasError() {
return
}
resp.Diagnostics.Append(resp.Plan.Set(ctx, planModel)...)
if resp.Diagnostics.HasError() {
return
}
}
// Configure adds the provider configured client to the resource.
func (r *affinityGroupResource) 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
}
apiClient := iaasUtils.ConfigureClient(ctx, &r.providerData, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}
r.client = apiClient
tflog.Info(ctx, "iaas client configured")
}
func (r *affinityGroupResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
description := "Affinity Group schema."
resp.Schema = schema.Schema{
Description: description,
MarkdownDescription: description + "\n\n" + exampleUsageWithServer + policies,
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: "Terraform's internal resource identifier. It is structured as \"`project_id`,`region`,`affinity_group_id`\".",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"project_id": schema.StringAttribute{
Description: "STACKIT Project ID to which the affinity group is associated.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"region": schema.StringAttribute{
Description: "The resource region. If not defined, the provider region is used.",
Optional: true,
// must be computed to allow for storing the override value from the provider
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"affinity_group_id": schema.StringAttribute{
Description: "The affinity group ID.",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"name": schema.StringAttribute{
Description: "The name of the affinity group.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
stringvalidator.LengthAtLeast(1),
stringvalidator.LengthAtMost(63),
stringvalidator.RegexMatches(
regexp.MustCompile(`^[A-Za-z0-9]+((-|_|\s|\.)[A-Za-z0-9]+)*$`),
"must match expression"),
},
},
"policy": schema.StringAttribute{
Description: "The policy of the affinity group.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{},
},
"members": schema.ListAttribute{
Description: "The servers that are part of the affinity group.",
Computed: true,
ElementType: types.StringType,
Validators: []validator.List{
listvalidator.ValueStringsAre(
validate.UUID(),
),
},
},
},
}
}
// Create creates the resource and sets the initial Terraform state.
func (r *affinityGroupResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform
var model Model
diags := req.Config.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
region := r.providerData.GetRegionWithOverride(model.Region)
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "region", region)
ctx = core.InitProviderContext(ctx)
// Create new affinityGroup
payload, err := toCreatePayload(&model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating affinity group", fmt.Sprintf("Creating API payload: %v", err))
return
}
affinityGroupResp, err := r.client.CreateAffinityGroup(ctx, projectId, region).CreateAffinityGroupPayload(*payload).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating affinity group", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
ctx = tflog.SetField(ctx, "affinity_group_id", affinityGroupResp.Id)
// Map response body to schema
err = mapFields(ctx, affinityGroupResp, &model, region)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating affinity group", 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, "Affinity group created")
}
// Read refreshes the Terraform state with the latest data.
func (r *affinityGroupResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform
var model Model
diags := req.State.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
region := r.providerData.GetRegionWithOverride(model.Region)
affinityGroupId := model.AffinityGroupId.ValueString()
ctx = core.InitProviderContext(ctx)
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "region", region)
ctx = tflog.SetField(ctx, "affinity_group_id", affinityGroupId)
affinityGroupResp, err := r.client.GetAffinityGroupExecute(ctx, projectId, region, affinityGroupId)
if err != nil {
oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped
if ok && oapiErr.StatusCode == http.StatusNotFound {
resp.State.RemoveResource(ctx)
return
}
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading affinity group", fmt.Sprintf("Call API: %v", err))
return
}
ctx = core.LogResponse(ctx)
err = mapFields(ctx, affinityGroupResp, &model, region)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading affinity group", fmt.Sprintf("Processing API payload: %v", err))
}
// Set refreshed state
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "Affinity group read")
}
func (r *affinityGroupResource) Update(_ context.Context, _ resource.UpdateRequest, _ *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform
// Update is not supported, all fields require replace
}
// Delete deletes the resource and removes the Terraform state on success.
func (r *affinityGroupResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve values from state
var model Model
diags := req.State.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
region := r.providerData.GetRegionWithOverride(model.Region)
affinityGroupId := model.AffinityGroupId.ValueString()
ctx = core.InitProviderContext(ctx)
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "region", region)
ctx = tflog.SetField(ctx, "affinity_group_id", affinityGroupId)
// Delete existing affinity group
err := r.client.DeleteAffinityGroupExecute(ctx, projectId, region, affinityGroupId)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting affinity group", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
tflog.Info(ctx, "Affinity group deleted")
}
func (r *affinityGroupResource) 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 affinity group",
fmt.Sprintf("Expected import indentifier with format: [project_id],[region],[affinity_group_id], got: %q", req.ID),
)
return
}
utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{
"project_id": idParts[0],
"region": idParts[1],
"affinity_group_id": idParts[2],
})
tflog.Info(ctx, "affinity group state imported")
}
func toCreatePayload(model *Model) (*iaas.CreateAffinityGroupPayload, error) {
if model == nil {
return nil, fmt.Errorf("nil model")
}
nameValue := conversion.StringValueToPointer(model.Name)
policyValue := conversion.StringValueToPointer(model.Policy)
return &iaas.CreateAffinityGroupPayload{
Name: nameValue,
Policy: policyValue,
}, nil
}
func mapFields(ctx context.Context, affinityGroupResp *iaas.AffinityGroup, model *Model, region string) error {
if affinityGroupResp == nil {
return fmt.Errorf("response input is nil")
}
if model == nil {
return fmt.Errorf("nil model")
}
var affinityGroupId string
if model.AffinityGroupId.ValueString() != "" {
affinityGroupId = model.AffinityGroupId.ValueString()
} else if affinityGroupResp.Id != nil {
affinityGroupId = *affinityGroupResp.Id
} else {
return fmt.Errorf("affinity group id not present")
}
model.Id = utils.BuildInternalTerraformId(model.ProjectId.ValueString(), region, affinityGroupId)
model.Region = types.StringValue(region)
if affinityGroupResp.Members != nil && len(*affinityGroupResp.Members) > 0 {
members, diags := types.ListValueFrom(ctx, types.StringType, *affinityGroupResp.Members)
if diags.HasError() {
return fmt.Errorf("convert members to StringValue list: %w", core.DiagsToError(diags))
}
model.Members = members
} else if model.Members.IsNull() {
model.Members = types.ListNull(types.StringType)
}
model.AffinityGroupId = types.StringValue(affinityGroupId)
model.Name = types.StringPointerValue(affinityGroupResp.Name)
model.Policy = types.StringPointerValue(affinityGroupResp.Policy)
return nil
}

View file

@ -1,118 +0,0 @@
package affinitygroup
import (
"context"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/stackitcloud/stackit-sdk-go/core/utils"
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
)
func TestMapFields(t *testing.T) {
type args struct {
state Model
input *iaas.AffinityGroup
region string
}
tests := []struct {
description string
args args
expected Model
isValid bool
}{
{
description: "default_values",
args: args{
state: Model{
ProjectId: types.StringValue("pid"),
AffinityGroupId: types.StringValue("aid"),
},
input: &iaas.AffinityGroup{
Id: utils.Ptr("aid"),
},
region: "eu01",
},
expected: Model{
Id: types.StringValue("pid,eu01,aid"),
ProjectId: types.StringValue("pid"),
AffinityGroupId: types.StringValue("aid"),
Name: types.StringNull(),
Policy: types.StringNull(),
Members: types.ListNull(types.StringType),
Region: types.StringValue("eu01"),
},
isValid: true,
},
{
description: "response_nil_fail",
},
{
description: "no_affinity_group_id",
args: args{
state: Model{
ProjectId: types.StringValue("pid"),
},
input: &iaas.AffinityGroup{},
},
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
err := mapFields(context.Background(), tt.args.input, &tt.args.state, tt.args.region)
if !tt.isValid && err == nil {
t.Fatalf("Should have failed")
}
if tt.isValid && err != nil {
t.Fatalf("Should not have failed")
}
if tt.isValid {
diff := cmp.Diff(tt.args.state, tt.expected)
if diff != "" {
t.Fatalf("Data does not match: %v", diff)
}
}
})
}
}
func TestToCreatePayload(t *testing.T) {
tests := []struct {
description string
input *Model
expected *iaas.CreateAffinityGroupPayload
isValid bool
}{
{
"default",
&Model{
ProjectId: types.StringValue("pid"),
Name: types.StringValue("name"),
Policy: types.StringValue("policy"),
},
&iaas.CreateAffinityGroupPayload{
Name: utils.Ptr("name"),
Policy: utils.Ptr("policy"),
},
true,
},
}
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)
}
}
})
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,361 +0,0 @@
package image
import (
"context"
"fmt"
"net/http"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
iaasUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/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/diag"
"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/services/iaas"
"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 = &imageDataSource{}
)
type DataSourceModel struct {
Id types.String `tfsdk:"id"` // needed by TF
ProjectId types.String `tfsdk:"project_id"`
Region types.String `tfsdk:"region"`
ImageId types.String `tfsdk:"image_id"`
Name types.String `tfsdk:"name"`
DiskFormat types.String `tfsdk:"disk_format"`
MinDiskSize types.Int64 `tfsdk:"min_disk_size"`
MinRAM types.Int64 `tfsdk:"min_ram"`
Protected types.Bool `tfsdk:"protected"`
Scope types.String `tfsdk:"scope"`
Config types.Object `tfsdk:"config"`
Checksum types.Object `tfsdk:"checksum"`
Labels types.Map `tfsdk:"labels"`
}
// NewImageDataSource is a helper function to simplify the provider implementation.
func NewImageDataSource() datasource.DataSource {
return &imageDataSource{}
}
// imageDataSource is the data source implementation.
type imageDataSource struct {
client *iaas.APIClient
providerData core.ProviderData
}
// Metadata returns the data source type name.
func (d *imageDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_image"
}
func (d *imageDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
var ok bool
d.providerData, ok = conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics)
if !ok {
return
}
apiClient := iaasUtils.ConfigureClient(ctx, &d.providerData, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}
d.client = apiClient
tflog.Info(ctx, "iaas client configured")
}
// Schema defines the schema for the datasource.
func (d *imageDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
description := "Image datasource schema. Must have a `region` specified in the provider configuration."
resp.Schema = schema.Schema{
MarkdownDescription: description,
Description: description,
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: "Terraform's internal resource ID. It is structured as \"`project_id`,`region`,`image_id`\".",
Computed: true,
},
"project_id": schema.StringAttribute{
Description: "STACKIT project ID to which the image is associated.",
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"region": schema.StringAttribute{
Description: "The resource region. If not defined, the provider region is used.",
// the region cannot be found, so it has to be passed
Optional: true,
},
"image_id": schema.StringAttribute{
Description: "The image ID.",
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"name": schema.StringAttribute{
Description: "The name of the image.",
Computed: true,
},
"disk_format": schema.StringAttribute{
Description: "The disk format of the image.",
Computed: true,
},
"min_disk_size": schema.Int64Attribute{
Description: "The minimum disk size of the image in GB.",
Computed: true,
},
"min_ram": schema.Int64Attribute{
Description: "The minimum RAM of the image in MB.",
Computed: true,
},
"protected": schema.BoolAttribute{
Description: "Whether the image is protected.",
Computed: true,
},
"scope": schema.StringAttribute{
Description: "The scope of the image.",
Computed: true,
},
"config": schema.SingleNestedAttribute{
Description: "Properties to set hardware and scheduling settings for an image.",
Computed: true,
Attributes: map[string]schema.Attribute{
"boot_menu": schema.BoolAttribute{
Description: "Enables the BIOS bootmenu.",
Computed: true,
},
"cdrom_bus": schema.StringAttribute{
Description: "Sets CDROM bus controller type.",
Computed: true,
},
"disk_bus": schema.StringAttribute{
Description: "Sets Disk bus controller type.",
Computed: true,
},
"nic_model": schema.StringAttribute{
Description: "Sets virtual network interface model.",
Computed: true,
},
"operating_system": schema.StringAttribute{
Description: "Enables operating system specific optimizations.",
Computed: true,
},
"operating_system_distro": schema.StringAttribute{
Description: "Operating system distribution.",
Computed: true,
},
"operating_system_version": schema.StringAttribute{
Description: "Version of the operating system.",
Computed: true,
},
"rescue_bus": schema.StringAttribute{
Description: "Sets the device bus when the image is used as a rescue image.",
Computed: true,
},
"rescue_device": schema.StringAttribute{
Description: "Sets the device when the image is used as a rescue image.",
Computed: true,
},
"secure_boot": schema.BoolAttribute{
Description: "Enables Secure Boot.",
Computed: true,
},
"uefi": schema.BoolAttribute{
Description: "Enables UEFI boot.",
Computed: true,
},
"video_model": schema.StringAttribute{
Description: "Sets Graphic device model.",
Computed: true,
},
"virtio_scsi": schema.BoolAttribute{
Description: "Enables the use of VirtIO SCSI to provide block device access. By default instances use VirtIO Block.",
Computed: true,
},
},
},
"checksum": schema.SingleNestedAttribute{
Description: "Representation of an image checksum.",
Computed: true,
Attributes: map[string]schema.Attribute{
"algorithm": schema.StringAttribute{
Description: "Algorithm for the checksum of the image data.",
Computed: true,
},
"digest": schema.StringAttribute{
Description: "Hexdigest of the checksum of the image data.",
Computed: true,
},
},
},
"labels": schema.MapAttribute{
Description: "Labels are key-value string pairs which can be attached to a resource container",
ElementType: types.StringType,
Computed: true,
},
},
}
}
// Read refreshes the Terraform state with the latest data.
func (d *imageDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform
var model DataSourceModel
diags := req.Config.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
region := d.providerData.GetRegionWithOverride(model.Region)
imageId := model.ImageId.ValueString()
ctx = core.InitProviderContext(ctx)
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "region", region)
ctx = tflog.SetField(ctx, "image_id", imageId)
imageResp, err := d.client.GetImage(ctx, projectId, region, imageId).Execute()
if err != nil {
utils.LogError(
ctx,
&resp.Diagnostics,
err,
"Reading image",
fmt.Sprintf("Image with ID %q does not exist in project %q.", imageId, 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)
// Map response body to schema
err = mapDataSourceFields(ctx, imageResp, &model, region)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading image", 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, "image read")
}
func mapDataSourceFields(ctx context.Context, imageResp *iaas.Image, model *DataSourceModel, region string) error {
if imageResp == nil {
return fmt.Errorf("response input is nil")
}
if model == nil {
return fmt.Errorf("model input is nil")
}
var imageId string
if model.ImageId.ValueString() != "" {
imageId = model.ImageId.ValueString()
} else if imageResp.Id != nil {
imageId = *imageResp.Id
} else {
return fmt.Errorf("image id not present")
}
model.Id = utils.BuildInternalTerraformId(model.ProjectId.ValueString(), region, imageId)
model.Region = types.StringValue(region)
// Map config
var configModel = &configModel{}
var configObject basetypes.ObjectValue
diags := diag.Diagnostics{}
if imageResp.Config != nil {
configModel.BootMenu = types.BoolPointerValue(imageResp.Config.BootMenu)
configModel.CDROMBus = types.StringPointerValue(imageResp.Config.GetCdromBus())
configModel.DiskBus = types.StringPointerValue(imageResp.Config.GetDiskBus())
configModel.NICModel = types.StringPointerValue(imageResp.Config.GetNicModel())
configModel.OperatingSystem = types.StringPointerValue(imageResp.Config.OperatingSystem)
configModel.OperatingSystemDistro = types.StringPointerValue(imageResp.Config.GetOperatingSystemDistro())
configModel.OperatingSystemVersion = types.StringPointerValue(imageResp.Config.GetOperatingSystemVersion())
configModel.RescueBus = types.StringPointerValue(imageResp.Config.GetRescueBus())
configModel.RescueDevice = types.StringPointerValue(imageResp.Config.GetRescueDevice())
configModel.SecureBoot = types.BoolPointerValue(imageResp.Config.SecureBoot)
configModel.UEFI = types.BoolPointerValue(imageResp.Config.Uefi)
configModel.VideoModel = types.StringPointerValue(imageResp.Config.GetVideoModel())
configModel.VirtioScsi = types.BoolPointerValue(iaas.PtrBool(imageResp.Config.GetVirtioScsi()))
configObject, diags = types.ObjectValue(configTypes, map[string]attr.Value{
"boot_menu": configModel.BootMenu,
"cdrom_bus": configModel.CDROMBus,
"disk_bus": configModel.DiskBus,
"nic_model": configModel.NICModel,
"operating_system": configModel.OperatingSystem,
"operating_system_distro": configModel.OperatingSystemDistro,
"operating_system_version": configModel.OperatingSystemVersion,
"rescue_bus": configModel.RescueBus,
"rescue_device": configModel.RescueDevice,
"secure_boot": configModel.SecureBoot,
"uefi": configModel.UEFI,
"video_model": configModel.VideoModel,
"virtio_scsi": configModel.VirtioScsi,
})
} else {
configObject = types.ObjectNull(configTypes)
}
if diags.HasError() {
return fmt.Errorf("creating config: %w", core.DiagsToError(diags))
}
// Map checksum
var checksumModel = &checksumModel{}
var checksumObject basetypes.ObjectValue
if imageResp.Checksum != nil {
checksumModel.Algorithm = types.StringPointerValue(imageResp.Checksum.Algorithm)
checksumModel.Digest = types.StringPointerValue(imageResp.Checksum.Digest)
checksumObject, diags = types.ObjectValue(checksumTypes, map[string]attr.Value{
"algorithm": checksumModel.Algorithm,
"digest": checksumModel.Digest,
})
} else {
checksumObject = types.ObjectNull(checksumTypes)
}
if diags.HasError() {
return fmt.Errorf("creating checksum: %w", core.DiagsToError(diags))
}
// Map labels
labels, err := iaasUtils.MapLabels(ctx, imageResp.Labels, model.Labels)
if err != nil {
return err
}
model.ImageId = types.StringValue(imageId)
model.Name = types.StringPointerValue(imageResp.Name)
model.DiskFormat = types.StringPointerValue(imageResp.DiskFormat)
model.MinDiskSize = types.Int64PointerValue(imageResp.MinDiskSize)
model.MinRAM = types.Int64PointerValue(imageResp.MinRam)
model.Protected = types.BoolPointerValue(imageResp.Protected)
model.Scope = types.StringPointerValue(imageResp.Scope)
model.Labels = labels
model.Config = configObject
model.Checksum = checksumObject
return nil
}

View file

@ -1,178 +0,0 @@
package image
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/iaas"
)
func TestMapDataSourceFields(t *testing.T) {
type args struct {
state DataSourceModel
input *iaas.Image
region string
}
tests := []struct {
description string
args args
expected DataSourceModel
isValid bool
}{
{
description: "default_values",
args: args{
state: DataSourceModel{
ProjectId: types.StringValue("pid"),
ImageId: types.StringValue("iid"),
},
input: &iaas.Image{
Id: utils.Ptr("iid"),
},
region: "eu01",
},
expected: DataSourceModel{
Id: types.StringValue("pid,eu01,iid"),
ProjectId: types.StringValue("pid"),
ImageId: types.StringValue("iid"),
Labels: types.MapNull(types.StringType),
Region: types.StringValue("eu01"),
},
isValid: true,
},
{
description: "simple_values",
args: args{
state: DataSourceModel{
ProjectId: types.StringValue("pid"),
ImageId: types.StringValue("iid"),
Region: types.StringValue("eu01"),
},
input: &iaas.Image{
Id: utils.Ptr("iid"),
Name: utils.Ptr("name"),
DiskFormat: utils.Ptr("format"),
MinDiskSize: utils.Ptr(int64(1)),
MinRam: utils.Ptr(int64(1)),
Protected: utils.Ptr(true),
Scope: utils.Ptr("scope"),
Config: &iaas.ImageConfig{
BootMenu: utils.Ptr(true),
CdromBus: iaas.NewNullableString(utils.Ptr("cdrom_bus")),
DiskBus: iaas.NewNullableString(utils.Ptr("disk_bus")),
NicModel: iaas.NewNullableString(utils.Ptr("model")),
OperatingSystem: utils.Ptr("os"),
OperatingSystemDistro: iaas.NewNullableString(utils.Ptr("os_distro")),
OperatingSystemVersion: iaas.NewNullableString(utils.Ptr("os_version")),
RescueBus: iaas.NewNullableString(utils.Ptr("rescue_bus")),
RescueDevice: iaas.NewNullableString(utils.Ptr("rescue_device")),
SecureBoot: utils.Ptr(true),
Uefi: utils.Ptr(true),
VideoModel: iaas.NewNullableString(utils.Ptr("model")),
VirtioScsi: utils.Ptr(true),
},
Checksum: &iaas.ImageChecksum{
Algorithm: utils.Ptr("algorithm"),
Digest: utils.Ptr("digest"),
},
Labels: &map[string]interface{}{
"key": "value",
},
},
region: "eu02",
},
expected: DataSourceModel{
Id: types.StringValue("pid,eu02,iid"),
ProjectId: types.StringValue("pid"),
ImageId: types.StringValue("iid"),
Name: types.StringValue("name"),
DiskFormat: types.StringValue("format"),
MinDiskSize: types.Int64Value(1),
MinRAM: types.Int64Value(1),
Protected: types.BoolValue(true),
Scope: types.StringValue("scope"),
Config: types.ObjectValueMust(configTypes, map[string]attr.Value{
"boot_menu": types.BoolValue(true),
"cdrom_bus": types.StringValue("cdrom_bus"),
"disk_bus": types.StringValue("disk_bus"),
"nic_model": types.StringValue("model"),
"operating_system": types.StringValue("os"),
"operating_system_distro": types.StringValue("os_distro"),
"operating_system_version": types.StringValue("os_version"),
"rescue_bus": types.StringValue("rescue_bus"),
"rescue_device": types.StringValue("rescue_device"),
"secure_boot": types.BoolValue(true),
"uefi": types.BoolValue(true),
"video_model": types.StringValue("model"),
"virtio_scsi": types.BoolValue(true),
}),
Checksum: types.ObjectValueMust(checksumTypes, map[string]attr.Value{
"algorithm": types.StringValue("algorithm"),
"digest": types.StringValue("digest"),
}),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
Region: types.StringValue("eu02"),
},
isValid: true,
},
{
description: "empty_labels",
args: args{
state: DataSourceModel{
ProjectId: types.StringValue("pid"),
ImageId: types.StringValue("iid"),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{}),
},
input: &iaas.Image{
Id: utils.Ptr("iid"),
},
region: "eu01",
},
expected: DataSourceModel{
Id: types.StringValue("pid,eu01,iid"),
ProjectId: types.StringValue("pid"),
ImageId: types.StringValue("iid"),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{}),
Region: types.StringValue("eu01"),
},
isValid: true,
},
{
description: "response_nil_fail",
},
{
description: "no_resource_id",
args: args{
state: DataSourceModel{
ProjectId: types.StringValue("pid"),
},
input: &iaas.Image{},
},
expected: DataSourceModel{},
isValid: false,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
err := mapDataSourceFields(context.Background(), tt.args.input, &tt.args.state, tt.args.region)
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.args.state, tt.expected)
if diff != "" {
t.Fatalf("Data does not match: %s", diff)
}
}
})
}
}

View file

@ -1,891 +0,0 @@
package image
import (
"bufio"
"context"
"fmt"
"net/http"
"os"
"strings"
"time"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
iaasUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/utils"
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/objectplanmodifier"
"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/iaas"
"github.com/stackitcloud/stackit-sdk-go/services/iaas/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/validate"
)
// Ensure the implementation satisfies the expected interfaces.
var (
_ resource.Resource = &imageResource{}
_ resource.ResourceWithConfigure = &imageResource{}
_ resource.ResourceWithImportState = &imageResource{}
_ resource.ResourceWithModifyPlan = &imageResource{}
)
type Model struct {
Id types.String `tfsdk:"id"` // needed by TF
ProjectId types.String `tfsdk:"project_id"`
Region types.String `tfsdk:"region"`
ImageId types.String `tfsdk:"image_id"`
Name types.String `tfsdk:"name"`
DiskFormat types.String `tfsdk:"disk_format"`
MinDiskSize types.Int64 `tfsdk:"min_disk_size"`
MinRAM types.Int64 `tfsdk:"min_ram"`
Protected types.Bool `tfsdk:"protected"`
Scope types.String `tfsdk:"scope"`
Config types.Object `tfsdk:"config"`
Checksum types.Object `tfsdk:"checksum"`
Labels types.Map `tfsdk:"labels"`
LocalFilePath types.String `tfsdk:"local_file_path"`
}
// Struct corresponding to Model.Config
type configModel struct {
BootMenu types.Bool `tfsdk:"boot_menu"`
CDROMBus types.String `tfsdk:"cdrom_bus"`
DiskBus types.String `tfsdk:"disk_bus"`
NICModel types.String `tfsdk:"nic_model"`
OperatingSystem types.String `tfsdk:"operating_system"`
OperatingSystemDistro types.String `tfsdk:"operating_system_distro"`
OperatingSystemVersion types.String `tfsdk:"operating_system_version"`
RescueBus types.String `tfsdk:"rescue_bus"`
RescueDevice types.String `tfsdk:"rescue_device"`
SecureBoot types.Bool `tfsdk:"secure_boot"`
UEFI types.Bool `tfsdk:"uefi"`
VideoModel types.String `tfsdk:"video_model"`
VirtioScsi types.Bool `tfsdk:"virtio_scsi"`
}
// Types corresponding to configModel
var configTypes = map[string]attr.Type{
"boot_menu": basetypes.BoolType{},
"cdrom_bus": basetypes.StringType{},
"disk_bus": basetypes.StringType{},
"nic_model": basetypes.StringType{},
"operating_system": basetypes.StringType{},
"operating_system_distro": basetypes.StringType{},
"operating_system_version": basetypes.StringType{},
"rescue_bus": basetypes.StringType{},
"rescue_device": basetypes.StringType{},
"secure_boot": basetypes.BoolType{},
"uefi": basetypes.BoolType{},
"video_model": basetypes.StringType{},
"virtio_scsi": basetypes.BoolType{},
}
// Struct corresponding to Model.Checksum
type checksumModel struct {
Algorithm types.String `tfsdk:"algorithm"`
Digest types.String `tfsdk:"digest"`
}
// Types corresponding to checksumModel
var checksumTypes = map[string]attr.Type{
"algorithm": basetypes.StringType{},
"digest": basetypes.StringType{},
}
// NewImageResource is a helper function to simplify the provider implementation.
func NewImageResource() resource.Resource {
return &imageResource{}
}
// imageResource is the resource implementation.
type imageResource struct {
client *iaas.APIClient
providerData core.ProviderData
}
// Metadata returns the resource type name.
func (r *imageResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_image"
}
// ModifyPlan implements resource.ResourceWithModifyPlan.
// Use the modifier to set the effective region in the current plan.
func (r *imageResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { // nolint:gocritic // function signature required by Terraform
var configModel Model
// skip initial empty configuration to avoid follow-up errors
if req.Config.Raw.IsNull() {
return
}
resp.Diagnostics.Append(req.Config.Get(ctx, &configModel)...)
if resp.Diagnostics.HasError() {
return
}
var planModel Model
resp.Diagnostics.Append(req.Plan.Get(ctx, &planModel)...)
if resp.Diagnostics.HasError() {
return
}
utils.AdaptRegion(ctx, configModel.Region, &planModel.Region, r.providerData.GetRegion(), resp)
if resp.Diagnostics.HasError() {
return
}
resp.Diagnostics.Append(resp.Plan.Set(ctx, planModel)...)
if resp.Diagnostics.HasError() {
return
}
}
// Configure adds the provider configured client to the resource.
func (r *imageResource) 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
}
apiClient := iaasUtils.ConfigureClient(ctx, &r.providerData, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}
r.client = apiClient
tflog.Info(ctx, "iaas client configured")
}
// Schema defines the schema for the resource.
func (r *imageResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Description: "Image resource schema. Must have a `region` specified in the provider configuration.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: "Terraform's internal resource ID. It is structured as \"`project_id`,`region`,`image_id`\".",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"project_id": schema.StringAttribute{
Description: "STACKIT project ID to which the image is associated.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"region": schema.StringAttribute{
Description: "The resource region. If not defined, the provider region is used.",
Optional: true,
// must be computed to allow for storing the override value from the provider
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"image_id": schema.StringAttribute{
Description: "The image ID.",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"name": schema.StringAttribute{
Description: "The name of the image.",
Required: true,
},
"disk_format": schema.StringAttribute{
Description: "The disk format of the image.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"local_file_path": schema.StringAttribute{
Description: "The filepath of the raw image file to be uploaded.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
// Validating that the file exists in the plan is useful to avoid
// creating an image resource where the local image upload will fail
validate.FileExists(),
},
},
"min_disk_size": schema.Int64Attribute{
Description: "The minimum disk size of the image in GB.",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.Int64{
int64planmodifier.UseStateForUnknown(),
},
},
"min_ram": schema.Int64Attribute{
Description: "The minimum RAM of the image in MB.",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.Int64{
int64planmodifier.UseStateForUnknown(),
},
},
"protected": schema.BoolAttribute{
Description: "Whether the image is protected.",
Computed: true,
PlanModifiers: []planmodifier.Bool{
boolplanmodifier.UseStateForUnknown(),
},
},
"scope": schema.StringAttribute{
Description: "The scope of the image.",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"config": schema.SingleNestedAttribute{
Description: "Properties to set hardware and scheduling settings for an image.",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.Object{
objectplanmodifier.UseStateForUnknown(),
},
Attributes: map[string]schema.Attribute{
"boot_menu": schema.BoolAttribute{
Description: "Enables the BIOS bootmenu.",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.Bool{
boolplanmodifier.UseStateForUnknown(),
},
},
"cdrom_bus": schema.StringAttribute{
Description: "Sets CDROM bus controller type.",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"disk_bus": schema.StringAttribute{
Description: "Sets Disk bus controller type.",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"nic_model": schema.StringAttribute{
Description: "Sets virtual network interface model.",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"operating_system": schema.StringAttribute{
Description: "Enables operating system specific optimizations.",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"operating_system_distro": schema.StringAttribute{
Description: "Operating system distribution.",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"operating_system_version": schema.StringAttribute{
Description: "Version of the operating system.",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"rescue_bus": schema.StringAttribute{
Description: "Sets the device bus when the image is used as a rescue image.",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"rescue_device": schema.StringAttribute{
Description: "Sets the device when the image is used as a rescue image.",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"secure_boot": schema.BoolAttribute{
Description: "Enables Secure Boot.",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.Bool{
boolplanmodifier.UseStateForUnknown(),
},
},
"uefi": schema.BoolAttribute{
Description: "Enables UEFI boot.",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.Bool{
boolplanmodifier.UseStateForUnknown(),
},
},
"video_model": schema.StringAttribute{
Description: "Sets Graphic device model.",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"virtio_scsi": schema.BoolAttribute{
Description: "Enables the use of VirtIO SCSI to provide block device access. By default instances use VirtIO Block.",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.Bool{
boolplanmodifier.UseStateForUnknown(),
},
},
},
},
"checksum": schema.SingleNestedAttribute{
Description: "Representation of an image checksum.",
Computed: true,
PlanModifiers: []planmodifier.Object{
objectplanmodifier.UseStateForUnknown(),
},
Attributes: map[string]schema.Attribute{
"algorithm": schema.StringAttribute{
Description: "Algorithm for the checksum of the image data.",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"digest": schema.StringAttribute{
Description: "Hexdigest of the checksum of the image data.",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
},
},
"labels": schema.MapAttribute{
Description: "Labels are key-value string pairs which can be attached to a resource container",
ElementType: types.StringType,
Optional: true,
},
},
}
}
// Create creates the resource and sets the initial Terraform state.
func (r *imageResource) 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
}
projectId := model.ProjectId.ValueString()
region := r.providerData.GetRegionWithOverride(model.Region)
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "region", region)
ctx = core.InitProviderContext(ctx)
// Generate API request body from model
payload, err := toCreatePayload(ctx, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating image", fmt.Sprintf("Creating API payload: %v", err))
return
}
// Create new image
imageCreateResp, err := r.client.CreateImage(ctx, projectId, region).CreateImagePayload(*payload).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating image", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
ctx = tflog.SetField(ctx, "image_id", *imageCreateResp.Id)
// Get the image object, as the creation response does not contain all fields
image, err := r.client.GetImage(ctx, projectId, region, *imageCreateResp.Id).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating image", fmt.Sprintf("Calling API: %v", err))
return
}
// Map response body to schema
err = mapFields(ctx, image, &model, region)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating image", fmt.Sprintf("Processing API payload: %v", err))
return
}
// Set state to partially populated data
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
// Upload image
err = uploadImage(ctx, &resp.Diagnostics, model.LocalFilePath.ValueString(), *imageCreateResp.UploadUrl)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating image", fmt.Sprintf("Uploading image: %v", err))
return
}
// Wait for image to become available
waiter := wait.UploadImageWaitHandler(ctx, r.client, projectId, region, *imageCreateResp.Id)
waiter = waiter.SetTimeout(7 * 24 * time.Hour) // Set timeout to one week, to make the timeout useless
waitResp, err := waiter.WaitWithContext(ctx)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating image", fmt.Sprintf("Waiting for image to become available: %v", err))
return
}
// Map response body to schema
err = mapFields(ctx, waitResp, &model, region)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating image", 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, "Image created")
}
// Read refreshes the Terraform state with the latest data.
func (r *imageResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform
var model Model
diags := req.State.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
region := r.providerData.GetRegionWithOverride(model.Region)
imageId := model.ImageId.ValueString()
ctx = core.InitProviderContext(ctx)
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "region", region)
ctx = tflog.SetField(ctx, "image_id", imageId)
imageResp, err := r.client.GetImage(ctx, projectId, region, imageId).Execute()
if err != nil {
oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped
if ok && oapiErr.StatusCode == http.StatusNotFound {
resp.State.RemoveResource(ctx)
return
}
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading image", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
// Map response body to schema
err = mapFields(ctx, imageResp, &model, region)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading image", 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, "Image read")
}
// Update updates the resource and sets the updated Terraform state on success.
func (r *imageResource) 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
}
projectId := model.ProjectId.ValueString()
region := r.providerData.GetRegionWithOverride(model.Region)
imageId := model.ImageId.ValueString()
ctx = core.InitProviderContext(ctx)
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "region", region)
ctx = tflog.SetField(ctx, "image_id", imageId)
// Retrieve values from state
var stateModel Model
diags = req.State.Get(ctx, &stateModel)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
// Generate API request body from model
payload, err := toUpdatePayload(ctx, &model, stateModel.Labels)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating image", fmt.Sprintf("Creating API payload: %v", err))
return
}
// Update existing image
updatedImage, err := r.client.UpdateImage(ctx, projectId, region, imageId).UpdateImagePayload(*payload).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating image", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
err = mapFields(ctx, updatedImage, &model, region)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating image", 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, "Image updated")
}
// Delete deletes the resource and removes the Terraform state on success.
func (r *imageResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve values from state
var model Model
diags := req.State.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
imageId := model.ImageId.ValueString()
region := r.providerData.GetRegionWithOverride(model.Region)
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "image_id", imageId)
ctx = tflog.SetField(ctx, "region", region)
ctx = core.InitProviderContext(ctx)
// Delete existing image
err := r.client.DeleteImage(ctx, projectId, region, imageId).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting image", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
_, err = wait.DeleteImageWaitHandler(ctx, r.client, projectId, region, imageId).WaitWithContext(ctx)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting image", fmt.Sprintf("image deletion waiting: %v", err))
return
}
tflog.Info(ctx, "Image deleted")
}
// ImportState imports a resource into the Terraform state on success.
// The expected format of the resource import identifier is: project_id,region,image_id
func (r *imageResource) 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 image",
fmt.Sprintf("Expected import identifier with format: [project_id],[region],[image_id] Got: %q", req.ID),
)
return
}
utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{
"project_id": idParts[0],
"region": idParts[1],
"image_id": idParts[2],
})
tflog.Info(ctx, "Image state imported")
}
func mapFields(ctx context.Context, imageResp *iaas.Image, model *Model, region string) error {
if imageResp == nil {
return fmt.Errorf("response input is nil")
}
if model == nil {
return fmt.Errorf("model input is nil")
}
var imageId string
if model.ImageId.ValueString() != "" {
imageId = model.ImageId.ValueString()
} else if imageResp.Id != nil {
imageId = *imageResp.Id
} else {
return fmt.Errorf("image id not present")
}
model.Id = utils.BuildInternalTerraformId(model.ProjectId.ValueString(), region, imageId)
model.Region = types.StringValue(region)
// Map config
var configModel = &configModel{}
var configObject basetypes.ObjectValue
diags := diag.Diagnostics{}
if imageResp.Config != nil {
configModel.BootMenu = types.BoolPointerValue(imageResp.Config.BootMenu)
configModel.CDROMBus = types.StringPointerValue(imageResp.Config.GetCdromBus())
configModel.DiskBus = types.StringPointerValue(imageResp.Config.GetDiskBus())
configModel.NICModel = types.StringPointerValue(imageResp.Config.GetNicModel())
configModel.OperatingSystem = types.StringPointerValue(imageResp.Config.OperatingSystem)
configModel.OperatingSystemDistro = types.StringPointerValue(imageResp.Config.GetOperatingSystemDistro())
configModel.OperatingSystemVersion = types.StringPointerValue(imageResp.Config.GetOperatingSystemVersion())
configModel.RescueBus = types.StringPointerValue(imageResp.Config.GetRescueBus())
configModel.RescueDevice = types.StringPointerValue(imageResp.Config.GetRescueDevice())
configModel.SecureBoot = types.BoolPointerValue(imageResp.Config.SecureBoot)
configModel.UEFI = types.BoolPointerValue(imageResp.Config.Uefi)
configModel.VideoModel = types.StringPointerValue(imageResp.Config.GetVideoModel())
configModel.VirtioScsi = types.BoolPointerValue(iaas.PtrBool(imageResp.Config.GetVirtioScsi()))
configObject, diags = types.ObjectValue(configTypes, map[string]attr.Value{
"boot_menu": configModel.BootMenu,
"cdrom_bus": configModel.CDROMBus,
"disk_bus": configModel.DiskBus,
"nic_model": configModel.NICModel,
"operating_system": configModel.OperatingSystem,
"operating_system_distro": configModel.OperatingSystemDistro,
"operating_system_version": configModel.OperatingSystemVersion,
"rescue_bus": configModel.RescueBus,
"rescue_device": configModel.RescueDevice,
"secure_boot": configModel.SecureBoot,
"uefi": configModel.UEFI,
"video_model": configModel.VideoModel,
"virtio_scsi": configModel.VirtioScsi,
})
} else {
configObject = types.ObjectNull(configTypes)
}
if diags.HasError() {
return fmt.Errorf("creating config: %w", core.DiagsToError(diags))
}
// Map checksum
var checksumModel = &checksumModel{}
var checksumObject basetypes.ObjectValue
if imageResp.Checksum != nil {
checksumModel.Algorithm = types.StringPointerValue(imageResp.Checksum.Algorithm)
checksumModel.Digest = types.StringPointerValue(imageResp.Checksum.Digest)
checksumObject, diags = types.ObjectValue(checksumTypes, map[string]attr.Value{
"algorithm": checksumModel.Algorithm,
"digest": checksumModel.Digest,
})
} else {
checksumObject = types.ObjectNull(checksumTypes)
}
if diags.HasError() {
return fmt.Errorf("creating checksum: %w", core.DiagsToError(diags))
}
// Map labels
labels, err := iaasUtils.MapLabels(ctx, imageResp.Labels, model.Labels)
if err != nil {
return err
}
model.ImageId = types.StringValue(imageId)
model.Name = types.StringPointerValue(imageResp.Name)
model.DiskFormat = types.StringPointerValue(imageResp.DiskFormat)
model.MinDiskSize = types.Int64PointerValue(imageResp.MinDiskSize)
model.MinRAM = types.Int64PointerValue(imageResp.MinRam)
model.Protected = types.BoolPointerValue(imageResp.Protected)
model.Scope = types.StringPointerValue(imageResp.Scope)
model.Labels = labels
model.Config = configObject
model.Checksum = checksumObject
return nil
}
func toCreatePayload(ctx context.Context, model *Model) (*iaas.CreateImagePayload, error) {
if model == nil {
return nil, fmt.Errorf("nil model")
}
var configModel = &configModel{}
if !(model.Config.IsNull() || model.Config.IsUnknown()) {
diags := model.Config.As(ctx, configModel, basetypes.ObjectAsOptions{})
if diags.HasError() {
return nil, fmt.Errorf("convert boot volume object to struct: %w", core.DiagsToError(diags))
}
}
configPayload := &iaas.ImageConfig{
BootMenu: conversion.BoolValueToPointer(configModel.BootMenu),
CdromBus: iaas.NewNullableString(conversion.StringValueToPointer(configModel.CDROMBus)),
DiskBus: iaas.NewNullableString(conversion.StringValueToPointer(configModel.DiskBus)),
NicModel: iaas.NewNullableString(conversion.StringValueToPointer(configModel.NICModel)),
OperatingSystem: conversion.StringValueToPointer(configModel.OperatingSystem),
OperatingSystemDistro: iaas.NewNullableString(conversion.StringValueToPointer(configModel.OperatingSystemDistro)),
OperatingSystemVersion: iaas.NewNullableString(conversion.StringValueToPointer(configModel.OperatingSystemVersion)),
RescueBus: iaas.NewNullableString(conversion.StringValueToPointer(configModel.RescueBus)),
RescueDevice: iaas.NewNullableString(conversion.StringValueToPointer(configModel.RescueDevice)),
SecureBoot: conversion.BoolValueToPointer(configModel.SecureBoot),
Uefi: conversion.BoolValueToPointer(configModel.UEFI),
VideoModel: iaas.NewNullableString(conversion.StringValueToPointer(configModel.VideoModel)),
VirtioScsi: conversion.BoolValueToPointer(configModel.VirtioScsi),
}
labels, err := conversion.ToStringInterfaceMap(ctx, model.Labels)
if err != nil {
return nil, fmt.Errorf("converting to Go map: %w", err)
}
return &iaas.CreateImagePayload{
Name: conversion.StringValueToPointer(model.Name),
DiskFormat: conversion.StringValueToPointer(model.DiskFormat),
MinDiskSize: conversion.Int64ValueToPointer(model.MinDiskSize),
MinRam: conversion.Int64ValueToPointer(model.MinRAM),
Protected: conversion.BoolValueToPointer(model.Protected),
Config: configPayload,
Labels: &labels,
}, nil
}
func toUpdatePayload(ctx context.Context, model *Model, currentLabels types.Map) (*iaas.UpdateImagePayload, error) {
if model == nil {
return nil, fmt.Errorf("nil model")
}
var configModel = &configModel{}
if !(model.Config.IsNull() || model.Config.IsUnknown()) {
diags := model.Config.As(ctx, configModel, basetypes.ObjectAsOptions{})
if diags.HasError() {
return nil, fmt.Errorf("convert boot volume object to struct: %w", core.DiagsToError(diags))
}
}
configPayload := &iaas.ImageConfig{
BootMenu: conversion.BoolValueToPointer(configModel.BootMenu),
CdromBus: iaas.NewNullableString(conversion.StringValueToPointer(configModel.CDROMBus)),
DiskBus: iaas.NewNullableString(conversion.StringValueToPointer(configModel.DiskBus)),
NicModel: iaas.NewNullableString(conversion.StringValueToPointer(configModel.NICModel)),
OperatingSystem: conversion.StringValueToPointer(configModel.OperatingSystem),
OperatingSystemDistro: iaas.NewNullableString(conversion.StringValueToPointer(configModel.OperatingSystemDistro)),
OperatingSystemVersion: iaas.NewNullableString(conversion.StringValueToPointer(configModel.OperatingSystemVersion)),
RescueBus: iaas.NewNullableString(conversion.StringValueToPointer(configModel.RescueBus)),
RescueDevice: iaas.NewNullableString(conversion.StringValueToPointer(configModel.RescueDevice)),
SecureBoot: conversion.BoolValueToPointer(configModel.SecureBoot),
Uefi: conversion.BoolValueToPointer(configModel.UEFI),
VideoModel: iaas.NewNullableString(conversion.StringValueToPointer(configModel.VideoModel)),
VirtioScsi: conversion.BoolValueToPointer(configModel.VirtioScsi),
}
labels, err := conversion.ToJSONMapPartialUpdatePayload(ctx, currentLabels, model.Labels)
if err != nil {
return nil, fmt.Errorf("converting to go map: %w", err)
}
// DiskFormat is not sent in the update payload as does not have effect after image upload,
// and the field has RequiresReplace set
return &iaas.UpdateImagePayload{
Name: conversion.StringValueToPointer(model.Name),
MinDiskSize: conversion.Int64ValueToPointer(model.MinDiskSize),
MinRam: conversion.Int64ValueToPointer(model.MinRAM),
Protected: conversion.BoolValueToPointer(model.Protected),
Config: configPayload,
Labels: &labels,
}, nil
}
func uploadImage(ctx context.Context, diags *diag.Diagnostics, filePath, uploadURL string) error {
if filePath == "" {
return fmt.Errorf("file path is empty")
}
if uploadURL == "" {
return fmt.Errorf("upload URL is empty")
}
file, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("open file: %w", err)
}
stat, err := file.Stat()
if err != nil {
return fmt.Errorf("stat file: %w", err)
}
req, err := http.NewRequest(http.MethodPut, uploadURL, bufio.NewReader(file))
if err != nil {
return fmt.Errorf("create upload request: %w", err)
}
req.Header.Set("Content-Type", "application/octet-stream")
req.ContentLength = stat.Size()
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("upload image: %w", err)
}
defer func() {
err = resp.Body.Close()
if err != nil {
core.LogAndAddError(ctx, diags, "Error uploading image", fmt.Sprintf("Closing response body: %v", err))
}
}()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("upload image: %s", resp.Status)
}
return nil
}

View file

@ -1,404 +0,0 @@
package image
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/stackitcloud/stackit-sdk-go/core/utils"
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
)
func TestMapFields(t *testing.T) {
type args struct {
state Model
input *iaas.Image
region string
}
tests := []struct {
description string
args args
expected Model
isValid bool
}{
{
description: "default_values",
args: args{
state: Model{
ProjectId: types.StringValue("pid"),
ImageId: types.StringValue("iid"),
},
input: &iaas.Image{
Id: utils.Ptr("iid"),
},
region: "eu01",
},
expected: Model{
Id: types.StringValue("pid,eu01,iid"),
ProjectId: types.StringValue("pid"),
ImageId: types.StringValue("iid"),
Labels: types.MapNull(types.StringType),
Region: types.StringValue("eu01"),
},
isValid: true,
},
{
description: "simple_values",
args: args{
state: Model{
ProjectId: types.StringValue("pid"),
ImageId: types.StringValue("iid"),
Region: types.StringValue("eu01"),
},
input: &iaas.Image{
Id: utils.Ptr("iid"),
Name: utils.Ptr("name"),
DiskFormat: utils.Ptr("format"),
MinDiskSize: utils.Ptr(int64(1)),
MinRam: utils.Ptr(int64(1)),
Protected: utils.Ptr(true),
Scope: utils.Ptr("scope"),
Config: &iaas.ImageConfig{
BootMenu: utils.Ptr(true),
CdromBus: iaas.NewNullableString(utils.Ptr("cdrom_bus")),
DiskBus: iaas.NewNullableString(utils.Ptr("disk_bus")),
NicModel: iaas.NewNullableString(utils.Ptr("model")),
OperatingSystem: utils.Ptr("os"),
OperatingSystemDistro: iaas.NewNullableString(utils.Ptr("os_distro")),
OperatingSystemVersion: iaas.NewNullableString(utils.Ptr("os_version")),
RescueBus: iaas.NewNullableString(utils.Ptr("rescue_bus")),
RescueDevice: iaas.NewNullableString(utils.Ptr("rescue_device")),
SecureBoot: utils.Ptr(true),
Uefi: utils.Ptr(true),
VideoModel: iaas.NewNullableString(utils.Ptr("model")),
VirtioScsi: utils.Ptr(true),
},
Checksum: &iaas.ImageChecksum{
Algorithm: utils.Ptr("algorithm"),
Digest: utils.Ptr("digest"),
},
Labels: &map[string]interface{}{
"key": "value",
},
},
region: "eu02",
},
expected: Model{
Id: types.StringValue("pid,eu02,iid"),
ProjectId: types.StringValue("pid"),
ImageId: types.StringValue("iid"),
Name: types.StringValue("name"),
DiskFormat: types.StringValue("format"),
MinDiskSize: types.Int64Value(1),
MinRAM: types.Int64Value(1),
Protected: types.BoolValue(true),
Scope: types.StringValue("scope"),
Config: types.ObjectValueMust(configTypes, map[string]attr.Value{
"boot_menu": types.BoolValue(true),
"cdrom_bus": types.StringValue("cdrom_bus"),
"disk_bus": types.StringValue("disk_bus"),
"nic_model": types.StringValue("model"),
"operating_system": types.StringValue("os"),
"operating_system_distro": types.StringValue("os_distro"),
"operating_system_version": types.StringValue("os_version"),
"rescue_bus": types.StringValue("rescue_bus"),
"rescue_device": types.StringValue("rescue_device"),
"secure_boot": types.BoolValue(true),
"uefi": types.BoolValue(true),
"video_model": types.StringValue("model"),
"virtio_scsi": types.BoolValue(true),
}),
Checksum: types.ObjectValueMust(checksumTypes, map[string]attr.Value{
"algorithm": types.StringValue("algorithm"),
"digest": types.StringValue("digest"),
}),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
Region: types.StringValue("eu02"),
},
isValid: true,
},
{
description: "empty_labels",
args: args{
state: Model{
ProjectId: types.StringValue("pid"),
ImageId: types.StringValue("iid"),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{}),
},
input: &iaas.Image{
Id: utils.Ptr("iid"),
},
region: "eu01",
},
expected: Model{
Id: types.StringValue("pid,eu01,iid"),
ProjectId: types.StringValue("pid"),
ImageId: types.StringValue("iid"),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{}),
Region: types.StringValue("eu01"),
},
isValid: true,
},
{
description: "response_nil_fail",
},
{
description: "no_resource_id",
args: args{
state: Model{
ProjectId: types.StringValue("pid"),
},
input: &iaas.Image{},
},
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
err := mapFields(context.Background(), tt.args.input, &tt.args.state, tt.args.region)
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.args.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 *iaas.CreateImagePayload
isValid bool
}{
{
"ok",
&Model{
Id: types.StringValue("pid,iid"),
ProjectId: types.StringValue("pid"),
ImageId: types.StringValue("iid"),
Name: types.StringValue("name"),
DiskFormat: types.StringValue("format"),
MinDiskSize: types.Int64Value(1),
MinRAM: types.Int64Value(1),
Protected: types.BoolValue(true),
Config: types.ObjectValueMust(configTypes, map[string]attr.Value{
"boot_menu": types.BoolValue(true),
"cdrom_bus": types.StringValue("cdrom_bus"),
"disk_bus": types.StringValue("disk_bus"),
"nic_model": types.StringValue("nic_model"),
"operating_system": types.StringValue("os"),
"operating_system_distro": types.StringValue("os_distro"),
"operating_system_version": types.StringValue("os_version"),
"rescue_bus": types.StringValue("rescue_bus"),
"rescue_device": types.StringValue("rescue_device"),
"secure_boot": types.BoolValue(true),
"uefi": types.BoolValue(true),
"video_model": types.StringValue("video_model"),
"virtio_scsi": types.BoolValue(true),
}),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
},
&iaas.CreateImagePayload{
Name: utils.Ptr("name"),
DiskFormat: utils.Ptr("format"),
MinDiskSize: utils.Ptr(int64(1)),
MinRam: utils.Ptr(int64(1)),
Protected: utils.Ptr(true),
Config: &iaas.ImageConfig{
BootMenu: utils.Ptr(true),
CdromBus: iaas.NewNullableString(utils.Ptr("cdrom_bus")),
DiskBus: iaas.NewNullableString(utils.Ptr("disk_bus")),
NicModel: iaas.NewNullableString(utils.Ptr("nic_model")),
OperatingSystem: utils.Ptr("os"),
OperatingSystemDistro: iaas.NewNullableString(utils.Ptr("os_distro")),
OperatingSystemVersion: iaas.NewNullableString(utils.Ptr("os_version")),
RescueBus: iaas.NewNullableString(utils.Ptr("rescue_bus")),
RescueDevice: iaas.NewNullableString(utils.Ptr("rescue_device")),
SecureBoot: utils.Ptr(true),
Uefi: utils.Ptr(true),
VideoModel: iaas.NewNullableString(utils.Ptr("video_model")),
VirtioScsi: utils.Ptr(true),
},
Labels: &map[string]interface{}{
"key": "value",
},
},
true,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
output, err := toCreatePayload(context.Background(), 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, cmp.AllowUnexported(iaas.NullableString{}))
if diff != "" {
t.Fatalf("Data does not match: %s", diff)
}
}
})
}
}
func TestToUpdatePayload(t *testing.T) {
tests := []struct {
description string
input *Model
expected *iaas.UpdateImagePayload
isValid bool
}{
{
"default_ok",
&Model{
Id: types.StringValue("pid,iid"),
ProjectId: types.StringValue("pid"),
ImageId: types.StringValue("iid"),
Name: types.StringValue("name"),
DiskFormat: types.StringValue("format"),
MinDiskSize: types.Int64Value(1),
MinRAM: types.Int64Value(1),
Protected: types.BoolValue(true),
Config: types.ObjectValueMust(configTypes, map[string]attr.Value{
"boot_menu": types.BoolValue(true),
"cdrom_bus": types.StringValue("cdrom_bus"),
"disk_bus": types.StringValue("disk_bus"),
"nic_model": types.StringValue("nic_model"),
"operating_system": types.StringValue("os"),
"operating_system_distro": types.StringValue("os_distro"),
"operating_system_version": types.StringValue("os_version"),
"rescue_bus": types.StringValue("rescue_bus"),
"rescue_device": types.StringValue("rescue_device"),
"secure_boot": types.BoolValue(true),
"uefi": types.BoolValue(true),
"video_model": types.StringValue("video_model"),
"virtio_scsi": types.BoolValue(true),
}),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
},
&iaas.UpdateImagePayload{
Name: utils.Ptr("name"),
MinDiskSize: utils.Ptr(int64(1)),
MinRam: utils.Ptr(int64(1)),
Protected: utils.Ptr(true),
Config: &iaas.ImageConfig{
BootMenu: utils.Ptr(true),
CdromBus: iaas.NewNullableString(utils.Ptr("cdrom_bus")),
DiskBus: iaas.NewNullableString(utils.Ptr("disk_bus")),
NicModel: iaas.NewNullableString(utils.Ptr("nic_model")),
OperatingSystem: utils.Ptr("os"),
OperatingSystemDistro: iaas.NewNullableString(utils.Ptr("os_distro")),
OperatingSystemVersion: iaas.NewNullableString(utils.Ptr("os_version")),
RescueBus: iaas.NewNullableString(utils.Ptr("rescue_bus")),
RescueDevice: iaas.NewNullableString(utils.Ptr("rescue_device")),
SecureBoot: utils.Ptr(true),
Uefi: utils.Ptr(true),
VideoModel: iaas.NewNullableString(utils.Ptr("video_model")),
VirtioScsi: utils.Ptr(true),
},
Labels: &map[string]interface{}{
"key": "value",
},
},
true,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
output, err := toUpdatePayload(context.Background(), tt.input, types.MapNull(types.StringType))
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, cmp.AllowUnexported(iaas.NullableString{}))
if diff != "" {
t.Fatalf("Data does not match: %s", diff)
}
}
})
}
}
func Test_UploadImage(t *testing.T) {
tests := []struct {
name string
filePath string
uploadFails bool
wantErr bool
}{
{
name: "ok",
filePath: "testdata/mock-image.txt",
uploadFails: false,
wantErr: false,
},
{
name: "upload_fails",
filePath: "testdata/mock-image.txt",
uploadFails: true,
wantErr: true,
},
{
name: "file_not_found",
filePath: "testdata/non-existing-file.txt",
uploadFails: false,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Setup a test server
handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if tt.uploadFails {
w.WriteHeader(http.StatusInternalServerError)
_, _ = fmt.Fprintln(w, `{"status":"some error occurred"}`)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = fmt.Fprintln(w, `{"status":"ok"}`)
})
server := httptest.NewServer(handler)
defer server.Close()
uploadURL, err := url.Parse(server.URL)
if err != nil {
t.Error(err)
return
}
// Call the function
err = uploadImage(context.Background(), &diag.Diagnostics{}, tt.filePath, uploadURL.String())
if (err != nil) != tt.wantErr {
t.Errorf("uploadImage() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}

View file

@ -1 +0,0 @@
I am a mock image file

View file

@ -1,648 +0,0 @@
package image
import (
"context"
"fmt"
"net/http"
"regexp"
"sort"
"github.com/hashicorp/terraform-plugin-framework-validators/datasourcevalidator"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
"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"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate"
"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/diag"
"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"
iaasUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/utils"
)
// Ensure the implementation satisfies the expected interfaces.
var (
_ datasource.DataSource = &imageDataV2Source{}
)
type DataSourceModel struct {
Id types.String `tfsdk:"id"` // needed by TF
ProjectId types.String `tfsdk:"project_id"`
Region types.String `tfsdk:"region"`
ImageId types.String `tfsdk:"image_id"`
Name types.String `tfsdk:"name"`
NameRegex types.String `tfsdk:"name_regex"`
SortAscending types.Bool `tfsdk:"sort_ascending"`
Filter types.Object `tfsdk:"filter"`
DiskFormat types.String `tfsdk:"disk_format"`
MinDiskSize types.Int64 `tfsdk:"min_disk_size"`
MinRAM types.Int64 `tfsdk:"min_ram"`
Protected types.Bool `tfsdk:"protected"`
Scope types.String `tfsdk:"scope"`
Config types.Object `tfsdk:"config"`
Checksum types.Object `tfsdk:"checksum"`
Labels types.Map `tfsdk:"labels"`
}
type Filter struct {
OS types.String `tfsdk:"os"`
Distro types.String `tfsdk:"distro"`
Version types.String `tfsdk:"version"`
UEFI types.Bool `tfsdk:"uefi"`
SecureBoot types.Bool `tfsdk:"secure_boot"`
}
// Struct corresponding to Model.Config
type configModel struct {
BootMenu types.Bool `tfsdk:"boot_menu"`
CDROMBus types.String `tfsdk:"cdrom_bus"`
DiskBus types.String `tfsdk:"disk_bus"`
NICModel types.String `tfsdk:"nic_model"`
OperatingSystem types.String `tfsdk:"operating_system"`
OperatingSystemDistro types.String `tfsdk:"operating_system_distro"`
OperatingSystemVersion types.String `tfsdk:"operating_system_version"`
RescueBus types.String `tfsdk:"rescue_bus"`
RescueDevice types.String `tfsdk:"rescue_device"`
SecureBoot types.Bool `tfsdk:"secure_boot"`
UEFI types.Bool `tfsdk:"uefi"`
VideoModel types.String `tfsdk:"video_model"`
VirtioScsi types.Bool `tfsdk:"virtio_scsi"`
}
// Types corresponding to configModel
var configTypes = map[string]attr.Type{
"boot_menu": basetypes.BoolType{},
"cdrom_bus": basetypes.StringType{},
"disk_bus": basetypes.StringType{},
"nic_model": basetypes.StringType{},
"operating_system": basetypes.StringType{},
"operating_system_distro": basetypes.StringType{},
"operating_system_version": basetypes.StringType{},
"rescue_bus": basetypes.StringType{},
"rescue_device": basetypes.StringType{},
"secure_boot": basetypes.BoolType{},
"uefi": basetypes.BoolType{},
"video_model": basetypes.StringType{},
"virtio_scsi": basetypes.BoolType{},
}
// Struct corresponding to Model.Checksum
type checksumModel struct {
Algorithm types.String `tfsdk:"algorithm"`
Digest types.String `tfsdk:"digest"`
}
// Types corresponding to checksumModel
var checksumTypes = map[string]attr.Type{
"algorithm": basetypes.StringType{},
"digest": basetypes.StringType{},
}
// NewImageV2DataSource is a helper function to simplify the provider implementation.
func NewImageV2DataSource() datasource.DataSource {
return &imageDataV2Source{}
}
// imageDataV2Source is the data source implementation.
type imageDataV2Source struct {
client *iaas.APIClient
providerData core.ProviderData
}
// Metadata returns the data source type name.
func (d *imageDataV2Source) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_image_v2"
}
func (d *imageDataV2Source) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
var ok bool
d.providerData, ok = conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics)
if !ok {
return
}
features.CheckBetaResourcesEnabled(ctx, &d.providerData, &resp.Diagnostics, "stackit_image_v2", "datasource")
if resp.Diagnostics.HasError() {
return
}
apiClient := iaasUtils.ConfigureClient(ctx, &d.providerData, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}
d.client = apiClient
tflog.Info(ctx, "iaas client configured")
}
func (d *imageDataV2Source) ConfigValidators(_ context.Context) []datasource.ConfigValidator {
return []datasource.ConfigValidator{
datasourcevalidator.Conflicting(
path.MatchRoot("name"),
path.MatchRoot("name_regex"),
path.MatchRoot("image_id"),
),
datasourcevalidator.AtLeastOneOf(
path.MatchRoot("name"),
path.MatchRoot("name_regex"),
path.MatchRoot("image_id"),
path.MatchRoot("filter"),
),
}
}
// Schema defines the schema for the datasource.
func (d *imageDataV2Source) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
description := features.AddBetaDescription(fmt.Sprintf(
"%s\n\n~> %s",
"Image datasource schema. Must have a `region` specified in the provider configuration.",
"Important: When using the `name`, `name_regex`, or `filter` attributes to select images dynamically, be aware that image IDs may change frequently. Each OS patch or update results in a new unique image ID. If this data source is used to populate fields like `boot_volume.source_id` in a server resource, it may cause Terraform to detect changes and recreate the associated resource.\n\n"+
"To avoid unintended updates or resource replacements:\n"+
" - Prefer using a static `image_id` to pin a specific image version.\n"+
" - If you accept automatic image updates but wish to suppress resource changes, use a `lifecycle` block to ignore relevant changes. For example:\n\n"+
"```hcl\n"+
"resource \"stackit_server\" \"example\" {\n"+
" boot_volume = {\n"+
" size = 64\n"+
" source_type = \"image\"\n"+
" source_id = data.stackit_image.latest.id\n"+
" }\n"+
"\n"+
" lifecycle {\n"+
" ignore_changes = [boot_volume[0].source_id]\n"+
" }\n"+
"}\n"+
"```\n\n"+
"You can also list available images using the [STACKIT CLI](https://github.com/stackitcloud/stackit-cli):\n\n"+
"```bash\n"+
"stackit image list\n"+
"```",
), core.Datasource)
resp.Schema = schema.Schema{
MarkdownDescription: description,
Description: description,
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: "Terraform's internal resource ID. It is structured as \"`project_id`,`region`,`image_id`\".",
Computed: true,
},
"project_id": schema.StringAttribute{
Description: "STACKIT project ID to which the image is associated.",
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"region": schema.StringAttribute{
Description: "The resource region. If not defined, the provider region is used.",
// the region cannot be found, so it has to be passed
Optional: true,
},
"image_id": schema.StringAttribute{
Description: "Image ID to fetch directly",
Optional: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"name": schema.StringAttribute{
Description: "Exact image name to match. Optionally applies a `filter` block to further refine results in case multiple images share the same name. The first match is returned, optionally sorted by name in ascending order. Cannot be used together with `name_regex`.",
Optional: true,
},
"name_regex": schema.StringAttribute{
Description: "Regular expression to match against image names. Optionally applies a `filter` block to narrow down results when multiple image names match the regex. The first match is returned, optionally sorted by name in ascending order. Cannot be used together with `name`.",
Optional: true,
},
"sort_ascending": schema.BoolAttribute{
Description: "If set to `true`, images are sorted in ascending lexicographical order by image name (such as `Ubuntu 18.04`, `Ubuntu 20.04`, `Ubuntu 22.04`) before selecting the first match. Defaults to `false` (descending such as `Ubuntu 22.04`, `Ubuntu 20.04`, `Ubuntu 18.04`).",
Optional: true,
},
"filter": schema.SingleNestedAttribute{
Optional: true,
Description: "Additional filtering options based on image properties. Can be used independently or in conjunction with `name` or `name_regex`.",
Attributes: map[string]schema.Attribute{
"os": schema.StringAttribute{
Optional: true,
Description: "Filter images by operating system type, such as `linux` or `windows`.",
},
"distro": schema.StringAttribute{
Optional: true,
Description: "Filter images by operating system distribution. For example: `ubuntu`, `ubuntu-arm64`, `debian`, `rhel`, etc.",
},
"version": schema.StringAttribute{
Optional: true,
Description: "Filter images by OS distribution version, such as `22.04`, `11`, or `9.1`.",
},
"uefi": schema.BoolAttribute{
Optional: true,
Description: "Filter images based on UEFI support. Set to `true` to match images that support UEFI.",
},
"secure_boot": schema.BoolAttribute{
Optional: true,
Description: "Filter images with Secure Boot support. Set to `true` to match images that support Secure Boot.",
},
},
},
"disk_format": schema.StringAttribute{
Description: "The disk format of the image.",
Computed: true,
},
"min_disk_size": schema.Int64Attribute{
Description: "The minimum disk size of the image in GB.",
Computed: true,
},
"min_ram": schema.Int64Attribute{
Description: "The minimum RAM of the image in MB.",
Computed: true,
},
"protected": schema.BoolAttribute{
Description: "Whether the image is protected.",
Computed: true,
},
"scope": schema.StringAttribute{
Description: "The scope of the image.",
Computed: true,
},
"config": schema.SingleNestedAttribute{
Description: "Properties to set hardware and scheduling settings for an image.",
Computed: true,
Attributes: map[string]schema.Attribute{
"boot_menu": schema.BoolAttribute{
Description: "Enables the BIOS bootmenu.",
Computed: true,
},
"cdrom_bus": schema.StringAttribute{
Description: "Sets CDROM bus controller type.",
Computed: true,
},
"disk_bus": schema.StringAttribute{
Description: "Sets Disk bus controller type.",
Computed: true,
},
"nic_model": schema.StringAttribute{
Description: "Sets virtual network interface model.",
Computed: true,
},
"operating_system": schema.StringAttribute{
Description: "Enables operating system specific optimizations.",
Computed: true,
},
"operating_system_distro": schema.StringAttribute{
Description: "Operating system distribution.",
Computed: true,
},
"operating_system_version": schema.StringAttribute{
Description: "Version of the operating system.",
Computed: true,
},
"rescue_bus": schema.StringAttribute{
Description: "Sets the device bus when the image is used as a rescue image.",
Computed: true,
},
"rescue_device": schema.StringAttribute{
Description: "Sets the device when the image is used as a rescue image.",
Computed: true,
},
"secure_boot": schema.BoolAttribute{
Description: "Enables Secure Boot.",
Computed: true,
},
"uefi": schema.BoolAttribute{
Description: "Enables UEFI boot.",
Computed: true,
},
"video_model": schema.StringAttribute{
Description: "Sets Graphic device model.",
Computed: true,
},
"virtio_scsi": schema.BoolAttribute{
Description: "Enables the use of VirtIO SCSI to provide block device access. By default instances use VirtIO Block.",
Computed: true,
},
},
},
"checksum": schema.SingleNestedAttribute{
Description: "Representation of an image checksum.",
Computed: true,
Attributes: map[string]schema.Attribute{
"algorithm": schema.StringAttribute{
Description: "Algorithm for the checksum of the image data.",
Computed: true,
},
"digest": schema.StringAttribute{
Description: "Hexdigest of the checksum of the image data.",
Computed: true,
},
},
},
"labels": schema.MapAttribute{
Description: "Labels are key-value string pairs which can be attached to a resource container",
ElementType: types.StringType,
Computed: true,
},
},
}
}
// Read refreshes the Terraform state with the latest data.
func (d *imageDataV2Source) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform
var model DataSourceModel
diags := req.Config.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectID := model.ProjectId.ValueString()
region := d.providerData.GetRegionWithOverride(model.Region)
imageID := model.ImageId.ValueString()
name := model.Name.ValueString()
nameRegex := model.NameRegex.ValueString()
sortAscending := model.SortAscending.ValueBool()
var filter Filter
if !model.Filter.IsNull() && !model.Filter.IsUnknown() {
if diagnostics := model.Filter.As(ctx, &filter, basetypes.ObjectAsOptions{}); diagnostics.HasError() {
resp.Diagnostics.Append(diagnostics...)
return
}
}
ctx = core.InitProviderContext(ctx)
ctx = tflog.SetField(ctx, "project_id", projectID)
ctx = tflog.SetField(ctx, "region", region)
ctx = tflog.SetField(ctx, "image_id", imageID)
ctx = tflog.SetField(ctx, "name", name)
ctx = tflog.SetField(ctx, "name_regex", nameRegex)
ctx = tflog.SetField(ctx, "sort_ascending", sortAscending)
var imageResp *iaas.Image
var err error
// Case 1: Direct lookup by image ID
if imageID != "" {
imageResp, err = d.client.GetImage(ctx, projectID, region, imageID).Execute()
if err != nil {
utils.LogError(ctx, &resp.Diagnostics, err, "Reading image",
fmt.Sprintf("Image with ID %q does not exist in project %q.", imageID, 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)
} else {
// Case 2: Lookup by name or name_regex
// Compile regex
var compiledRegex *regexp.Regexp
if nameRegex != "" {
compiledRegex, err = regexp.Compile(nameRegex)
if err != nil {
core.LogAndAddWarning(ctx, &resp.Diagnostics, "Invalid name_regex", err.Error())
return
}
}
// Fetch all available images
imageList, err := d.client.ListImages(ctx, projectID, region).Execute()
if err != nil {
utils.LogError(ctx, &resp.Diagnostics, err, "List images", "Unable to fetch images", nil)
return
}
ctx = core.LogResponse(ctx)
// Step 1: Match images by name or regular expression (name or name_regex, if provided)
var matchedImages []*iaas.Image
for i := range *imageList.Items {
img := &(*imageList.Items)[i]
if name != "" && img.Name != nil && *img.Name == name {
matchedImages = append(matchedImages, img)
}
if compiledRegex != nil && img.Name != nil && compiledRegex.MatchString(*img.Name) {
matchedImages = append(matchedImages, img)
}
// If neither name nor name_regex is specified, include all images for filter evaluation later
if name == "" && nameRegex == "" {
matchedImages = append(matchedImages, img)
}
}
// Step 2: Sort matched images by name (optional, based on sortAscending flag)
if len(matchedImages) > 1 {
sortImagesByName(matchedImages, sortAscending)
}
// Step 3: Apply additional filtering based on OS, distro, version, UEFI, secure boot, etc.
var filteredImages []*iaas.Image
for _, img := range matchedImages {
if imageMatchesFilter(img, &filter) {
filteredImages = append(filteredImages, img)
}
}
// Check if any images passed all filters; warn if no matching image was found
if len(filteredImages) == 0 {
core.LogAndAddWarning(ctx, &resp.Diagnostics, "No match",
"No matching image found using name, name_regex, and filter criteria.")
return
}
// Step 4: Use the first image from the filtered and sorted result list
imageResp = filteredImages[0]
}
err = mapDataSourceFields(ctx, imageResp, &model, region)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading image", 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, "image read")
}
func mapDataSourceFields(ctx context.Context, imageResp *iaas.Image, model *DataSourceModel, region string) error {
if imageResp == nil {
return fmt.Errorf("response input is nil")
}
if model == nil {
return fmt.Errorf("model input is nil")
}
var imageId string
if model.ImageId.ValueString() != "" {
imageId = model.ImageId.ValueString()
} else if imageResp.Id != nil {
imageId = *imageResp.Id
} else {
return fmt.Errorf("image id not present")
}
model.Id = utils.BuildInternalTerraformId(model.ProjectId.ValueString(), region, imageId)
model.Region = types.StringValue(region)
// Map config
var configModel = &configModel{}
var configObject basetypes.ObjectValue
diags := diag.Diagnostics{}
if imageResp.Config != nil {
configModel.BootMenu = types.BoolPointerValue(imageResp.Config.BootMenu)
configModel.CDROMBus = types.StringPointerValue(imageResp.Config.GetCdromBus())
configModel.DiskBus = types.StringPointerValue(imageResp.Config.GetDiskBus())
configModel.NICModel = types.StringPointerValue(imageResp.Config.GetNicModel())
configModel.OperatingSystem = types.StringPointerValue(imageResp.Config.OperatingSystem)
configModel.OperatingSystemDistro = types.StringPointerValue(imageResp.Config.GetOperatingSystemDistro())
configModel.OperatingSystemVersion = types.StringPointerValue(imageResp.Config.GetOperatingSystemVersion())
configModel.RescueBus = types.StringPointerValue(imageResp.Config.GetRescueBus())
configModel.RescueDevice = types.StringPointerValue(imageResp.Config.GetRescueDevice())
configModel.SecureBoot = types.BoolPointerValue(imageResp.Config.SecureBoot)
configModel.UEFI = types.BoolPointerValue(imageResp.Config.Uefi)
configModel.VideoModel = types.StringPointerValue(imageResp.Config.GetVideoModel())
configModel.VirtioScsi = types.BoolPointerValue(iaas.PtrBool(imageResp.Config.GetVirtioScsi()))
configObject, diags = types.ObjectValue(configTypes, map[string]attr.Value{
"boot_menu": configModel.BootMenu,
"cdrom_bus": configModel.CDROMBus,
"disk_bus": configModel.DiskBus,
"nic_model": configModel.NICModel,
"operating_system": configModel.OperatingSystem,
"operating_system_distro": configModel.OperatingSystemDistro,
"operating_system_version": configModel.OperatingSystemVersion,
"rescue_bus": configModel.RescueBus,
"rescue_device": configModel.RescueDevice,
"secure_boot": configModel.SecureBoot,
"uefi": configModel.UEFI,
"video_model": configModel.VideoModel,
"virtio_scsi": configModel.VirtioScsi,
})
} else {
configObject = types.ObjectNull(configTypes)
}
if diags.HasError() {
return fmt.Errorf("creating config: %w", core.DiagsToError(diags))
}
// Map checksum
var checksumModel = &checksumModel{}
var checksumObject basetypes.ObjectValue
if imageResp.Checksum != nil {
checksumModel.Algorithm = types.StringPointerValue(imageResp.Checksum.Algorithm)
checksumModel.Digest = types.StringPointerValue(imageResp.Checksum.Digest)
checksumObject, diags = types.ObjectValue(checksumTypes, map[string]attr.Value{
"algorithm": checksumModel.Algorithm,
"digest": checksumModel.Digest,
})
} else {
checksumObject = types.ObjectNull(checksumTypes)
}
if diags.HasError() {
return fmt.Errorf("creating checksum: %w", core.DiagsToError(diags))
}
// Map labels
labels, err := iaasUtils.MapLabels(ctx, imageResp.Labels, model.Labels)
if err != nil {
return err
}
model.ImageId = types.StringValue(imageId)
model.Name = types.StringPointerValue(imageResp.Name)
model.DiskFormat = types.StringPointerValue(imageResp.DiskFormat)
model.MinDiskSize = types.Int64PointerValue(imageResp.MinDiskSize)
model.MinRAM = types.Int64PointerValue(imageResp.MinRam)
model.Protected = types.BoolPointerValue(imageResp.Protected)
model.Scope = types.StringPointerValue(imageResp.Scope)
model.Labels = labels
model.Config = configObject
model.Checksum = checksumObject
return nil
}
// imageMatchesFilter checks whether a given image matches all specified filter conditions.
// It returns true only if all non-null fields in the filter match corresponding fields in the image's config.
func imageMatchesFilter(img *iaas.Image, filter *Filter) bool {
if filter == nil {
return true
}
if img.Config == nil {
return false
}
cfg := img.Config
if !filter.OS.IsNull() &&
(cfg.OperatingSystem == nil || filter.OS.ValueString() != *cfg.OperatingSystem) {
return false
}
if !filter.Distro.IsNull() &&
(cfg.OperatingSystemDistro == nil || cfg.OperatingSystemDistro.Get() == nil ||
filter.Distro.ValueString() != *cfg.OperatingSystemDistro.Get()) {
return false
}
if !filter.Version.IsNull() &&
(cfg.OperatingSystemVersion == nil || cfg.OperatingSystemVersion.Get() == nil ||
filter.Version.ValueString() != *cfg.OperatingSystemVersion.Get()) {
return false
}
if !filter.UEFI.IsNull() &&
(cfg.Uefi == nil || filter.UEFI.ValueBool() != *cfg.Uefi) {
return false
}
if !filter.SecureBoot.IsNull() &&
(cfg.SecureBoot == nil || filter.SecureBoot.ValueBool() != *cfg.SecureBoot) {
return false
}
return true
}
// sortImagesByName sorts a slice of images by name, respecting nils and order direction.
func sortImagesByName(images []*iaas.Image, sortAscending bool) {
if len(images) <= 1 {
return
}
sort.SliceStable(images, func(i, j int) bool {
a, b := images[i].Name, images[j].Name
switch {
case a == nil && b == nil:
return false // Equal
case a == nil:
return false // Nil goes after non-nil
case b == nil:
return true // Non-nil goes before nil
case sortAscending:
return *a < *b
default:
return *a > *b
}
})
}

View file

@ -1,484 +0,0 @@
package image
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/iaas"
)
func TestMapDataSourceFields(t *testing.T) {
type args struct {
state DataSourceModel
input *iaas.Image
region string
}
tests := []struct {
description string
args args
expected DataSourceModel
isValid bool
}{
{
description: "default_values",
args: args{
state: DataSourceModel{
ProjectId: types.StringValue("pid"),
ImageId: types.StringValue("iid"),
},
input: &iaas.Image{
Id: utils.Ptr("iid"),
},
region: "eu01",
},
expected: DataSourceModel{
Id: types.StringValue("pid,eu01,iid"),
ProjectId: types.StringValue("pid"),
ImageId: types.StringValue("iid"),
Labels: types.MapNull(types.StringType),
Region: types.StringValue("eu01"),
},
isValid: true,
},
{
description: "simple_values",
args: args{
state: DataSourceModel{
ProjectId: types.StringValue("pid"),
ImageId: types.StringValue("iid"),
Region: types.StringValue("eu01"),
},
input: &iaas.Image{
Id: utils.Ptr("iid"),
Name: utils.Ptr("name"),
DiskFormat: utils.Ptr("format"),
MinDiskSize: utils.Ptr(int64(1)),
MinRam: utils.Ptr(int64(1)),
Protected: utils.Ptr(true),
Scope: utils.Ptr("scope"),
Config: &iaas.ImageConfig{
BootMenu: utils.Ptr(true),
CdromBus: iaas.NewNullableString(utils.Ptr("cdrom_bus")),
DiskBus: iaas.NewNullableString(utils.Ptr("disk_bus")),
NicModel: iaas.NewNullableString(utils.Ptr("model")),
OperatingSystem: utils.Ptr("os"),
OperatingSystemDistro: iaas.NewNullableString(utils.Ptr("os_distro")),
OperatingSystemVersion: iaas.NewNullableString(utils.Ptr("os_version")),
RescueBus: iaas.NewNullableString(utils.Ptr("rescue_bus")),
RescueDevice: iaas.NewNullableString(utils.Ptr("rescue_device")),
SecureBoot: utils.Ptr(true),
Uefi: utils.Ptr(true),
VideoModel: iaas.NewNullableString(utils.Ptr("model")),
VirtioScsi: utils.Ptr(true),
},
Checksum: &iaas.ImageChecksum{
Algorithm: utils.Ptr("algorithm"),
Digest: utils.Ptr("digest"),
},
Labels: &map[string]interface{}{
"key": "value",
},
},
region: "eu02",
},
expected: DataSourceModel{
Id: types.StringValue("pid,eu02,iid"),
ProjectId: types.StringValue("pid"),
ImageId: types.StringValue("iid"),
Name: types.StringValue("name"),
DiskFormat: types.StringValue("format"),
MinDiskSize: types.Int64Value(1),
MinRAM: types.Int64Value(1),
Protected: types.BoolValue(true),
Scope: types.StringValue("scope"),
Config: types.ObjectValueMust(configTypes, map[string]attr.Value{
"boot_menu": types.BoolValue(true),
"cdrom_bus": types.StringValue("cdrom_bus"),
"disk_bus": types.StringValue("disk_bus"),
"nic_model": types.StringValue("model"),
"operating_system": types.StringValue("os"),
"operating_system_distro": types.StringValue("os_distro"),
"operating_system_version": types.StringValue("os_version"),
"rescue_bus": types.StringValue("rescue_bus"),
"rescue_device": types.StringValue("rescue_device"),
"secure_boot": types.BoolValue(true),
"uefi": types.BoolValue(true),
"video_model": types.StringValue("model"),
"virtio_scsi": types.BoolValue(true),
}),
Checksum: types.ObjectValueMust(checksumTypes, map[string]attr.Value{
"algorithm": types.StringValue("algorithm"),
"digest": types.StringValue("digest"),
}),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
Region: types.StringValue("eu02"),
},
isValid: true,
},
{
description: "empty_labels",
args: args{
state: DataSourceModel{
ProjectId: types.StringValue("pid"),
ImageId: types.StringValue("iid"),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{}),
},
input: &iaas.Image{
Id: utils.Ptr("iid"),
},
region: "eu01",
},
expected: DataSourceModel{
Id: types.StringValue("pid,eu01,iid"),
ProjectId: types.StringValue("pid"),
ImageId: types.StringValue("iid"),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{}),
Region: types.StringValue("eu01"),
},
isValid: true,
},
{
description: "response_nil_fail",
},
{
description: "no_resource_id",
args: args{
state: DataSourceModel{
ProjectId: types.StringValue("pid"),
},
input: &iaas.Image{},
},
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
err := mapDataSourceFields(context.Background(), tt.args.input, &tt.args.state, tt.args.region)
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.args.state, tt.expected)
if diff != "" {
t.Fatalf("Data does not match: %s", diff)
}
}
})
}
}
func TestImageMatchesFilter(t *testing.T) {
testCases := []struct {
name string
img *iaas.Image
filter *Filter
expected bool
}{
{
name: "nil filter - always match",
img: &iaas.Image{Config: &iaas.ImageConfig{}},
filter: nil,
expected: true,
},
{
name: "nil config - always false",
img: &iaas.Image{Config: nil},
filter: &Filter{},
expected: false,
},
{
name: "all fields match",
img: &iaas.Image{
Config: &iaas.ImageConfig{
OperatingSystem: utils.Ptr("linux"),
OperatingSystemDistro: iaas.NewNullableString(utils.Ptr("ubuntu")),
OperatingSystemVersion: iaas.NewNullableString(utils.Ptr("22.04")),
Uefi: utils.Ptr(true),
SecureBoot: utils.Ptr(true),
},
},
filter: &Filter{
OS: types.StringValue("linux"),
Distro: types.StringValue("ubuntu"),
Version: types.StringValue("22.04"),
UEFI: types.BoolValue(true),
SecureBoot: types.BoolValue(true),
},
expected: true,
},
{
name: "OS mismatch",
img: &iaas.Image{
Config: &iaas.ImageConfig{
OperatingSystem: utils.Ptr("windows"),
},
},
filter: &Filter{
OS: types.StringValue("linux"),
},
expected: false,
},
{
name: "Distro mismatch",
img: &iaas.Image{
Config: &iaas.ImageConfig{
OperatingSystemDistro: iaas.NewNullableString(utils.Ptr("debian")),
},
},
filter: &Filter{
Distro: types.StringValue("ubuntu"),
},
expected: false,
},
{
name: "Version mismatch",
img: &iaas.Image{
Config: &iaas.ImageConfig{
OperatingSystemVersion: iaas.NewNullableString(utils.Ptr("20.04")),
},
},
filter: &Filter{
Version: types.StringValue("22.04"),
},
expected: false,
},
{
name: "UEFI mismatch",
img: &iaas.Image{
Config: &iaas.ImageConfig{
Uefi: utils.Ptr(false),
},
},
filter: &Filter{
UEFI: types.BoolValue(true),
},
expected: false,
},
{
name: "SecureBoot mismatch",
img: &iaas.Image{
Config: &iaas.ImageConfig{
SecureBoot: utils.Ptr(false),
},
},
filter: &Filter{
SecureBoot: types.BoolValue(true),
},
expected: false,
},
{
name: "SecureBoot match - true",
img: &iaas.Image{
Config: &iaas.ImageConfig{
SecureBoot: utils.Ptr(true),
},
},
filter: &Filter{
SecureBoot: types.BoolValue(true),
},
expected: true,
},
{
name: "SecureBoot match - false",
img: &iaas.Image{
Config: &iaas.ImageConfig{
SecureBoot: utils.Ptr(false),
},
},
filter: &Filter{
SecureBoot: types.BoolValue(false),
},
expected: true,
},
{
name: "SecureBoot field missing in image but required in filter",
img: &iaas.Image{
Config: &iaas.ImageConfig{
SecureBoot: nil,
},
},
filter: &Filter{
SecureBoot: types.BoolValue(true),
},
expected: false,
},
{
name: "partial filter match - only distro set and match",
img: &iaas.Image{
Config: &iaas.ImageConfig{
OperatingSystemDistro: iaas.NewNullableString(utils.Ptr("ubuntu")),
},
},
filter: &Filter{
Distro: types.StringValue("ubuntu"),
},
expected: true,
},
{
name: "partial filter match - distro mismatch",
img: &iaas.Image{
Config: &iaas.ImageConfig{
OperatingSystemDistro: iaas.NewNullableString(utils.Ptr("centos")),
},
},
filter: &Filter{
Distro: types.StringValue("ubuntu"),
},
expected: false,
},
{
name: "filter provided but attribute is null in image",
img: &iaas.Image{
Config: &iaas.ImageConfig{
OperatingSystemDistro: nil,
},
},
filter: &Filter{
Distro: types.StringValue("ubuntu"),
},
expected: false,
},
{
name: "image has valid config, but filter has null values",
img: &iaas.Image{
Config: &iaas.ImageConfig{
OperatingSystem: utils.Ptr("linux"),
OperatingSystemDistro: iaas.NewNullableString(utils.Ptr("ubuntu")),
OperatingSystemVersion: iaas.NewNullableString(utils.Ptr("22.04")),
Uefi: utils.Ptr(false),
SecureBoot: utils.Ptr(false),
},
},
filter: &Filter{
OS: types.StringNull(),
Distro: types.StringNull(),
Version: types.StringNull(),
UEFI: types.BoolNull(),
SecureBoot: types.BoolNull(),
},
expected: true,
},
{
name: "image has nil fields in config, filter expects values",
img: &iaas.Image{
Config: &iaas.ImageConfig{
OperatingSystem: nil,
OperatingSystemDistro: nil,
OperatingSystemVersion: nil,
Uefi: nil,
SecureBoot: nil,
},
},
filter: &Filter{
OS: types.StringValue("linux"),
Distro: types.StringValue("ubuntu"),
Version: types.StringValue("22.04"),
UEFI: types.BoolValue(true),
SecureBoot: types.BoolValue(true),
},
expected: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := imageMatchesFilter(tc.img, tc.filter)
if result != tc.expected {
t.Errorf("Expected match = %v, got %v", tc.expected, result)
}
})
}
}
func TestSortImagesByName(t *testing.T) {
tests := []struct {
desc string
input []*iaas.Image
ascending bool
wantSorted []string
}{
{
desc: "ascending by name",
ascending: true,
input: []*iaas.Image{
{Name: utils.Ptr("Ubuntu 22.04")},
{Name: utils.Ptr("Ubuntu 18.04")},
{Name: utils.Ptr("Ubuntu 20.04")},
},
wantSorted: []string{"Ubuntu 18.04", "Ubuntu 20.04", "Ubuntu 22.04"},
},
{
desc: "descending by name",
ascending: false,
input: []*iaas.Image{
{Name: utils.Ptr("Ubuntu 22.04")},
{Name: utils.Ptr("Ubuntu 18.04")},
{Name: utils.Ptr("Ubuntu 20.04")},
},
wantSorted: []string{"Ubuntu 22.04", "Ubuntu 20.04", "Ubuntu 18.04"},
},
{
desc: "nil names go last ascending",
ascending: true,
input: []*iaas.Image{
{Name: nil},
{Name: utils.Ptr("Ubuntu 18.04")},
{Name: nil},
{Name: utils.Ptr("Ubuntu 20.04")},
},
wantSorted: []string{"Ubuntu 18.04", "Ubuntu 20.04", "<nil>", "<nil>"},
},
{
desc: "nil names go last descending",
ascending: false,
input: []*iaas.Image{
{Name: nil},
{Name: utils.Ptr("Ubuntu 18.04")},
{Name: utils.Ptr("Ubuntu 20.04")},
{Name: nil},
},
wantSorted: []string{"Ubuntu 20.04", "Ubuntu 18.04", "<nil>", "<nil>"},
},
{
desc: "empty slice",
ascending: true,
input: []*iaas.Image{},
wantSorted: []string{},
},
{
desc: "single element slice",
ascending: true,
input: []*iaas.Image{
{Name: utils.Ptr("Ubuntu 22.04")},
},
wantSorted: []string{"Ubuntu 22.04"},
},
}
for _, tc := range tests {
t.Run(tc.desc, func(t *testing.T) {
sortImagesByName(tc.input, tc.ascending)
gotNames := make([]string, len(tc.input))
for i, img := range tc.input {
if img.Name == nil {
gotNames[i] = "<nil>"
} else {
gotNames[i] = *img.Name
}
}
if diff := cmp.Diff(tc.wantSorted, gotNames); diff != "" {
t.Fatalf("incorrect sort order (-want +got):\n%s", diff)
}
})
}
}

View file

@ -1,24 +0,0 @@
package keypair
const exampleUsageWithServer = `
### Usage with server` + "\n" +
"```terraform" + `
resource "stackit_key_pair" "keypair" {
name = "example-key-pair"
public_key = chomp(file("path/to/id_rsa.pub"))
}
resource "stackit_server" "example-server" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
name = "example-server"
boot_volume = {
size = 64
source_type = "image"
source_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
availability_zone = "eu01-1"
machine_type = "g2i.1"
keypair_name = "example-key-pair"
}
` + "\n```"

View file

@ -1,129 +0,0 @@
package keypair
import (
"context"
"fmt"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
iaasUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/utils"
"github.com/hashicorp/terraform-plugin-framework/datasource"
"github.com/hashicorp/terraform-plugin-framework/datasource/schema"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
)
// Ensure the implementation satisfies the expected interfaces.
var (
_ datasource.DataSource = &keyPairDataSource{}
)
// NewKeyPairDataSource is a helper function to simplify the provider implementation.
func NewKeyPairDataSource() datasource.DataSource {
return &keyPairDataSource{}
}
// keyPairDataSource is the data source implementation.
type keyPairDataSource struct {
client *iaas.APIClient
}
// Metadata returns the data source type name.
func (d *keyPairDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_key_pair"
}
func (d *keyPairDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
providerData, ok := conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics)
if !ok {
return
}
apiClient := iaasUtils.ConfigureClient(ctx, &providerData, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}
d.client = apiClient
tflog.Info(ctx, "iaas client configured")
}
// Schema defines the schema for the resource.
func (d *keyPairDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
description := "Key pair resource schema. Must have a `region` specified in the provider configuration."
resp.Schema = schema.Schema{
MarkdownDescription: description,
Description: description,
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: "Terraform's internal resource ID. It takes the value of the key pair \"`name`\".",
Computed: true,
},
"name": schema.StringAttribute{
Description: "The name of the SSH key pair.",
Required: true,
},
"public_key": schema.StringAttribute{
Description: "A string representation of the public SSH key. E.g., `ssh-rsa <key_data>` or `ssh-ed25519 <key-data>`.",
Computed: true,
},
"fingerprint": schema.StringAttribute{
Description: "The fingerprint of the public SSH key.",
Computed: true,
},
"labels": schema.MapAttribute{
Description: "Labels are key-value string pairs which can be attached to a resource container.",
ElementType: types.StringType,
Computed: true,
},
},
}
}
// Read refreshes the Terraform state with the latest data.
func (d *keyPairDataSource) 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
}
name := model.Name.ValueString()
ctx = core.InitProviderContext(ctx)
ctx = tflog.SetField(ctx, "name", name)
keypairResp, err := d.client.GetKeyPair(ctx, name).Execute()
if err != nil {
utils.LogError(
ctx,
&resp.Diagnostics,
err,
"Reading key pair",
fmt.Sprintf("Key pair with name %q does not exist.", name),
nil,
)
resp.State.RemoveResource(ctx)
return
}
ctx = core.LogResponse(ctx)
// Map response body to schema
err = mapFields(ctx, keypairResp, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading key pair", 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, "Key pair read")
}

View file

@ -1,387 +0,0 @@
package keypair
import (
"context"
"fmt"
"net/http"
"strings"
iaasUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/utils"
"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/types"
"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/stackitcloud/stackit-sdk-go/core/oapierror"
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
)
// Ensure the implementation satisfies the expected interfaces.
var (
_ resource.Resource = &keyPairResource{}
_ resource.ResourceWithConfigure = &keyPairResource{}
_ resource.ResourceWithImportState = &keyPairResource{}
)
type Model struct {
Id types.String `tfsdk:"id"` // needed by TF
Name types.String `tfsdk:"name"`
PublicKey types.String `tfsdk:"public_key"`
Fingerprint types.String `tfsdk:"fingerprint"`
Labels types.Map `tfsdk:"labels"`
}
// NewKeyPairResource is a helper function to simplify the provider implementation.
func NewKeyPairResource() resource.Resource {
return &keyPairResource{}
}
// keyPairResource is the resource implementation.
type keyPairResource struct {
client *iaas.APIClient
}
// Metadata returns the resource type name.
func (r *keyPairResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_key_pair"
}
// Configure adds the provider configured client to the resource.
func (r *keyPairResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
providerData, ok := conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics)
if !ok {
return
}
apiClient := iaasUtils.ConfigureClient(ctx, &providerData, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}
r.client = apiClient
tflog.Info(ctx, "iaas client configured")
}
// Schema defines the schema for the resource.
func (r *keyPairResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
description := "Key pair resource schema. Must have a `region` specified in the provider configuration. Allows uploading an SSH public key to be used for server authentication."
resp.Schema = schema.Schema{
MarkdownDescription: description + "\n\n" + exampleUsageWithServer,
Description: description,
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: "Terraform's internal resource ID. It takes the value of the key pair \"`name`\".",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"name": schema.StringAttribute{
Description: "The name of the SSH key pair.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
stringplanmodifier.UseStateForUnknown(),
},
},
"public_key": schema.StringAttribute{
Description: "A string representation of the public SSH key. E.g., `ssh-rsa <key_data>` or `ssh-ed25519 <key-data>`.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"fingerprint": schema.StringAttribute{
Description: "The fingerprint of the public SSH key.",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"labels": schema.MapAttribute{
Description: "Labels are key-value string pairs which can be attached to a resource container.",
ElementType: types.StringType,
Optional: true,
},
},
}
}
// ModifyPlan will be called in the Plan phase.
// It will check if the plan contains a change that requires replacement. If yes, it will show a warning to the user.
func (r *keyPairResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { // nolint:gocritic // function signature required by Terraform
// If the state is empty we are creating a new resource
// If the plan is empty we are deleting the resource
// In both cases we don't need to check for replacement
if req.Plan.Raw.IsNull() || req.State.Raw.IsNull() {
return
}
var planModel Model
diags := req.Plan.Get(ctx, &planModel)
resp.Diagnostics.Append(diags...)
var stateModel Model
diags = req.State.Get(ctx, &stateModel)
resp.Diagnostics.Append(diags...)
if planModel.PublicKey.ValueString() != stateModel.PublicKey.ValueString() {
core.LogAndAddWarning(ctx, &resp.Diagnostics, "Key pair public key change", "Changing the public key will trigger a replacement of the key pair resource. The new key pair will not be valid to access servers on which the old key was used, as the key is only registered during server creation.")
}
}
// Create creates the resource and sets the initial Terraform state.
func (r *keyPairResource) 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
}
name := model.Name.ValueString()
ctx = core.InitProviderContext(ctx)
ctx = tflog.SetField(ctx, "name", name)
// Generate API request body from model
payload, err := toCreatePayload(ctx, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating key pair", fmt.Sprintf("Creating API payload: %v", err))
return
}
// Create new key pair
keyPair, err := r.client.CreateKeyPair(ctx).CreateKeyPairPayload(*payload).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating key pair", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
// Map response body to schema
err = mapFields(ctx, keyPair, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating key pair", 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, "Key pair created")
}
// Read refreshes the Terraform state with the latest data.
func (r *keyPairResource) 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
}
name := model.Name.ValueString()
ctx = core.InitProviderContext(ctx)
ctx = tflog.SetField(ctx, "name", name)
keyPairResp, err := r.client.GetKeyPair(ctx, name).Execute()
if err != nil {
oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped
if ok && oapiErr.StatusCode == http.StatusNotFound {
resp.State.RemoveResource(ctx)
return
}
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading key pair", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
// Map response body to schema
err = mapFields(ctx, keyPairResp, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading key pair", 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, "Key pair read")
}
// Update updates the resource and sets the updated Terraform state on success.
func (r *keyPairResource) 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
}
name := model.Name.ValueString()
ctx = core.InitProviderContext(ctx)
ctx = tflog.SetField(ctx, "name", name)
// Retrieve values from state
var stateModel Model
diags = req.State.Get(ctx, &stateModel)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
// Generate API request body from model
payload, err := toUpdatePayload(ctx, &model, stateModel.Labels)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating key pair", fmt.Sprintf("Creating API payload: %v", err))
return
}
// Update existing key pair
updatedKeyPair, err := r.client.UpdateKeyPair(ctx, name).UpdateKeyPairPayload(*payload).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating key pair", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
err = mapFields(ctx, updatedKeyPair, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating key pair", 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, "key pair updated")
}
// Delete deletes the resource and removes the Terraform state on success.
func (r *keyPairResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve values from state
var model Model
diags := req.State.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
name := model.Name.ValueString()
ctx = core.InitProviderContext(ctx)
ctx = tflog.SetField(ctx, "name", name)
// Delete existing key pair
err := r.client.DeleteKeyPair(ctx, name).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting key pair", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
tflog.Info(ctx, "Key pair deleted")
}
// ImportState imports a resource into the Terraform state on success.
// The expected format of the resource import identifier is: project_id,key_pair_id
func (r *keyPairResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
idParts := strings.Split(req.ID, core.Separator)
if len(idParts) != 1 || idParts[0] == "" {
core.LogAndAddError(ctx, &resp.Diagnostics,
"Error importing key pair",
fmt.Sprintf("Expected import identifier with format: [name] Got: %q", req.ID),
)
return
}
name := idParts[0]
ctx = tflog.SetField(ctx, "name", name)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("name"), name)...)
tflog.Info(ctx, "Key pair state imported")
}
func mapFields(ctx context.Context, keyPairResp *iaas.Keypair, model *Model) error {
if keyPairResp == nil {
return fmt.Errorf("response input is nil")
}
if model == nil {
return fmt.Errorf("model input is nil")
}
var name string
if model.Name.ValueString() != "" {
name = model.Name.ValueString()
} else if keyPairResp.Name != nil {
name = *keyPairResp.Name
} else {
return fmt.Errorf("key pair name not present")
}
model.Id = types.StringValue(name)
model.PublicKey = types.StringPointerValue(keyPairResp.PublicKey)
model.Fingerprint = types.StringPointerValue(keyPairResp.Fingerprint)
var err error
model.Labels, err = iaasUtils.MapLabels(ctx, keyPairResp.Labels, model.Labels)
if err != nil {
return err
}
return nil
}
func toCreatePayload(ctx context.Context, model *Model) (*iaas.CreateKeyPairPayload, error) {
if model == nil {
return nil, fmt.Errorf("nil model")
}
labels, err := conversion.ToStringInterfaceMap(ctx, model.Labels)
if err != nil {
return nil, fmt.Errorf("converting to Go map: %w", err)
}
return &iaas.CreateKeyPairPayload{
Name: conversion.StringValueToPointer(model.Name),
PublicKey: conversion.StringValueToPointer(model.PublicKey),
Labels: &labels,
}, nil
}
func toUpdatePayload(ctx context.Context, model *Model, currentLabels types.Map) (*iaas.UpdateKeyPairPayload, error) {
if model == nil {
return nil, fmt.Errorf("nil model")
}
labels, err := conversion.ToJSONMapPartialUpdatePayload(ctx, currentLabels, model.Labels)
if err != nil {
return nil, fmt.Errorf("converting to Go map: %w", err)
}
return &iaas.UpdateKeyPairPayload{
Labels: &labels,
}, nil
}

View file

@ -1,211 +0,0 @@
package keypair
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/iaas"
)
func TestMapFields(t *testing.T) {
tests := []struct {
description string
state Model
input *iaas.Keypair
expected Model
isValid bool
}{
{
"default_values",
Model{
Name: types.StringValue("name"),
},
&iaas.Keypair{
Name: utils.Ptr("name"),
},
Model{
Id: types.StringValue("name"),
Name: types.StringValue("name"),
PublicKey: types.StringNull(),
Fingerprint: types.StringNull(),
Labels: types.MapNull(types.StringType),
},
true,
},
{
"simple_values",
Model{
Name: types.StringValue("name"),
},
&iaas.Keypair{
Name: utils.Ptr("name"),
PublicKey: utils.Ptr("public_key"),
Fingerprint: utils.Ptr("fingerprint"),
Labels: &map[string]interface{}{
"key": "value",
},
},
Model{
Id: types.StringValue("name"),
Name: types.StringValue("name"),
PublicKey: types.StringValue("public_key"),
Fingerprint: types.StringValue("fingerprint"),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
},
true,
},
{
"empty_labels",
Model{
Name: types.StringValue("name"),
},
&iaas.Keypair{
Name: utils.Ptr("name"),
PublicKey: utils.Ptr("public_key"),
Fingerprint: utils.Ptr("fingerprint"),
Labels: &map[string]interface{}{},
},
Model{
Id: types.StringValue("name"),
Name: types.StringValue("name"),
PublicKey: types.StringValue("public_key"),
Fingerprint: types.StringValue("fingerprint"),
Labels: types.MapNull(types.StringType),
},
true,
},
{
"response_nil_fail",
Model{},
nil,
Model{},
false,
},
{
"no_resource_id",
Model{},
&iaas.Keypair{
PublicKey: utils.Ptr("public_key"),
Fingerprint: utils.Ptr("fingerprint"),
Labels: &map[string]interface{}{},
},
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 *iaas.CreateKeyPairPayload
isValid bool
}{
{
"default_ok",
&Model{
Name: types.StringValue("name"),
PublicKey: types.StringValue("public_key"),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key1": types.StringValue("value1"),
"key2": types.StringValue("value2"),
}),
},
&iaas.CreateKeyPairPayload{
Name: utils.Ptr("name"),
PublicKey: utils.Ptr("public_key"),
Labels: &map[string]interface{}{
"key1": "value1",
"key2": "value2",
},
},
true,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
output, err := toCreatePayload(context.Background(), 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, cmp.AllowUnexported(iaas.NullableString{}))
if diff != "" {
t.Fatalf("Data does not match: %s", diff)
}
}
})
}
}
func TestToUpdatePayload(t *testing.T) {
tests := []struct {
description string
input *Model
expected *iaas.UpdateKeyPairPayload
isValid bool
}{
{
"default_ok",
&Model{
Name: types.StringValue("name"),
PublicKey: types.StringValue("public_key"),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key1": types.StringValue("value1"),
"key2": types.StringValue("value2"),
}),
},
&iaas.UpdateKeyPairPayload{
Labels: &map[string]interface{}{
"key1": "value1",
"key2": "value2",
},
},
true,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
output, err := toUpdatePayload(context.Background(), tt.input, types.MapNull(types.StringType))
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, cmp.AllowUnexported(iaas.NullableString{}))
if diff != "" {
t.Fatalf("Data does not match: %s", diff)
}
}
})
}
}

View file

@ -1,263 +0,0 @@
package machineType
import (
"context"
"fmt"
"net/http"
"sort"
"strings"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate"
"github.com/hashicorp/terraform-plugin-framework/datasource"
"github.com/hashicorp/terraform-plugin-framework/datasource/schema"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
"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"
iaasUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/utils"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
)
// Ensure the implementation satisfies the expected interfaces.
var _ datasource.DataSource = &machineTypeDataSource{}
type DataSourceModel struct {
Id types.String `tfsdk:"id"` // required by Terraform to identify state
ProjectId types.String `tfsdk:"project_id"`
Region types.String `tfsdk:"region"`
SortAscending types.Bool `tfsdk:"sort_ascending"`
Filter types.String `tfsdk:"filter"`
Description types.String `tfsdk:"description"`
Disk types.Int64 `tfsdk:"disk"`
ExtraSpecs types.Map `tfsdk:"extra_specs"`
Name types.String `tfsdk:"name"`
Ram types.Int64 `tfsdk:"ram"`
Vcpus types.Int64 `tfsdk:"vcpus"`
}
// NewMachineTypeDataSource instantiates the data source
func NewMachineTypeDataSource() datasource.DataSource {
return &machineTypeDataSource{}
}
type machineTypeDataSource struct {
client *iaas.APIClient
providerData core.ProviderData
}
func (d *machineTypeDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_machine_type"
}
func (d *machineTypeDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
var ok bool
d.providerData, ok = conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics)
if !ok {
return
}
features.CheckBetaResourcesEnabled(ctx, &d.providerData, &resp.Diagnostics, "stackit_machine_type", "datasource")
if resp.Diagnostics.HasError() {
return
}
client := iaasUtils.ConfigureClient(ctx, &d.providerData, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}
d.client = client
tflog.Info(ctx, "IAAS client configured")
}
func (d *machineTypeDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: features.AddBetaDescription("Machine type data source.", core.Datasource),
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: "Terraform's internal resource ID. It is structured as \"`project_id`,`region`,`image_id`\".",
Computed: true,
},
"project_id": schema.StringAttribute{
Description: "STACKIT Project ID.",
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"region": schema.StringAttribute{
Description: "The resource region. If not defined, the provider region is used.",
// the region cannot be found, so it has to be passed
Optional: true,
},
"sort_ascending": schema.BoolAttribute{
Description: "Sort machine types by name ascending (`true`) or descending (`false`). Defaults to `false`",
Optional: true,
},
"filter": schema.StringAttribute{
Description: "Expr-lang filter for filtering machine types.\n\n" +
"Examples:\n" +
"- vcpus == 2\n" +
"- ram >= 2048\n" +
"- extraSpecs.cpu == \"intel-icelake-generic\"\n" +
"- extraSpecs.cpu == \"intel-icelake-generic\" && vcpus == 2\n\n" +
"Syntax reference: https://expr-lang.org/docs/language-definition\n\n" +
"You can also list available machine-types using the [STACKIT CLI](https://github.com/stackitcloud/stackit-cli):\n\n" +
"```bash\n" +
"stackit server machine-type list\n" +
"```",
Required: true,
},
"description": schema.StringAttribute{
Description: "Machine type description.",
Computed: true,
},
"disk": schema.Int64Attribute{
Description: "Disk size in GB.",
Computed: true,
},
"extra_specs": schema.MapAttribute{
Description: "Extra specs (e.g., CPU type, overcommit ratio).",
ElementType: types.StringType,
Computed: true,
},
"name": schema.StringAttribute{
Description: "Name of the machine type (e.g. 's1.2').",
Computed: true,
},
"ram": schema.Int64Attribute{
Description: "RAM size in MB.",
Computed: true,
},
"vcpus": schema.Int64Attribute{
Description: "Number of vCPUs.",
Computed: true,
},
},
}
}
func (d *machineTypeDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform
var model DataSourceModel
resp.Diagnostics.Append(req.Config.Get(ctx, &model)...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
region := d.providerData.GetRegionWithOverride(model.Region)
sortAscending := model.SortAscending.ValueBool()
ctx = core.InitProviderContext(ctx)
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "region", region)
ctx = tflog.SetField(ctx, "filter_is_null", model.Filter.IsNull())
ctx = tflog.SetField(ctx, "filter_is_unknown", model.Filter.IsUnknown())
listMachineTypeReq := d.client.ListMachineTypes(ctx, projectId, region)
if !model.Filter.IsNull() && !model.Filter.IsUnknown() && strings.TrimSpace(model.Filter.ValueString()) != "" {
listMachineTypeReq = listMachineTypeReq.Filter(strings.TrimSpace(model.Filter.ValueString()))
}
apiResp, err := listMachineTypeReq.Execute()
if err != nil {
utils.LogError(ctx, &resp.Diagnostics, err, "Failed to read machine types",
fmt.Sprintf("Unable to retrieve machine types for project %q %s.", projectId, err),
map[int]string{
http.StatusForbidden: fmt.Sprintf("Access denied to project %q.", projectId),
},
)
resp.State.RemoveResource(ctx)
return
}
ctx = core.LogResponse(ctx)
if apiResp.Items == nil || len(*apiResp.Items) == 0 {
core.LogAndAddWarning(ctx, &resp.Diagnostics, "No machine types found", "No matching machine types.")
return
}
// Convert items to []*iaas.MachineType
machineTypes := make([]*iaas.MachineType, len(*apiResp.Items))
for i := range *apiResp.Items {
machineTypes[i] = &(*apiResp.Items)[i]
}
sorted, err := sortMachineTypeByName(machineTypes, sortAscending)
if err != nil {
core.LogAndAddWarning(ctx, &resp.Diagnostics, "Unable to sort", err.Error())
return
}
if err := mapDataSourceFields(ctx, sorted[0], &model, region); err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading machine type", fmt.Sprintf("Failed to translate API response: %v", err))
return
}
resp.Diagnostics.Append(resp.State.Set(ctx, model)...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "Successfully read machine type")
}
func mapDataSourceFields(ctx context.Context, machineType *iaas.MachineType, model *DataSourceModel, region string) error {
if machineType == nil || model == nil {
return fmt.Errorf("nil input provided")
}
if machineType.Name == nil || *machineType.Name == "" {
return fmt.Errorf("machine type name is missing")
}
model.Id = utils.BuildInternalTerraformId(model.ProjectId.ValueString(), region, *machineType.Name)
model.Region = types.StringValue(region)
model.Name = types.StringPointerValue(machineType.Name)
model.Description = types.StringPointerValue(machineType.Description)
model.Disk = types.Int64PointerValue(machineType.Disk)
model.Ram = types.Int64PointerValue(machineType.Ram)
model.Vcpus = types.Int64PointerValue(machineType.Vcpus)
extra := types.MapNull(types.StringType)
if machineType.ExtraSpecs != nil && len(*machineType.ExtraSpecs) > 0 {
var diags diag.Diagnostics
extra, diags = types.MapValueFrom(ctx, types.StringType, *machineType.ExtraSpecs)
if diags.HasError() {
return fmt.Errorf("converting extraspecs: %w", core.DiagsToError(diags))
}
}
model.ExtraSpecs = extra
return nil
}
func sortMachineTypeByName(input []*iaas.MachineType, ascending bool) ([]*iaas.MachineType, error) {
if input == nil {
return nil, fmt.Errorf("input slice is nil")
}
// Filter out nil or missing name
var filtered []*iaas.MachineType
for _, m := range input {
if m != nil && m.Name != nil {
filtered = append(filtered, m)
}
}
sort.SliceStable(filtered, func(i, j int) bool {
if ascending {
return *filtered[i].Name < *filtered[j].Name
}
return *filtered[i].Name > *filtered[j].Name
})
return filtered, nil
}

View file

@ -1,263 +0,0 @@
package machineType
import (
"context"
"strings"
"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/iaas"
)
func TestMapDataSourceFields(t *testing.T) {
type args struct {
initial DataSourceModel
input *iaas.MachineType
region string
}
tests := []struct {
name string
args args
expected DataSourceModel
expectError bool
}{
{
name: "valid simple values",
args: args{
initial: DataSourceModel{
ProjectId: types.StringValue("pid"),
},
input: &iaas.MachineType{
Name: utils.Ptr("s1.2"),
Description: utils.Ptr("general-purpose small"),
Disk: utils.Ptr(int64(20)),
Ram: utils.Ptr(int64(2048)),
Vcpus: utils.Ptr(int64(2)),
ExtraSpecs: &map[string]interface{}{
"cpu": "amd-epycrome-7702",
"overcommit": "1",
"environment": "general",
},
},
region: "eu01",
},
expected: DataSourceModel{
Id: types.StringValue("pid,eu01,s1.2"),
ProjectId: types.StringValue("pid"),
Name: types.StringValue("s1.2"),
Description: types.StringValue("general-purpose small"),
Disk: types.Int64Value(20),
Ram: types.Int64Value(2048),
Vcpus: types.Int64Value(2),
ExtraSpecs: types.MapValueMust(types.StringType, map[string]attr.Value{
"cpu": types.StringValue("amd-epycrome-7702"),
"overcommit": types.StringValue("1"),
"environment": types.StringValue("general"),
}),
Region: types.StringValue("eu01"),
},
expectError: false,
},
{
name: "missing name should fail",
args: args{
initial: DataSourceModel{
ProjectId: types.StringValue("pid-456"),
},
input: &iaas.MachineType{
Description: utils.Ptr("gp-medium"),
},
},
expected: DataSourceModel{},
expectError: true,
},
{
name: "nil machineType should fail",
args: args{
initial: DataSourceModel{},
input: nil,
},
expected: DataSourceModel{},
expectError: true,
},
{
name: "empty extraSpecs should return null map",
args: args{
initial: DataSourceModel{
ProjectId: types.StringValue("pid-789"),
},
input: &iaas.MachineType{
Name: utils.Ptr("m1.noextras"),
Description: utils.Ptr("no extras"),
Disk: utils.Ptr(int64(10)),
Ram: utils.Ptr(int64(1024)),
Vcpus: utils.Ptr(int64(1)),
ExtraSpecs: &map[string]interface{}{},
},
region: "eu01",
},
expected: DataSourceModel{
Id: types.StringValue("pid-789,eu01,m1.noextras"),
ProjectId: types.StringValue("pid-789"),
Name: types.StringValue("m1.noextras"),
Description: types.StringValue("no extras"),
Disk: types.Int64Value(10),
Ram: types.Int64Value(1024),
Vcpus: types.Int64Value(1),
ExtraSpecs: types.MapNull(types.StringType),
Region: types.StringValue("eu01"),
},
expectError: false,
},
{
name: "nil extrasSpecs should return null map",
args: args{
initial: DataSourceModel{
ProjectId: types.StringValue("pid-987"),
},
input: &iaas.MachineType{
Name: utils.Ptr("g1.nil"),
Description: utils.Ptr("missing extras"),
Disk: utils.Ptr(int64(40)),
Ram: utils.Ptr(int64(8096)),
Vcpus: utils.Ptr(int64(4)),
ExtraSpecs: nil,
},
region: "eu01",
},
expected: DataSourceModel{
Id: types.StringValue("pid-987,eu01,g1.nil"),
ProjectId: types.StringValue("pid-987"),
Name: types.StringValue("g1.nil"),
Description: types.StringValue("missing extras"),
Disk: types.Int64Value(40),
Ram: types.Int64Value(8096),
Vcpus: types.Int64Value(4),
ExtraSpecs: types.MapNull(types.StringType),
Region: types.StringValue("eu01"),
},
expectError: false,
},
{
name: "invalid extraSpecs with non-string values",
args: args{
initial: DataSourceModel{
ProjectId: types.StringValue("test-err"),
},
input: &iaas.MachineType{
Name: utils.Ptr("invalid"),
Description: utils.Ptr("bad map"),
Disk: utils.Ptr(int64(10)),
Ram: utils.Ptr(int64(4096)),
Vcpus: utils.Ptr(int64(2)),
ExtraSpecs: &map[string]interface{}{
"cpu": "intel",
"burst": true, // not a string
"gen": 8, // not a string
},
},
},
expected: DataSourceModel{},
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := mapDataSourceFields(context.Background(), tt.args.input, &tt.args.initial, tt.args.region)
if tt.expectError {
if err == nil {
t.Errorf("expected error but got none")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
diff := cmp.Diff(tt.expected, tt.args.initial)
if diff != "" {
t.Errorf("unexpected diff (-want +got):\n%s", diff)
}
// Extra sanity check for proper ID format
if id := tt.args.initial.Id.ValueString(); !strings.HasPrefix(id, tt.args.initial.ProjectId.ValueString()+",") {
t.Errorf("unexpected ID format: got %q", id)
}
})
}
}
func TestSortMachineTypeByName(t *testing.T) {
tests := []struct {
name string
input []*iaas.MachineType
ascending bool
expected []string
expectError bool
}{
{
name: "ascending order",
input: []*iaas.MachineType{{Name: utils.Ptr("zeta")}, {Name: utils.Ptr("alpha")}, {Name: utils.Ptr("gamma")}},
ascending: true,
expected: []string{"alpha", "gamma", "zeta"},
},
{
name: "descending order",
input: []*iaas.MachineType{{Name: utils.Ptr("zeta")}, {Name: utils.Ptr("alpha")}, {Name: utils.Ptr("gamma")}},
ascending: false,
expected: []string{"zeta", "gamma", "alpha"},
},
{
name: "handles nil names",
input: []*iaas.MachineType{{Name: utils.Ptr("beta")}, nil, {Name: nil}, {Name: utils.Ptr("alpha")}},
ascending: true,
expected: []string{"alpha", "beta"},
},
{
name: "empty input",
input: []*iaas.MachineType{},
ascending: true,
expected: nil,
expectError: false,
},
{
name: "nil input",
input: nil,
ascending: true,
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sorted, err := sortMachineTypeByName(tt.input, tt.ascending)
if tt.expectError {
if err == nil {
t.Errorf("expected error but got none")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var result []string
for _, mt := range sorted {
if mt.Name != nil {
result = append(result, *mt.Name)
}
}
if diff := cmp.Diff(tt.expected, result); diff != "" {
t.Errorf("unexpected sorted order (-want +got):\n%s", diff)
}
})
}
}

View file

@ -1,402 +0,0 @@
package network
import (
"context"
"fmt"
"net"
"net/http"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
"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/iaas"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
iaasUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/utils"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate"
)
// Ensure the implementation satisfies the expected interfaces.
var (
_ datasource.DataSource = &networkDataSource{}
)
type DataSourceModel struct {
Id types.String `tfsdk:"id"` // needed by TF
ProjectId types.String `tfsdk:"project_id"`
NetworkId types.String `tfsdk:"network_id"`
Name types.String `tfsdk:"name"`
Nameservers types.List `tfsdk:"nameservers"`
IPv4Gateway types.String `tfsdk:"ipv4_gateway"`
IPv4Nameservers types.List `tfsdk:"ipv4_nameservers"`
IPv4Prefix types.String `tfsdk:"ipv4_prefix"`
IPv4PrefixLength types.Int64 `tfsdk:"ipv4_prefix_length"`
Prefixes types.List `tfsdk:"prefixes"`
IPv4Prefixes types.List `tfsdk:"ipv4_prefixes"`
IPv6Gateway types.String `tfsdk:"ipv6_gateway"`
IPv6Nameservers types.List `tfsdk:"ipv6_nameservers"`
IPv6Prefix types.String `tfsdk:"ipv6_prefix"`
IPv6PrefixLength types.Int64 `tfsdk:"ipv6_prefix_length"`
IPv6Prefixes types.List `tfsdk:"ipv6_prefixes"`
PublicIP types.String `tfsdk:"public_ip"`
Labels types.Map `tfsdk:"labels"`
Routed types.Bool `tfsdk:"routed"`
Region types.String `tfsdk:"region"`
RoutingTableID types.String `tfsdk:"routing_table_id"`
}
// NewNetworkDataSource is a helper function to simplify the provider implementation.
func NewNetworkDataSource() datasource.DataSource {
return &networkDataSource{}
}
// networkDataSource is the data source implementation.
type networkDataSource struct {
client *iaas.APIClient
providerData core.ProviderData
}
// Metadata returns the data source type name.
func (d *networkDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_network"
}
func (d *networkDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
var ok bool
d.providerData, ok = conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics)
if !ok {
return
}
apiClient := iaasUtils.ConfigureClient(ctx, &d.providerData, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}
d.client = apiClient
tflog.Info(ctx, "IaaS client configured")
}
// Schema defines the schema for the data source.
func (d *networkDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
resp.Schema = schema.Schema{
Description: "Network resource schema. Must have a `region` specified in the provider configuration.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: "Terraform's internal resource ID. It is structured as \"`project_id`,`network_id`\".",
Computed: true,
},
"project_id": schema.StringAttribute{
Description: "STACKIT project ID to which the network is associated.",
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"network_id": schema.StringAttribute{
Description: "The network ID.",
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"name": schema.StringAttribute{
Description: "The name of the network.",
Computed: true,
Validators: []validator.String{
stringvalidator.LengthAtLeast(1),
stringvalidator.LengthAtMost(63),
},
},
"nameservers": schema.ListAttribute{
Description: "The nameservers of the network. This field is deprecated and will be removed soon, use `ipv4_nameservers` to configure the nameservers for IPv4.",
DeprecationMessage: "Use `ipv4_nameservers` to configure the nameservers for IPv4.",
Computed: true,
ElementType: types.StringType,
},
"ipv4_gateway": schema.StringAttribute{
Description: "The IPv4 gateway of a network. If not specified, the first IP of the network will be assigned as the gateway.",
Computed: true,
},
"ipv4_nameservers": schema.ListAttribute{
Description: "The IPv4 nameservers of the network.",
Computed: true,
ElementType: types.StringType,
},
"ipv4_prefix": schema.StringAttribute{
Description: "The IPv4 prefix of the network (CIDR).",
DeprecationMessage: "The API supports reading multiple prefixes. So using the attribute 'ipv4_prefixes` should be preferred. This attribute will be populated with the first element from the list",
Computed: true,
},
"ipv4_prefix_length": schema.Int64Attribute{
Description: "The IPv4 prefix length of the network.",
Computed: true,
},
"prefixes": schema.ListAttribute{
Description: "The prefixes of the network. This field is deprecated and will be removed soon, use `ipv4_prefixes` to read the prefixes of the IPv4 networks.",
DeprecationMessage: "Use `ipv4_prefixes` to read the prefixes of the IPv4 networks.",
Computed: true,
ElementType: types.StringType,
},
"ipv4_prefixes": schema.ListAttribute{
Description: "The IPv4 prefixes of the network.",
Computed: true,
ElementType: types.StringType,
},
"ipv6_gateway": schema.StringAttribute{
Description: "The IPv6 gateway of a network. If not specified, the first IP of the network will be assigned as the gateway.",
Computed: true,
},
"ipv6_nameservers": schema.ListAttribute{
Description: "The IPv6 nameservers of the network.",
Computed: true,
ElementType: types.StringType,
},
"ipv6_prefix": schema.StringAttribute{
Description: "The IPv6 prefix of the network (CIDR).",
DeprecationMessage: "The API supports reading multiple prefixes. So using the attribute 'ipv6_prefixes` should be preferred. This attribute will be populated with the first element from the list",
Computed: true,
},
"ipv6_prefix_length": schema.Int64Attribute{
Description: "The IPv6 prefix length of the network.",
Computed: true,
},
"ipv6_prefixes": schema.ListAttribute{
Description: "The IPv6 prefixes of the network.",
Computed: true,
ElementType: types.StringType,
},
"public_ip": schema.StringAttribute{
Description: "The public IP of the network.",
Computed: true,
},
"labels": schema.MapAttribute{
Description: "Labels are key-value string pairs which can be attached to a resource container",
ElementType: types.StringType,
Computed: true,
},
"routed": schema.BoolAttribute{
Description: "Shows if the network is routed and therefore accessible from other networks.",
Computed: true,
},
"region": schema.StringAttribute{
// the region cannot be found, so it has to be passed
Optional: true,
Description: "Can only be used when experimental \"network\" is set. This is likely going to undergo significant changes or be removed in the future.\nThe resource region. If not defined, the provider region is used.",
},
"routing_table_id": schema.StringAttribute{
Description: "Can only be used when experimental \"network\" is set. This is likely going to undergo significant changes or be removed in the future. Use it at your own discretion.\nThe ID of the routing table associated with the network.",
Computed: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
},
}
}
// Read refreshes the Terraform state with the latest data.
func (d *networkDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform
var model DataSourceModel
diags := req.Config.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
networkId := model.NetworkId.ValueString()
region := d.providerData.GetRegionWithOverride(model.Region)
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "network_id", networkId)
networkResp, err := d.client.GetNetwork(ctx, projectId, region, networkId).Execute()
if err != nil {
utils.LogError(
ctx,
&resp.Diagnostics,
err,
"Reading network",
fmt.Sprintf("Network with ID %q does not exist in project %q.", networkId, projectId),
map[int]string{
http.StatusForbidden: fmt.Sprintf("Project with ID %q not found or forbidden access", projectId),
},
)
resp.State.RemoveResource(ctx)
return
}
err = mapDataSourceFields(ctx, networkResp, &model, region)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading network", 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, "Network read")
}
func mapDataSourceFields(ctx context.Context, networkResp *iaas.Network, model *DataSourceModel, region string) error {
if networkResp == nil {
return fmt.Errorf("response input is nil")
}
if model == nil {
return fmt.Errorf("model input is nil")
}
var networkId string
if model.NetworkId.ValueString() != "" {
networkId = model.NetworkId.ValueString()
} else if networkResp.Id != nil {
networkId = *networkResp.Id
} else {
return fmt.Errorf("network id not present")
}
model.Id = utils.BuildInternalTerraformId(model.ProjectId.ValueString(), region, networkId)
labels, err := iaasUtils.MapLabels(ctx, networkResp.Labels, model.Labels)
if err != nil {
return err
}
// IPv4
if networkResp.Ipv4 == nil || networkResp.Ipv4.Nameservers == nil {
model.Nameservers = types.ListNull(types.StringType)
model.IPv4Nameservers = types.ListNull(types.StringType)
} else {
respNameservers := *networkResp.Ipv4.Nameservers
modelNameservers, err := utils.ListValuetoStringSlice(model.Nameservers)
modelIPv4Nameservers, errIpv4 := utils.ListValuetoStringSlice(model.IPv4Nameservers)
if err != nil {
return fmt.Errorf("get current network nameservers from model: %w", err)
}
if errIpv4 != nil {
return fmt.Errorf("get current IPv4 network nameservers from model: %w", errIpv4)
}
reconciledNameservers := utils.ReconcileStringSlices(modelNameservers, respNameservers)
reconciledIPv4Nameservers := utils.ReconcileStringSlices(modelIPv4Nameservers, respNameservers)
nameserversTF, diags := types.ListValueFrom(ctx, types.StringType, reconciledNameservers)
ipv4NameserversTF, ipv4Diags := types.ListValueFrom(ctx, types.StringType, reconciledIPv4Nameservers)
if diags.HasError() {
return fmt.Errorf("map network nameservers: %w", core.DiagsToError(diags))
}
if ipv4Diags.HasError() {
return fmt.Errorf("map IPv4 network nameservers: %w", core.DiagsToError(ipv4Diags))
}
model.Nameservers = nameserversTF
model.IPv4Nameservers = ipv4NameserversTF
}
if networkResp.Ipv4 == nil || networkResp.Ipv4.Prefixes == nil {
model.Prefixes = types.ListNull(types.StringType)
model.IPv4Prefixes = types.ListNull(types.StringType)
} else {
respPrefixes := *networkResp.Ipv4.Prefixes
prefixesTF, diags := types.ListValueFrom(ctx, types.StringType, respPrefixes)
if diags.HasError() {
return fmt.Errorf("map network prefixes: %w", core.DiagsToError(diags))
}
if len(respPrefixes) > 0 {
model.IPv4Prefix = types.StringValue(respPrefixes[0])
_, netmask, err := net.ParseCIDR(respPrefixes[0])
if err != nil {
// silently ignore parsing error for the netmask
model.IPv4PrefixLength = types.Int64Null()
} else {
ones, _ := netmask.Mask.Size()
model.IPv4PrefixLength = types.Int64Value(int64(ones))
}
}
model.Prefixes = prefixesTF
model.IPv4Prefixes = prefixesTF
}
if networkResp.Ipv4 == nil || networkResp.Ipv4.Gateway == nil {
model.IPv4Gateway = types.StringNull()
} else {
model.IPv4Gateway = types.StringPointerValue(networkResp.Ipv4.GetGateway())
}
if networkResp.Ipv4 == nil || networkResp.Ipv4.PublicIp == nil {
model.PublicIP = types.StringNull()
} else {
model.PublicIP = types.StringPointerValue(networkResp.Ipv4.PublicIp)
}
// IPv6
if networkResp.Ipv6 == nil || networkResp.Ipv6.Nameservers == nil {
model.IPv6Nameservers = types.ListNull(types.StringType)
} else {
respIPv6Nameservers := *networkResp.Ipv6.Nameservers
modelIPv6Nameservers, errIpv6 := utils.ListValuetoStringSlice(model.IPv6Nameservers)
if errIpv6 != nil {
return fmt.Errorf("get current IPv6 network nameservers from model: %w", errIpv6)
}
reconciledIPv6Nameservers := utils.ReconcileStringSlices(modelIPv6Nameservers, respIPv6Nameservers)
ipv6NameserversTF, ipv6Diags := types.ListValueFrom(ctx, types.StringType, reconciledIPv6Nameservers)
if ipv6Diags.HasError() {
return fmt.Errorf("map IPv6 network nameservers: %w", core.DiagsToError(ipv6Diags))
}
model.IPv6Nameservers = ipv6NameserversTF
}
if networkResp.Ipv6 == nil || networkResp.Ipv6.Prefixes == nil {
model.IPv6Prefixes = types.ListNull(types.StringType)
} else {
respPrefixesV6 := *networkResp.Ipv6.Prefixes
prefixesV6TF, diags := types.ListValueFrom(ctx, types.StringType, respPrefixesV6)
if diags.HasError() {
return fmt.Errorf("map network IPv6 prefixes: %w", core.DiagsToError(diags))
}
if len(respPrefixesV6) > 0 {
model.IPv6Prefix = types.StringValue(respPrefixesV6[0])
_, netmask, err := net.ParseCIDR(respPrefixesV6[0])
if err != nil {
// silently ignore parsing error for the netmask
model.IPv6PrefixLength = types.Int64Null()
} else {
ones, _ := netmask.Mask.Size()
model.IPv6PrefixLength = types.Int64Value(int64(ones))
}
}
model.IPv6Prefixes = prefixesV6TF
}
if networkResp.Ipv6 == nil || networkResp.Ipv6.Gateway == nil {
model.IPv6Gateway = types.StringNull()
} else {
model.IPv6Gateway = types.StringPointerValue(networkResp.Ipv6.GetGateway())
}
model.RoutingTableID = types.StringNull()
if networkResp.RoutingTableId != nil {
model.RoutingTableID = types.StringValue(*networkResp.RoutingTableId)
}
model.NetworkId = types.StringValue(networkId)
model.Name = types.StringPointerValue(networkResp.Name)
model.Labels = labels
model.Routed = types.BoolPointerValue(networkResp.Routed)
model.Region = types.StringValue(region)
return nil
}

View file

@ -1,387 +0,0 @@
package network
import (
"context"
"testing"
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
"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"
)
const (
testRegion = "region"
)
func TestMapDataSourceFields(t *testing.T) {
tests := []struct {
description string
state DataSourceModel
input *iaas.Network
region string
expected DataSourceModel
isValid bool
}{
{
"id_ok",
DataSourceModel{
ProjectId: types.StringValue("pid"),
NetworkId: types.StringValue("nid"),
},
&iaas.Network{
Id: utils.Ptr("nid"),
Ipv4: &iaas.NetworkIPv4{
Gateway: iaas.NewNullableString(nil),
},
},
testRegion,
DataSourceModel{
Id: types.StringValue("pid,region,nid"),
ProjectId: types.StringValue("pid"),
NetworkId: types.StringValue("nid"),
Name: types.StringNull(),
Nameservers: types.ListNull(types.StringType),
IPv4Nameservers: types.ListNull(types.StringType),
IPv4PrefixLength: types.Int64Null(),
IPv4Gateway: types.StringNull(),
IPv4Prefix: types.StringNull(),
Prefixes: types.ListNull(types.StringType),
IPv4Prefixes: types.ListNull(types.StringType),
IPv6Nameservers: types.ListNull(types.StringType),
IPv6PrefixLength: types.Int64Null(),
IPv6Gateway: types.StringNull(),
IPv6Prefix: types.StringNull(),
IPv6Prefixes: types.ListNull(types.StringType),
PublicIP: types.StringNull(),
Labels: types.MapNull(types.StringType),
Routed: types.BoolNull(),
Region: types.StringValue(testRegion),
},
true,
},
{
"values_ok",
DataSourceModel{
ProjectId: types.StringValue("pid"),
NetworkId: types.StringValue("nid"),
},
&iaas.Network{
Id: utils.Ptr("nid"),
Name: utils.Ptr("name"),
Ipv4: &iaas.NetworkIPv4{
Nameservers: &[]string{
"ns1",
"ns2",
},
Prefixes: &[]string{
"192.168.42.0/24",
"10.100.10.0/16",
},
PublicIp: utils.Ptr("publicIp"),
Gateway: iaas.NewNullableString(utils.Ptr("gateway")),
},
Ipv6: &iaas.NetworkIPv6{
Nameservers: &[]string{
"ns1",
"ns2",
},
Prefixes: &[]string{
"fd12:3456:789a:1::/64",
"fd12:3456:789a:2::/64",
},
Gateway: iaas.NewNullableString(utils.Ptr("gateway")),
},
Labels: &map[string]interface{}{
"key": "value",
},
Routed: utils.Ptr(true),
},
testRegion,
DataSourceModel{
Id: types.StringValue("pid,region,nid"),
ProjectId: types.StringValue("pid"),
NetworkId: types.StringValue("nid"),
Name: types.StringValue("name"),
Nameservers: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("ns1"),
types.StringValue("ns2"),
}),
IPv4Nameservers: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("ns1"),
types.StringValue("ns2"),
}),
IPv4PrefixLength: types.Int64Value(24),
Prefixes: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("192.168.42.0/24"),
types.StringValue("10.100.10.0/16"),
}),
IPv4Prefix: types.StringValue("192.168.42.0/24"),
IPv4Prefixes: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("192.168.42.0/24"),
types.StringValue("10.100.10.0/16"),
}),
IPv6Nameservers: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("ns1"),
types.StringValue("ns2"),
}),
IPv6PrefixLength: types.Int64Value(64),
IPv6Prefix: types.StringValue("fd12:3456:789a:1::/64"),
IPv6Prefixes: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("fd12:3456:789a:1::/64"),
types.StringValue("fd12:3456:789a:2::/64"),
}),
PublicIP: types.StringValue("publicIp"),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
Routed: types.BoolValue(true),
IPv4Gateway: types.StringValue("gateway"),
IPv6Gateway: types.StringValue("gateway"),
Region: types.StringValue(testRegion),
},
true,
},
{
"ipv4_nameservers_changed_outside_tf",
DataSourceModel{
ProjectId: types.StringValue("pid"),
NetworkId: types.StringValue("nid"),
Nameservers: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("ns1"),
types.StringValue("ns2"),
}),
IPv4Nameservers: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("ns1"),
types.StringValue("ns2"),
}),
},
&iaas.Network{
Id: utils.Ptr("nid"),
Ipv4: &iaas.NetworkIPv4{
Nameservers: &[]string{
"ns2",
"ns3",
},
},
},
testRegion,
DataSourceModel{
Id: types.StringValue("pid,region,nid"),
ProjectId: types.StringValue("pid"),
NetworkId: types.StringValue("nid"),
Name: types.StringNull(),
IPv6Prefixes: types.ListNull(types.StringType),
IPv6Nameservers: types.ListNull(types.StringType),
Prefixes: types.ListNull(types.StringType),
IPv4Prefixes: types.ListNull(types.StringType),
Nameservers: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("ns2"),
types.StringValue("ns3"),
}),
IPv4Nameservers: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("ns2"),
types.StringValue("ns3"),
}),
Labels: types.MapNull(types.StringType),
Region: types.StringValue(testRegion),
},
true,
},
{
"ipv6_nameservers_changed_outside_tf",
DataSourceModel{
ProjectId: types.StringValue("pid"),
NetworkId: types.StringValue("nid"),
IPv6Nameservers: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("ns1"),
types.StringValue("ns2"),
}),
},
&iaas.Network{
Id: utils.Ptr("nid"),
Ipv6: &iaas.NetworkIPv6{
Nameservers: &[]string{
"ns2",
"ns3",
},
},
},
testRegion,
DataSourceModel{
Id: types.StringValue("pid,region,nid"),
ProjectId: types.StringValue("pid"),
NetworkId: types.StringValue("nid"),
Name: types.StringNull(),
IPv6Prefixes: types.ListNull(types.StringType),
IPv4Nameservers: types.ListNull(types.StringType),
Prefixes: types.ListNull(types.StringType),
IPv4Prefixes: types.ListNull(types.StringType),
Nameservers: types.ListNull(types.StringType),
IPv6Nameservers: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("ns2"),
types.StringValue("ns3"),
}),
Labels: types.MapNull(types.StringType),
Region: types.StringValue(testRegion),
},
true,
},
{
"ipv4_prefixes_changed_outside_tf",
DataSourceModel{
ProjectId: types.StringValue("pid"),
NetworkId: types.StringValue("nid"),
Prefixes: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("192.168.42.0/24"),
types.StringValue("10.100.10.0/16"),
}),
},
&iaas.Network{
Id: utils.Ptr("nid"),
Ipv4: &iaas.NetworkIPv4{
Prefixes: &[]string{
"10.100.20.0/16",
"10.100.10.0/16",
},
},
},
testRegion,
DataSourceModel{
Id: types.StringValue("pid,region,nid"),
ProjectId: types.StringValue("pid"),
NetworkId: types.StringValue("nid"),
Name: types.StringNull(),
IPv6Nameservers: types.ListNull(types.StringType),
IPv6PrefixLength: types.Int64Null(),
IPv6Prefixes: types.ListNull(types.StringType),
Labels: types.MapNull(types.StringType),
Nameservers: types.ListNull(types.StringType),
IPv4Nameservers: types.ListNull(types.StringType),
IPv4PrefixLength: types.Int64Value(16),
IPv4Prefix: types.StringValue("10.100.20.0/16"),
Prefixes: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("10.100.20.0/16"),
types.StringValue("10.100.10.0/16"),
}),
IPv4Prefixes: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("10.100.20.0/16"),
types.StringValue("10.100.10.0/16"),
}),
Region: types.StringValue(testRegion),
},
true,
},
{
"ipv6_prefixes_changed_outside_tf",
DataSourceModel{
ProjectId: types.StringValue("pid"),
NetworkId: types.StringValue("nid"),
IPv6Prefixes: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("fd12:3456:789a:1::/64"),
types.StringValue("fd12:3456:789a:2::/64"),
}),
},
&iaas.Network{
Id: utils.Ptr("nid"),
Ipv6: &iaas.NetworkIPv6{
Prefixes: &[]string{
"fd12:3456:789a:3::/64",
"fd12:3456:789a:4::/64",
},
},
},
testRegion,
DataSourceModel{
Id: types.StringValue("pid,region,nid"),
ProjectId: types.StringValue("pid"),
NetworkId: types.StringValue("nid"),
Name: types.StringNull(),
IPv4Nameservers: types.ListNull(types.StringType),
IPv4PrefixLength: types.Int64Null(),
Prefixes: types.ListNull(types.StringType),
IPv4Prefixes: types.ListNull(types.StringType),
Labels: types.MapNull(types.StringType),
Nameservers: types.ListNull(types.StringType),
IPv6Nameservers: types.ListNull(types.StringType),
IPv6PrefixLength: types.Int64Value(64),
IPv6Prefix: types.StringValue("fd12:3456:789a:3::/64"),
IPv6Prefixes: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("fd12:3456:789a:3::/64"),
types.StringValue("fd12:3456:789a:4::/64"),
}),
Region: types.StringValue(testRegion),
},
true,
},
{
"ipv4_ipv6_gateway_nil",
DataSourceModel{
ProjectId: types.StringValue("pid"),
NetworkId: types.StringValue("nid"),
},
&iaas.Network{
Id: utils.Ptr("nid"),
},
testRegion,
DataSourceModel{
Id: types.StringValue("pid,region,nid"),
ProjectId: types.StringValue("pid"),
NetworkId: types.StringValue("nid"),
Name: types.StringNull(),
Nameservers: types.ListNull(types.StringType),
IPv4Nameservers: types.ListNull(types.StringType),
IPv4PrefixLength: types.Int64Null(),
IPv4Gateway: types.StringNull(),
Prefixes: types.ListNull(types.StringType),
IPv4Prefixes: types.ListNull(types.StringType),
IPv6Nameservers: types.ListNull(types.StringType),
IPv6PrefixLength: types.Int64Null(),
IPv6Gateway: types.StringNull(),
IPv6Prefixes: types.ListNull(types.StringType),
PublicIP: types.StringNull(),
Labels: types.MapNull(types.StringType),
Routed: types.BoolNull(),
Region: types.StringValue(testRegion),
},
true,
},
{
"response_nil_fail",
DataSourceModel{},
nil,
testRegion,
DataSourceModel{},
false,
},
{
"no_resource_id",
DataSourceModel{
ProjectId: types.StringValue("pid"),
},
&iaas.Network{},
testRegion,
DataSourceModel{},
false,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
err := mapDataSourceFields(context.Background(), tt.input, &tt.state, tt.region)
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)
}
}
})
}
}

View file

@ -1,956 +0,0 @@
package network
import (
"context"
"fmt"
"net"
"net/http"
"strings"
"github.com/hashicorp/terraform-plugin-framework-validators/resourcevalidator"
"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/boolplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/listplanmodifier"
"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/core/oapierror"
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
"github.com/stackitcloud/stackit-sdk-go/services/iaas/wait"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
iaasUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/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 = &networkResource{}
_ resource.ResourceWithConfigure = &networkResource{}
_ resource.ResourceWithImportState = &networkResource{}
_ resource.ResourceWithModifyPlan = &networkResource{}
)
const (
ipv4BehaviorChangeTitle = "Behavior of not configured `ipv4_nameservers` will change from January 2026"
ipv4BehaviorChangeDescription = "When `ipv4_nameservers` is not set, it will be set to the network area's `default_nameservers`.\n" +
"To prevent any nameserver configuration, the `ipv4_nameservers` attribute should be explicitly set to an empty list `[]`.\n" +
"In cases where `ipv4_nameservers` are defined within the resource, the existing behavior will remain unchanged."
)
type Model struct {
Id types.String `tfsdk:"id"` // needed by TF
ProjectId types.String `tfsdk:"project_id"`
NetworkId types.String `tfsdk:"network_id"`
Name types.String `tfsdk:"name"`
Nameservers types.List `tfsdk:"nameservers"`
IPv4Gateway types.String `tfsdk:"ipv4_gateway"`
IPv4Nameservers types.List `tfsdk:"ipv4_nameservers"`
IPv4Prefix types.String `tfsdk:"ipv4_prefix"`
IPv4PrefixLength types.Int64 `tfsdk:"ipv4_prefix_length"`
Prefixes types.List `tfsdk:"prefixes"`
IPv4Prefixes types.List `tfsdk:"ipv4_prefixes"`
IPv6Gateway types.String `tfsdk:"ipv6_gateway"`
IPv6Nameservers types.List `tfsdk:"ipv6_nameservers"`
IPv6Prefix types.String `tfsdk:"ipv6_prefix"`
IPv6PrefixLength types.Int64 `tfsdk:"ipv6_prefix_length"`
IPv6Prefixes types.List `tfsdk:"ipv6_prefixes"`
PublicIP types.String `tfsdk:"public_ip"`
Labels types.Map `tfsdk:"labels"`
Routed types.Bool `tfsdk:"routed"`
NoIPv4Gateway types.Bool `tfsdk:"no_ipv4_gateway"`
NoIPv6Gateway types.Bool `tfsdk:"no_ipv6_gateway"`
Region types.String `tfsdk:"region"`
RoutingTableID types.String `tfsdk:"routing_table_id"`
}
// NewNetworkResource is a helper function to simplify the provider implementation.
func NewNetworkResource() resource.Resource {
return &networkResource{}
}
// networkResource is the resource implementation.
type networkResource struct {
client *iaas.APIClient
providerData core.ProviderData
}
// Metadata returns the resource type name.
func (r *networkResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_network"
}
// Configure adds the provider configured client to the resource.
func (r *networkResource) 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
}
apiClient := iaasUtils.ConfigureClient(ctx, &r.providerData, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}
r.client = apiClient
tflog.Info(ctx, "IaaS client configured")
}
// ModifyPlan implements resource.ResourceWithModifyPlan.
// Use the modifier to set the effective region in the current plan.
func (r *networkResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { // nolint:gocritic // function signature required by Terraform
var configModel Model
// skip initial empty configuration to avoid follow-up errors
if req.Config.Raw.IsNull() {
return
}
resp.Diagnostics.Append(req.Config.Get(ctx, &configModel)...)
if resp.Diagnostics.HasError() {
return
}
var planModel Model
resp.Diagnostics.Append(req.Plan.Get(ctx, &planModel)...)
if resp.Diagnostics.HasError() {
return
}
// Warning should only be shown during the plan of the creation. This can be detected by checking if the ID is set.
if utils.IsUndefined(planModel.Id) && utils.IsUndefined(planModel.IPv4Nameservers) {
addIPv4Warning(&resp.Diagnostics)
}
utils.AdaptRegion(ctx, configModel.Region, &planModel.Region, r.providerData.GetRegion(), resp)
if resp.Diagnostics.HasError() {
return
}
resp.Diagnostics.Append(resp.Plan.Set(ctx, planModel)...)
if resp.Diagnostics.HasError() {
return
}
}
func (r *networkResource) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) {
var resourceModel Model
resp.Diagnostics.Append(req.Config.Get(ctx, &resourceModel)...)
if resp.Diagnostics.HasError() {
return
}
if !resourceModel.Nameservers.IsUnknown() && !resourceModel.IPv4Nameservers.IsUnknown() && !resourceModel.Nameservers.IsNull() && !resourceModel.IPv4Nameservers.IsNull() {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring network", "You cannot provide both the `nameservers` and `ipv4_nameservers` fields simultaneously. Please remove the deprecated `nameservers` field, and use `ipv4_nameservers` to configure nameservers for IPv4.")
}
}
// ConfigValidators validates the resource configuration
func (r *networkResource) ConfigValidators(_ context.Context) []resource.ConfigValidator {
return []resource.ConfigValidator{
resourcevalidator.Conflicting(
path.MatchRoot("no_ipv4_gateway"),
path.MatchRoot("ipv4_gateway"),
),
resourcevalidator.Conflicting(
path.MatchRoot("no_ipv6_gateway"),
path.MatchRoot("ipv6_gateway"),
),
resourcevalidator.Conflicting(
path.MatchRoot("ipv4_prefix"),
path.MatchRoot("ipv4_prefix_length"),
),
resourcevalidator.Conflicting(
path.MatchRoot("ipv6_prefix"),
path.MatchRoot("ipv6_prefix_length"),
),
resourcevalidator.Conflicting(
path.MatchRoot("ipv4_prefix_length"),
path.MatchRoot("ipv4_gateway"),
),
resourcevalidator.Conflicting(
path.MatchRoot("ipv6_prefix_length"),
path.MatchRoot("ipv6_gateway"),
),
}
}
// Schema defines the schema for the resource.
func (r *networkResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
description := "Network resource schema. Must have a `region` specified in the provider configuration."
descriptionNote := fmt.Sprintf("~> %s. %s", ipv4BehaviorChangeTitle, ipv4BehaviorChangeDescription)
resp.Schema = schema.Schema{
MarkdownDescription: fmt.Sprintf("%s\n%s", description, descriptionNote),
Description: description,
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: "Terraform's internal resource ID. It is structured as \"`project_id`,`region`,`network_id`\".",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"project_id": schema.StringAttribute{
Description: "STACKIT project ID to which the network is associated.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"network_id": schema.StringAttribute{
Description: "The network ID.",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"name": schema.StringAttribute{
Description: "The name of the network.",
Required: true,
Validators: []validator.String{
stringvalidator.LengthAtLeast(1),
stringvalidator.LengthAtMost(63),
},
},
"nameservers": schema.ListAttribute{
Description: "The nameservers of the network. This field is deprecated and will be removed in January 2026, use `ipv4_nameservers` to configure the nameservers for IPv4.",
DeprecationMessage: "Use `ipv4_nameservers` to configure the nameservers for IPv4.",
Optional: true,
Computed: true,
ElementType: types.StringType,
},
"no_ipv4_gateway": schema.BoolAttribute{
Description: "If set to `true`, the network doesn't have a gateway.",
Optional: true,
PlanModifiers: []planmodifier.Bool{
boolplanmodifier.UseStateForUnknown(),
},
},
"ipv4_gateway": schema.StringAttribute{
Description: "The IPv4 gateway of a network. If not specified, the first IP of the network will be assigned as the gateway.",
Optional: true,
Computed: true,
Validators: []validator.String{
validate.IP(false),
},
},
"ipv4_nameservers": schema.ListAttribute{
Description: "The IPv4 nameservers of the network.",
Optional: true,
Computed: true,
ElementType: types.StringType,
},
"ipv4_prefix": schema.StringAttribute{
Description: "The IPv4 prefix of the network (CIDR).",
Optional: true,
Computed: true,
Validators: []validator.String{
validate.CIDR(),
},
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplaceIfConfigured(),
},
},
"ipv4_prefix_length": schema.Int64Attribute{
Description: "The IPv4 prefix length of the network.",
Computed: true,
Optional: true,
PlanModifiers: []planmodifier.Int64{
int64planmodifier.RequiresReplaceIfConfigured(),
},
},
"prefixes": schema.ListAttribute{
Description: "The prefixes of the network. This field is deprecated and will be removed in January 2026, use `ipv4_prefixes` to read the prefixes of the IPv4 networks.",
DeprecationMessage: "Use `ipv4_prefixes` to read the prefixes of the IPv4 networks.",
Computed: true,
ElementType: types.StringType,
PlanModifiers: []planmodifier.List{
listplanmodifier.UseStateForUnknown(),
},
},
"ipv4_prefixes": schema.ListAttribute{
Description: "The IPv4 prefixes of the network.",
Computed: true,
ElementType: types.StringType,
PlanModifiers: []planmodifier.List{
listplanmodifier.UseStateForUnknown(),
},
},
"no_ipv6_gateway": schema.BoolAttribute{
Description: "If set to `true`, the network doesn't have a gateway.",
Optional: true,
PlanModifiers: []planmodifier.Bool{
boolplanmodifier.UseStateForUnknown(),
},
},
"ipv6_gateway": schema.StringAttribute{
Description: "The IPv6 gateway of a network. If not specified, the first IP of the network will be assigned as the gateway.",
Optional: true,
Computed: true,
Validators: []validator.String{
validate.IP(false),
},
},
"ipv6_nameservers": schema.ListAttribute{
Description: "The IPv6 nameservers of the network.",
Optional: true,
Computed: true,
ElementType: types.StringType,
},
"ipv6_prefix": schema.StringAttribute{
Description: "The IPv6 prefix of the network (CIDR).",
Optional: true,
Computed: true,
Validators: []validator.String{
validate.CIDR(),
},
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"ipv6_prefix_length": schema.Int64Attribute{
Description: "The IPv6 prefix length of the network.",
Optional: true,
Computed: true,
},
"ipv6_prefixes": schema.ListAttribute{
Description: "The IPv6 prefixes of the network.",
Computed: true,
ElementType: types.StringType,
PlanModifiers: []planmodifier.List{
listplanmodifier.UseStateForUnknown(),
},
},
"public_ip": schema.StringAttribute{
Description: "The public IP of the network.",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"labels": schema.MapAttribute{
Description: "Labels are key-value string pairs which can be attached to a resource container",
ElementType: types.StringType,
Optional: true,
},
"routed": schema.BoolAttribute{
Description: "If set to `true`, the network is routed and therefore accessible from other networks.",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.Bool{
boolplanmodifier.UseStateForUnknown(),
boolplanmodifier.RequiresReplace(),
},
},
"routing_table_id": schema.StringAttribute{
Description: "The ID of the routing table associated with the network.",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"region": schema.StringAttribute{
Optional: true,
// must be computed to allow for storing the override value from the provider
Computed: true,
Description: "The resource region. If not defined, the provider region is used.",
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplaceIfConfigured(),
},
},
},
}
}
// Create creates the resource and sets the initial Terraform state.
func (r *networkResource) 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
}
// When IPv4Nameserver is not set, print warning that the behavior of ipv4_nameservers will change
if utils.IsUndefined(model.IPv4Nameservers) {
addIPv4Warning(&resp.Diagnostics)
}
projectId := model.ProjectId.ValueString()
region := r.providerData.GetRegionWithOverride(model.Region)
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "region", region)
// Generate API request body from model
payload, err := toCreatePayload(ctx, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating network", fmt.Sprintf("Creating API payload: %v", err))
return
}
// Create new network
network, err := r.client.CreateNetwork(ctx, projectId, region).CreateNetworkPayload(*payload).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating network", fmt.Sprintf("Calling API: %v", err))
return
}
networkId := *network.Id
ctx = tflog.SetField(ctx, "network_id", networkId)
network, err = wait.CreateNetworkWaitHandler(ctx, r.client, projectId, region, networkId).WaitWithContext(ctx)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating network", fmt.Sprintf("Network creation waiting: %v", err))
return
}
// Map response body to schema
err = mapFields(ctx, network, &model, region)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating network", 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, "Network created")
}
// Read refreshes the Terraform state with the latest data.
func (r *networkResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform
var model Model
diags := req.State.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
networkId := model.NetworkId.ValueString()
region := r.providerData.GetRegionWithOverride(model.Region)
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "network_id", networkId)
ctx = tflog.SetField(ctx, "region", region)
networkResp, err := r.client.GetNetwork(ctx, projectId, region, networkId).Execute()
if err != nil {
oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped
if ok && oapiErr.StatusCode == http.StatusNotFound {
resp.State.RemoveResource(ctx)
return
}
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading network", fmt.Sprintf("Calling API: %v", err))
return
}
// Map response body to schema
err = mapFields(ctx, networkResp, &model, region)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading network", 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, "Network read")
}
// Update updates the resource and sets the updated Terraform state on success.
func (r *networkResource) 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
}
projectId := model.ProjectId.ValueString()
networkId := model.NetworkId.ValueString()
region := r.providerData.GetRegionWithOverride(model.Region)
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "network_id", networkId)
ctx = tflog.SetField(ctx, "region", region)
// Retrieve values from state
var stateModel Model
diags = req.State.Get(ctx, &stateModel)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
// Generate API request body from model
payload, err := toUpdatePayload(ctx, &model, &stateModel)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating network", fmt.Sprintf("Creating API payload: %v", err))
return
}
// Update existing network
err = r.client.PartialUpdateNetwork(ctx, projectId, region, networkId).PartialUpdateNetworkPayload(*payload).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating network", fmt.Sprintf("Calling API: %v", err))
return
}
waitResp, err := wait.UpdateNetworkWaitHandler(ctx, r.client, projectId, region, networkId).WaitWithContext(ctx)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating network", fmt.Sprintf("Network update waiting: %v", err))
return
}
err = mapFields(ctx, waitResp, &model, region)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating network", 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, "Network updated")
}
// Delete deletes the resource and removes the Terraform state on success.
func (r *networkResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve values from state
var model Model
diags := req.State.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
networkId := model.NetworkId.ValueString()
region := model.Region.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "network_id", networkId)
ctx = tflog.SetField(ctx, "region", region)
// Delete existing network
err := r.client.DeleteNetwork(ctx, projectId, region, networkId).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting network", fmt.Sprintf("Calling API: %v", err))
return
}
_, err = wait.DeleteNetworkWaitHandler(ctx, r.client, projectId, region, networkId).WaitWithContext(ctx)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting network", fmt.Sprintf("Network deletion waiting: %v", err))
return
}
tflog.Info(ctx, "Network deleted")
}
// ImportState imports a resource into the Terraform state on success.
// The expected format of the resource import identifier is: project_id,region,network_id
func (r *networkResource) 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 network",
fmt.Sprintf("Expected import identifier with format: [project_id],[region],[network_id] Got: %q", req.ID),
)
return
}
projectId := idParts[0]
region := idParts[1]
networkId := idParts[2]
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "region", region)
ctx = tflog.SetField(ctx, "network_id", networkId)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), projectId)...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("region"), region)...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("network_id"), networkId)...)
tflog.Info(ctx, "Network state imported")
}
func mapFields(ctx context.Context, networkResp *iaas.Network, model *Model, region string) error {
if networkResp == nil {
return fmt.Errorf("response input is nil")
}
if model == nil {
return fmt.Errorf("model input is nil")
}
var networkId string
if model.NetworkId.ValueString() != "" {
networkId = model.NetworkId.ValueString()
} else if networkResp.Id != nil {
networkId = *networkResp.Id
} else {
return fmt.Errorf("network id not present")
}
model.Id = utils.BuildInternalTerraformId(model.ProjectId.ValueString(), region, networkId)
labels, err := iaasUtils.MapLabels(ctx, networkResp.Labels, model.Labels)
if err != nil {
return err
}
// IPv4
if networkResp.Ipv4 == nil || networkResp.Ipv4.Nameservers == nil {
model.Nameservers = types.ListNull(types.StringType)
model.IPv4Nameservers = types.ListNull(types.StringType)
} else {
respNameservers := *networkResp.Ipv4.Nameservers
modelNameservers, err := utils.ListValuetoStringSlice(model.Nameservers)
modelIPv4Nameservers, errIpv4 := utils.ListValuetoStringSlice(model.IPv4Nameservers)
if err != nil {
return fmt.Errorf("get current network nameservers from model: %w", err)
}
if errIpv4 != nil {
return fmt.Errorf("get current IPv4 network nameservers from model: %w", errIpv4)
}
reconciledNameservers := utils.ReconcileStringSlices(modelNameservers, respNameservers)
reconciledIPv4Nameservers := utils.ReconcileStringSlices(modelIPv4Nameservers, respNameservers)
nameserversTF, diags := types.ListValueFrom(ctx, types.StringType, reconciledNameservers)
ipv4NameserversTF, ipv4Diags := types.ListValueFrom(ctx, types.StringType, reconciledIPv4Nameservers)
if diags.HasError() {
return fmt.Errorf("map network nameservers: %w", core.DiagsToError(diags))
}
if ipv4Diags.HasError() {
return fmt.Errorf("map IPv4 network nameservers: %w", core.DiagsToError(ipv4Diags))
}
model.Nameservers = nameserversTF
model.IPv4Nameservers = ipv4NameserversTF
}
model.IPv4PrefixLength = types.Int64Null()
if networkResp.Ipv4 == nil || networkResp.Ipv4.Prefixes == nil {
model.Prefixes = types.ListNull(types.StringType)
model.IPv4Prefixes = types.ListNull(types.StringType)
} else {
respPrefixes := *networkResp.Ipv4.Prefixes
prefixesTF, diags := types.ListValueFrom(ctx, types.StringType, respPrefixes)
if diags.HasError() {
return fmt.Errorf("map network prefixes: %w", core.DiagsToError(diags))
}
if len(respPrefixes) > 0 {
model.IPv4Prefix = types.StringValue(respPrefixes[0])
_, netmask, err := net.ParseCIDR(respPrefixes[0])
if err != nil {
tflog.Error(ctx, fmt.Sprintf("ipv4_prefix_length: %+v", err))
// silently ignore parsing error for the netmask
model.IPv4PrefixLength = types.Int64Null()
} else {
ones, _ := netmask.Mask.Size()
model.IPv4PrefixLength = types.Int64Value(int64(ones))
}
}
model.Prefixes = prefixesTF
model.IPv4Prefixes = prefixesTF
}
if networkResp.Ipv4 == nil || networkResp.Ipv4.Gateway == nil {
model.IPv4Gateway = types.StringNull()
} else {
model.IPv4Gateway = types.StringPointerValue(networkResp.Ipv4.GetGateway())
}
if networkResp.Ipv4 == nil || networkResp.Ipv4.PublicIp == nil {
model.PublicIP = types.StringNull()
} else {
model.PublicIP = types.StringPointerValue(networkResp.Ipv4.PublicIp)
}
// IPv6
if networkResp.Ipv6 == nil || networkResp.Ipv6.Nameservers == nil {
model.IPv6Nameservers = types.ListNull(types.StringType)
} else {
respIPv6Nameservers := *networkResp.Ipv6.Nameservers
modelIPv6Nameservers, errIpv6 := utils.ListValuetoStringSlice(model.IPv6Nameservers)
if errIpv6 != nil {
return fmt.Errorf("get current IPv6 network nameservers from model: %w", errIpv6)
}
reconciledIPv6Nameservers := utils.ReconcileStringSlices(modelIPv6Nameservers, respIPv6Nameservers)
ipv6NameserversTF, ipv6Diags := types.ListValueFrom(ctx, types.StringType, reconciledIPv6Nameservers)
if ipv6Diags.HasError() {
return fmt.Errorf("map IPv6 network nameservers: %w", core.DiagsToError(ipv6Diags))
}
model.IPv6Nameservers = ipv6NameserversTF
}
model.IPv6PrefixLength = types.Int64Null()
model.IPv6Prefix = types.StringNull()
if networkResp.Ipv6 == nil || networkResp.Ipv6.Prefixes == nil {
model.IPv6Prefixes = types.ListNull(types.StringType)
} else {
respPrefixesV6 := *networkResp.Ipv6.Prefixes
prefixesV6TF, diags := types.ListValueFrom(ctx, types.StringType, respPrefixesV6)
if diags.HasError() {
return fmt.Errorf("map network IPv6 prefixes: %w", core.DiagsToError(diags))
}
if len(respPrefixesV6) > 0 {
model.IPv6Prefix = types.StringValue(respPrefixesV6[0])
_, netmask, err := net.ParseCIDR(respPrefixesV6[0])
if err != nil {
// silently ignore parsing error for the netmask
model.IPv6PrefixLength = types.Int64Null()
} else {
ones, _ := netmask.Mask.Size()
model.IPv6PrefixLength = types.Int64Value(int64(ones))
}
}
model.IPv6Prefixes = prefixesV6TF
}
if networkResp.Ipv6 == nil || networkResp.Ipv6.Gateway == nil {
model.IPv6Gateway = types.StringNull()
} else {
model.IPv6Gateway = types.StringPointerValue(networkResp.Ipv6.GetGateway())
}
model.RoutingTableID = types.StringPointerValue(networkResp.RoutingTableId)
model.NetworkId = types.StringValue(networkId)
model.Name = types.StringPointerValue(networkResp.Name)
model.Labels = labels
model.Routed = types.BoolPointerValue(networkResp.Routed)
model.Region = types.StringValue(region)
return nil
}
func toCreatePayload(ctx context.Context, model *Model) (*iaas.CreateNetworkPayload, error) {
if model == nil {
return nil, fmt.Errorf("nil model")
}
var modelIPv6Nameservers []string
// Is true when IPv6Nameservers is not null or unset
if !utils.IsUndefined(model.IPv6Nameservers) {
// If ipv6Nameservers is empty, modelIPv6Nameservers will be set to an empty slice.
// empty slice != nil slice. Empty slice will result in an empty list in the payload []. Nil slice will result in a payload without the property set
modelIPv6Nameservers = []string{}
for _, ipv6ns := range model.IPv6Nameservers.Elements() {
ipv6NameserverString, ok := ipv6ns.(types.String)
if !ok {
return nil, fmt.Errorf("type assertion failed")
}
modelIPv6Nameservers = append(modelIPv6Nameservers, ipv6NameserverString.ValueString())
}
}
var ipv6Body *iaas.CreateNetworkIPv6
if !utils.IsUndefined(model.IPv6PrefixLength) {
ipv6Body = &iaas.CreateNetworkIPv6{
CreateNetworkIPv6WithPrefixLength: &iaas.CreateNetworkIPv6WithPrefixLength{
PrefixLength: conversion.Int64ValueToPointer(model.IPv6PrefixLength),
},
}
// IPv6 nameservers should only be set, if it contains any value. If the slice is nil, it should NOT be set.
// Setting it to a nil slice would result in a payload, where nameservers is set to null in the json payload,
// but it should actually be unset. Setting it to "null" will result in an error, because it's NOT nullable.
if modelIPv6Nameservers != nil {
ipv6Body.CreateNetworkIPv6WithPrefixLength.Nameservers = &modelIPv6Nameservers
}
} else if !utils.IsUndefined(model.IPv6Prefix) {
var gateway *iaas.NullableString
if model.NoIPv6Gateway.ValueBool() {
gateway = iaas.NewNullableString(nil)
} else if !(model.IPv6Gateway.IsUnknown() || model.IPv6Gateway.IsNull()) {
gateway = iaas.NewNullableString(conversion.StringValueToPointer(model.IPv6Gateway))
}
ipv6Body = &iaas.CreateNetworkIPv6{
CreateNetworkIPv6WithPrefix: &iaas.CreateNetworkIPv6WithPrefix{
Gateway: gateway,
Prefix: conversion.StringValueToPointer(model.IPv6Prefix),
},
}
// IPv6 nameservers should only be set, if it contains any value. If the slice is nil, it should NOT be set.
// Setting it to a nil slice would result in a payload, where nameservers is set to null in the json payload,
// but it should actually be unset. Setting it to "null" will result in an error, because it's NOT nullable.
if modelIPv6Nameservers != nil {
ipv6Body.CreateNetworkIPv6WithPrefix.Nameservers = &modelIPv6Nameservers
}
}
modelIPv4Nameservers := []string{}
var modelIPv4List []attr.Value
if !(model.IPv4Nameservers.IsNull() || model.IPv4Nameservers.IsUnknown()) {
modelIPv4List = model.IPv4Nameservers.Elements()
} else {
modelIPv4List = model.Nameservers.Elements()
}
for _, ipv4ns := range modelIPv4List {
ipv4NameserverString, ok := ipv4ns.(types.String)
if !ok {
return nil, fmt.Errorf("type assertion failed")
}
modelIPv4Nameservers = append(modelIPv4Nameservers, ipv4NameserverString.ValueString())
}
var ipv4Body *iaas.CreateNetworkIPv4
if !utils.IsUndefined(model.IPv4PrefixLength) {
ipv4Body = &iaas.CreateNetworkIPv4{
CreateNetworkIPv4WithPrefixLength: &iaas.CreateNetworkIPv4WithPrefixLength{
Nameservers: &modelIPv4Nameservers,
PrefixLength: conversion.Int64ValueToPointer(model.IPv4PrefixLength),
},
}
} else if !utils.IsUndefined(model.IPv4Prefix) {
var gateway *iaas.NullableString
if model.NoIPv4Gateway.ValueBool() {
gateway = iaas.NewNullableString(nil)
} else if !(model.IPv4Gateway.IsUnknown() || model.IPv4Gateway.IsNull()) {
gateway = iaas.NewNullableString(conversion.StringValueToPointer(model.IPv4Gateway))
}
ipv4Body = &iaas.CreateNetworkIPv4{
CreateNetworkIPv4WithPrefix: &iaas.CreateNetworkIPv4WithPrefix{
Nameservers: &modelIPv4Nameservers,
Prefix: conversion.StringValueToPointer(model.IPv4Prefix),
Gateway: gateway,
},
}
}
labels, err := conversion.ToStringInterfaceMap(ctx, model.Labels)
if err != nil {
return nil, fmt.Errorf("converting to Go map: %w", err)
}
payload := iaas.CreateNetworkPayload{
Name: conversion.StringValueToPointer(model.Name),
Labels: &labels,
Routed: conversion.BoolValueToPointer(model.Routed),
Ipv4: ipv4Body,
Ipv6: ipv6Body,
RoutingTableId: conversion.StringValueToPointer(model.RoutingTableID),
}
return &payload, nil
}
func toUpdatePayload(ctx context.Context, model, stateModel *Model) (*iaas.PartialUpdateNetworkPayload, error) {
if model == nil {
return nil, fmt.Errorf("nil model")
}
var modelIPv6Nameservers []string
// Is true when IPv6Nameservers is not null or unset
if !utils.IsUndefined(model.IPv6Nameservers) {
// If ipv6Nameservers is empty, modelIPv6Nameservers will be set to an empty slice.
// empty slice != nil slice. Empty slice will result in an empty list in the payload []. Nil slice will result in a payload without the property set
modelIPv6Nameservers = []string{}
for _, ipv6ns := range model.IPv6Nameservers.Elements() {
ipv6NameserverString, ok := ipv6ns.(types.String)
if !ok {
return nil, fmt.Errorf("type assertion failed")
}
modelIPv6Nameservers = append(modelIPv6Nameservers, ipv6NameserverString.ValueString())
}
}
var ipv6Body *iaas.UpdateNetworkIPv6Body
if modelIPv6Nameservers != nil || !utils.IsUndefined(model.NoIPv6Gateway) || !utils.IsUndefined(model.IPv6Gateway) {
ipv6Body = &iaas.UpdateNetworkIPv6Body{}
// IPv6 nameservers should only be set, if it contains any value. If the slice is nil, it should NOT be set.
// Setting it to a nil slice would result in a payload, where nameservers is set to null in the json payload,
// but it should actually be unset. Setting it to "null" will result in an error, because it's NOT nullable.
if modelIPv6Nameservers != nil {
ipv6Body.Nameservers = &modelIPv6Nameservers
}
if model.NoIPv6Gateway.ValueBool() {
ipv6Body.Gateway = iaas.NewNullableString(nil)
} else if !(model.IPv6Gateway.IsUnknown() || model.IPv6Gateway.IsNull()) {
ipv6Body.Gateway = iaas.NewNullableString(conversion.StringValueToPointer(model.IPv6Gateway))
}
}
modelIPv4Nameservers := []string{}
var modelIPv4List []attr.Value
if !(model.IPv4Nameservers.IsNull() || model.IPv4Nameservers.IsUnknown()) {
modelIPv4List = model.IPv4Nameservers.Elements()
} else {
modelIPv4List = model.Nameservers.Elements()
}
for _, ipv4ns := range modelIPv4List {
ipv4NameserverString, ok := ipv4ns.(types.String)
if !ok {
return nil, fmt.Errorf("type assertion failed")
}
modelIPv4Nameservers = append(modelIPv4Nameservers, ipv4NameserverString.ValueString())
}
var ipv4Body *iaas.UpdateNetworkIPv4Body
if !model.IPv4Nameservers.IsNull() || !model.Nameservers.IsNull() {
ipv4Body = &iaas.UpdateNetworkIPv4Body{
Nameservers: &modelIPv4Nameservers,
}
if model.NoIPv4Gateway.ValueBool() {
ipv4Body.Gateway = iaas.NewNullableString(nil)
} else if !(model.IPv4Gateway.IsUnknown() || model.IPv4Gateway.IsNull()) {
ipv4Body.Gateway = iaas.NewNullableString(conversion.StringValueToPointer(model.IPv4Gateway))
}
}
currentLabels := stateModel.Labels
labels, err := conversion.ToJSONMapPartialUpdatePayload(ctx, currentLabels, model.Labels)
if err != nil {
return nil, fmt.Errorf("converting to Go map: %w", err)
}
payload := iaas.PartialUpdateNetworkPayload{
Name: conversion.StringValueToPointer(model.Name),
Labels: &labels,
Ipv4: ipv4Body,
Ipv6: ipv6Body,
RoutingTableId: conversion.StringValueToPointer(model.RoutingTableID),
}
return &payload, nil
}
func addIPv4Warning(diags *diag.Diagnostics) {
diags.AddAttributeWarning(path.Root("ipv4_nameservers"),
ipv4BehaviorChangeTitle,
ipv4BehaviorChangeDescription)
}

View file

@ -1,818 +0,0 @@
package network
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/iaas"
)
func TestMapFields(t *testing.T) {
const testRegion = "region"
tests := []struct {
description string
state Model
input *iaas.Network
region string
expected Model
isValid bool
}{
{
"id_ok",
Model{
ProjectId: types.StringValue("pid"),
NetworkId: types.StringValue("nid"),
},
&iaas.Network{
Id: utils.Ptr("nid"),
Ipv4: &iaas.NetworkIPv4{
Gateway: iaas.NewNullableString(nil),
},
},
testRegion,
Model{
Id: types.StringValue("pid,region,nid"),
ProjectId: types.StringValue("pid"),
NetworkId: types.StringValue("nid"),
Name: types.StringNull(),
Nameservers: types.ListNull(types.StringType),
IPv4Nameservers: types.ListNull(types.StringType),
IPv4PrefixLength: types.Int64Null(),
IPv4Gateway: types.StringNull(),
IPv4Prefix: types.StringNull(),
Prefixes: types.ListNull(types.StringType),
IPv4Prefixes: types.ListNull(types.StringType),
IPv6Nameservers: types.ListNull(types.StringType),
IPv6PrefixLength: types.Int64Null(),
IPv6Gateway: types.StringNull(),
IPv6Prefix: types.StringNull(),
IPv6Prefixes: types.ListNull(types.StringType),
PublicIP: types.StringNull(),
Labels: types.MapNull(types.StringType),
Routed: types.BoolNull(),
Region: types.StringValue(testRegion),
},
true,
},
{
"values_ok",
Model{
ProjectId: types.StringValue("pid"),
NetworkId: types.StringValue("nid"),
},
&iaas.Network{
Id: utils.Ptr("nid"),
Name: utils.Ptr("name"),
Ipv4: &iaas.NetworkIPv4{
Nameservers: utils.Ptr([]string{"ns1", "ns2"}),
Prefixes: utils.Ptr(
[]string{
"192.168.42.0/24",
"10.100.10.0/16",
},
),
PublicIp: utils.Ptr("publicIp"),
Gateway: iaas.NewNullableString(utils.Ptr("gateway")),
},
Ipv6: &iaas.NetworkIPv6{
Nameservers: utils.Ptr([]string{"ns1", "ns2"}),
Prefixes: utils.Ptr([]string{
"fd12:3456:789a:1::/64",
"fd12:3456:789b:1::/64",
}),
Gateway: iaas.NewNullableString(utils.Ptr("gateway")),
},
Labels: &map[string]interface{}{
"key": "value",
},
Routed: utils.Ptr(true),
},
testRegion,
Model{
Id: types.StringValue("pid,region,nid"),
ProjectId: types.StringValue("pid"),
NetworkId: types.StringValue("nid"),
Name: types.StringValue("name"),
Nameservers: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("ns1"),
types.StringValue("ns2"),
}),
IPv4Nameservers: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("ns1"),
types.StringValue("ns2"),
}),
IPv4PrefixLength: types.Int64Value(24),
Prefixes: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("192.168.42.0/24"),
types.StringValue("10.100.10.0/16"),
}),
IPv4Prefixes: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("192.168.42.0/24"),
types.StringValue("10.100.10.0/16"),
}),
IPv4Prefix: types.StringValue("192.168.42.0/24"),
IPv6Nameservers: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("ns1"),
types.StringValue("ns2"),
}),
IPv6PrefixLength: types.Int64Value(64),
IPv6Prefixes: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("fd12:3456:789a:1::/64"),
types.StringValue("fd12:3456:789b:1::/64"),
}),
IPv6Prefix: types.StringValue("fd12:3456:789a:1::/64"),
PublicIP: types.StringValue("publicIp"),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
Routed: types.BoolValue(true),
IPv4Gateway: types.StringValue("gateway"),
IPv6Gateway: types.StringValue("gateway"),
Region: types.StringValue(testRegion),
},
true,
},
{
"ipv4_nameservers_changed_outside_tf",
Model{
ProjectId: types.StringValue("pid"),
NetworkId: types.StringValue("nid"),
Nameservers: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("ns1"),
types.StringValue("ns2"),
}),
IPv4Nameservers: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("ns1"),
types.StringValue("ns2"),
}),
},
&iaas.Network{
Id: utils.Ptr("nid"),
Ipv4: &iaas.NetworkIPv4{
Nameservers: utils.Ptr([]string{
"ns2",
"ns3",
}),
},
},
testRegion,
Model{
Id: types.StringValue("pid,region,nid"),
ProjectId: types.StringValue("pid"),
NetworkId: types.StringValue("nid"),
Name: types.StringNull(),
IPv6Prefixes: types.ListNull(types.StringType),
IPv6Nameservers: types.ListNull(types.StringType),
Prefixes: types.ListNull(types.StringType),
IPv4Prefixes: types.ListNull(types.StringType),
Nameservers: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("ns2"),
types.StringValue("ns3"),
}),
IPv4Nameservers: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("ns2"),
types.StringValue("ns3"),
}),
Labels: types.MapNull(types.StringType),
Region: types.StringValue(testRegion),
},
true,
},
{
"ipv6_nameservers_changed_outside_tf",
Model{
ProjectId: types.StringValue("pid"),
NetworkId: types.StringValue("nid"),
IPv6Nameservers: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("ns1"),
types.StringValue("ns2"),
}),
},
&iaas.Network{
Id: utils.Ptr("nid"),
Ipv6: &iaas.NetworkIPv6{
Nameservers: utils.Ptr([]string{
"ns2",
"ns3",
}),
},
},
testRegion,
Model{
Id: types.StringValue("pid,region,nid"),
ProjectId: types.StringValue("pid"),
NetworkId: types.StringValue("nid"),
Name: types.StringNull(),
IPv6Prefixes: types.ListNull(types.StringType),
IPv4Nameservers: types.ListNull(types.StringType),
Prefixes: types.ListNull(types.StringType),
IPv4Prefixes: types.ListNull(types.StringType),
Nameservers: types.ListNull(types.StringType),
IPv6Nameservers: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("ns2"),
types.StringValue("ns3"),
}),
Labels: types.MapNull(types.StringType),
Region: types.StringValue(testRegion),
},
true,
},
{
"ipv4_prefixes_changed_outside_tf",
Model{
ProjectId: types.StringValue("pid"),
NetworkId: types.StringValue("nid"),
Prefixes: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("192.168.42.0/24"),
types.StringValue("10.100.10.0/24"),
}),
},
&iaas.Network{
Id: utils.Ptr("nid"),
Ipv4: &iaas.NetworkIPv4{
Prefixes: utils.Ptr(
[]string{
"192.168.54.0/24",
"192.168.55.0/24",
},
),
},
},
testRegion,
Model{
Id: types.StringValue("pid,region,nid"),
ProjectId: types.StringValue("pid"),
NetworkId: types.StringValue("nid"),
Name: types.StringNull(),
IPv6Nameservers: types.ListNull(types.StringType),
IPv6PrefixLength: types.Int64Null(),
IPv6Prefixes: types.ListNull(types.StringType),
Labels: types.MapNull(types.StringType),
Nameservers: types.ListNull(types.StringType),
IPv4Nameservers: types.ListNull(types.StringType),
IPv4PrefixLength: types.Int64Value(24),
IPv4Prefix: types.StringValue("192.168.54.0/24"),
Prefixes: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("192.168.54.0/24"),
types.StringValue("192.168.55.0/24"),
}),
IPv4Prefixes: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("192.168.54.0/24"),
types.StringValue("192.168.55.0/24"),
}),
Region: types.StringValue(testRegion),
},
true,
},
{
"ipv6_prefixes_changed_outside_tf",
Model{
ProjectId: types.StringValue("pid"),
NetworkId: types.StringValue("nid"),
IPv6Prefixes: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("fd12:3456:789a:1::/64"),
types.StringValue("fd12:3456:789a:2::/64"),
}),
},
&iaas.Network{
Id: utils.Ptr("nid"),
Ipv6: &iaas.NetworkIPv6{
Prefixes: utils.Ptr(
[]string{
"fd12:3456:789a:1::/64",
"fd12:3456:789a:2::/64",
},
),
},
},
testRegion,
Model{
Id: types.StringValue("pid,region,nid"),
ProjectId: types.StringValue("pid"),
NetworkId: types.StringValue("nid"),
Name: types.StringNull(),
IPv4Nameservers: types.ListNull(types.StringType),
IPv4PrefixLength: types.Int64Null(),
Prefixes: types.ListNull(types.StringType),
IPv4Prefixes: types.ListNull(types.StringType),
Labels: types.MapNull(types.StringType),
Nameservers: types.ListNull(types.StringType),
IPv6Nameservers: types.ListNull(types.StringType),
IPv6PrefixLength: types.Int64Value(64),
IPv6Prefixes: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("fd12:3456:789a:1::/64"),
types.StringValue("fd12:3456:789a:2::/64"),
}),
IPv6Prefix: types.StringValue("fd12:3456:789a:1::/64"),
Region: types.StringValue(testRegion),
},
true,
},
{
"ipv4_ipv6_gateway_nil",
Model{
ProjectId: types.StringValue("pid"),
NetworkId: types.StringValue("nid"),
},
&iaas.Network{
Id: utils.Ptr("nid"),
},
testRegion,
Model{
Id: types.StringValue("pid,region,nid"),
ProjectId: types.StringValue("pid"),
NetworkId: types.StringValue("nid"),
Name: types.StringNull(),
Nameservers: types.ListNull(types.StringType),
IPv4Nameservers: types.ListNull(types.StringType),
IPv4PrefixLength: types.Int64Null(),
IPv4Gateway: types.StringNull(),
Prefixes: types.ListNull(types.StringType),
IPv4Prefixes: types.ListNull(types.StringType),
IPv6Nameservers: types.ListNull(types.StringType),
IPv6PrefixLength: types.Int64Null(),
IPv6Gateway: types.StringNull(),
IPv6Prefixes: types.ListNull(types.StringType),
PublicIP: types.StringNull(),
Labels: types.MapNull(types.StringType),
Routed: types.BoolNull(),
Region: types.StringValue(testRegion),
},
true,
},
{
"response_nil_fail",
Model{},
nil,
testRegion,
Model{},
false,
},
{
"no_resource_id",
Model{
ProjectId: types.StringValue("pid"),
},
&iaas.Network{},
testRegion,
Model{},
false,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
err := mapFields(context.Background(), tt.input, &tt.state, tt.region)
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 *iaas.CreateNetworkPayload
isValid bool
}{
{
"default_ok",
&Model{
Name: types.StringValue("name"),
IPv4Nameservers: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("ns1"),
types.StringValue("ns2"),
}),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
Routed: types.BoolValue(false),
IPv4Gateway: types.StringValue("gateway"),
IPv4Prefix: types.StringValue("prefix"),
},
&iaas.CreateNetworkPayload{
Name: utils.Ptr("name"),
Ipv4: &iaas.CreateNetworkIPv4{
CreateNetworkIPv4WithPrefix: &iaas.CreateNetworkIPv4WithPrefix{
Gateway: iaas.NewNullableString(utils.Ptr("gateway")),
Nameservers: utils.Ptr([]string{
"ns1",
"ns2",
}),
Prefix: utils.Ptr("prefix"),
},
},
Labels: &map[string]interface{}{
"key": "value",
},
Routed: utils.Ptr(false),
},
true,
},
{
"ipv4_nameservers_okay",
&Model{
Name: types.StringValue("name"),
Nameservers: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("ns1"),
types.StringValue("ns2"),
}),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
Routed: types.BoolValue(false),
IPv4Gateway: types.StringValue("gateway"),
IPv4Prefix: types.StringValue("prefix"),
},
&iaas.CreateNetworkPayload{
Name: utils.Ptr("name"),
Ipv4: &iaas.CreateNetworkIPv4{
CreateNetworkIPv4WithPrefix: &iaas.CreateNetworkIPv4WithPrefix{
Gateway: iaas.NewNullableString(utils.Ptr("gateway")),
Nameservers: utils.Ptr([]string{
"ns1",
"ns2",
}),
Prefix: utils.Ptr("prefix"),
},
},
Labels: &map[string]interface{}{
"key": "value",
},
Routed: utils.Ptr(false),
},
true,
},
{
"ipv6_default_ok",
&Model{
Name: types.StringValue("name"),
IPv6Nameservers: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("ns1"),
types.StringValue("ns2"),
}),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
Routed: types.BoolValue(false),
IPv6Gateway: types.StringValue("gateway"),
IPv6Prefix: types.StringValue("prefix"),
},
&iaas.CreateNetworkPayload{
Name: utils.Ptr("name"),
Ipv6: &iaas.CreateNetworkIPv6{
CreateNetworkIPv6WithPrefix: &iaas.CreateNetworkIPv6WithPrefix{
Gateway: iaas.NewNullableString(utils.Ptr("gateway")),
Nameservers: utils.Ptr([]string{
"ns1",
"ns2",
}),
Prefix: utils.Ptr("prefix"),
},
},
Labels: &map[string]interface{}{
"key": "value",
},
Routed: utils.Ptr(false),
},
true,
},
{
"ipv6_nameserver_null",
&Model{
Name: types.StringValue("name"),
IPv6Nameservers: types.ListNull(types.StringType),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
Routed: types.BoolValue(false),
IPv6Gateway: types.StringValue("gateway"),
IPv6Prefix: types.StringValue("prefix"),
},
&iaas.CreateNetworkPayload{
Name: utils.Ptr("name"),
Ipv6: &iaas.CreateNetworkIPv6{
CreateNetworkIPv6WithPrefix: &iaas.CreateNetworkIPv6WithPrefix{
Nameservers: nil,
Gateway: iaas.NewNullableString(utils.Ptr("gateway")),
Prefix: utils.Ptr("prefix"),
},
},
Labels: &map[string]interface{}{
"key": "value",
},
Routed: utils.Ptr(false),
},
true,
},
{
"ipv6_nameserver_empty_list",
&Model{
Name: types.StringValue("name"),
IPv6Nameservers: types.ListValueMust(types.StringType, []attr.Value{}),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
Routed: types.BoolValue(false),
IPv6Gateway: types.StringValue("gateway"),
IPv6Prefix: types.StringValue("prefix"),
},
&iaas.CreateNetworkPayload{
Name: utils.Ptr("name"),
Ipv6: &iaas.CreateNetworkIPv6{
CreateNetworkIPv6WithPrefix: &iaas.CreateNetworkIPv6WithPrefix{
Nameservers: utils.Ptr([]string{}),
Gateway: iaas.NewNullableString(utils.Ptr("gateway")),
Prefix: utils.Ptr("prefix"),
},
},
Labels: &map[string]interface{}{
"key": "value",
},
Routed: utils.Ptr(false),
},
true,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
output, err := toCreatePayload(context.Background(), 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, cmp.AllowUnexported(iaas.NullableString{}))
if diff != "" {
t.Fatalf("Data does not match: %s", diff)
}
}
})
}
}
func TestToUpdatePayload(t *testing.T) {
tests := []struct {
description string
input *Model
state Model
expected *iaas.PartialUpdateNetworkPayload
isValid bool
}{
{
"default_ok",
&Model{
Name: types.StringValue("name"),
IPv4Nameservers: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("ns1"),
types.StringValue("ns2"),
}),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
Routed: types.BoolValue(true),
IPv4Gateway: types.StringValue("gateway"),
},
Model{
ProjectId: types.StringValue("pid"),
NetworkId: types.StringValue("nid"),
Labels: types.MapNull(types.StringType),
},
&iaas.PartialUpdateNetworkPayload{
Name: utils.Ptr("name"),
Ipv4: &iaas.UpdateNetworkIPv4Body{
Gateway: iaas.NewNullableString(utils.Ptr("gateway")),
Nameservers: utils.Ptr([]string{
"ns1",
"ns2",
}),
},
Labels: &map[string]interface{}{
"key": "value",
},
},
true,
},
{
"ipv4_nameservers_okay",
&Model{
Name: types.StringValue("name"),
Nameservers: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("ns1"),
types.StringValue("ns2"),
}),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
Routed: types.BoolValue(true),
IPv4Gateway: types.StringValue("gateway"),
},
Model{
ProjectId: types.StringValue("pid"),
NetworkId: types.StringValue("nid"),
Labels: types.MapNull(types.StringType),
},
&iaas.PartialUpdateNetworkPayload{
Name: utils.Ptr("name"),
Ipv4: &iaas.UpdateNetworkIPv4Body{
Gateway: iaas.NewNullableString(utils.Ptr("gateway")),
Nameservers: utils.Ptr([]string{
"ns1",
"ns2",
}),
},
Labels: &map[string]interface{}{
"key": "value",
},
},
true,
},
{
"ipv4_gateway_nil",
&Model{
Name: types.StringValue("name"),
IPv4Nameservers: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("ns1"),
types.StringValue("ns2"),
}),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
Routed: types.BoolValue(true),
},
Model{
ProjectId: types.StringValue("pid"),
NetworkId: types.StringValue("nid"),
Labels: types.MapNull(types.StringType),
},
&iaas.PartialUpdateNetworkPayload{
Name: utils.Ptr("name"),
Ipv4: &iaas.UpdateNetworkIPv4Body{
Nameservers: utils.Ptr([]string{
"ns1",
"ns2",
}),
},
Labels: &map[string]interface{}{
"key": "value",
},
},
true,
},
{
"ipv6_default_ok",
&Model{
Name: types.StringValue("name"),
IPv6Nameservers: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("ns1"),
types.StringValue("ns2"),
}),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
Routed: types.BoolValue(true),
IPv6Gateway: types.StringValue("gateway"),
},
Model{
ProjectId: types.StringValue("pid"),
NetworkId: types.StringValue("nid"),
Labels: types.MapNull(types.StringType),
},
&iaas.PartialUpdateNetworkPayload{
Name: utils.Ptr("name"),
Ipv6: &iaas.UpdateNetworkIPv6Body{
Gateway: iaas.NewNullableString(utils.Ptr("gateway")),
Nameservers: utils.Ptr([]string{
"ns1",
"ns2",
}),
},
Labels: &map[string]interface{}{
"key": "value",
},
},
true,
},
{
"ipv6_gateway_nil",
&Model{
Name: types.StringValue("name"),
IPv6Nameservers: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("ns1"),
types.StringValue("ns2"),
}),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
Routed: types.BoolValue(true),
},
Model{
ProjectId: types.StringValue("pid"),
NetworkId: types.StringValue("nid"),
Labels: types.MapNull(types.StringType),
},
&iaas.PartialUpdateNetworkPayload{
Name: utils.Ptr("name"),
Ipv6: &iaas.UpdateNetworkIPv6Body{
Nameservers: utils.Ptr([]string{
"ns1",
"ns2",
}),
},
Labels: &map[string]interface{}{
"key": "value",
},
},
true,
},
{
"ipv6_nameserver_null",
&Model{
Name: types.StringValue("name"),
IPv6Nameservers: types.ListNull(types.StringType),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
Routed: types.BoolValue(true),
IPv6Gateway: types.StringValue("gateway"),
},
Model{
ProjectId: types.StringValue("pid"),
NetworkId: types.StringValue("nid"),
Labels: types.MapNull(types.StringType),
},
&iaas.PartialUpdateNetworkPayload{
Name: utils.Ptr("name"),
Ipv6: &iaas.UpdateNetworkIPv6Body{
Nameservers: nil,
Gateway: iaas.NewNullableString(utils.Ptr("gateway")),
},
Labels: &map[string]interface{}{
"key": "value",
},
},
true,
},
{
"ipv6_nameserver_empty_list",
&Model{
Name: types.StringValue("name"),
IPv6Nameservers: types.ListValueMust(types.StringType, []attr.Value{}),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
Routed: types.BoolValue(true),
IPv6Gateway: types.StringValue("gateway"),
},
Model{
ProjectId: types.StringValue("pid"),
NetworkId: types.StringValue("nid"),
Labels: types.MapNull(types.StringType),
},
&iaas.PartialUpdateNetworkPayload{
Name: utils.Ptr("name"),
Ipv6: &iaas.UpdateNetworkIPv6Body{
Nameservers: utils.Ptr([]string{}),
Gateway: iaas.NewNullableString(utils.Ptr("gateway")),
},
Labels: &map[string]interface{}{
"key": "value",
},
},
true,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
output, err := toUpdatePayload(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(output, tt.expected, cmp.AllowUnexported(iaas.NullableString{}))
if diff != "" {
t.Fatalf("Data does not match: %s", diff)
}
}
})
}
}

View file

@ -1,242 +0,0 @@
package networkarea
import (
"context"
"errors"
"fmt"
"net/http"
"github.com/stackitcloud/stackit-sdk-go/core/oapierror"
"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/conversion"
iaasUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/utils"
"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/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/iaas"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate"
)
// Ensure the implementation satisfies the expected interfaces.
var (
_ datasource.DataSource = &networkAreaDataSource{}
)
// NewNetworkDataSource is a helper function to simplify the provider implementation.
func NewNetworkAreaDataSource() datasource.DataSource {
return &networkAreaDataSource{}
}
// networkDataSource is the data source implementation.
type networkAreaDataSource struct {
client *iaas.APIClient
}
// Metadata returns the data source type name.
func (d *networkAreaDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_network_area"
}
func (d *networkAreaDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
providerData, ok := conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics)
if !ok {
return
}
apiClient := iaasUtils.ConfigureClient(ctx, &providerData, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}
d.client = apiClient
tflog.Info(ctx, "IaaS client configured")
}
// Schema defines the schema for the data source.
func (d *networkAreaDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
deprecationMsg := "Deprecated because of the IaaS API v1 -> v2 migration. Will be removed in May 2026."
description := "Network area datasource schema. Must have a `region` specified in the provider configuration."
resp.Schema = schema.Schema{
Description: description,
MarkdownDescription: description,
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: "Terraform's internal resource ID. It is structured as \"`organization_id`,`network_area_id`\".",
Computed: true,
},
"organization_id": schema.StringAttribute{
Description: "STACKIT organization ID to which the network area is associated.",
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"network_area_id": schema.StringAttribute{
Description: "The network area ID.",
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"name": schema.StringAttribute{
Description: "The name of the network area.",
Computed: true,
Validators: []validator.String{
stringvalidator.LengthAtLeast(1),
stringvalidator.LengthAtMost(63),
},
},
"project_count": schema.Int64Attribute{
Description: "The amount of projects currently referencing this area.",
Computed: true,
Validators: []validator.Int64{
int64validator.AtLeast(0),
},
},
"default_nameservers": schema.ListAttribute{
DeprecationMessage: deprecationMsg,
Description: "List of DNS Servers/Nameservers.",
Computed: true,
ElementType: types.StringType,
},
"network_ranges": schema.ListNestedAttribute{
DeprecationMessage: deprecationMsg,
Description: "List of Network ranges.",
Computed: true,
Validators: []validator.List{
listvalidator.SizeAtLeast(1),
listvalidator.SizeAtMost(64),
},
NestedObject: schema.NestedAttributeObject{
Attributes: map[string]schema.Attribute{
"network_range_id": schema.StringAttribute{
Computed: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"prefix": schema.StringAttribute{
Computed: true,
},
},
},
},
"transfer_network": schema.StringAttribute{
DeprecationMessage: deprecationMsg,
Description: "Classless Inter-Domain Routing (CIDR).",
Computed: true,
},
"default_prefix_length": schema.Int64Attribute{
DeprecationMessage: deprecationMsg,
Description: "The default prefix length for networks in the network area.",
Computed: true,
Validators: []validator.Int64{
int64validator.AtLeast(24),
int64validator.AtMost(29),
},
},
"max_prefix_length": schema.Int64Attribute{
DeprecationMessage: deprecationMsg,
Description: "The maximal prefix length for networks in the network area.",
Computed: true,
Validators: []validator.Int64{
int64validator.AtLeast(24),
int64validator.AtMost(29),
},
},
"min_prefix_length": schema.Int64Attribute{
DeprecationMessage: deprecationMsg,
Description: "The minimal prefix length for networks in the network area.",
Computed: true,
Validators: []validator.Int64{
int64validator.AtLeast(22),
int64validator.AtMost(29),
},
},
"labels": schema.MapAttribute{
Description: "Labels are key-value string pairs which can be attached to a resource container",
ElementType: types.StringType,
Computed: true,
},
},
}
}
// Read refreshes the Terraform state with the latest data.
func (d *networkAreaDataSource) 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
}
organizationId := model.OrganizationId.ValueString()
networkAreaId := model.NetworkAreaId.ValueString()
ctx = core.InitProviderContext(ctx)
ctx = tflog.SetField(ctx, "organization_id", organizationId)
ctx = tflog.SetField(ctx, "network_area_id", networkAreaId)
networkAreaResp, err := d.client.GetNetworkArea(ctx, organizationId, networkAreaId).Execute()
if err != nil {
utils.LogError(
ctx,
&resp.Diagnostics,
err,
"Reading network area",
fmt.Sprintf("Network area with ID %q does not exist in organization %q.", networkAreaId, organizationId),
map[int]string{
http.StatusForbidden: fmt.Sprintf("Organization with ID %q not found or forbidden access", organizationId),
},
)
resp.State.RemoveResource(ctx)
return
}
ctx = core.LogResponse(ctx)
err = mapFields(ctx, networkAreaResp, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading network area", fmt.Sprintf("Processing API payload: %v", err))
return
}
// Deprecated: Will be removed in May 2026. Only introduced to make the IaaS v1 -> v2 API migration non-breaking in the Terraform provider.
networkAreaRegionResp, err := d.client.GetNetworkAreaRegion(ctx, organizationId, networkAreaId, "eu01").Execute()
if err != nil {
var oapiErr *oapierror.GenericOpenAPIError
ok := errors.As(err, &oapiErr)
if !(ok && (oapiErr.StatusCode == http.StatusNotFound || oapiErr.StatusCode == http.StatusBadRequest)) { // TODO: iaas api returns http 400 in case network area region is not found
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading network area region", fmt.Sprintf("Calling API: %v", err))
return
}
networkAreaRegionResp = &iaas.RegionalArea{}
}
// Deprecated: Will be removed in May 2026. Only introduced to make the IaaS v1 -> v2 API migration non-breaking in the Terraform provider.
err = mapNetworkAreaRegionFields(ctx, networkAreaRegionResp, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading network area region", 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, "Network area read")
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,181 +0,0 @@
package networkarearegion
import (
"context"
"fmt"
"github.com/hashicorp/terraform-plugin-framework-validators/listvalidator"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
iaasUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/utils"
"github.com/hashicorp/terraform-plugin-framework/datasource"
"github.com/hashicorp/terraform-plugin-framework/datasource/schema"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
)
// Ensure the implementation satisfies the expected interfaces.
var (
_ datasource.DataSource = &networkAreaRegionDataSource{}
)
// NewNetworkAreaRegionDataSource is a helper function to simplify the provider implementation.
func NewNetworkAreaRegionDataSource() datasource.DataSource {
return &networkAreaRegionDataSource{}
}
// networkAreaRegionDataSource is the data source implementation.
type networkAreaRegionDataSource struct {
client *iaas.APIClient
providerData core.ProviderData
}
// Metadata returns the data source type name.
func (d *networkAreaRegionDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_network_area_region"
}
func (d *networkAreaRegionDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
var ok bool
d.providerData, ok = conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics)
if !ok {
return
}
apiClient := iaasUtils.ConfigureClient(ctx, &d.providerData, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}
d.client = apiClient
tflog.Info(ctx, "iaas client configured")
}
// Schema defines the schema for the resource.
func (d *networkAreaRegionDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
description := "Network area region data source schema."
resp.Schema = schema.Schema{
MarkdownDescription: description,
Description: description,
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: "Terraform's internal resource ID. It is structured as \"`organization_id`,`network_area_id`,`region`\".",
Computed: true,
},
"organization_id": schema.StringAttribute{
Description: "STACKIT organization ID to which the network area is associated.",
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"network_area_id": schema.StringAttribute{
Description: "The network area ID.",
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"region": schema.StringAttribute{
Description: "The resource region. If not defined, the provider region is used.",
// the region cannot be found, so it has to be passed
Optional: true,
},
"ipv4": schema.SingleNestedAttribute{
Computed: true,
Description: "The regional IPv4 config of a network area.",
Attributes: map[string]schema.Attribute{
"default_nameservers": schema.ListAttribute{
Description: "List of DNS Servers/Nameservers.",
Computed: true,
ElementType: types.StringType,
},
"network_ranges": schema.ListNestedAttribute{
Description: "List of Network ranges.",
Computed: true,
Validators: []validator.List{
listvalidator.SizeAtLeast(1),
listvalidator.SizeAtMost(64),
},
NestedObject: schema.NestedAttributeObject{
Attributes: map[string]schema.Attribute{
"network_range_id": schema.StringAttribute{
Computed: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"prefix": schema.StringAttribute{
Description: "Classless Inter-Domain Routing (CIDR).",
Computed: true,
},
},
},
},
"transfer_network": schema.StringAttribute{
Description: "IPv4 Classless Inter-Domain Routing (CIDR).",
Computed: true,
},
"default_prefix_length": schema.Int64Attribute{
Description: "The default prefix length for networks in the network area.",
Computed: true,
},
"max_prefix_length": schema.Int64Attribute{
Description: "The maximal prefix length for networks in the network area.",
Computed: true,
},
"min_prefix_length": schema.Int64Attribute{
Description: "The minimal prefix length for networks in the network area.",
Computed: true,
},
},
},
},
}
}
// Read refreshes the Terraform state with the latest data.
func (d *networkAreaRegionDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform
var model Model
resp.Diagnostics.Append(req.Config.Get(ctx, &model)...)
if resp.Diagnostics.HasError() {
return
}
organizationId := model.OrganizationId.ValueString()
networkAreaId := model.NetworkAreaId.ValueString()
region := d.providerData.GetRegionWithOverride(model.Region)
ctx = tflog.SetField(ctx, "organization_id", organizationId)
ctx = tflog.SetField(ctx, "network_area_id", networkAreaId)
ctx = tflog.SetField(ctx, "region", region)
networkAreaRegionResp, err := d.client.GetNetworkAreaRegion(ctx, organizationId, networkAreaId, region).Execute()
if err != nil {
utils.LogError(ctx, &resp.Diagnostics, err, "Reading network area region", fmt.Sprintf("Region configuration for %q for network area %q does not exist.", region, networkAreaId), nil)
resp.State.RemoveResource(ctx)
return
}
// Map response body to schema
err = mapFields(ctx, networkAreaRegionResp, &model, region)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading network area region", fmt.Sprintf("Processing API payload: %v", err))
return
}
// Set refreshed state
resp.Diagnostics.Append(resp.State.Set(ctx, model)...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "Network area region read")
}

View file

@ -1,728 +0,0 @@
package networkarearegion
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"github.com/stackitcloud/stackit-sdk-go/services/resourcemanager"
resourcemanagerUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/resourcemanager/utils"
sdkUtils "github.com/stackitcloud/stackit-sdk-go/core/utils"
"github.com/hashicorp/terraform-plugin-framework-validators/int64validator"
"github.com/hashicorp/terraform-plugin-framework-validators/listvalidator"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/stackitcloud/stackit-sdk-go/services/iaas/wait"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate"
iaasUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/utils"
"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/types"
"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/stackitcloud/stackit-sdk-go/core/oapierror"
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
)
// Ensure the implementation satisfies the expected interfaces.
var (
_ resource.Resource = &networkAreaRegionResource{}
_ resource.ResourceWithConfigure = &networkAreaRegionResource{}
_ resource.ResourceWithImportState = &networkAreaRegionResource{}
_ resource.ResourceWithModifyPlan = &networkAreaRegionResource{}
)
type Model struct {
Id types.String `tfsdk:"id"` // needed by TF
OrganizationId types.String `tfsdk:"organization_id"`
NetworkAreaId types.String `tfsdk:"network_area_id"`
Region types.String `tfsdk:"region"`
Ipv4 *ipv4Model `tfsdk:"ipv4"`
}
// Struct corresponding to Model.Ipv4
type ipv4Model struct {
DefaultNameservers types.List `tfsdk:"default_nameservers"`
NetworkRanges []networkRangeModel `tfsdk:"network_ranges"`
TransferNetwork types.String `tfsdk:"transfer_network"`
DefaultPrefixLength types.Int64 `tfsdk:"default_prefix_length"`
MaxPrefixLength types.Int64 `tfsdk:"max_prefix_length"`
MinPrefixLength types.Int64 `tfsdk:"min_prefix_length"`
}
// Struct corresponding to Model.NetworkRanges[i]
type networkRangeModel struct {
Prefix types.String `tfsdk:"prefix"`
NetworkRangeId types.String `tfsdk:"network_range_id"`
}
// NewNetworkAreaRegionResource is a helper function to simplify the provider implementation.
func NewNetworkAreaRegionResource() resource.Resource {
return &networkAreaRegionResource{}
}
// networkAreaRegionResource is the resource implementation.
type networkAreaRegionResource struct {
client *iaas.APIClient
resourceManagerClient *resourcemanager.APIClient
providerData core.ProviderData
}
// Metadata returns the resource type name.
func (r *networkAreaRegionResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_network_area_region"
}
// ModifyPlan implements resource.ResourceWithModifyPlan.
// Use the modifier to set the effective region in the current plan.
func (r *networkAreaRegionResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { // nolint:gocritic // function signature required by Terraform
var configModel Model
// skip initial empty configuration to avoid follow-up errors
if req.Config.Raw.IsNull() {
return
}
resp.Diagnostics.Append(req.Config.Get(ctx, &configModel)...)
if resp.Diagnostics.HasError() {
return
}
var planModel Model
resp.Diagnostics.Append(req.Plan.Get(ctx, &planModel)...)
if resp.Diagnostics.HasError() {
return
}
utils.AdaptRegion(ctx, configModel.Region, &planModel.Region, r.providerData.GetRegion(), resp)
if resp.Diagnostics.HasError() {
return
}
resp.Diagnostics.Append(resp.Plan.Set(ctx, planModel)...)
if resp.Diagnostics.HasError() {
return
}
}
// Configure adds the provider configured client to the resource.
func (r *networkAreaRegionResource) 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
}
r.client = iaasUtils.ConfigureClient(ctx, &r.providerData, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}
r.resourceManagerClient = resourcemanagerUtils.ConfigureClient(ctx, &r.providerData, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "iaas client configured")
}
// Schema defines the schema for the resource.
func (r *networkAreaRegionResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
description := "Network area region resource schema."
resp.Schema = schema.Schema{
Description: description,
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: "Terraform's internal resource ID. It is structured as \"`organization_id`,`network_area_id`,`region`\".",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"organization_id": schema.StringAttribute{
Description: "STACKIT organization ID to which the network area is associated.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"network_area_id": schema.StringAttribute{
Description: "The network area ID.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"region": schema.StringAttribute{
Description: "The resource region. If not defined, the provider region is used.",
Optional: true,
// must be computed to allow for storing the override value from the provider
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"ipv4": schema.SingleNestedAttribute{
Description: "The regional IPv4 config of a network area.",
Required: true,
Attributes: map[string]schema.Attribute{
"default_nameservers": schema.ListAttribute{
Description: "List of DNS Servers/Nameservers.",
Optional: true,
ElementType: types.StringType,
},
"network_ranges": schema.ListNestedAttribute{
Description: "List of Network ranges.",
Required: true,
Validators: []validator.List{
listvalidator.SizeAtLeast(1),
listvalidator.SizeAtMost(64),
},
NestedObject: schema.NestedAttributeObject{
Attributes: map[string]schema.Attribute{
"network_range_id": schema.StringAttribute{
Computed: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"prefix": schema.StringAttribute{
Description: "Classless Inter-Domain Routing (CIDR).",
Required: true,
},
},
},
},
"transfer_network": schema.StringAttribute{
Description: "IPv4 Classless Inter-Domain Routing (CIDR).",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"default_prefix_length": schema.Int64Attribute{
Description: "The default prefix length for networks in the network area.",
Optional: true,
Computed: true,
Validators: []validator.Int64{
int64validator.AtLeast(24),
int64validator.AtMost(29),
},
Default: int64default.StaticInt64(25),
},
"max_prefix_length": schema.Int64Attribute{
Description: "The maximal prefix length for networks in the network area.",
Optional: true,
Computed: true,
Validators: []validator.Int64{
int64validator.AtLeast(24),
int64validator.AtMost(29),
},
Default: int64default.StaticInt64(29),
},
"min_prefix_length": schema.Int64Attribute{
Description: "The minimal prefix length for networks in the network area.",
Optional: true,
Computed: true,
Validators: []validator.Int64{
int64validator.AtLeast(8),
int64validator.AtMost(29),
},
Default: int64default.StaticInt64(24),
},
},
},
},
}
}
// Create creates the resource and sets the initial Terraform state.
func (r *networkAreaRegionResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve values from plan
var model Model
resp.Diagnostics.Append(req.Plan.Get(ctx, &model)...)
if resp.Diagnostics.HasError() {
return
}
organizationId := model.OrganizationId.ValueString()
networkAreaId := model.NetworkAreaId.ValueString()
region := r.providerData.GetRegionWithOverride(model.Region)
ctx = tflog.SetField(ctx, "organization_id", organizationId)
ctx = tflog.SetField(ctx, "network_area_id", networkAreaId)
ctx = tflog.SetField(ctx, "region", region)
ctx = core.InitProviderContext(ctx)
// Generate API request body from model
payload, err := toCreatePayload(ctx, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating network area region", fmt.Sprintf("Creating API payload: %v", err))
return
}
// Create new network area region configuration
networkAreaRegion, err := r.client.CreateNetworkAreaRegion(ctx, organizationId, networkAreaId, region).CreateNetworkAreaRegionPayload(*payload).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating network area region", 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{
"organization_id": organizationId,
"network_area_id": networkAreaId,
"region": region,
})
// wait for creation of network area region to complete
_, err = wait.CreateNetworkAreaRegionWaitHandler(ctx, r.client, organizationId, networkAreaId, region).WaitWithContext(ctx)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating server", fmt.Sprintf("server creation waiting: %v", err))
return
}
// Map response body to schema
err = mapFields(ctx, networkAreaRegion, &model, region)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating network area region", fmt.Sprintf("Processing API payload: %v", err))
return
}
// Set state to fully populated data
resp.Diagnostics.Append(resp.State.Set(ctx, model)...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "Network area region created")
}
// Read refreshes the Terraform state with the latest data.
func (r *networkAreaRegionResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform
var model Model
resp.Diagnostics.Append(req.State.Get(ctx, &model)...)
if resp.Diagnostics.HasError() {
return
}
organizationId := model.OrganizationId.ValueString()
networkAreaId := model.NetworkAreaId.ValueString()
region := r.providerData.GetRegionWithOverride(model.Region)
ctx = tflog.SetField(ctx, "organization_id", organizationId)
ctx = tflog.SetField(ctx, "network_area_id", networkAreaId)
ctx = tflog.SetField(ctx, "region", region)
ctx = core.InitProviderContext(ctx)
networkAreaRegionResp, err := r.client.GetNetworkAreaRegion(ctx, organizationId, networkAreaId, region).Execute()
if err != nil {
var oapiErr *oapierror.GenericOpenAPIError
ok := errors.As(err, &oapiErr)
if ok && oapiErr.StatusCode == http.StatusNotFound {
resp.State.RemoveResource(ctx)
return
}
}
ctx = core.LogResponse(ctx)
// Map response body to schema
err = mapFields(ctx, networkAreaRegionResp, &model, region)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading network area region", fmt.Sprintf("Processing API payload: %v", err))
return
}
// Set refreshed state
resp.Diagnostics.Append(resp.State.Set(ctx, model)...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "Network area region read")
}
// Update updates the resource and sets the updated Terraform state on success.
func (r *networkAreaRegionResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve values from plan
var model Model
resp.Diagnostics.Append(req.Plan.Get(ctx, &model)...)
if resp.Diagnostics.HasError() {
return
}
organizationId := model.OrganizationId.ValueString()
networkAreaId := model.NetworkAreaId.ValueString()
region := r.providerData.GetRegionWithOverride(model.Region)
ctx = tflog.SetField(ctx, "organization_id", organizationId)
ctx = tflog.SetField(ctx, "network_area_id", networkAreaId)
ctx = tflog.SetField(ctx, "region", region)
ctx = core.InitProviderContext(ctx)
// Retrieve values from state
var stateModel Model
resp.Diagnostics.Append(req.State.Get(ctx, &stateModel)...)
if resp.Diagnostics.HasError() {
return
}
// Generate API request body from model
payload, err := toUpdatePayload(ctx, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating network area region", fmt.Sprintf("Creating API payload: %v", err))
return
}
// Update existing network area region configuration
_, err = r.client.UpdateNetworkAreaRegion(ctx, organizationId, networkAreaId, region).UpdateNetworkAreaRegionPayload(*payload).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating network area region", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
err = updateIpv4NetworkRanges(ctx, organizationId, networkAreaId, model.Ipv4.NetworkRanges, r.client, region)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating network area region", fmt.Sprintf("Updating Network ranges: %v", err))
return
}
updatedNetworkAreaRegion, err := r.client.GetNetworkAreaRegion(ctx, organizationId, networkAreaId, region).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating network area region", fmt.Sprintf("Calling API: %v", err))
return
}
err = mapFields(ctx, updatedNetworkAreaRegion, &model, region)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating network area region", fmt.Sprintf("Processing API payload: %v", err))
return
}
resp.Diagnostics.Append(resp.State.Set(ctx, model)...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "network area region updated")
}
// Delete deletes the resource and removes the Terraform state on success.
func (r *networkAreaRegionResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve values from state
var model Model
resp.Diagnostics.Append(req.State.Get(ctx, &model)...)
if resp.Diagnostics.HasError() {
return
}
organizationId := model.OrganizationId.ValueString()
networkAreaId := model.NetworkAreaId.ValueString()
region := r.providerData.GetRegionWithOverride(model.Region)
ctx = tflog.SetField(ctx, "organization_id", organizationId)
ctx = tflog.SetField(ctx, "network_area_id", networkAreaId)
ctx = tflog.SetField(ctx, "region", region)
_, err := wait.ReadyForNetworkAreaDeletionWaitHandler(ctx, r.client, r.resourceManagerClient, organizationId, networkAreaId).WaitWithContext(ctx)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting network area region", fmt.Sprintf("Network area ready for deletion waiting: %v", err))
return
}
ctx = core.InitProviderContext(ctx)
// Delete network area region configuration
err = r.client.DeleteNetworkAreaRegion(ctx, organizationId, networkAreaId, region).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting network area region", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
_, err = wait.DeleteNetworkAreaRegionWaitHandler(ctx, r.client, organizationId, networkAreaId, region).WaitWithContext(ctx)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting network area region", fmt.Sprintf("network area deletion waiting: %v", err))
return
}
tflog.Info(ctx, "Network area region deleted")
}
// ImportState imports a resource into the Terraform state on success.
// The expected format of the resource import identifier is: organization_id,network_area_id,region
func (r *networkAreaRegionResource) 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 network area region",
fmt.Sprintf("Expected import identifier with format: [organization_id],[network_area_id],[region] Got: %q", req.ID),
)
return
}
utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{
"organization_id": idParts[0],
"network_area_id": idParts[1],
"region": idParts[2],
})
tflog.Info(ctx, "Network area region state imported")
}
// mapFields maps the API response values to the Terraform resource model fields
func mapFields(ctx context.Context, networkAreaRegion *iaas.RegionalArea, model *Model, region string) error {
if networkAreaRegion == nil {
return fmt.Errorf("network are region input is nil")
}
if model == nil {
return fmt.Errorf("model input is nil")
}
model.Id = utils.BuildInternalTerraformId(model.OrganizationId.ValueString(), model.NetworkAreaId.ValueString(), region)
model.Region = types.StringValue(region)
model.Ipv4 = &ipv4Model{}
if networkAreaRegion.Ipv4 != nil {
model.Ipv4.TransferNetwork = types.StringPointerValue(networkAreaRegion.Ipv4.TransferNetwork)
model.Ipv4.DefaultPrefixLength = types.Int64PointerValue(networkAreaRegion.Ipv4.DefaultPrefixLen)
model.Ipv4.MaxPrefixLength = types.Int64PointerValue(networkAreaRegion.Ipv4.MaxPrefixLen)
model.Ipv4.MinPrefixLength = types.Int64PointerValue(networkAreaRegion.Ipv4.MinPrefixLen)
}
// map default nameservers
if networkAreaRegion.Ipv4 == nil || networkAreaRegion.Ipv4.DefaultNameservers == nil {
model.Ipv4.DefaultNameservers = types.ListNull(types.StringType)
} else {
respDefaultNameservers := *networkAreaRegion.Ipv4.DefaultNameservers
modelDefaultNameservers, err := utils.ListValuetoStringSlice(model.Ipv4.DefaultNameservers)
if err != nil {
return fmt.Errorf("get current network area default nameservers from model: %w", err)
}
reconciledDefaultNameservers := utils.ReconcileStringSlices(modelDefaultNameservers, respDefaultNameservers)
defaultNameserversTF, diags := types.ListValueFrom(ctx, types.StringType, reconciledDefaultNameservers)
if diags.HasError() {
return fmt.Errorf("map network area default nameservers: %w", core.DiagsToError(diags))
}
model.Ipv4.DefaultNameservers = defaultNameserversTF
}
// map network ranges
err := mapIpv4NetworkRanges(ctx, networkAreaRegion.Ipv4.NetworkRanges, model)
if err != nil {
return fmt.Errorf("mapping network ranges: %w", err)
}
return nil
}
// mapFields maps the API ipv4 network ranges response values to the Terraform resource model fields
func mapIpv4NetworkRanges(_ context.Context, networkAreaRangesList *[]iaas.NetworkRange, model *Model) error {
if networkAreaRangesList == nil {
return fmt.Errorf("nil network area ranges list")
}
if len(*networkAreaRangesList) == 0 {
model.Ipv4.NetworkRanges = []networkRangeModel{}
return nil
}
modelNetworkRangePrefixes := []string{}
for _, m := range model.Ipv4.NetworkRanges {
modelNetworkRangePrefixes = append(modelNetworkRangePrefixes, m.Prefix.ValueString())
}
apiNetworkRangePrefixes := []string{}
for _, n := range *networkAreaRangesList {
apiNetworkRangePrefixes = append(apiNetworkRangePrefixes, *n.Prefix)
}
reconciledRangePrefixes := utils.ReconcileStringSlices(modelNetworkRangePrefixes, apiNetworkRangePrefixes)
model.Ipv4.NetworkRanges = []networkRangeModel{}
for _, prefix := range reconciledRangePrefixes {
var networkRangeId string
for _, networkRangeElement := range *networkAreaRangesList {
if *networkRangeElement.Prefix == prefix {
networkRangeId = *networkRangeElement.Id
break
}
}
model.Ipv4.NetworkRanges = append(model.Ipv4.NetworkRanges, networkRangeModel{
Prefix: types.StringValue(prefix),
NetworkRangeId: types.StringValue(networkRangeId),
})
}
return nil
}
func toDefaultNameserversPayload(_ context.Context, model *Model) ([]string, error) {
if model == nil {
return nil, fmt.Errorf("model is nil")
}
modelDefaultNameservers := []string{}
for _, ns := range model.Ipv4.DefaultNameservers.Elements() {
nameserverString, ok := ns.(types.String)
if !ok {
return nil, fmt.Errorf("type assertion failed")
}
modelDefaultNameservers = append(modelDefaultNameservers, nameserverString.ValueString())
}
return modelDefaultNameservers, nil
}
func toNetworkRangesPayload(_ context.Context, model *Model) (*[]iaas.NetworkRange, error) {
if model == nil {
return nil, fmt.Errorf("model is nil")
}
if len(model.Ipv4.NetworkRanges) == 0 {
return nil, nil
}
payload := []iaas.NetworkRange{}
for _, networkRange := range model.Ipv4.NetworkRanges {
payload = append(payload, iaas.NetworkRange{
Prefix: conversion.StringValueToPointer(networkRange.Prefix),
})
}
return &payload, nil
}
func toCreatePayload(ctx context.Context, model *Model) (*iaas.CreateNetworkAreaRegionPayload, error) {
if model == nil {
return nil, fmt.Errorf("nil model")
} else if model.Ipv4 == nil {
return nil, fmt.Errorf("nil model.Ipv4")
}
modelDefaultNameservers, err := toDefaultNameserversPayload(ctx, model)
if err != nil {
return nil, fmt.Errorf("converting default nameservers: %w", err)
}
networkRangesPayload, err := toNetworkRangesPayload(ctx, model)
if err != nil {
return nil, fmt.Errorf("converting network ranges: %w", err)
}
return &iaas.CreateNetworkAreaRegionPayload{
Ipv4: &iaas.RegionalAreaIPv4{
DefaultNameservers: &modelDefaultNameservers,
DefaultPrefixLen: conversion.Int64ValueToPointer(model.Ipv4.DefaultPrefixLength),
MaxPrefixLen: conversion.Int64ValueToPointer(model.Ipv4.MaxPrefixLength),
MinPrefixLen: conversion.Int64ValueToPointer(model.Ipv4.MinPrefixLength),
TransferNetwork: conversion.StringValueToPointer(model.Ipv4.TransferNetwork),
NetworkRanges: networkRangesPayload,
},
}, nil
}
func toUpdatePayload(ctx context.Context, model *Model) (*iaas.UpdateNetworkAreaRegionPayload, error) {
if model == nil {
return nil, fmt.Errorf("nil model")
}
modelDefaultNameservers, err := toDefaultNameserversPayload(ctx, model)
if err != nil {
return nil, fmt.Errorf("converting default nameservers: %w", err)
}
return &iaas.UpdateNetworkAreaRegionPayload{
Ipv4: &iaas.UpdateRegionalAreaIPv4{
DefaultNameservers: &modelDefaultNameservers,
DefaultPrefixLen: conversion.Int64ValueToPointer(model.Ipv4.DefaultPrefixLength),
MaxPrefixLen: conversion.Int64ValueToPointer(model.Ipv4.MaxPrefixLength),
MinPrefixLen: conversion.Int64ValueToPointer(model.Ipv4.MinPrefixLength),
},
}, nil
}
// updateIpv4NetworkRanges creates and deletes network ranges so that network area ranges are the ones in the model.
func updateIpv4NetworkRanges(ctx context.Context, organizationId, networkAreaId string, ranges []networkRangeModel, client *iaas.APIClient, region string) error {
// Get network ranges current state
currentNetworkRangesResp, err := client.ListNetworkAreaRanges(ctx, organizationId, networkAreaId, region).Execute()
if err != nil {
return fmt.Errorf("error reading network area ranges: %w", err)
}
type networkRangeState struct {
isInModel bool
isCreated bool
id string
}
networkRangesState := make(map[string]*networkRangeState)
for _, nwRange := range ranges {
networkRangesState[nwRange.Prefix.ValueString()] = &networkRangeState{
isInModel: true,
}
}
for _, networkRange := range *currentNetworkRangesResp.Items {
prefix := *networkRange.Prefix
if _, ok := networkRangesState[prefix]; !ok {
networkRangesState[prefix] = &networkRangeState{}
}
networkRangesState[prefix].isCreated = true
networkRangesState[prefix].id = *networkRange.Id
}
// Delete network ranges
for prefix, state := range networkRangesState {
if !state.isInModel && state.isCreated {
err := client.DeleteNetworkAreaRange(ctx, organizationId, networkAreaId, region, state.id).Execute()
if err != nil {
return fmt.Errorf("deleting network area range '%v': %w", prefix, err)
}
}
}
// Create network ranges
for prefix, state := range networkRangesState {
if state.isInModel && !state.isCreated {
payload := iaas.CreateNetworkAreaRangePayload{
Ipv4: &[]iaas.NetworkRange{
{
Prefix: sdkUtils.Ptr(prefix),
},
},
}
_, err := client.CreateNetworkAreaRange(ctx, organizationId, networkAreaId, region).CreateNetworkAreaRangePayload(payload).Execute()
if err != nil {
return fmt.Errorf("creating network range '%v': %w", prefix, err)
}
}
}
return nil
}

View file

@ -1,186 +0,0 @@
package networkarearoute
import (
"context"
"fmt"
"net/http"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
iaasUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/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/iaas"
"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 = &networkAreaRouteDataSource{}
)
// NewNetworkAreaRouteDataSource is a helper function to simplify the provider implementation.
func NewNetworkAreaRouteDataSource() datasource.DataSource {
return &networkAreaRouteDataSource{}
}
// networkDataSource is the data source implementation.
type networkAreaRouteDataSource struct {
client *iaas.APIClient
providerData core.ProviderData
}
// Metadata returns the data source type name.
func (d *networkAreaRouteDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_network_area_route"
}
func (d *networkAreaRouteDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
var ok bool
d.providerData, ok = conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics)
if !ok {
return
}
apiClient := iaasUtils.ConfigureClient(ctx, &d.providerData, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}
d.client = apiClient
tflog.Info(ctx, "IaaS client configured")
}
// Schema defines the schema for the data source.
func (d *networkAreaRouteDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
description := "Network area route data resource schema. Must have a `region` specified in the provider configuration."
resp.Schema = schema.Schema{
Description: description,
MarkdownDescription: description,
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: "Terraform's internal data source ID. It is structured as \"`organization_id`,`region`,`network_area_id`,`network_area_route_id`\".",
Computed: true,
},
"organization_id": schema.StringAttribute{
Description: "STACKIT organization ID to which the network area is associated.",
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"network_area_id": schema.StringAttribute{
Description: "The network area ID to which the network area route is associated.",
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"region": schema.StringAttribute{
Description: "The resource region. If not defined, the provider region is used.",
// the region cannot be found, so it has to be passed
Optional: true,
},
"network_area_route_id": schema.StringAttribute{
Description: "The network area route ID.",
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"destination": schema.SingleNestedAttribute{
Description: "Destination of the route.",
Computed: true,
Attributes: map[string]schema.Attribute{
"type": schema.StringAttribute{
Description: fmt.Sprintf("CIDRV type. %s", utils.FormatPossibleValues("cidrv4", "cidrv6")),
Computed: true,
},
"value": schema.StringAttribute{
Description: "An CIDR string.",
Computed: true,
},
},
},
"next_hop": schema.SingleNestedAttribute{
Description: "Next hop destination.",
Computed: true,
Attributes: map[string]schema.Attribute{
"type": schema.StringAttribute{
Description: "Type of the next hop. " + utils.FormatPossibleValues("blackhole", "internet", "ipv4", "ipv6"),
Computed: true,
},
"value": schema.StringAttribute{
Description: "Either IPv4 or IPv6 (not set for blackhole and internet).",
Computed: true,
},
},
},
"labels": schema.MapAttribute{
Description: "Labels are key-value string pairs which can be attached to a resource container",
ElementType: types.StringType,
Computed: true,
},
},
}
}
// Read refreshes the Terraform state with the latest data.
func (d *networkAreaRouteDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform
var model ModelV1
diags := req.Config.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
organizationId := model.OrganizationId.ValueString()
networkAreaId := model.NetworkAreaId.ValueString()
region := d.providerData.GetRegionWithOverride(model.Region)
networkAreaRouteId := model.NetworkAreaRouteId.ValueString()
ctx = core.InitProviderContext(ctx)
ctx = tflog.SetField(ctx, "organization_id", organizationId)
ctx = tflog.SetField(ctx, "network_area_id", networkAreaId)
ctx = tflog.SetField(ctx, "region", region)
ctx = tflog.SetField(ctx, "network_area_route_id", networkAreaRouteId)
networkAreaRouteResp, err := d.client.GetNetworkAreaRoute(ctx, organizationId, networkAreaId, region, networkAreaRouteId).Execute()
if err != nil {
utils.LogError(
ctx,
&resp.Diagnostics,
err,
"Reading network area route",
fmt.Sprintf("Network area route with ID %q or network area with ID %q does not exist in organization %q.", networkAreaRouteId, networkAreaId, organizationId),
map[int]string{
http.StatusForbidden: fmt.Sprintf("Organization with ID %q not found or forbidden access", organizationId),
},
)
resp.State.RemoveResource(ctx)
return
}
ctx = core.LogResponse(ctx)
err = mapFields(ctx, networkAreaRouteResp, &model, region)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading network area route", 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, "Network area route read")
}

View file

@ -1,739 +0,0 @@
package networkarearoute
import (
"context"
"fmt"
"net/http"
"strings"
sdkUtils "github.com/stackitcloud/stackit-sdk-go/core/utils"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
iaasUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/utils"
"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-log/tflog"
"github.com/stackitcloud/stackit-sdk-go/core/oapierror"
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
"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/validate"
)
// Ensure the implementation satisfies the expected interfaces.
var (
_ resource.Resource = &networkAreaRouteResource{}
_ resource.ResourceWithConfigure = &networkAreaRouteResource{}
_ resource.ResourceWithImportState = &networkAreaRouteResource{}
_ resource.ResourceWithModifyPlan = &networkAreaRouteResource{}
_ resource.ResourceWithUpgradeState = &networkAreaRouteResource{}
)
// ModelV1 is the currently used model
type ModelV1 struct {
Id types.String `tfsdk:"id"` // needed by TF
OrganizationId types.String `tfsdk:"organization_id"`
Region types.String `tfsdk:"region"`
NetworkAreaId types.String `tfsdk:"network_area_id"`
NetworkAreaRouteId types.String `tfsdk:"network_area_route_id"`
NextHop *NexthopModelV1 `tfsdk:"next_hop"`
Destination *DestinationModelV1 `tfsdk:"destination"`
Labels types.Map `tfsdk:"labels"`
}
// ModelV0 is the old model (only needed for state upgrade)
type ModelV0 struct {
Id types.String `tfsdk:"id"`
OrganizationId types.String `tfsdk:"organization_id"`
NetworkAreaId types.String `tfsdk:"network_area_id"`
NetworkAreaRouteId types.String `tfsdk:"network_area_route_id"`
NextHop types.String `tfsdk:"next_hop"`
Prefix types.String `tfsdk:"prefix"`
Labels types.Map `tfsdk:"labels"`
}
// DestinationModelV1 maps the route destination data
type DestinationModelV1 struct {
Type types.String `tfsdk:"type"`
Value types.String `tfsdk:"value"`
}
// NexthopModelV1 maps the route nexthop data
type NexthopModelV1 struct {
Type types.String `tfsdk:"type"`
Value types.String `tfsdk:"value"`
}
// NewNetworkAreaRouteResource is a helper function to simplify the provider implementation.
func NewNetworkAreaRouteResource() resource.Resource {
return &networkAreaRouteResource{}
}
// networkResource is the resource implementation.
type networkAreaRouteResource struct {
client *iaas.APIClient
providerData core.ProviderData
}
// Metadata returns the resource type name.
func (r *networkAreaRouteResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_network_area_route"
}
// ModifyPlan implements resource.ResourceWithModifyPlan.
// Use the modifier to set the effective region in the current plan.
func (r *networkAreaRouteResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { // nolint:gocritic // function signature required by Terraform
var configModel ModelV1
// skip initial empty configuration to avoid follow-up errors
if req.Config.Raw.IsNull() {
return
}
resp.Diagnostics.Append(req.Config.Get(ctx, &configModel)...)
if resp.Diagnostics.HasError() {
return
}
var planModel ModelV1
resp.Diagnostics.Append(req.Plan.Get(ctx, &planModel)...)
if resp.Diagnostics.HasError() {
return
}
utils.AdaptRegion(ctx, configModel.Region, &planModel.Region, r.providerData.GetRegion(), resp)
if resp.Diagnostics.HasError() {
return
}
resp.Diagnostics.Append(resp.Plan.Set(ctx, planModel)...)
if resp.Diagnostics.HasError() {
return
}
}
// Configure adds the provider configured client to the resource.
func (r *networkAreaRouteResource) 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
}
apiClient := iaasUtils.ConfigureClient(ctx, &r.providerData, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}
r.client = apiClient
tflog.Info(ctx, "IaaS client configured")
}
// Schema defines the schema for the resource.
func (r *networkAreaRouteResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
description := "Network area route resource schema. Must have a `region` specified in the provider configuration."
resp.Schema = schema.Schema{
Description: description,
MarkdownDescription: description,
Version: 1,
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: "Terraform's internal resource ID. It is structured as \"`organization_id`,`network_area_id`,`region`,`network_area_route_id`\".",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"organization_id": schema.StringAttribute{
Description: "STACKIT organization ID to which the network area is associated.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"region": schema.StringAttribute{
Description: "The resource region. If not defined, the provider region is used.",
Optional: true,
// must be computed to allow for storing the override value from the provider
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"network_area_id": schema.StringAttribute{
Description: "The network area ID to which the network area route is associated.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"network_area_route_id": schema.StringAttribute{
Description: "The network area route ID.",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"next_hop": schema.SingleNestedAttribute{
Description: "Next hop destination.",
Required: true,
Attributes: map[string]schema.Attribute{
"type": schema.StringAttribute{
Description: fmt.Sprintf("Type of the next hop. %s %s", utils.FormatPossibleValues("blackhole", "internet", "ipv4", "ipv6"), "Only `ipv4` supported currently."),
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"value": schema.StringAttribute{
Description: "Either IPv4 or IPv6 (not set for blackhole and internet). Only IPv4 supported currently.",
Optional: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.IP(false),
},
},
},
},
"destination": schema.SingleNestedAttribute{
Description: "Destination of the route.",
Required: true,
Attributes: map[string]schema.Attribute{
"type": schema.StringAttribute{
Description: fmt.Sprintf("CIDRV type. %s %s", utils.FormatPossibleValues("cidrv4", "cidrv6"), "Only `cidrv4` is supported currently."),
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"value": schema.StringAttribute{
Description: "An CIDR string.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.CIDR(),
},
},
},
},
"labels": schema.MapAttribute{
Description: "Labels are key-value string pairs which can be attached to a resource container",
ElementType: types.StringType,
Optional: true,
},
},
}
}
func (r *networkAreaRouteResource) UpgradeState(_ context.Context) map[int64]resource.StateUpgrader {
return map[int64]resource.StateUpgrader{
0: {
// This handles moving from version 0 to 1
PriorSchema: &schema.Schema{
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
},
"organization_id": schema.StringAttribute{
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"network_area_id": schema.StringAttribute{
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"network_area_route_id": schema.StringAttribute{
Computed: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"next_hop": schema.StringAttribute{
Required: true,
Validators: []validator.String{
validate.IP(false),
},
},
"prefix": schema.StringAttribute{
Required: true,
Validators: []validator.String{
validate.CIDR(),
},
},
"labels": schema.MapAttribute{
ElementType: types.StringType,
Optional: true,
},
},
},
StateUpgrader: func(ctx context.Context, req resource.UpgradeStateRequest, resp *resource.UpgradeStateResponse) {
var priorStateData ModelV0
resp.Diagnostics.Append(req.State.Get(ctx, &priorStateData)...)
if resp.Diagnostics.HasError() {
return
}
nexthopValue := priorStateData.NextHop.ValueString()
prefixValue := priorStateData.Prefix.ValueString()
newStateData := ModelV1{
Id: priorStateData.Id,
OrganizationId: priorStateData.OrganizationId,
NetworkAreaId: priorStateData.NetworkAreaId,
NetworkAreaRouteId: priorStateData.NetworkAreaRouteId,
Labels: priorStateData.Labels,
NextHop: &NexthopModelV1{
Type: types.StringValue("ipv4"),
Value: types.StringValue(nexthopValue),
},
Destination: &DestinationModelV1{
Type: types.StringValue("cidrv4"),
Value: types.StringValue(prefixValue),
},
}
resp.Diagnostics.Append(resp.State.Set(ctx, newStateData)...)
},
},
}
}
// Create creates the resource and sets the initial Terraform state.
func (r *networkAreaRouteResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve values from plan
var model ModelV1
diags := req.Plan.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
ctx = core.InitProviderContext(ctx)
organizationId := model.OrganizationId.ValueString()
region := r.providerData.GetRegionWithOverride(model.Region)
networkAreaId := model.NetworkAreaId.ValueString()
ctx = tflog.SetField(ctx, "organization_id", organizationId)
ctx = tflog.SetField(ctx, "region", region)
ctx = tflog.SetField(ctx, "network_area_id", networkAreaId)
// Generate API request body from model
payload, err := toCreatePayload(ctx, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating network area route", fmt.Sprintf("Creating API payload: %v", err))
return
}
// Create new network area route
routes, err := r.client.CreateNetworkAreaRoute(ctx, organizationId, networkAreaId, region).CreateNetworkAreaRoutePayload(*payload).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating network area route", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
if routes.Items == nil || len(*routes.Items) == 0 {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating network area route.", "Empty response from API")
return
}
if len(*routes.Items) != 1 {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating network area route.", "New static route not found or more than 1 route found in API response.")
return
}
// Gets the route ID from the first element, routes.Items[0]
routeItems := *routes.Items
route := routeItems[0]
routeId := *route.Id
ctx = tflog.SetField(ctx, "network_area_route_id", routeId)
// Map response body to schema
err = mapFields(ctx, &route, &model, region)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating network area route.", 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, "Network area route created")
}
// Read refreshes the Terraform state with the latest data.
func (r *networkAreaRouteResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform
var model ModelV1
diags := req.State.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
organizationId := model.OrganizationId.ValueString()
networkAreaId := model.NetworkAreaId.ValueString()
region := r.providerData.GetRegionWithOverride(model.Region)
networkAreaRouteId := model.NetworkAreaRouteId.ValueString()
ctx = core.InitProviderContext(ctx)
ctx = tflog.SetField(ctx, "organization_id", organizationId)
ctx = tflog.SetField(ctx, "network_area_id", networkAreaId)
ctx = tflog.SetField(ctx, "region", region)
ctx = tflog.SetField(ctx, "network_area_route_id", networkAreaRouteId)
networkAreaRouteResp, err := r.client.GetNetworkAreaRoute(ctx, organizationId, networkAreaId, region, networkAreaRouteId).Execute()
if err != nil {
oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped
if ok && oapiErr.StatusCode == http.StatusNotFound {
resp.State.RemoveResource(ctx)
return
}
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading network area route.", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
// Map response body to schema
err = mapFields(ctx, networkAreaRouteResp, &model, region)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading network area route", 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, "Network area route read")
}
// Delete deletes the resource and removes the Terraform state on success.
func (r *networkAreaRouteResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve values from state
var model ModelV1
diags := req.State.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
organizationId := model.OrganizationId.ValueString()
networkAreaId := model.NetworkAreaId.ValueString()
region := r.providerData.GetRegionWithOverride(model.Region)
networkAreaRouteId := model.NetworkAreaRouteId.ValueString()
ctx = core.InitProviderContext(ctx)
ctx = tflog.SetField(ctx, "organization_id", organizationId)
ctx = tflog.SetField(ctx, "network_area_id", networkAreaId)
ctx = tflog.SetField(ctx, "region", region)
ctx = tflog.SetField(ctx, "network_area_route_id", networkAreaRouteId)
// Delete existing network
err := r.client.DeleteNetworkAreaRoute(ctx, organizationId, networkAreaId, region, networkAreaRouteId).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting network area route", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
tflog.Info(ctx, "Network area route deleted")
}
// Update updates the resource and sets the updated Terraform state on success.
func (r *networkAreaRouteResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve values from plan
var model ModelV1
diags := req.Plan.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
organizationId := model.OrganizationId.ValueString()
networkAreaId := model.NetworkAreaId.ValueString()
region := r.providerData.GetRegionWithOverride(model.Region)
networkAreaRouteId := model.NetworkAreaRouteId.ValueString()
ctx = core.InitProviderContext(ctx)
ctx = tflog.SetField(ctx, "organization_id", organizationId)
ctx = tflog.SetField(ctx, "network_area_id", networkAreaId)
ctx = tflog.SetField(ctx, "region", region)
ctx = tflog.SetField(ctx, "network_area_route_id", networkAreaRouteId)
// Retrieve values from state
var stateModel ModelV1
diags = req.State.Get(ctx, &stateModel)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
// Generate API request body from model
payload, err := toUpdatePayload(ctx, &model, stateModel.Labels)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating network area route", fmt.Sprintf("Creating API payload: %v", err))
return
}
// Update existing network area route
networkAreaRouteResp, err := r.client.UpdateNetworkAreaRoute(ctx, organizationId, networkAreaId, region, networkAreaRouteId).UpdateNetworkAreaRoutePayload(*payload).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating network area route", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
err = mapFields(ctx, networkAreaRouteResp, &model, region)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating network area route", 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, "Network area route updated")
}
// ImportState imports a resource into the Terraform state on success.
// The expected format of the resource import identifier is: organization_id,network_aread_id,network_area_route_id
func (r *networkAreaRouteResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
idParts := strings.Split(req.ID, core.Separator)
if len(idParts) != 4 || idParts[0] == "" || idParts[1] == "" || idParts[2] == "" || idParts[3] == "" {
core.LogAndAddError(ctx, &resp.Diagnostics,
"Error importing network area route",
fmt.Sprintf("Expected import identifier with format: [organization_id],[network_area_id],[region],[network_area_route_id] Got: %q", req.ID),
)
return
}
utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{
"organization_id": idParts[0],
"network_area_id": idParts[1],
"region": idParts[2],
"network_area_route_id": idParts[3],
})
tflog.Info(ctx, "Network area route state imported")
}
func mapFields(ctx context.Context, networkAreaRoute *iaas.Route, model *ModelV1, region string) error {
if networkAreaRoute == nil {
return fmt.Errorf("response input is nil")
}
if model == nil {
return fmt.Errorf("model input is nil")
}
var networkAreaRouteId string
if model.NetworkAreaRouteId.ValueString() != "" {
networkAreaRouteId = model.NetworkAreaRouteId.ValueString()
} else if networkAreaRoute.Id != nil {
networkAreaRouteId = *networkAreaRoute.Id
} else {
return fmt.Errorf("network area route id not present")
}
model.Id = utils.BuildInternalTerraformId(model.OrganizationId.ValueString(), model.NetworkAreaId.ValueString(), region, networkAreaRouteId)
model.Region = types.StringValue(region)
labels, err := iaasUtils.MapLabels(ctx, networkAreaRoute.Labels, model.Labels)
if err != nil {
return err
}
model.NetworkAreaRouteId = types.StringValue(networkAreaRouteId)
model.Labels = labels
model.NextHop, err = mapRouteNextHop(networkAreaRoute)
if err != nil {
return err
}
model.Destination, err = mapRouteDestination(networkAreaRoute)
if err != nil {
return err
}
return nil
}
func toCreatePayload(ctx context.Context, model *ModelV1) (*iaas.CreateNetworkAreaRoutePayload, error) {
if model == nil {
return nil, fmt.Errorf("nil model")
}
labels, err := conversion.ToStringInterfaceMap(ctx, model.Labels)
if err != nil {
return nil, fmt.Errorf("converting to Go map: %w", err)
}
nextHopPayload, err := toNextHopPayload(model)
if err != nil {
return nil, err
}
destinationPayload, err := toDestinationPayload(model)
if err != nil {
return nil, err
}
return &iaas.CreateNetworkAreaRoutePayload{
Items: &[]iaas.Route{
{
Destination: destinationPayload,
Labels: &labels,
Nexthop: nextHopPayload,
},
},
}, nil
}
func toUpdatePayload(ctx context.Context, model *ModelV1, currentLabels types.Map) (*iaas.UpdateNetworkAreaRoutePayload, error) {
if model == nil {
return nil, fmt.Errorf("nil model")
}
labels, err := conversion.ToJSONMapPartialUpdatePayload(ctx, currentLabels, model.Labels)
if err != nil {
return nil, fmt.Errorf("converting to Go map: %w", err)
}
return &iaas.UpdateNetworkAreaRoutePayload{
Labels: &labels,
}, nil
}
func toNextHopPayload(model *ModelV1) (*iaas.RouteNexthop, error) {
if model == nil {
return nil, fmt.Errorf("nil model")
} else if model.NextHop == nil {
return nil, fmt.Errorf("nexthop is nil in model")
}
switch model.NextHop.Type.ValueString() {
case "blackhole":
return sdkUtils.Ptr(iaas.NexthopBlackholeAsRouteNexthop(iaas.NewNexthopBlackhole("blackhole"))), nil
case "internet":
return sdkUtils.Ptr(iaas.NexthopInternetAsRouteNexthop(iaas.NewNexthopInternet("internet"))), nil
case "ipv4":
return sdkUtils.Ptr(iaas.NexthopIPv4AsRouteNexthop(iaas.NewNexthopIPv4("ipv4", model.NextHop.Value.ValueString()))), nil
case "ipv6":
return sdkUtils.Ptr(iaas.NexthopIPv6AsRouteNexthop(iaas.NewNexthopIPv6("ipv6", model.NextHop.Value.ValueString()))), nil
}
return nil, fmt.Errorf("unknown nexthop type: %s", model.NextHop.Type.ValueString())
}
func toDestinationPayload(model *ModelV1) (*iaas.RouteDestination, error) {
if model == nil {
return nil, fmt.Errorf("nil model")
} else if model.Destination == nil {
return nil, fmt.Errorf("destination is nil in model")
}
switch model.Destination.Type.ValueString() {
case "cidrv4":
return sdkUtils.Ptr(iaas.DestinationCIDRv4AsRouteDestination(iaas.NewDestinationCIDRv4("cidrv4", model.Destination.Value.ValueString()))), nil
case "cidrv6":
return sdkUtils.Ptr(iaas.DestinationCIDRv6AsRouteDestination(iaas.NewDestinationCIDRv6("cidrv6", model.Destination.Value.ValueString()))), nil
}
return nil, fmt.Errorf("unknown destination type: %s", model.Destination.Type.ValueString())
}
func mapRouteNextHop(routeResp *iaas.Route) (*NexthopModelV1, error) {
if routeResp.Nexthop == nil {
return &NexthopModelV1{
Type: types.StringNull(),
Value: types.StringNull(),
}, nil
}
switch i := routeResp.Nexthop.GetActualInstance().(type) {
case *iaas.NexthopIPv4:
return &NexthopModelV1{
Type: types.StringPointerValue(i.Type),
Value: types.StringPointerValue(i.Value),
}, nil
case *iaas.NexthopIPv6:
return &NexthopModelV1{
Type: types.StringPointerValue(i.Type),
Value: types.StringPointerValue(i.Value),
}, nil
case *iaas.NexthopBlackhole:
return &NexthopModelV1{
Type: types.StringPointerValue(i.Type),
Value: types.StringNull(),
}, nil
case *iaas.NexthopInternet:
return &NexthopModelV1{
Type: types.StringPointerValue(i.Type),
Value: types.StringNull(),
}, nil
default:
return nil, fmt.Errorf("unexpected nexthop type: %T", i)
}
}
func mapRouteDestination(routeResp *iaas.Route) (*DestinationModelV1, error) {
if routeResp.Destination == nil {
return &DestinationModelV1{
Type: types.StringNull(),
Value: types.StringNull(),
}, nil
}
switch i := routeResp.Destination.GetActualInstance().(type) {
case *iaas.DestinationCIDRv4:
return &DestinationModelV1{
Type: types.StringPointerValue(i.Type),
Value: types.StringPointerValue(i.Value),
}, nil
case *iaas.DestinationCIDRv6:
return &DestinationModelV1{
Type: types.StringPointerValue(i.Type),
Value: types.StringPointerValue(i.Value),
}, nil
default:
return nil, fmt.Errorf("unexpected Destionation type: %T", i)
}
}

View file

@ -1,623 +0,0 @@
package networkarearoute
import (
"context"
"reflect"
"testing"
"github.com/stackitcloud/stackit-sdk-go/core/utils"
"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/iaas"
)
func TestMapFields(t *testing.T) {
type args struct {
state ModelV1
input *iaas.Route
region string
}
tests := []struct {
description string
args args
expected ModelV1
isValid bool
}{
{
description: "id_ok",
args: args{
state: ModelV1{
OrganizationId: types.StringValue("oid"),
NetworkAreaId: types.StringValue("naid"),
NetworkAreaRouteId: types.StringValue("narid"),
},
input: &iaas.Route{},
region: "eu01",
},
expected: ModelV1{
Id: types.StringValue("oid,naid,eu01,narid"),
OrganizationId: types.StringValue("oid"),
NetworkAreaId: types.StringValue("naid"),
NetworkAreaRouteId: types.StringValue("narid"),
Destination: &DestinationModelV1{
Type: types.StringNull(),
Value: types.StringNull(),
},
NextHop: &NexthopModelV1{
Type: types.StringNull(),
Value: types.StringNull(),
},
Labels: types.MapNull(types.StringType),
Region: types.StringValue("eu01"),
},
isValid: true,
},
{
description: "values_ok",
args: args{
state: ModelV1{
OrganizationId: types.StringValue("oid"),
NetworkAreaId: types.StringValue("naid"),
NetworkAreaRouteId: types.StringValue("narid"),
Region: types.StringValue("eu01"),
},
input: &iaas.Route{
Destination: &iaas.RouteDestination{
DestinationCIDRv4: &iaas.DestinationCIDRv4{
Type: utils.Ptr("cidrv4"),
Value: utils.Ptr("prefix"),
},
DestinationCIDRv6: nil,
},
Nexthop: &iaas.RouteNexthop{
NexthopIPv4: &iaas.NexthopIPv4{
Type: utils.Ptr("ipv4"),
Value: utils.Ptr("hop"),
},
},
Labels: &map[string]interface{}{
"key": "value",
},
},
region: "eu02",
},
expected: ModelV1{
Id: types.StringValue("oid,naid,eu02,narid"),
OrganizationId: types.StringValue("oid"),
NetworkAreaId: types.StringValue("naid"),
NetworkAreaRouteId: types.StringValue("narid"),
Destination: &DestinationModelV1{
Type: types.StringValue("cidrv4"),
Value: types.StringValue("prefix"),
},
NextHop: &NexthopModelV1{
Type: types.StringValue("ipv4"),
Value: types.StringValue("hop"),
},
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
Region: types.StringValue("eu02"),
},
isValid: true,
},
{
description: "response_fields_nil_fail",
args: args{
input: &iaas.Route{
Destination: nil,
Nexthop: nil,
},
},
},
{
description: "response_nil_fail",
},
{
description: "no_resource_id",
args: args{
state: ModelV1{
OrganizationId: types.StringValue("oid"),
NetworkAreaId: types.StringValue("naid"),
},
input: &iaas.Route{},
},
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
err := mapFields(context.Background(), tt.args.input, &tt.args.state, tt.args.region)
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.args.state, tt.expected)
if diff != "" {
t.Fatalf("Data does not match: %s", diff)
}
}
})
}
}
func TestToCreatePayload(t *testing.T) {
tests := []struct {
description string
input *ModelV1
expected *iaas.CreateNetworkAreaRoutePayload
isValid bool
}{
{
description: "default_ok",
input: &ModelV1{
Destination: &DestinationModelV1{
Type: types.StringValue("cidrv4"),
Value: types.StringValue("prefix"),
},
NextHop: &NexthopModelV1{
Type: types.StringValue("ipv4"),
Value: types.StringValue("hop"),
},
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
},
expected: &iaas.CreateNetworkAreaRoutePayload{
Items: &[]iaas.Route{
{
Destination: &iaas.RouteDestination{
DestinationCIDRv4: &iaas.DestinationCIDRv4{
Type: utils.Ptr("cidrv4"),
Value: utils.Ptr("prefix"),
},
DestinationCIDRv6: nil,
},
Nexthop: &iaas.RouteNexthop{
NexthopIPv4: &iaas.NexthopIPv4{
Type: utils.Ptr("ipv4"),
Value: utils.Ptr("hop"),
},
},
Labels: &map[string]interface{}{
"key": "value",
},
},
},
},
isValid: true,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
output, err := toCreatePayload(context.Background(), 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 *ModelV1
expected *iaas.UpdateNetworkAreaRoutePayload
isValid bool
}{
{
"default_ok",
&ModelV1{
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key1": types.StringValue("value1"),
"key2": types.StringValue("value2"),
}),
},
&iaas.UpdateNetworkAreaRoutePayload{
Labels: &map[string]interface{}{
"key1": "value1",
"key2": "value2",
},
},
true,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
output, err := toUpdatePayload(context.Background(), tt.input, types.MapNull(types.StringType))
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, cmp.AllowUnexported(iaas.NullableString{}))
if diff != "" {
t.Fatalf("Data does not match: %s", diff)
}
}
})
}
}
func TestToNextHopPayload(t *testing.T) {
type args struct {
model *ModelV1
}
tests := []struct {
name string
args args
want *iaas.RouteNexthop
wantErr bool
}{
{
name: "ipv4",
args: args{
model: &ModelV1{
NextHop: &NexthopModelV1{
Type: types.StringValue("ipv4"),
Value: types.StringValue("10.20.30.40"),
},
},
},
want: &iaas.RouteNexthop{
NexthopIPv4: &iaas.NexthopIPv4{
Type: utils.Ptr("ipv4"),
Value: utils.Ptr("10.20.30.40"),
},
},
wantErr: false,
},
{
name: "ipv6",
args: args{
model: &ModelV1{
NextHop: &NexthopModelV1{
Type: types.StringValue("ipv6"),
Value: types.StringValue("2001:db8:85a3:0:0:8a2e:370:7334"),
},
},
},
want: &iaas.RouteNexthop{
NexthopIPv6: &iaas.NexthopIPv6{
Type: utils.Ptr("ipv6"),
Value: utils.Ptr("2001:db8:85a3:0:0:8a2e:370:7334"),
},
},
wantErr: false,
},
{
name: "internet",
args: args{
model: &ModelV1{
NextHop: &NexthopModelV1{
Type: types.StringValue("internet"),
},
},
},
want: &iaas.RouteNexthop{
NexthopInternet: &iaas.NexthopInternet{
Type: utils.Ptr("internet"),
},
},
wantErr: false,
},
{
name: "blackhole",
args: args{
model: &ModelV1{
NextHop: &NexthopModelV1{
Type: types.StringValue("blackhole"),
},
},
},
want: &iaas.RouteNexthop{
NexthopBlackhole: &iaas.NexthopBlackhole{
Type: utils.Ptr("blackhole"),
},
},
wantErr: false,
},
{
name: "invalid type",
args: args{
model: &ModelV1{
NextHop: &NexthopModelV1{
Type: types.StringValue("foobar"),
},
},
},
wantErr: true,
},
{
name: "model is nil",
args: args{
model: nil,
},
wantErr: true,
},
{
name: "nexthop in model is nil",
args: args{
model: &ModelV1{
NextHop: nil,
},
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := toNextHopPayload(tt.args.model)
if (err != nil) != tt.wantErr {
t.Errorf("toNextHopPayload() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("toNextHopPayload() got = %v, want %v", got, tt.want)
}
})
}
}
func TestToDestinationPayload(t *testing.T) {
type args struct {
model *ModelV1
}
tests := []struct {
name string
args args
want *iaas.RouteDestination
wantErr bool
}{
{
name: "cidrv4",
args: args{
model: &ModelV1{
Destination: &DestinationModelV1{
Type: types.StringValue("cidrv4"),
Value: types.StringValue("192.168.1.0/24"),
},
},
},
want: &iaas.RouteDestination{
DestinationCIDRv4: &iaas.DestinationCIDRv4{
Type: utils.Ptr("cidrv4"),
Value: utils.Ptr("192.168.1.0/24"),
},
},
wantErr: false,
},
{
name: "cidrv6",
args: args{
model: &ModelV1{
Destination: &DestinationModelV1{
Type: types.StringValue("cidrv6"),
Value: types.StringValue("2001:db8:1234::/48"),
},
},
},
want: &iaas.RouteDestination{
DestinationCIDRv6: &iaas.DestinationCIDRv6{
Type: utils.Ptr("cidrv6"),
Value: utils.Ptr("2001:db8:1234::/48"),
},
},
wantErr: false,
},
{
name: "invalid type",
args: args{
model: &ModelV1{
Destination: &DestinationModelV1{
Type: types.StringValue("foobar"),
},
},
},
wantErr: true,
},
{
name: "model is nil",
args: args{
model: nil,
},
wantErr: true,
},
{
name: "destination in model is nil",
args: args{
model: &ModelV1{
Destination: nil,
},
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := toDestinationPayload(tt.args.model)
if (err != nil) != tt.wantErr {
t.Errorf("toDestinationPayload() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("toDestinationPayload() got = %v, want %v", got, tt.want)
}
})
}
}
func TestMapRouteNextHop(t *testing.T) {
type args struct {
routeResp *iaas.Route
}
tests := []struct {
name string
args args
want *NexthopModelV1
wantErr bool
}{
{
name: "ipv4",
args: args{
routeResp: &iaas.Route{
Nexthop: &iaas.RouteNexthop{
NexthopIPv4: &iaas.NexthopIPv4{
Type: utils.Ptr("ipv4"),
Value: utils.Ptr("192.168.1.0/24"),
},
},
},
},
want: &NexthopModelV1{
Type: types.StringValue("ipv4"),
Value: types.StringValue("192.168.1.0/24"),
},
},
{
name: "ipv6",
args: args{
routeResp: &iaas.Route{
Nexthop: &iaas.RouteNexthop{
NexthopIPv4: &iaas.NexthopIPv4{
Type: utils.Ptr("ipv6"),
Value: utils.Ptr("2001:db8:85a3:0:0:8a2e:370:7334"),
},
},
},
},
want: &NexthopModelV1{
Type: types.StringValue("ipv6"),
Value: types.StringValue("2001:db8:85a3:0:0:8a2e:370:7334"),
},
},
{
name: "blackhole",
args: args{
routeResp: &iaas.Route{
Nexthop: &iaas.RouteNexthop{
NexthopBlackhole: &iaas.NexthopBlackhole{
Type: utils.Ptr("blackhole"),
},
},
},
},
want: &NexthopModelV1{
Type: types.StringValue("blackhole"),
},
},
{
name: "internet",
args: args{
routeResp: &iaas.Route{
Nexthop: &iaas.RouteNexthop{
NexthopInternet: &iaas.NexthopInternet{
Type: utils.Ptr("internet"),
},
},
},
},
want: &NexthopModelV1{
Type: types.StringValue("internet"),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := mapRouteNextHop(tt.args.routeResp)
if (err != nil) != tt.wantErr {
t.Errorf("mapRouteNextHop() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("mapRouteNextHop() got = %v, want %v", got, tt.want)
}
})
}
}
func TestMapRouteDestination(t *testing.T) {
type args struct {
routeResp *iaas.Route
}
tests := []struct {
name string
args args
want *DestinationModelV1
wantErr bool
}{
{
name: "cidrv4",
args: args{
routeResp: &iaas.Route{
Destination: &iaas.RouteDestination{
DestinationCIDRv4: &iaas.DestinationCIDRv4{
Type: utils.Ptr("cidrv4"),
Value: utils.Ptr("192.168.1.0/24"),
},
},
},
},
want: &DestinationModelV1{
Type: types.StringValue("cidrv4"),
Value: types.StringValue("192.168.1.0/24"),
},
},
{
name: "cidrv6",
args: args{
routeResp: &iaas.Route{
Destination: &iaas.RouteDestination{
DestinationCIDRv4: &iaas.DestinationCIDRv4{
Type: utils.Ptr("cidrv6"),
Value: utils.Ptr("2001:db8:1234::/48"),
},
},
},
},
want: &DestinationModelV1{
Type: types.StringValue("cidrv6"),
Value: types.StringValue("2001:db8:1234::/48"),
},
},
{
name: "destination in API response is nil",
args: args{
routeResp: &iaas.Route{
Destination: nil,
},
},
want: &DestinationModelV1{
Type: types.StringNull(),
Value: types.StringNull(),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := mapRouteDestination(tt.args.routeResp)
if (err != nil) != tt.wantErr {
t.Errorf("mapRouteDestination() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("mapRouteDestination() got = %v, want %v", got, tt.want)
}
})
}
}

View file

@ -1,193 +0,0 @@
package networkinterface
import (
"context"
"fmt"
"net/http"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
iaasUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/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/iaas"
"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 = &networkInterfaceDataSource{}
)
// NewNetworkInterfaceDataSource is a helper function to simplify the provider implementation.
func NewNetworkInterfaceDataSource() datasource.DataSource {
return &networkInterfaceDataSource{}
}
// networkInterfaceDataSource is the data source implementation.
type networkInterfaceDataSource struct {
client *iaas.APIClient
providerData core.ProviderData
}
// Metadata returns the data source type name.
func (d *networkInterfaceDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_network_interface"
}
func (d *networkInterfaceDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
var ok bool
d.providerData, ok = conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics)
if !ok {
return
}
apiClient := iaasUtils.ConfigureClient(ctx, &d.providerData, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}
d.client = apiClient
tflog.Info(ctx, "IaaS client configured")
}
// Schema defines the schema for the data source.
func (d *networkInterfaceDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
typeOptions := []string{"server", "metadata", "gateway"}
description := "Network interface datasource schema. Must have a `region` specified in the provider configuration."
resp.Schema = schema.Schema{
MarkdownDescription: description,
Description: description,
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: "Terraform's internal data source ID. It is structured as \"`project_id`,`region`,`network_id`,`network_interface_id`\".",
Computed: true,
},
"project_id": schema.StringAttribute{
Description: "STACKIT project ID to which the network interface is associated.",
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"region": schema.StringAttribute{
Description: "The resource region. If not defined, the provider region is used.",
// the region cannot be found, so it has to be passed
Optional: true,
},
"network_id": schema.StringAttribute{
Description: "The network ID to which the network interface is associated.",
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"network_interface_id": schema.StringAttribute{
Description: "The network interface ID.",
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"name": schema.StringAttribute{
Description: "The name of the network interface.",
Computed: true,
},
"allowed_addresses": schema.ListAttribute{
Description: "The list of CIDR (Classless Inter-Domain Routing) notations.",
Computed: true,
ElementType: types.StringType,
},
"device": schema.StringAttribute{
Description: "The device UUID of the network interface.",
Computed: true,
},
"ipv4": schema.StringAttribute{
Description: "The IPv4 address.",
Computed: true,
},
"labels": schema.MapAttribute{
Description: "Labels are key-value string pairs which can be attached to a network interface.",
ElementType: types.StringType,
Computed: true,
},
"mac": schema.StringAttribute{
Description: "The MAC address of network interface.",
Computed: true,
},
"security": schema.BoolAttribute{
Description: "The Network Interface Security. If set to false, then no security groups will apply to this network interface.",
Computed: true,
},
"security_group_ids": schema.ListAttribute{
Description: "The list of security group UUIDs. If security is set to false, setting this field will lead to an error.",
Computed: true,
ElementType: types.StringType,
},
"type": schema.StringAttribute{
Description: "Type of network interface. Some of the possible values are: " + utils.FormatPossibleValues(typeOptions...),
Computed: true,
},
},
}
}
// Read refreshes the Terraform state with the latest data.
func (d *networkInterfaceDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform
var model Model
diags := req.Config.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
region := d.providerData.GetRegionWithOverride(model.Region)
networkId := model.NetworkId.ValueString()
networkInterfaceId := model.NetworkInterfaceId.ValueString()
ctx = core.InitProviderContext(ctx)
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "region", region)
ctx = tflog.SetField(ctx, "network_id", networkId)
ctx = tflog.SetField(ctx, "network_interface_id", networkInterfaceId)
networkInterfaceResp, err := d.client.GetNic(ctx, projectId, region, networkId, networkInterfaceId).Execute()
if err != nil {
utils.LogError(
ctx,
&resp.Diagnostics,
err,
"Reading network interface",
fmt.Sprintf("Network interface with ID %q or network with ID %q does not exist in project %q.", networkInterfaceId, networkId, 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)
err = mapFields(ctx, networkInterfaceResp, &model, region)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading network interface", 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, "Network interface read")
}

View file

@ -1,683 +0,0 @@
package networkinterface
import (
"context"
"fmt"
"net/http"
"regexp"
"strings"
iaasUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/utils"
"github.com/hashicorp/terraform-plugin-framework-validators/listvalidator"
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
"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-log/tflog"
"github.com/stackitcloud/stackit-sdk-go/core/oapierror"
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
"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/utils"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate"
)
// Ensure the implementation satisfies the expected interfaces.
var (
_ resource.Resource = &networkInterfaceResource{}
_ resource.ResourceWithConfigure = &networkInterfaceResource{}
_ resource.ResourceWithImportState = &networkInterfaceResource{}
_ resource.ResourceWithModifyPlan = &networkInterfaceResource{}
)
type Model struct {
Id types.String `tfsdk:"id"` // needed by TF
ProjectId types.String `tfsdk:"project_id"`
NetworkId types.String `tfsdk:"network_id"`
Region types.String `tfsdk:"region"`
NetworkInterfaceId types.String `tfsdk:"network_interface_id"`
Name types.String `tfsdk:"name"`
AllowedAddresses types.List `tfsdk:"allowed_addresses"`
IPv4 types.String `tfsdk:"ipv4"`
Labels types.Map `tfsdk:"labels"`
Security types.Bool `tfsdk:"security"`
SecurityGroupIds types.List `tfsdk:"security_group_ids"`
Device types.String `tfsdk:"device"`
Mac types.String `tfsdk:"mac"`
Type types.String `tfsdk:"type"`
}
// NewNetworkInterfaceResource is a helper function to simplify the provider implementation.
func NewNetworkInterfaceResource() resource.Resource {
return &networkInterfaceResource{}
}
// networkResource is the resource implementation.
type networkInterfaceResource struct {
client *iaas.APIClient
providerData core.ProviderData
}
// ModifyPlan implements resource.ResourceWithModifyPlan.
func (r *networkInterfaceResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { // nolint:gocritic // function signature required by Terraform
// skip initial empty configuration to avoid follow-up errors
if req.Config.Raw.IsNull() {
return
}
var configModel Model
resp.Diagnostics.Append(req.Config.Get(ctx, &configModel)...)
if resp.Diagnostics.HasError() {
return
}
var planModel Model
resp.Diagnostics.Append(req.Plan.Get(ctx, &planModel)...)
if resp.Diagnostics.HasError() {
return
}
// If allowed_addresses were completly removed from the config this is not recognized by terraform
// since this field is optional and computed therefore this plan modifier is needed.
utils.CheckListRemoval(ctx, configModel.AllowedAddresses, planModel.AllowedAddresses, path.Root("allowed_addresses"), types.StringType, false, resp)
if resp.Diagnostics.HasError() {
return
}
// If security_group_ids were completly removed from the config this is not recognized by terraform
// since this field is optional and computed therefore this plan modifier is needed.
utils.CheckListRemoval(ctx, configModel.SecurityGroupIds, planModel.SecurityGroupIds, path.Root("security_group_ids"), types.StringType, true, resp)
if resp.Diagnostics.HasError() {
return
}
// Use the modifier to set the effective region in the current plan.
utils.AdaptRegion(ctx, configModel.Region, &planModel.Region, r.providerData.GetRegion(), resp)
if resp.Diagnostics.HasError() {
return
}
resp.Diagnostics.Append(resp.Plan.Set(ctx, planModel)...)
if resp.Diagnostics.HasError() {
return
}
}
// Metadata returns the resource type name.
func (r *networkInterfaceResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_network_interface"
}
// Configure adds the provider configured client to the resource.
func (r *networkInterfaceResource) 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
}
apiClient := iaasUtils.ConfigureClient(ctx, &r.providerData, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}
r.client = apiClient
tflog.Info(ctx, "iaas client configured")
}
// Schema defines the schema for the resource.
func (r *networkInterfaceResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
typeOptions := []string{"server", "metadata", "gateway"}
description := "Network interface resource schema. Must have a `region` specified in the provider configuration."
resp.Schema = schema.Schema{
MarkdownDescription: description,
Description: description,
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: "Terraform's internal resource ID. It is structured as \"`project_id`,`region`,`network_id`,`network_interface_id`\".",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"project_id": schema.StringAttribute{
Description: "STACKIT project ID to which the network is associated.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"network_id": schema.StringAttribute{
Description: "The network ID to which the network interface is associated.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"network_interface_id": schema.StringAttribute{
Description: "The network interface ID.",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"region": schema.StringAttribute{
Description: "The resource region. If not defined, the provider region is used.",
Optional: true,
// must be computed to allow for storing the override value from the provider
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"name": schema.StringAttribute{
Description: "The name of the network interface.",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
Validators: []validator.String{
stringvalidator.LengthAtLeast(1),
stringvalidator.LengthAtMost(63),
stringvalidator.RegexMatches(
regexp.MustCompile(`^[A-Za-z0-9]+((-|_|\s|\.)[A-Za-z0-9]+)*$`),
"must match expression"),
},
},
"allowed_addresses": schema.ListAttribute{
Description: "The list of CIDR (Classless Inter-Domain Routing) notations.",
Optional: true,
Computed: true,
ElementType: types.StringType,
Validators: []validator.List{
listvalidator.ValueStringsAre(
validate.CIDR(),
),
},
},
"device": schema.StringAttribute{
Description: "The device UUID of the network interface.",
Computed: true,
},
"ipv4": schema.StringAttribute{
Description: "The IPv4 address.",
Optional: true,
Computed: true,
Validators: []validator.String{
validate.IP(false),
},
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
stringplanmodifier.UseStateForUnknown(),
},
},
"labels": schema.MapAttribute{
Description: "Labels are key-value string pairs which can be attached to a network interface.",
ElementType: types.StringType,
Optional: true,
},
"mac": schema.StringAttribute{
Description: "The MAC address of network interface.",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"security": schema.BoolAttribute{
Description: "The Network Interface Security. If set to false, then no security groups will apply to this network interface.",
Computed: true,
Optional: true,
},
"security_group_ids": schema.ListAttribute{
Description: "The list of security group UUIDs. If security is set to false, setting this field will lead to an error.",
Optional: true,
Computed: true,
ElementType: types.StringType,
Validators: []validator.List{
listvalidator.ValueStringsAre(
stringvalidator.RegexMatches(
regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`),
"must match expression"),
),
},
},
"type": schema.StringAttribute{
Description: "Type of network interface. Some of the possible values are: " + utils.FormatPossibleValues(typeOptions...),
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
},
}
}
// Create creates the resource and sets the initial Terraform state.
func (r *networkInterfaceResource) 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()
region := r.providerData.GetRegionWithOverride(model.Region)
networkId := model.NetworkId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "region", region)
ctx = tflog.SetField(ctx, "network_id", networkId)
// Generate API request body from model
payload, err := toCreatePayload(ctx, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating network interface", fmt.Sprintf("Creating API payload: %v", err))
return
}
// Create new network interface
networkInterface, err := r.client.CreateNic(ctx, projectId, region, networkId).CreateNicPayload(*payload).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating network interface", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
networkInterfaceId := *networkInterface.Id
ctx = tflog.SetField(ctx, "network_interface_id", networkInterfaceId)
// Map response body to schema
err = mapFields(ctx, networkInterface, &model, region)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating network interface", 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, "Network interface created")
}
// Read refreshes the Terraform state with the latest data.
func (r *networkInterfaceResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform
var model Model
diags := req.State.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
region := r.providerData.GetRegionWithOverride(model.Region)
networkId := model.NetworkId.ValueString()
networkInterfaceId := model.NetworkInterfaceId.ValueString()
ctx = core.InitProviderContext(ctx)
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "region", region)
ctx = tflog.SetField(ctx, "network_id", networkId)
ctx = tflog.SetField(ctx, "network_interface_id", networkInterfaceId)
networkInterfaceResp, err := r.client.GetNic(ctx, projectId, region, networkId, networkInterfaceId).Execute()
if err != nil {
oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped
if ok && oapiErr.StatusCode == http.StatusNotFound {
resp.State.RemoveResource(ctx)
return
}
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading network interface", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
// Map response body to schema
err = mapFields(ctx, networkInterfaceResp, &model, region)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading network interface", 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, "Network interface read")
}
// Update updates the resource and sets the updated Terraform state on success.
func (r *networkInterfaceResource) 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
}
projectId := model.ProjectId.ValueString()
region := r.providerData.GetRegionWithOverride(model.Region)
networkId := model.NetworkId.ValueString()
networkInterfaceId := model.NetworkInterfaceId.ValueString()
ctx = core.InitProviderContext(ctx)
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "region", region)
ctx = tflog.SetField(ctx, "network_id", networkId)
ctx = tflog.SetField(ctx, "network_interface_id", networkInterfaceId)
// Retrieve values from state
var stateModel Model
diags = req.State.Get(ctx, &stateModel)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
// Generate API request body from model
payload, err := toUpdatePayload(ctx, &model, stateModel.Labels)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating network interface", fmt.Sprintf("Creating API payload: %v", err))
return
}
// Update existing network
nicResp, err := r.client.UpdateNic(ctx, projectId, region, networkId, networkInterfaceId).UpdateNicPayload(*payload).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating network interface", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
err = mapFields(ctx, nicResp, &model, region)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating network interface", 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, "Network interface updated")
}
// Delete deletes the resource and removes the Terraform state on success.
func (r *networkInterfaceResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve values from state
var model Model
diags := req.State.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
region := r.providerData.GetRegionWithOverride(model.Region)
networkId := model.NetworkId.ValueString()
networkInterfaceId := model.NetworkInterfaceId.ValueString()
ctx = core.InitProviderContext(ctx)
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "region", region)
ctx = tflog.SetField(ctx, "network_id", networkId)
ctx = tflog.SetField(ctx, "network_interface_id", networkInterfaceId)
// Delete existing network interface
err := r.client.DeleteNic(ctx, projectId, region, networkId, networkInterfaceId).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting network interface", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
tflog.Info(ctx, "Network interface deleted")
}
// ImportState imports a resource into the Terraform state on success.
// The expected format of the resource import identifier is: project_id,network_id,network_interface_id
func (r *networkInterfaceResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
idParts := strings.Split(req.ID, core.Separator)
if len(idParts) != 4 || idParts[0] == "" || idParts[1] == "" || idParts[2] == "" || idParts[3] == "" {
core.LogAndAddError(ctx, &resp.Diagnostics,
"Error importing network interface",
fmt.Sprintf("Expected import identifier with format: [project_id],[region],[network_id],[network_interface_id] Got: %q", req.ID),
)
return
}
utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{
"project_id": idParts[0],
"region": idParts[1],
"network_id": idParts[2],
"network_interface_id": idParts[3],
})
tflog.Info(ctx, "Network interface state imported")
}
func mapFields(ctx context.Context, networkInterfaceResp *iaas.NIC, model *Model, region string) error {
if networkInterfaceResp == nil {
return fmt.Errorf("response input is nil")
}
if model == nil {
return fmt.Errorf("model input is nil")
}
var networkInterfaceId string
if model.NetworkInterfaceId.ValueString() != "" {
networkInterfaceId = model.NetworkInterfaceId.ValueString()
} else if networkInterfaceResp.NetworkId != nil {
networkInterfaceId = *networkInterfaceResp.Id
} else {
return fmt.Errorf("network interface id not present")
}
model.Id = utils.BuildInternalTerraformId(model.ProjectId.ValueString(), region, model.NetworkId.ValueString(), networkInterfaceId)
model.Region = types.StringValue(region)
respAllowedAddresses := []string{}
var diags diag.Diagnostics
if networkInterfaceResp.AllowedAddresses == nil {
// If we send an empty list, the API will send null in the response
// We should handle this case and set the value to an empty list
if !model.AllowedAddresses.IsNull() {
model.AllowedAddresses, diags = types.ListValueFrom(ctx, types.StringType, []string{})
if diags.HasError() {
return fmt.Errorf("map network interface allowed addresses: %w", core.DiagsToError(diags))
}
} else {
model.AllowedAddresses = types.ListNull(types.StringType)
}
} else {
for _, n := range *networkInterfaceResp.AllowedAddresses {
respAllowedAddresses = append(respAllowedAddresses, *n.String)
}
modelAllowedAddresses, err := utils.ListValuetoStringSlice(model.AllowedAddresses)
if err != nil {
return fmt.Errorf("get current network interface allowed addresses from model: %w", err)
}
reconciledAllowedAddresses := utils.ReconcileStringSlices(modelAllowedAddresses, respAllowedAddresses)
allowedAddressesTF, diags := types.ListValueFrom(ctx, types.StringType, reconciledAllowedAddresses)
if diags.HasError() {
return fmt.Errorf("map network interface allowed addresses: %w", core.DiagsToError(diags))
}
model.AllowedAddresses = allowedAddressesTF
}
if networkInterfaceResp.SecurityGroups == nil {
model.SecurityGroupIds = types.ListNull(types.StringType)
} else {
respSecurityGroups := *networkInterfaceResp.SecurityGroups
modelSecurityGroups, err := utils.ListValuetoStringSlice(model.SecurityGroupIds)
if err != nil {
return fmt.Errorf("get current network interface security groups from model: %w", err)
}
reconciledSecurityGroups := utils.ReconcileStringSlices(modelSecurityGroups, respSecurityGroups)
securityGroupsTF, diags := types.ListValueFrom(ctx, types.StringType, reconciledSecurityGroups)
if diags.HasError() {
return fmt.Errorf("map network interface security groups: %w", core.DiagsToError(diags))
}
model.SecurityGroupIds = securityGroupsTF
}
labels, err := iaasUtils.MapLabels(ctx, networkInterfaceResp.Labels, model.Labels)
if err != nil {
return err
}
networkInterfaceName := types.StringNull()
if networkInterfaceResp.Name != nil && *networkInterfaceResp.Name != "" {
networkInterfaceName = types.StringPointerValue(networkInterfaceResp.Name)
}
model.NetworkInterfaceId = types.StringValue(networkInterfaceId)
model.Name = networkInterfaceName
model.IPv4 = types.StringPointerValue(networkInterfaceResp.Ipv4)
model.Security = types.BoolPointerValue(networkInterfaceResp.NicSecurity)
model.Device = types.StringPointerValue(networkInterfaceResp.Device)
model.Mac = types.StringPointerValue(networkInterfaceResp.Mac)
model.Type = types.StringPointerValue(networkInterfaceResp.Type)
model.Labels = labels
return nil
}
func toCreatePayload(ctx context.Context, model *Model) (*iaas.CreateNicPayload, error) {
if model == nil {
return nil, fmt.Errorf("nil model")
}
var labelPayload *map[string]interface{}
modelSecurityGroups := []string{}
if !(model.SecurityGroupIds.IsNull() || model.SecurityGroupIds.IsUnknown()) {
for _, ns := range model.SecurityGroupIds.Elements() {
securityGroupString, ok := ns.(types.String)
if !ok {
return nil, fmt.Errorf("type assertion failed")
}
modelSecurityGroups = append(modelSecurityGroups, securityGroupString.ValueString())
}
}
allowedAddressesPayload := &[]iaas.AllowedAddressesInner{}
if !(model.AllowedAddresses.IsNull() || model.AllowedAddresses.IsUnknown()) {
for _, allowedAddressModel := range model.AllowedAddresses.Elements() {
allowedAddressString, ok := allowedAddressModel.(types.String)
if !ok {
return nil, fmt.Errorf("type assertion failed")
}
*allowedAddressesPayload = append(*allowedAddressesPayload, iaas.AllowedAddressesInner{
String: conversion.StringValueToPointer(allowedAddressString),
})
}
} else {
allowedAddressesPayload = nil
}
if !model.Labels.IsNull() && !model.Labels.IsUnknown() {
labelMap, err := conversion.ToStringInterfaceMap(ctx, model.Labels)
if err != nil {
return nil, fmt.Errorf("mapping labels: %w", err)
}
labelPayload = &labelMap
}
return &iaas.CreateNicPayload{
AllowedAddresses: allowedAddressesPayload,
SecurityGroups: &modelSecurityGroups,
Labels: labelPayload,
Name: conversion.StringValueToPointer(model.Name),
Device: conversion.StringValueToPointer(model.Device),
Ipv4: conversion.StringValueToPointer(model.IPv4),
Mac: conversion.StringValueToPointer(model.Mac),
Type: conversion.StringValueToPointer(model.Type),
NicSecurity: conversion.BoolValueToPointer(model.Security),
}, nil
}
func toUpdatePayload(ctx context.Context, model *Model, currentLabels types.Map) (*iaas.UpdateNicPayload, error) {
if model == nil {
return nil, fmt.Errorf("nil model")
}
var labelPayload *map[string]interface{}
modelSecurityGroups := []string{}
for _, ns := range model.SecurityGroupIds.Elements() {
securityGroupString, ok := ns.(types.String)
if !ok {
return nil, fmt.Errorf("type assertion failed")
}
modelSecurityGroups = append(modelSecurityGroups, securityGroupString.ValueString())
}
allowedAddressesPayload := []iaas.AllowedAddressesInner{} // Even if null in the model, we need to send an empty list to the API since it's a PATCH endpoint
if !(model.AllowedAddresses.IsNull() || model.AllowedAddresses.IsUnknown()) {
for _, allowedAddressModel := range model.AllowedAddresses.Elements() {
allowedAddressString, ok := allowedAddressModel.(types.String)
if !ok {
return nil, fmt.Errorf("type assertion failed")
}
allowedAddressesPayload = append(allowedAddressesPayload, iaas.AllowedAddressesInner{
String: conversion.StringValueToPointer(allowedAddressString),
})
}
}
if !model.Labels.IsNull() && !model.Labels.IsUnknown() {
labelMap, err := conversion.ToJSONMapPartialUpdatePayload(ctx, currentLabels, model.Labels)
if err != nil {
return nil, fmt.Errorf("mapping labels: %w", err)
}
labelPayload = &labelMap
}
return &iaas.UpdateNicPayload{
AllowedAddresses: &allowedAddressesPayload,
SecurityGroups: &modelSecurityGroups,
Labels: labelPayload,
Name: conversion.StringValueToPointer(model.Name),
NicSecurity: conversion.BoolValueToPointer(model.Security),
}, nil
}

View file

@ -1,368 +0,0 @@
package networkinterface
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/iaas"
)
func TestMapFields(t *testing.T) {
type args struct {
state Model
input *iaas.NIC
region string
}
tests := []struct {
description string
args args
expected Model
isValid bool
}{
{
description: "id_ok",
args: args{
state: Model{
ProjectId: types.StringValue("pid"),
NetworkId: types.StringValue("nid"),
NetworkInterfaceId: types.StringValue("nicid"),
},
input: &iaas.NIC{
Id: utils.Ptr("nicid"),
},
region: "eu01",
},
expected: Model{
Id: types.StringValue("pid,eu01,nid,nicid"),
ProjectId: types.StringValue("pid"),
NetworkId: types.StringValue("nid"),
NetworkInterfaceId: types.StringValue("nicid"),
Name: types.StringNull(),
AllowedAddresses: types.ListNull(types.StringType),
SecurityGroupIds: types.ListNull(types.StringType),
IPv4: types.StringNull(),
Security: types.BoolNull(),
Device: types.StringNull(),
Mac: types.StringNull(),
Type: types.StringNull(),
Labels: types.MapNull(types.StringType),
Region: types.StringValue("eu01"),
},
isValid: true,
},
{
description: "values_ok",
args: args{
state: Model{
ProjectId: types.StringValue("pid"),
NetworkId: types.StringValue("nid"),
NetworkInterfaceId: types.StringValue("nicid"),
Region: types.StringValue("eu01"),
},
input: &iaas.NIC{
Id: utils.Ptr("nicid"),
Name: utils.Ptr("name"),
AllowedAddresses: &[]iaas.AllowedAddressesInner{
{
String: utils.Ptr("aa1"),
},
},
SecurityGroups: &[]string{
"prefix1",
"prefix2",
},
Ipv4: utils.Ptr("ipv4"),
Ipv6: utils.Ptr("ipv6"),
NicSecurity: utils.Ptr(true),
Device: utils.Ptr("device"),
Mac: utils.Ptr("mac"),
Status: utils.Ptr("status"),
Type: utils.Ptr("type"),
Labels: &map[string]interface{}{
"label1": "ref1",
},
},
region: "eu02",
},
expected: Model{
Id: types.StringValue("pid,eu02,nid,nicid"),
ProjectId: types.StringValue("pid"),
NetworkId: types.StringValue("nid"),
NetworkInterfaceId: types.StringValue("nicid"),
Name: types.StringValue("name"),
AllowedAddresses: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("aa1"),
}),
SecurityGroupIds: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("prefix1"),
types.StringValue("prefix2"),
}),
IPv4: types.StringValue("ipv4"),
Security: types.BoolValue(true),
Device: types.StringValue("device"),
Mac: types.StringValue("mac"),
Type: types.StringValue("type"),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{"label1": types.StringValue("ref1")}),
Region: types.StringValue("eu02"),
},
isValid: true,
},
{
description: "allowed_addresses_changed_outside_tf",
args: args{
state: Model{
ProjectId: types.StringValue("pid"),
NetworkId: types.StringValue("nid"),
NetworkInterfaceId: types.StringValue("nicid"),
AllowedAddresses: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("aa1"),
}),
},
input: &iaas.NIC{
Id: utils.Ptr("nicid"),
AllowedAddresses: &[]iaas.AllowedAddressesInner{
{
String: utils.Ptr("aa2"),
},
},
},
region: "eu01",
},
expected: Model{
Id: types.StringValue("pid,eu01,nid,nicid"),
ProjectId: types.StringValue("pid"),
NetworkId: types.StringValue("nid"),
NetworkInterfaceId: types.StringValue("nicid"),
Name: types.StringNull(),
SecurityGroupIds: types.ListNull(types.StringType),
AllowedAddresses: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("aa2"),
}),
Labels: types.MapNull(types.StringType),
Region: types.StringValue("eu01"),
},
isValid: true,
},
{
description: "empty_list_allowed_addresses",
args: args{
state: Model{
ProjectId: types.StringValue("pid"),
NetworkId: types.StringValue("nid"),
NetworkInterfaceId: types.StringValue("nicid"),
AllowedAddresses: types.ListValueMust(types.StringType, []attr.Value{}),
},
input: &iaas.NIC{
Id: utils.Ptr("nicid"),
AllowedAddresses: nil,
},
region: "eu01",
},
expected: Model{
Id: types.StringValue("pid,eu01,nid,nicid"),
ProjectId: types.StringValue("pid"),
NetworkId: types.StringValue("nid"),
NetworkInterfaceId: types.StringValue("nicid"),
Name: types.StringNull(),
SecurityGroupIds: types.ListNull(types.StringType),
AllowedAddresses: types.ListValueMust(types.StringType, []attr.Value{}),
Labels: types.MapNull(types.StringType),
Region: types.StringValue("eu01"),
},
isValid: true,
},
{
description: "response_nil_fail",
args: args{
state: Model{},
input: nil,
},
expected: Model{},
isValid: false,
},
{
description: "no_resource_id",
args: args{
state: Model{
ProjectId: types.StringValue("pid"),
},
input: &iaas.NIC{},
},
expected: Model{},
isValid: false,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
err := mapFields(context.Background(), tt.args.input, &tt.args.state, tt.args.region)
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.args.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 *iaas.CreateNicPayload
isValid bool
}{
{
"default_ok",
&Model{
Name: types.StringValue("name"),
SecurityGroupIds: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("sg1"),
types.StringValue("sg2"),
}),
AllowedAddresses: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("aa1"),
}),
Security: types.BoolValue(true),
},
&iaas.CreateNicPayload{
Name: utils.Ptr("name"),
SecurityGroups: &[]string{
"sg1",
"sg2",
},
AllowedAddresses: &[]iaas.AllowedAddressesInner{
{
String: utils.Ptr("aa1"),
},
},
NicSecurity: utils.Ptr(true),
},
true,
},
{
"empty_allowed_addresses",
&Model{
Name: types.StringValue("name"),
SecurityGroupIds: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("sg1"),
types.StringValue("sg2"),
}),
AllowedAddresses: types.ListNull(types.StringType),
},
&iaas.CreateNicPayload{
Name: utils.Ptr("name"),
SecurityGroups: &[]string{
"sg1",
"sg2",
},
AllowedAddresses: nil,
},
true,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
output, err := toCreatePayload(context.Background(), 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 *iaas.UpdateNicPayload
isValid bool
}{
{
"default_ok",
&Model{
Name: types.StringValue("name"),
SecurityGroupIds: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("sg1"),
types.StringValue("sg2"),
}),
AllowedAddresses: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("aa1"),
}),
Security: types.BoolValue(true),
},
&iaas.UpdateNicPayload{
Name: utils.Ptr("name"),
SecurityGroups: &[]string{
"sg1",
"sg2",
},
AllowedAddresses: &[]iaas.AllowedAddressesInner{
{
String: utils.Ptr("aa1"),
},
},
NicSecurity: utils.Ptr(true),
},
true,
},
{
"empty_allowed_addresses",
&Model{
Name: types.StringValue("name"),
SecurityGroupIds: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("sg1"),
types.StringValue("sg2"),
}),
AllowedAddresses: types.ListNull(types.StringType),
},
&iaas.UpdateNicPayload{
Name: utils.Ptr("name"),
SecurityGroups: &[]string{
"sg1",
"sg2",
},
AllowedAddresses: utils.Ptr([]iaas.AllowedAddressesInner{}),
},
true,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
output, err := toUpdatePayload(context.Background(), tt.input, types.MapNull(types.StringType))
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,328 +0,0 @@
package networkinterfaceattach
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
iaasUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/utils"
"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-log/tflog"
"github.com/stackitcloud/stackit-sdk-go/core/oapierror"
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate"
)
// Ensure the implementation satisfies the expected interfaces.
var (
_ resource.Resource = &networkInterfaceAttachResource{}
_ resource.ResourceWithConfigure = &networkInterfaceAttachResource{}
_ resource.ResourceWithImportState = &networkInterfaceAttachResource{}
_ resource.ResourceWithModifyPlan = &networkInterfaceAttachResource{}
)
type Model struct {
Id types.String `tfsdk:"id"` // needed by TF
ProjectId types.String `tfsdk:"project_id"`
Region types.String `tfsdk:"region"`
ServerId types.String `tfsdk:"server_id"`
NetworkInterfaceId types.String `tfsdk:"network_interface_id"`
}
// NewNetworkInterfaceAttachResource is a helper function to simplify the provider implementation.
func NewNetworkInterfaceAttachResource() resource.Resource {
return &networkInterfaceAttachResource{}
}
// networkInterfaceAttachResource is the resource implementation.
type networkInterfaceAttachResource struct {
client *iaas.APIClient
providerData core.ProviderData
}
// Metadata returns the resource type name.
func (r *networkInterfaceAttachResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_server_network_interface_attach"
}
// ModifyPlan implements resource.ResourceWithModifyPlan.
// Use the modifier to set the effective region in the current plan.
func (r *networkInterfaceAttachResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { // nolint:gocritic // function signature required by Terraform
var configModel Model
// skip initial empty configuration to avoid follow-up errors
if req.Config.Raw.IsNull() {
return
}
resp.Diagnostics.Append(req.Config.Get(ctx, &configModel)...)
if resp.Diagnostics.HasError() {
return
}
var planModel Model
resp.Diagnostics.Append(req.Plan.Get(ctx, &planModel)...)
if resp.Diagnostics.HasError() {
return
}
utils.AdaptRegion(ctx, configModel.Region, &planModel.Region, r.providerData.GetRegion(), resp)
if resp.Diagnostics.HasError() {
return
}
resp.Diagnostics.Append(resp.Plan.Set(ctx, planModel)...)
if resp.Diagnostics.HasError() {
return
}
}
// Configure adds the provider configured client to the resource.
func (r *networkInterfaceAttachResource) 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
}
apiClient := iaasUtils.ConfigureClient(ctx, &r.providerData, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}
r.client = apiClient
tflog.Info(ctx, "iaas client configured")
}
// Schema defines the schema for the resource.
func (r *networkInterfaceAttachResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
description := "Network interface attachment resource schema. Attaches a network interface to a server. The attachment only takes full effect after server reboot."
resp.Schema = schema.Schema{
MarkdownDescription: description,
Description: description,
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: "Terraform's internal resource ID. It is structured as \"`project_id`,`region`,`server_id`,`network_interface_id`\".",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"project_id": schema.StringAttribute{
Description: "STACKIT project ID to which the network interface attachment is associated.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"region": schema.StringAttribute{
Description: "The resource region. If not defined, the provider region is used.",
Optional: true,
// must be computed to allow for storing the override value from the provider
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"server_id": schema.StringAttribute{
Description: "The server ID.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"network_interface_id": schema.StringAttribute{
Description: "The network interface ID.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
},
}
}
// Create creates the resource and sets the initial Terraform state.
func (r *networkInterfaceAttachResource) 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()
region := r.providerData.GetRegionWithOverride(model.Region)
serverId := model.ServerId.ValueString()
networkInterfaceId := model.NetworkInterfaceId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "region", region)
ctx = tflog.SetField(ctx, "server_id", serverId)
ctx = tflog.SetField(ctx, "network_interface_id", networkInterfaceId)
// Create new network interface attachment
err := r.client.AddNicToServer(ctx, projectId, region, serverId, networkInterfaceId).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error attaching network interface to server", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
model.Id = utils.BuildInternalTerraformId(projectId, region, serverId, networkInterfaceId)
model.Region = types.StringValue(region)
// Set state to fully populated data
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "Network interface attachment created")
}
// Read refreshes the Terraform state with the latest data.
func (r *networkInterfaceAttachResource) 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()
region := r.providerData.GetRegionWithOverride(model.Region)
serverId := model.ServerId.ValueString()
networkInterfaceId := model.NetworkInterfaceId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "server_id", serverId)
ctx = tflog.SetField(ctx, "network_interface_id", networkInterfaceId)
nics, err := r.client.ListServerNICs(ctx, projectId, region, serverId).Execute()
if err != nil {
oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped
if ok && oapiErr.StatusCode == http.StatusNotFound {
resp.State.RemoveResource(ctx)
return
}
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading network interface attachment", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
if nics == nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading network interface attachment", "List of network interfaces attached to the server is nil")
return
}
if nics.Items != nil {
for _, nic := range *nics.Items {
if nic.Id == nil || (nic.Id != nil && *nic.Id != networkInterfaceId) {
continue
}
model.Id = utils.BuildInternalTerraformId(projectId, region, serverId, networkInterfaceId)
model.Region = types.StringValue(region)
// Set refreshed state
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "Network interface attachment read")
return
}
}
// no matching network interface was found, the attachment no longer exists
resp.State.RemoveResource(ctx)
}
// Update updates the resource and sets the updated Terraform state on success.
func (r *networkInterfaceAttachResource) Update(_ context.Context, _ resource.UpdateRequest, _ *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform
// Update is not supported, all fields require replace
}
// Delete deletes the resource and removes the Terraform state on success.
func (r *networkInterfaceAttachResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve values from state
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()
region := r.providerData.GetRegionWithOverride(model.Region)
serverId := model.ServerId.ValueString()
network_interfaceId := model.NetworkInterfaceId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "region", region)
ctx = tflog.SetField(ctx, "server_id", serverId)
ctx = tflog.SetField(ctx, "network_interface_id", network_interfaceId)
// Remove network_interface from server
err := r.client.RemoveNicFromServer(ctx, projectId, region, serverId, network_interfaceId).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error removing network interface from server", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
tflog.Info(ctx, "Network interface attachment deleted")
}
// ImportState imports a resource into the Terraform state on success.
// The expected format of the resource import identifier is: project_id,server_id
func (r *networkInterfaceAttachResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
idParts := strings.Split(req.ID, core.Separator)
if len(idParts) != 4 || idParts[0] == "" || idParts[1] == "" || idParts[2] == "" || idParts[3] == "" {
core.LogAndAddError(ctx, &resp.Diagnostics,
"Error importing network_interface attachment",
fmt.Sprintf("Expected import identifier with format: [project_id],[region],[server_id],[network_interface_id] Got: %q", req.ID),
)
return
}
utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]interface{}{
"project_id": idParts[0],
"region": idParts[1],
"server_id": idParts[2],
"network_interface_id": idParts[3],
})
tflog.Info(ctx, "Network interface attachment state imported")
}

View file

@ -1,219 +0,0 @@
package project
import (
"context"
"fmt"
"time"
"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-framework/types/basetypes"
"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
iaasUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/utils"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate"
)
var (
_ datasource.DataSourceWithConfigure = &projectDataSource{}
)
type DatasourceModel struct {
Id types.String `tfsdk:"id"` // needed by TF
ProjectId types.String `tfsdk:"project_id"`
AreaId types.String `tfsdk:"area_id"`
InternetAccess types.Bool `tfsdk:"internet_access"`
Status types.String `tfsdk:"status"`
CreatedAt types.String `tfsdk:"created_at"`
UpdatedAt types.String `tfsdk:"updated_at"`
// Deprecated: Will be removed in May 2026. Only kept to make the IaaS v1 -> v2 API migration non-breaking in the Terraform provider.
State types.String `tfsdk:"state"`
}
// NewProjectDataSource is a helper function to simplify the provider implementation.
func NewProjectDataSource() datasource.DataSource {
return &projectDataSource{}
}
// projectDatasource is the data source implementation.
type projectDataSource struct {
client *iaas.APIClient
}
func (d *projectDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
providerData, ok := conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics)
if !ok {
return
}
apiClient := iaasUtils.ConfigureClient(ctx, &providerData, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}
d.client = apiClient
tflog.Info(ctx, "iaas client configured")
}
// Metadata returns the data source type name.
func (d *projectDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_iaas_project"
}
// Schema defines the schema for the datasource.
func (d *projectDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
descriptions := map[string]string{
"main": "Project details. Must have a `region` specified in the provider configuration.",
"id": "Terraform's internal resource ID. It is structured as \"`project_id`\".",
"project_id": "STACKIT project ID.",
"area_id": "The area ID to which the project belongs to.",
"internet_access": "Specifies if the project has internet_access",
"status": "Specifies the status of the project.",
"created_at": "Date-time when the project was created.",
"updated_at": "Date-time when the project was last updated.",
}
resp.Schema = schema.Schema{
MarkdownDescription: descriptions["main"],
Description: descriptions["main"],
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: descriptions["id"],
Computed: true,
},
"project_id": schema.StringAttribute{
Description: descriptions["project_id"],
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"area_id": schema.StringAttribute{
Description: descriptions["area_id"],
Computed: true,
},
"internet_access": schema.BoolAttribute{
Description: descriptions["internet_access"],
Computed: true,
},
// Deprecated: Will be removed in May 2026. Only kept to make the IaaS v1 -> v2 API migration non-breaking in the Terraform provider.
"state": schema.StringAttribute{
DeprecationMessage: "Deprecated: Will be removed in May 2026. Use the `status` field instead.",
Description: descriptions["status"],
Computed: true,
},
"status": schema.StringAttribute{
Description: descriptions["status"],
Computed: true,
},
"created_at": schema.StringAttribute{
Description: descriptions["created_at"],
Computed: true,
},
"updated_at": schema.StringAttribute{
Description: descriptions["updated_at"],
Computed: true,
},
},
}
}
// Read refreshes the Terraform state with the latest data.
func (d *projectDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform
var model DatasourceModel
diags := req.Config.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
ctx = core.InitProviderContext(ctx)
ctx = tflog.SetField(ctx, "project_id", projectId)
projectResp, err := d.client.GetProjectDetailsExecute(ctx, projectId)
if err != nil {
utils.LogError(
ctx,
&resp.Diagnostics,
err,
"Reading project",
fmt.Sprintf("Project with ID %q does not exists.", projectId),
nil,
)
resp.State.RemoveResource(ctx)
return
}
ctx = core.LogResponse(ctx)
// Map response body to schema
err = mapDataSourceFields(projectResp, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading project", fmt.Sprintf("Process 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, "project read")
}
func mapDataSourceFields(projectResp *iaas.Project, model *DatasourceModel) error {
if projectResp == nil {
return fmt.Errorf("response input is nil")
}
if model == nil {
return fmt.Errorf("model input is nil")
}
var projectId string
if model.ProjectId.ValueString() != "" {
projectId = model.ProjectId.ValueString()
} else if projectResp.Id != nil {
projectId = *projectResp.Id
} else {
return fmt.Errorf("project id is not present")
}
model.Id = utils.BuildInternalTerraformId(projectId)
model.ProjectId = types.StringValue(projectId)
var areaId basetypes.StringValue
if projectResp.AreaId != nil {
if projectResp.AreaId.String != nil {
areaId = types.StringPointerValue(projectResp.AreaId.String)
} else if projectResp.AreaId.StaticAreaID != nil {
areaId = types.StringValue(string(*projectResp.AreaId.StaticAreaID))
}
}
var createdAt basetypes.StringValue
if projectResp.CreatedAt != nil {
createdAtValue := *projectResp.CreatedAt
createdAt = types.StringValue(createdAtValue.Format(time.RFC3339))
}
var updatedAt basetypes.StringValue
if projectResp.UpdatedAt != nil {
updatedAtValue := *projectResp.UpdatedAt
updatedAt = types.StringValue(updatedAtValue.Format(time.RFC3339))
}
model.AreaId = areaId
model.InternetAccess = types.BoolPointerValue(projectResp.InternetAccess)
model.State = types.StringPointerValue(projectResp.Status)
model.Status = types.StringPointerValue(projectResp.Status)
model.CreatedAt = createdAt
model.UpdatedAt = updatedAt
return nil
}

View file

@ -1,120 +0,0 @@
package project
import (
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/stackitcloud/stackit-sdk-go/core/utils"
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
)
const (
testTimestampValue = "2006-01-02T15:04:05Z"
)
func testTimestamp() time.Time {
timestamp, _ := time.Parse(time.RFC3339, testTimestampValue)
return timestamp
}
func TestMapDataSourceFields(t *testing.T) {
const projectId = "pid"
tests := []struct {
description string
state *DatasourceModel
input *iaas.Project
expected *DatasourceModel
isValid bool
}{
{
description: "default_values",
state: &DatasourceModel{
ProjectId: types.StringValue(projectId),
},
input: &iaas.Project{
Id: utils.Ptr(projectId),
},
expected: &DatasourceModel{
Id: types.StringValue(projectId),
ProjectId: types.StringValue(projectId),
},
isValid: true,
},
{
description: "simple_values",
state: &DatasourceModel{
ProjectId: types.StringValue(projectId),
},
input: &iaas.Project{
AreaId: utils.Ptr(iaas.AreaId{String: utils.Ptr("aid")}),
CreatedAt: utils.Ptr(testTimestamp()),
InternetAccess: utils.Ptr(true),
Id: utils.Ptr(projectId),
Status: utils.Ptr("CREATED"),
UpdatedAt: utils.Ptr(testTimestamp()),
},
expected: &DatasourceModel{
Id: types.StringValue(projectId),
ProjectId: types.StringValue(projectId),
AreaId: types.StringValue("aid"),
InternetAccess: types.BoolValue(true),
State: types.StringValue("CREATED"),
Status: types.StringValue("CREATED"),
CreatedAt: types.StringValue(testTimestampValue),
UpdatedAt: types.StringValue(testTimestampValue),
},
isValid: true,
},
{
description: "static_area_id",
state: &DatasourceModel{
ProjectId: types.StringValue(projectId),
},
input: &iaas.Project{
AreaId: utils.Ptr(iaas.AreaId{
StaticAreaID: iaas.STATICAREAID_PUBLIC.Ptr(),
}),
Id: utils.Ptr(projectId),
},
expected: &DatasourceModel{
Id: types.StringValue(projectId),
ProjectId: types.StringValue(projectId),
AreaId: types.StringValue("PUBLIC"),
},
isValid: true,
},
{
description: "response_nil_fail",
state: &DatasourceModel{},
input: nil,
expected: &DatasourceModel{},
isValid: false,
},
{
description: "no_project_id_fail",
state: &DatasourceModel{},
input: &iaas.Project{},
expected: &DatasourceModel{},
isValid: false,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
err := mapDataSourceFields(tt.input, tt.state)
if !tt.isValid && err == nil {
t.Fatal("should have failed")
}
if tt.isValid && err != nil {
t.Fatalf("Should not have failed: %v", err)
}
if tt.isValid {
diff := cmp.Diff(tt.expected, tt.state)
if diff != "" {
t.Fatalf("Data does not match: %s", diff)
}
}
})
}
}

View file

@ -1,158 +0,0 @@
package publicip
import (
"context"
"fmt"
"net/http"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
iaasUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/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/iaas"
"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 = &publicIpDataSource{}
)
// NewPublicIpDataSource is a helper function to simplify the provider implementation.
func NewPublicIpDataSource() datasource.DataSource {
return &publicIpDataSource{}
}
// publicIpDataSource is the data source implementation.
type publicIpDataSource struct {
client *iaas.APIClient
providerData core.ProviderData
}
// Metadata returns the data source type name.
func (d *publicIpDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_public_ip"
}
func (d *publicIpDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
var ok bool
d.providerData, ok = conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics)
if !ok {
return
}
apiClient := iaasUtils.ConfigureClient(ctx, &d.providerData, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}
d.client = apiClient
tflog.Info(ctx, "iaas client configured")
}
// Schema defines the schema for the resource.
func (d *publicIpDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
description := "Public IP resource schema. Must have a `region` specified in the provider configuration."
resp.Schema = schema.Schema{
MarkdownDescription: description,
Description: description,
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: "Terraform's internal datasource ID. It is structured as \"`project_id`,`region`,`public_ip_id`\".",
Computed: true,
},
"project_id": schema.StringAttribute{
Description: "STACKIT project ID to which the public IP is associated.",
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"region": schema.StringAttribute{
Description: "The resource region. If not defined, the provider region is used.",
// the region cannot be found, so it has to be passed
Optional: true,
},
"public_ip_id": schema.StringAttribute{
Description: "The public IP ID.",
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"ip": schema.StringAttribute{
Description: "The IP address.",
Computed: true,
},
"network_interface_id": schema.StringAttribute{
Description: "Associates the public IP with a network interface or a virtual IP (ID).",
Computed: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"labels": schema.MapAttribute{
Description: "Labels are key-value string pairs which can be attached to a resource container",
ElementType: types.StringType,
Computed: true,
},
},
}
}
// Read refreshes the Terraform state with the latest data.
func (d *publicIpDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform
var model Model
diags := req.Config.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
region := d.providerData.GetRegionWithOverride(model.Region)
publicIpId := model.PublicIpId.ValueString()
ctx = core.InitProviderContext(ctx)
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "region", region)
ctx = tflog.SetField(ctx, "public_ip_id", publicIpId)
publicIpResp, err := d.client.GetPublicIP(ctx, projectId, region, publicIpId).Execute()
if err != nil {
utils.LogError(
ctx,
&resp.Diagnostics,
err,
"Reading public ip",
fmt.Sprintf("Public ip with ID %q does not exist in project %q.", publicIpId, 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)
err = mapFields(ctx, publicIpResp, &model, region)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading public IP", 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, "public IP read")
}

View file

@ -1,453 +0,0 @@
package publicip
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
iaasUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/utils"
"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-log/tflog"
"github.com/stackitcloud/stackit-sdk-go/core/oapierror"
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
"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/validate"
)
// Ensure the implementation satisfies the expected interfaces.
var (
_ resource.Resource = &publicIpResource{}
_ resource.ResourceWithConfigure = &publicIpResource{}
_ resource.ResourceWithImportState = &publicIpResource{}
_ resource.ResourceWithModifyPlan = &publicIpResource{}
)
type Model struct {
Id types.String `tfsdk:"id"` // needed by TF
ProjectId types.String `tfsdk:"project_id"`
Region types.String `tfsdk:"region"`
PublicIpId types.String `tfsdk:"public_ip_id"`
Ip types.String `tfsdk:"ip"`
NetworkInterfaceId types.String `tfsdk:"network_interface_id"`
Labels types.Map `tfsdk:"labels"`
}
// NewPublicIpResource is a helper function to simplify the provider implementation.
func NewPublicIpResource() resource.Resource {
return &publicIpResource{}
}
// publicIpResource is the resource implementation.
type publicIpResource struct {
client *iaas.APIClient
providerData core.ProviderData
}
// Metadata returns the resource type name.
func (r *publicIpResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_public_ip"
}
// ModifyPlan implements resource.ResourceWithModifyPlan.
// Use the modifier to set the effective region in the current plan.
func (r *publicIpResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { // nolint:gocritic // function signature required by Terraform
var configModel Model
// skip initial empty configuration to avoid follow-up errors
if req.Config.Raw.IsNull() {
return
}
resp.Diagnostics.Append(req.Config.Get(ctx, &configModel)...)
if resp.Diagnostics.HasError() {
return
}
var planModel Model
resp.Diagnostics.Append(req.Plan.Get(ctx, &planModel)...)
if resp.Diagnostics.HasError() {
return
}
utils.AdaptRegion(ctx, configModel.Region, &planModel.Region, r.providerData.GetRegion(), resp)
if resp.Diagnostics.HasError() {
return
}
resp.Diagnostics.Append(resp.Plan.Set(ctx, planModel)...)
if resp.Diagnostics.HasError() {
return
}
}
// Configure adds the provider configured client to the resource.
func (r *publicIpResource) 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
}
apiClient := iaasUtils.ConfigureClient(ctx, &r.providerData, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}
r.client = apiClient
tflog.Info(ctx, "iaas client configured")
}
// Schema defines the schema for the resource.
func (r *publicIpResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
description := "Public IP resource schema. Must have a `region` specified in the provider configuration."
resp.Schema = schema.Schema{
MarkdownDescription: description,
Description: description,
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: "Terraform's internal resource ID. It is structured as \"`project_id`,`region`,`public_ip_id`\".",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"project_id": schema.StringAttribute{
Description: "STACKIT project ID to which the public IP is associated.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"region": schema.StringAttribute{
Description: "The resource region. If not defined, the provider region is used.",
Optional: true,
// must be computed to allow for storing the override value from the provider
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"public_ip_id": schema.StringAttribute{
Description: "The public IP ID.",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"ip": schema.StringAttribute{
Description: "The IP address.",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
Validators: []validator.String{
validate.IP(false),
},
},
"network_interface_id": schema.StringAttribute{
Description: "Associates the public IP with a network interface or a virtual IP (ID). If you are using this resource with a Kubernetes Load Balancer or any other resource which associates a network interface implicitly, use the lifecycle `ignore_changes` property in this field to prevent unintentional removal of the network interface due to drift in the Terraform state",
Optional: true,
Computed: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"labels": schema.MapAttribute{
Description: "Labels are key-value string pairs which can be attached to a resource container",
ElementType: types.StringType,
Optional: true,
},
},
}
}
// Create creates the resource and sets the initial Terraform state.
func (r *publicIpResource) 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()
region := r.providerData.GetRegionWithOverride(model.Region)
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "region", region)
// Generate API request body from model
payload, err := toCreatePayload(ctx, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating public IP", fmt.Sprintf("Creating API payload: %v", err))
return
}
// Create new public IP
publicIp, err := r.client.CreatePublicIP(ctx, projectId, region).CreatePublicIPPayload(*payload).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating public IP", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
ctx = tflog.SetField(ctx, "public_ip_id", *publicIp.Id)
// Map response body to schema
err = mapFields(ctx, publicIp, &model, region)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating public IP", 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, "Public IP created")
}
// Read refreshes the Terraform state with the latest data.
func (r *publicIpResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform
var model Model
diags := req.State.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
region := r.providerData.GetRegionWithOverride(model.Region)
publicIpId := model.PublicIpId.ValueString()
ctx = core.InitProviderContext(ctx)
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "region", region)
ctx = tflog.SetField(ctx, "public_ip_id", publicIpId)
publicIpResp, err := r.client.GetPublicIP(ctx, projectId, region, publicIpId).Execute()
if err != nil {
oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped
if ok && oapiErr.StatusCode == http.StatusNotFound {
resp.State.RemoveResource(ctx)
return
}
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading public IP", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
// Map response body to schema
err = mapFields(ctx, publicIpResp, &model, region)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading public IP", 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, "public IP read")
}
// Update updates the resource and sets the updated Terraform state on success.
func (r *publicIpResource) 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
}
projectId := model.ProjectId.ValueString()
region := r.providerData.GetRegionWithOverride(model.Region)
publicIpId := model.PublicIpId.ValueString()
ctx = core.InitProviderContext(ctx)
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "region", region)
ctx = tflog.SetField(ctx, "public_ip_id", publicIpId)
// Retrieve values from state
var stateModel Model
diags = req.State.Get(ctx, &stateModel)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
// Generate API request body from model
payload, err := toUpdatePayload(ctx, &model, stateModel.Labels)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating public IP", fmt.Sprintf("Creating API payload: %v", err))
return
}
// Update existing public IP
updatedPublicIp, err := r.client.UpdatePublicIP(ctx, projectId, region, publicIpId).UpdatePublicIPPayload(*payload).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating public IP", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
err = mapFields(ctx, updatedPublicIp, &model, region)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating public IP", 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, "public IP updated")
}
// Delete deletes the resource and removes the Terraform state on success.
func (r *publicIpResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve values from state
var model Model
diags := req.State.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
region := r.providerData.GetRegionWithOverride(model.Region)
publicIpId := model.PublicIpId.ValueString()
ctx = core.InitProviderContext(ctx)
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "region", region)
ctx = tflog.SetField(ctx, "public_ip_id", publicIpId)
// Delete existing publicIp
err := r.client.DeletePublicIP(ctx, projectId, region, publicIpId).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting public IP", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
tflog.Info(ctx, "public IP deleted")
}
// ImportState imports a resource into the Terraform state on success.
// The expected format of the resource import identifier is: project_id,public_ip_id
func (r *publicIpResource) 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 public IP",
fmt.Sprintf("Expected import identifier with format: [project_id],[region],[public_ip_id] Got: %q", req.ID),
)
return
}
utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{
"project_id": idParts[0],
"region": idParts[1],
"public_ip_id": idParts[2],
})
tflog.Info(ctx, "public IP state imported")
}
func mapFields(ctx context.Context, publicIpResp *iaas.PublicIp, model *Model, region string) error {
if publicIpResp == nil {
return fmt.Errorf("response input is nil")
}
if model == nil {
return fmt.Errorf("model input is nil")
}
var publicIpId string
if model.PublicIpId.ValueString() != "" {
publicIpId = model.PublicIpId.ValueString()
} else if publicIpResp.Id != nil {
publicIpId = *publicIpResp.Id
} else {
return fmt.Errorf("public IP id not present")
}
model.Id = utils.BuildInternalTerraformId(model.ProjectId.ValueString(), region, publicIpId)
model.Region = types.StringValue(region)
labels, err := iaasUtils.MapLabels(ctx, publicIpResp.Labels, model.Labels)
if err != nil {
return err
}
model.PublicIpId = types.StringValue(publicIpId)
model.Ip = types.StringPointerValue(publicIpResp.Ip)
if publicIpResp.NetworkInterface != nil {
model.NetworkInterfaceId = types.StringPointerValue(publicIpResp.GetNetworkInterface())
} else {
model.NetworkInterfaceId = types.StringNull()
}
model.Labels = labels
return nil
}
func toCreatePayload(ctx context.Context, model *Model) (*iaas.CreatePublicIPPayload, error) {
if model == nil {
return nil, fmt.Errorf("nil model")
}
labels, err := conversion.ToStringInterfaceMap(ctx, model.Labels)
if err != nil {
return nil, fmt.Errorf("converting to Go map: %w", err)
}
return &iaas.CreatePublicIPPayload{
Labels: &labels,
Ip: conversion.StringValueToPointer(model.Ip),
NetworkInterface: iaas.NewNullableString(conversion.StringValueToPointer(model.NetworkInterfaceId)),
}, nil
}
func toUpdatePayload(ctx context.Context, model *Model, currentLabels types.Map) (*iaas.UpdatePublicIPPayload, error) {
if model == nil {
return nil, fmt.Errorf("nil model")
}
labels, err := conversion.ToJSONMapPartialUpdatePayload(ctx, currentLabels, model.Labels)
if err != nil {
return nil, fmt.Errorf("converting to Go map: %w", err)
}
return &iaas.UpdatePublicIPPayload{
Labels: &labels,
NetworkInterface: iaas.NewNullableString(conversion.StringValueToPointer(model.NetworkInterfaceId)),
}, nil
}

View file

@ -1,281 +0,0 @@
package publicip
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/iaas"
)
func TestMapFields(t *testing.T) {
type args struct {
state Model
input *iaas.PublicIp
region string
}
tests := []struct {
description string
args args
expected Model
isValid bool
}{
{
description: "default_values",
args: args{
state: Model{
ProjectId: types.StringValue("pid"),
PublicIpId: types.StringValue("pipid"),
},
input: &iaas.PublicIp{
Id: utils.Ptr("pipid"),
NetworkInterface: iaas.NewNullableString(nil),
},
region: "eu01",
},
expected: Model{
Id: types.StringValue("pid,eu01,pipid"),
ProjectId: types.StringValue("pid"),
PublicIpId: types.StringValue("pipid"),
Ip: types.StringNull(),
Labels: types.MapNull(types.StringType),
NetworkInterfaceId: types.StringNull(),
Region: types.StringValue("eu01"),
},
isValid: true,
},
{
description: "simple_values",
args: args{
state: Model{
ProjectId: types.StringValue("pid"),
PublicIpId: types.StringValue("pipid"),
Region: types.StringValue("eu01"),
},
input: &iaas.PublicIp{
Id: utils.Ptr("pipid"),
Ip: utils.Ptr("ip"),
Labels: &map[string]interface{}{
"key": "value",
},
NetworkInterface: iaas.NewNullableString(utils.Ptr("interface")),
},
region: "eu02",
},
expected: Model{
Id: types.StringValue("pid,eu02,pipid"),
ProjectId: types.StringValue("pid"),
PublicIpId: types.StringValue("pipid"),
Ip: types.StringValue("ip"),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
NetworkInterfaceId: types.StringValue("interface"),
Region: types.StringValue("eu02"),
},
isValid: true,
},
{
description: "empty_labels",
args: args{
state: Model{
ProjectId: types.StringValue("pid"),
PublicIpId: types.StringValue("pipid"),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{}),
},
input: &iaas.PublicIp{
Id: utils.Ptr("pipid"),
NetworkInterface: iaas.NewNullableString(utils.Ptr("interface")),
},
region: "eu01",
},
expected: Model{
Id: types.StringValue("pid,eu01,pipid"),
ProjectId: types.StringValue("pid"),
PublicIpId: types.StringValue("pipid"),
Ip: types.StringNull(),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{}),
NetworkInterfaceId: types.StringValue("interface"),
Region: types.StringValue("eu01"),
},
isValid: true,
},
{
description: "network_interface_id_nil",
args: args{
state: Model{
ProjectId: types.StringValue("pid"),
PublicIpId: types.StringValue("pipid"),
},
input: &iaas.PublicIp{
Id: utils.Ptr("pipid"),
},
region: "eu01",
},
expected: Model{
Id: types.StringValue("pid,eu01,pipid"),
ProjectId: types.StringValue("pid"),
PublicIpId: types.StringValue("pipid"),
Ip: types.StringNull(),
Labels: types.MapNull(types.StringType),
NetworkInterfaceId: types.StringNull(),
Region: types.StringValue("eu01"),
},
isValid: true,
},
{
description: "response_nil_fail",
},
{
description: "no_resource_id",
args: args{
state: Model{
ProjectId: types.StringValue("pid"),
},
input: &iaas.PublicIp{},
},
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
err := mapFields(context.Background(), tt.args.input, &tt.args.state, tt.args.region)
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.args.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 *iaas.CreatePublicIPPayload
isValid bool
}{
{
"default_ok",
&Model{
Ip: types.StringValue("ip"),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
NetworkInterfaceId: types.StringValue("interface"),
},
&iaas.CreatePublicIPPayload{
Ip: utils.Ptr("ip"),
Labels: &map[string]interface{}{
"key": "value",
},
NetworkInterface: iaas.NewNullableString(utils.Ptr("interface")),
},
true,
},
{
"network_interface_nil",
&Model{
Ip: types.StringValue("ip"),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
},
&iaas.CreatePublicIPPayload{
Ip: utils.Ptr("ip"),
Labels: &map[string]interface{}{
"key": "value",
},
NetworkInterface: iaas.NewNullableString(nil),
},
true,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
output, err := toCreatePayload(context.Background(), 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, cmp.AllowUnexported(iaas.NullableString{}))
if diff != "" {
t.Fatalf("Data does not match: %s", diff)
}
}
})
}
}
func TestToUpdatePayload(t *testing.T) {
tests := []struct {
description string
input *Model
expected *iaas.UpdatePublicIPPayload
isValid bool
}{
{
"default_ok",
&Model{
Ip: types.StringValue("ip"),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
NetworkInterfaceId: types.StringValue("interface"),
},
&iaas.UpdatePublicIPPayload{
Labels: &map[string]interface{}{
"key": "value",
},
NetworkInterface: iaas.NewNullableString(utils.Ptr("interface")),
},
true,
},
{
"network_interface_nil",
&Model{
Ip: types.StringValue("ip"),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
},
&iaas.UpdatePublicIPPayload{
Labels: &map[string]interface{}{
"key": "value",
},
NetworkInterface: iaas.NewNullableString(nil),
},
true,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
output, err := toUpdatePayload(context.Background(), tt.input, types.MapNull(types.StringType))
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, cmp.AllowUnexported(iaas.NullableString{}))
if diff != "" {
t.Fatalf("Data does not match: %s", diff)
}
}
})
}
}

View file

@ -1,389 +0,0 @@
package publicipassociate
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
iaasUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/utils"
"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-log/tflog"
"github.com/stackitcloud/stackit-sdk-go/core/oapierror"
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
"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/validate"
)
// Ensure the implementation satisfies the expected interfaces.
var (
_ resource.Resource = &publicIpAssociateResource{}
_ resource.ResourceWithConfigure = &publicIpAssociateResource{}
_ resource.ResourceWithImportState = &publicIpAssociateResource{}
_ resource.ResourceWithModifyPlan = &publicIpAssociateResource{}
)
type Model struct {
Id types.String `tfsdk:"id"` // needed by TF
ProjectId types.String `tfsdk:"project_id"`
Region types.String `tfsdk:"region"`
PublicIpId types.String `tfsdk:"public_ip_id"`
Ip types.String `tfsdk:"ip"`
NetworkInterfaceId types.String `tfsdk:"network_interface_id"`
}
// NewPublicIpAssociateResource is a helper function to simplify the provider implementation.
func NewPublicIpAssociateResource() resource.Resource {
return &publicIpAssociateResource{}
}
// publicIpAssociateResource is the resource implementation.
type publicIpAssociateResource struct {
client *iaas.APIClient
providerData core.ProviderData
}
// Metadata returns the resource type name.
func (r *publicIpAssociateResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_public_ip_associate"
}
// ModifyPlan implements resource.ResourceWithModifyPlan.
// Use the modifier to set the effective region in the current plan.
func (r *publicIpAssociateResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { // nolint:gocritic // function signature required by Terraform
var configModel Model
// skip initial empty configuration to avoid follow-up errors
if req.Config.Raw.IsNull() {
return
}
resp.Diagnostics.Append(req.Config.Get(ctx, &configModel)...)
if resp.Diagnostics.HasError() {
return
}
var planModel Model
resp.Diagnostics.Append(req.Plan.Get(ctx, &planModel)...)
if resp.Diagnostics.HasError() {
return
}
utils.AdaptRegion(ctx, configModel.Region, &planModel.Region, r.providerData.GetRegion(), resp)
if resp.Diagnostics.HasError() {
return
}
resp.Diagnostics.Append(resp.Plan.Set(ctx, planModel)...)
if resp.Diagnostics.HasError() {
return
}
}
// Configure adds the provider configured client to the resource.
func (r *publicIpAssociateResource) 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
}
apiClient := iaasUtils.ConfigureClient(ctx, &r.providerData, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}
core.LogAndAddWarning(ctx, &resp.Diagnostics, "The `stackit_public_ip_associate` resource should not be used together with the `stackit_public_ip` resource for the same public IP or for the same network interface.",
"Using both resources together for the same public IP or network interface WILL lead to conflicts, as they both have control of the public IP and network interface association.")
r.client = apiClient
tflog.Info(ctx, "iaas client configured")
}
// Schema defines the schema for the resource.
func (r *publicIpAssociateResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
descriptions := map[string]string{
"main": "Associates an existing public IP to a network interface. " +
"This is useful for situations where you have a pre-allocated public IP or unable to use the `stackit_public_ip` resource to create a new public IP. " +
"Must have a `region` specified in the provider configuration.",
"warning_message": "The `stackit_public_ip_associate` resource should not be used together with the `stackit_public_ip` resource for the same public IP or for the same network interface. \n" +
"Using both resources together for the same public IP or network interface WILL lead to conflicts, as they both have control of the public IP and network interface association.",
}
resp.Schema = schema.Schema{
MarkdownDescription: fmt.Sprintf("%s\n\n!> %s", descriptions["main"], descriptions["warning_message"]),
Description: fmt.Sprintf("%s\n\n%s", descriptions["main"], descriptions["warning_message"]),
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: "Terraform's internal resource ID. It is structured as \"`project_id`,`region`,`public_ip_id`,`network_interface_id`\".",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"project_id": schema.StringAttribute{
Description: "STACKIT project ID to which the public IP is associated.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"region": schema.StringAttribute{
Description: "The resource region. If not defined, the provider region is used.",
Optional: true,
// must be computed to allow for storing the override value from the provider
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"public_ip_id": schema.StringAttribute{
Description: "The public IP ID.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"ip": schema.StringAttribute{
Description: "The IP address.",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
Validators: []validator.String{
validate.IP(false),
},
},
"network_interface_id": schema.StringAttribute{
Description: "The ID of the network interface (or virtual IP) to which the public IP should be attached to.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
},
}
}
// Create creates the resource and sets the initial Terraform state.
func (r *publicIpAssociateResource) 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
}
projectId := model.ProjectId.ValueString()
region := r.providerData.GetRegionWithOverride(model.Region)
publicIpId := model.PublicIpId.ValueString()
networkInterfaceId := model.NetworkInterfaceId.ValueString()
ctx = core.InitProviderContext(ctx)
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "region", region)
ctx = tflog.SetField(ctx, "public_ip_id", publicIpId)
ctx = tflog.SetField(ctx, "network_interface_id", networkInterfaceId)
// Generate API request body from model
payload, err := toCreatePayload(&model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error associating public IP to network interface", fmt.Sprintf("Creating API payload: %v", err))
return
}
// Update existing public IP
updatedPublicIp, err := r.client.UpdatePublicIP(ctx, projectId, region, publicIpId).UpdatePublicIPPayload(*payload).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error associating public IP to network interface", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
err = mapFields(updatedPublicIp, &model, region)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error associating public IP to network interface", 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, "public IP associated to network interface")
}
// Read refreshes the Terraform state with the latest data.
func (r *publicIpAssociateResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform
var model Model
diags := req.State.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
region := r.providerData.GetRegionWithOverride(model.Region)
publicIpId := model.PublicIpId.ValueString()
networkInterfaceId := model.NetworkInterfaceId.ValueString()
ctx = core.InitProviderContext(ctx)
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "region", region)
ctx = tflog.SetField(ctx, "public_ip_id", publicIpId)
ctx = tflog.SetField(ctx, "network_interface_id", networkInterfaceId)
publicIpResp, err := r.client.GetPublicIP(ctx, projectId, region, publicIpId).Execute()
if err != nil {
oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped
if ok && oapiErr.StatusCode == http.StatusNotFound {
resp.State.RemoveResource(ctx)
return
}
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading public IP association", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
// Map response body to schema
err = mapFields(publicIpResp, &model, region)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading public IP association", 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, "public IP associate read")
}
// Update updates the resource and sets the updated Terraform state on success.
func (r *publicIpAssociateResource) Update(_ context.Context, _ resource.UpdateRequest, _ *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform
// Update is not supported, all fields require replace
}
// Delete deletes the resource and removes the Terraform state on success.
func (r *publicIpAssociateResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve values from state
var model Model
diags := req.State.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
region := r.providerData.GetRegionWithOverride(model.Region)
publicIpId := model.PublicIpId.ValueString()
networkInterfaceId := model.NetworkInterfaceId.ValueString()
ctx = core.InitProviderContext(ctx)
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "region", region)
ctx = tflog.SetField(ctx, "public_ip_id", publicIpId)
ctx = tflog.SetField(ctx, "network_interface_id", networkInterfaceId)
payload := &iaas.UpdatePublicIPPayload{
NetworkInterface: iaas.NewNullableString(nil),
}
_, err := r.client.UpdatePublicIP(ctx, projectId, region, publicIpId).UpdatePublicIPPayload(*payload).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting public IP association", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
tflog.Info(ctx, "public IP association deleted")
}
// ImportState imports a resource into the Terraform state on success.
// The expected format of the resource import identifier is: project_id,public_ip_id
func (r *publicIpAssociateResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
idParts := strings.Split(req.ID, core.Separator)
if len(idParts) != 4 || idParts[0] == "" || idParts[1] == "" || idParts[2] == "" || idParts[3] == "" {
core.LogAndAddError(ctx, &resp.Diagnostics,
"Error importing public IP associate",
fmt.Sprintf("Expected import identifier with format: [project_id],[region],[public_ip_id],[network_interface_id] Got: %q", req.ID),
)
return
}
utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{
"project_id": idParts[0],
"region": idParts[1],
"public_ip_id": idParts[2],
"network_interface_id": idParts[3],
})
tflog.Info(ctx, "public IP state imported")
}
func mapFields(publicIpResp *iaas.PublicIp, model *Model, region string) error {
if publicIpResp == nil {
return fmt.Errorf("response input is nil")
}
if model == nil {
return fmt.Errorf("model input is nil")
}
var publicIpId string
if model.PublicIpId.ValueString() != "" {
publicIpId = model.PublicIpId.ValueString()
} else if publicIpResp.Id != nil {
publicIpId = *publicIpResp.Id
} else {
return fmt.Errorf("public IP id not present")
}
if publicIpResp.NetworkInterface != nil {
model.NetworkInterfaceId = types.StringPointerValue(publicIpResp.GetNetworkInterface())
} else {
model.NetworkInterfaceId = types.StringNull()
}
model.Id = utils.BuildInternalTerraformId(
model.ProjectId.ValueString(), region, publicIpId, model.NetworkInterfaceId.ValueString(),
)
model.Region = types.StringValue(region)
model.PublicIpId = types.StringValue(publicIpId)
model.Ip = types.StringPointerValue(publicIpResp.Ip)
return nil
}
func toCreatePayload(model *Model) (*iaas.UpdatePublicIPPayload, error) {
if model == nil {
return nil, fmt.Errorf("nil model")
}
return &iaas.UpdatePublicIPPayload{
NetworkInterface: iaas.NewNullableString(conversion.StringValueToPointer(model.NetworkInterfaceId)),
}, nil
}

View file

@ -1,140 +0,0 @@
package publicipassociate
import (
"testing"
"github.com/google/go-cmp/cmp"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/stackitcloud/stackit-sdk-go/core/utils"
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
)
func TestMapFields(t *testing.T) {
type args struct {
state Model
input *iaas.PublicIp
region string
}
tests := []struct {
description string
args args
expected Model
isValid bool
}{
{
description: "default_values",
args: args{
state: Model{
ProjectId: types.StringValue("pid"),
PublicIpId: types.StringValue("pipid"),
NetworkInterfaceId: types.StringValue("nicid"),
},
input: &iaas.PublicIp{
Id: utils.Ptr("pipid"),
NetworkInterface: iaas.NewNullableString(utils.Ptr("nicid")),
},
region: "eu01",
},
expected: Model{
Id: types.StringValue("pid,eu01,pipid,nicid"),
ProjectId: types.StringValue("pid"),
PublicIpId: types.StringValue("pipid"),
Ip: types.StringNull(),
NetworkInterfaceId: types.StringValue("nicid"),
Region: types.StringValue("eu01"),
},
isValid: true,
},
{
description: "simple_values",
args: args{
state: Model{
ProjectId: types.StringValue("pid"),
PublicIpId: types.StringValue("pipid"),
NetworkInterfaceId: types.StringValue("nicid"),
},
input: &iaas.PublicIp{
Id: utils.Ptr("pipid"),
Ip: utils.Ptr("ip"),
NetworkInterface: iaas.NewNullableString(utils.Ptr("nicid")),
},
region: "eu02",
},
expected: Model{
Id: types.StringValue("pid,eu02,pipid,nicid"),
ProjectId: types.StringValue("pid"),
PublicIpId: types.StringValue("pipid"),
Ip: types.StringValue("ip"),
NetworkInterfaceId: types.StringValue("nicid"),
Region: types.StringValue("eu02"),
},
isValid: true,
},
{
description: "response_nil_fail",
},
{
description: "no_resource_id",
args: args{
state: Model{
ProjectId: types.StringValue("pid"),
},
input: &iaas.PublicIp{},
},
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
err := mapFields(tt.args.input, &tt.args.state, tt.args.region)
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.args.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 *iaas.UpdatePublicIPPayload
isValid bool
}{
{
"default_ok",
&Model{
NetworkInterfaceId: types.StringValue("interface"),
},
&iaas.UpdatePublicIPPayload{
NetworkInterface: iaas.NewNullableString(utils.Ptr("interface")),
},
true,
},
}
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, cmp.AllowUnexported(iaas.NullableString{}))
if diff != "" {
t.Fatalf("Data does not match: %s", diff)
}
}
})
}
}

View file

@ -1,220 +0,0 @@
package publicipranges
import (
"context"
"fmt"
"net/http"
"sort"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
iaasUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/utils"
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
"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"
"github.com/hashicorp/terraform-plugin-framework-validators/listvalidator"
"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"
)
// Ensure the implementation satisfies the expected interfaces.
var (
_ datasource.DataSource = &publicIpRangesDataSource{}
)
// NewPublicIpRangesDataSource is a helper function to simplify the provider implementation.
func NewPublicIpRangesDataSource() datasource.DataSource {
return &publicIpRangesDataSource{}
}
// publicIpRangesDataSource is the data source implementation.
type publicIpRangesDataSource struct {
client *iaas.APIClient
}
type Model struct {
Id types.String `tfsdk:"id"` // needed by TF
PublicIpRanges types.List `tfsdk:"public_ip_ranges"`
CidrList types.List `tfsdk:"cidr_list"`
}
var publicIpRangesTypes = map[string]attr.Type{
"cidr": types.StringType,
}
// Metadata returns the data source type name.
func (d *publicIpRangesDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_public_ip_ranges"
}
func (d *publicIpRangesDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
providerData, ok := conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics)
if !ok {
return
}
apiClient := iaasUtils.ConfigureClient(ctx, &providerData, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}
d.client = apiClient
tflog.Info(ctx, "iaas client configured")
}
// Schema defines the schema for the resource.
func (d *publicIpRangesDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
description := "A list of all public IP ranges that STACKIT uses."
resp.Schema = schema.Schema{
MarkdownDescription: description,
Description: description,
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: "Terraform's internal resource ID. It takes the values of \"`public_ip_ranges.*.cidr`\".",
Computed: true,
Optional: false,
},
"public_ip_ranges": schema.ListNestedAttribute{
Description: "A list of all public IP ranges.",
Computed: true,
Optional: false,
Validators: []validator.List{
listvalidator.ValueStringsAre(
validate.CIDR(),
),
},
NestedObject: schema.NestedAttributeObject{
Attributes: map[string]schema.Attribute{
"cidr": schema.StringAttribute{
Description: "Classless Inter-Domain Routing (CIDR)",
Computed: true,
},
},
},
},
"cidr_list": schema.ListAttribute{
Description: "A list of IP range strings (CIDRs) extracted from the public_ip_ranges for easy consumption.",
ElementType: types.StringType,
Computed: true,
},
},
}
}
// Read refreshes the Terraform state with the latest data.
func (d *publicIpRangesDataSource) 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)
publicIpRangeResp, err := d.client.ListPublicIPRangesExecute(ctx)
if err != nil {
utils.LogError(
ctx,
&resp.Diagnostics,
err,
"Reading public ip ranges",
"Public ip ranges cannot be found",
map[int]string{
http.StatusForbidden: "Forbidden access",
},
)
resp.State.RemoveResource(ctx)
return
}
ctx = core.LogResponse(ctx)
// Map response body to schema
err = mapFields(ctx, publicIpRangeResp, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading public IP ranges", 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, "read public IP ranges")
}
func mapFields(ctx context.Context, publicIpRangeResp *iaas.PublicNetworkListResponse, model *Model) error {
if publicIpRangeResp == nil {
return fmt.Errorf("response input is nil")
}
if model == nil {
return fmt.Errorf("model input is nil")
}
err := mapPublicIpRanges(ctx, publicIpRangeResp.Items, model)
if err != nil {
return fmt.Errorf("error mapping public IP ranges: %w", err)
}
return nil
}
// mapPublicIpRanges map the response publicIpRanges to the model
func mapPublicIpRanges(ctx context.Context, publicIpRanges *[]iaas.PublicNetwork, model *Model) error {
if publicIpRanges == nil {
return fmt.Errorf("publicIpRanges input is nil")
}
if len(*publicIpRanges) == 0 {
model.PublicIpRanges = types.ListNull(types.ObjectType{AttrTypes: publicIpRangesTypes})
model.CidrList = types.ListNull(types.StringType)
return nil
}
var apiIpRanges []string
for _, ipRange := range *publicIpRanges {
if ipRange.Cidr != nil && *ipRange.Cidr != "" {
apiIpRanges = append(apiIpRanges, *ipRange.Cidr)
}
}
// Sort to prevent unnecessary recreation of dependent resources due to order changes.
sort.Strings(apiIpRanges)
model.Id = utils.BuildInternalTerraformId(apiIpRanges...)
var ipRangesList []attr.Value
for _, cidr := range apiIpRanges {
ipRangeValues := map[string]attr.Value{
"cidr": types.StringValue(cidr),
}
ipRangeObject, diag := types.ObjectValue(publicIpRangesTypes, ipRangeValues)
if diag.HasError() {
return core.DiagsToError(diag)
}
ipRangesList = append(ipRangesList, ipRangeObject)
}
ipRangesTF, diags := types.ListValue(
types.ObjectType{AttrTypes: publicIpRangesTypes},
ipRangesList,
)
if diags.HasError() {
return core.DiagsToError(diags)
}
model.PublicIpRanges = ipRangesTF
cidrListTF, diags := types.ListValueFrom(ctx, types.StringType, apiIpRanges)
if diags.HasError() {
return core.DiagsToError(diags)
}
model.CidrList = cidrListTF
return nil
}

View file

@ -1,115 +0,0 @@
package publicipranges
import (
"context"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/types"
coreUtils "github.com/stackitcloud/stackit-sdk-go/core/utils"
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
)
func TestMapPublicIpRanges(t *testing.T) {
ctx := context.Background()
tests := []struct {
name string
input *[]iaas.PublicNetwork
expected Model
isValid bool
}{
{
name: "nil input should return error",
input: nil,
isValid: false,
},
{
name: "empty input should return nulls",
input: &[]iaas.PublicNetwork{},
expected: Model{
PublicIpRanges: types.ListNull(types.ObjectType{AttrTypes: publicIpRangesTypes}),
CidrList: types.ListNull(types.StringType),
},
isValid: true,
},
{
name: "valid cidr entries",
input: &[]iaas.PublicNetwork{
{Cidr: coreUtils.Ptr("192.168.0.0/24")},
{Cidr: coreUtils.Ptr("192.168.1.0/24")},
},
expected: func() Model {
cidrs := []string{"192.168.0.0/24", "192.168.1.0/24"}
ipRangesList := make([]attr.Value, 0, len(cidrs))
for _, cidr := range cidrs {
ipRange, _ := types.ObjectValue(publicIpRangesTypes, map[string]attr.Value{
"cidr": types.StringValue(cidr),
})
ipRangesList = append(ipRangesList, ipRange)
}
ipRangesVal, _ := types.ListValue(types.ObjectType{AttrTypes: publicIpRangesTypes}, ipRangesList)
cidrListVal, _ := types.ListValueFrom(ctx, types.StringType, cidrs)
return Model{
PublicIpRanges: ipRangesVal,
CidrList: cidrListVal,
Id: utils.BuildInternalTerraformId(cidrs...),
}
}(),
isValid: true,
},
{
name: "filter out empty CIDRs",
input: &[]iaas.PublicNetwork{
{Cidr: coreUtils.Ptr("")},
{Cidr: nil},
{Cidr: coreUtils.Ptr("10.0.0.0/8")},
},
expected: func() Model {
cidrs := []string{"10.0.0.0/8"}
ipRange, _ := types.ObjectValue(publicIpRangesTypes, map[string]attr.Value{
"cidr": types.StringValue("10.0.0.0/8"),
})
ipRangesVal, _ := types.ListValue(types.ObjectType{AttrTypes: publicIpRangesTypes}, []attr.Value{ipRange})
cidrListVal, _ := types.ListValueFrom(ctx, types.StringType, cidrs)
return Model{
PublicIpRanges: ipRangesVal,
CidrList: cidrListVal,
Id: utils.BuildInternalTerraformId(cidrs...),
}
}(),
isValid: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var model Model
err := mapPublicIpRanges(ctx, tt.input, &model)
if !tt.isValid {
if err == nil {
t.Fatalf("Expected error but got nil")
}
return
} else if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if diff := cmp.Diff(tt.expected.Id, model.Id); diff != "" {
t.Errorf("ID does not match:\n%s", diff)
}
if diff := cmp.Diff(tt.expected.CidrList, model.CidrList); diff != "" {
t.Errorf("cidr_list does not match:\n%s", diff)
}
if diff := cmp.Diff(tt.expected.PublicIpRanges, model.PublicIpRanges); diff != "" {
t.Errorf("public_ip_ranges does not match:\n%s", diff)
}
})
}
}

View file

@ -1,158 +0,0 @@
package securitygroup
import (
"context"
"fmt"
"net/http"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
iaasUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/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/iaas"
"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 = &securityGroupDataSource{}
)
// NewSecurityGroupDataSource is a helper function to simplify the provider implementation.
func NewSecurityGroupDataSource() datasource.DataSource {
return &securityGroupDataSource{}
}
// securityGroupDataSource is the data source implementation.
type securityGroupDataSource struct {
client *iaas.APIClient
providerData core.ProviderData
}
// Metadata returns the data source type name.
func (d *securityGroupDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_security_group"
}
func (d *securityGroupDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
var ok bool
d.providerData, ok = conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics)
if !ok {
return
}
apiClient := iaasUtils.ConfigureClient(ctx, &d.providerData, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}
d.client = apiClient
tflog.Info(ctx, "iaas client configured")
}
// Schema defines the schema for the resource.
func (d *securityGroupDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
description := "Security group datasource schema. Must have a `region` specified in the provider configuration."
resp.Schema = schema.Schema{
MarkdownDescription: description,
Description: description,
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: "Terraform's internal resource ID. It is structured as \"`project_id`,`region`,`security_group_id`\".",
Computed: true,
},
"project_id": schema.StringAttribute{
Description: "STACKIT project ID to which the security group is associated.",
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"region": schema.StringAttribute{
Description: "The resource region. If not defined, the provider region is used.",
// the region cannot be found, so it has to be passed
Optional: true,
},
"security_group_id": schema.StringAttribute{
Description: "The security group ID.",
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"name": schema.StringAttribute{
Description: "The name of the security group.",
Computed: true,
},
"description": schema.StringAttribute{
Description: "The description of the security group.",
Computed: true,
},
"labels": schema.MapAttribute{
Description: "Labels are key-value string pairs which can be attached to a resource container",
ElementType: types.StringType,
Computed: true,
},
"stateful": schema.BoolAttribute{
Description: "Configures if a security group is stateful or stateless. There can only be one type of security groups per network interface/server.",
Computed: true,
},
},
}
}
// Read refreshes the Terraform state with the latest data.
func (d *securityGroupDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform
var model Model
diags := req.Config.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
region := d.providerData.GetRegionWithOverride(model.Region)
securityGroupId := model.SecurityGroupId.ValueString()
ctx = core.InitProviderContext(ctx)
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "region", region)
ctx = tflog.SetField(ctx, "security_group_id", securityGroupId)
securityGroupResp, err := d.client.GetSecurityGroup(ctx, projectId, region, securityGroupId).Execute()
if err != nil {
utils.LogError(
ctx,
&resp.Diagnostics,
err,
"Reading security group",
fmt.Sprintf("Security group with ID %q does not exist in project %q.", securityGroupId, 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)
err = mapFields(ctx, securityGroupResp, &model, region)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading security group", 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, "security group read")
}

View file

@ -1,472 +0,0 @@
package securitygroup
import (
"context"
"fmt"
"net/http"
"regexp"
"strings"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
iaasUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/utils"
"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/boolplanmodifier"
"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/core/oapierror"
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
"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/validate"
)
// Ensure the implementation satisfies the expected interfaces.
var (
_ resource.Resource = &securityGroupResource{}
_ resource.ResourceWithConfigure = &securityGroupResource{}
_ resource.ResourceWithImportState = &securityGroupResource{}
_ resource.ResourceWithModifyPlan = &securityGroupResource{}
)
type Model struct {
Id types.String `tfsdk:"id"` // needed by TF
ProjectId types.String `tfsdk:"project_id"`
Region types.String `tfsdk:"region"`
SecurityGroupId types.String `tfsdk:"security_group_id"`
Name types.String `tfsdk:"name"`
Description types.String `tfsdk:"description"`
Labels types.Map `tfsdk:"labels"`
Stateful types.Bool `tfsdk:"stateful"`
}
// NewSecurityGroupResource is a helper function to simplify the provider implementation.
func NewSecurityGroupResource() resource.Resource {
return &securityGroupResource{}
}
// securityGroupResource is the resource implementation.
type securityGroupResource struct {
client *iaas.APIClient
providerData core.ProviderData
}
// Metadata returns the resource type name.
func (r *securityGroupResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_security_group"
}
// ModifyPlan implements resource.ResourceWithModifyPlan.
// Use the modifier to set the effective region in the current plan.
func (r *securityGroupResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { // nolint:gocritic // function signature required by Terraform
var configModel Model
// skip initial empty configuration to avoid follow-up errors
if req.Config.Raw.IsNull() {
return
}
resp.Diagnostics.Append(req.Config.Get(ctx, &configModel)...)
if resp.Diagnostics.HasError() {
return
}
var planModel Model
resp.Diagnostics.Append(req.Plan.Get(ctx, &planModel)...)
if resp.Diagnostics.HasError() {
return
}
utils.AdaptRegion(ctx, configModel.Region, &planModel.Region, r.providerData.GetRegion(), resp)
if resp.Diagnostics.HasError() {
return
}
resp.Diagnostics.Append(resp.Plan.Set(ctx, planModel)...)
if resp.Diagnostics.HasError() {
return
}
}
// Configure adds the provider configured client to the resource.
func (r *securityGroupResource) 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
}
apiClient := iaasUtils.ConfigureClient(ctx, &r.providerData, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}
r.client = apiClient
tflog.Info(ctx, "iaas client configured")
}
// Schema defines the schema for the resource.
func (r *securityGroupResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
description := "Security group resource schema. Must have a `region` specified in the provider configuration."
resp.Schema = schema.Schema{
MarkdownDescription: description,
Description: description,
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: "Terraform's internal resource ID. It is structured as \"`project_id`,`region`,`security_group_id`\".",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"project_id": schema.StringAttribute{
Description: "STACKIT project ID to which the security group is associated.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"region": schema.StringAttribute{
Description: "The resource region. If not defined, the provider region is used.",
Optional: true,
// must be computed to allow for storing the override value from the provider
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"security_group_id": schema.StringAttribute{
Description: "The security group ID.",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"name": schema.StringAttribute{
Description: "The name of the security group.",
Required: true,
Validators: []validator.String{
stringvalidator.LengthAtLeast(1),
stringvalidator.LengthAtMost(63),
stringvalidator.RegexMatches(
regexp.MustCompile(`^[A-Za-z0-9]+((-|_|\s|\.)[A-Za-z0-9]+)*$`),
"must match expression"),
},
},
"description": schema.StringAttribute{
Description: "The description of the security group.",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
Validators: []validator.String{
stringvalidator.LengthAtLeast(1),
stringvalidator.LengthAtMost(127),
},
},
"labels": schema.MapAttribute{
Description: "Labels are key-value string pairs which can be attached to a resource container",
ElementType: types.StringType,
Optional: true,
},
"stateful": schema.BoolAttribute{
Description: "Configures if a security group is stateful or stateless. There can only be one type of security groups per network interface/server.",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.Bool{
boolplanmodifier.RequiresReplace(),
boolplanmodifier.UseStateForUnknown(),
},
},
},
}
}
// Create creates the resource and sets the initial Terraform state.
func (r *securityGroupResource) 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()
region := r.providerData.GetRegionWithOverride(model.Region)
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "region", region)
// Generate API request body from model
payload, err := toCreatePayload(ctx, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating security group", fmt.Sprintf("Creating API payload: %v", err))
return
}
// Create new security group
securityGroup, err := r.client.CreateSecurityGroup(ctx, projectId, region).CreateSecurityGroupPayload(*payload).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating security group", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
securityGroupId := *securityGroup.Id
ctx = tflog.SetField(ctx, "security_group_id", securityGroupId)
// Map response body to schema
err = mapFields(ctx, securityGroup, &model, region)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating security group", 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, "Security group created")
}
// Read refreshes the Terraform state with the latest data.
func (r *securityGroupResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform
var model Model
diags := req.State.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
region := r.providerData.GetRegionWithOverride(model.Region)
securityGroupId := model.SecurityGroupId.ValueString()
ctx = core.InitProviderContext(ctx)
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "region", region)
ctx = tflog.SetField(ctx, "security_id", securityGroupId)
securityGroupResp, err := r.client.GetSecurityGroup(ctx, projectId, region, securityGroupId).Execute()
if err != nil {
oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped
if ok && oapiErr.StatusCode == http.StatusNotFound {
resp.State.RemoveResource(ctx)
return
}
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading security group", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
// Map response body to schema
err = mapFields(ctx, securityGroupResp, &model, region)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading security group", 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, "security group read")
}
// Update updates the resource and sets the updated Terraform state on success.
func (r *securityGroupResource) 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
}
projectId := model.ProjectId.ValueString()
region := r.providerData.GetRegionWithOverride(model.Region)
securityGroupId := model.SecurityGroupId.ValueString()
ctx = core.InitProviderContext(ctx)
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "region", region)
ctx = tflog.SetField(ctx, "security_group_id", securityGroupId)
// Retrieve values from state
var stateModel Model
diags = req.State.Get(ctx, &stateModel)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
// Generate API request body from model
payload, err := toUpdatePayload(ctx, &model, stateModel.Labels)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating security group", fmt.Sprintf("Creating API payload: %v", err))
return
}
// Update existing security group
updatedSecurityGroup, err := r.client.UpdateSecurityGroup(ctx, projectId, region, securityGroupId).UpdateSecurityGroupPayload(*payload).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating security group", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
err = mapFields(ctx, updatedSecurityGroup, &model, region)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating security group", 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, "security group updated")
}
// Delete deletes the resource and removes the Terraform state on success.
func (r *securityGroupResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve values from state
var model Model
diags := req.State.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
region := r.providerData.GetRegionWithOverride(model.Region)
securityGroupId := model.SecurityGroupId.ValueString()
ctx = core.InitProviderContext(ctx)
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "region", region)
ctx = tflog.SetField(ctx, "security_group_id", securityGroupId)
// Delete existing security group
err := r.client.DeleteSecurityGroup(ctx, projectId, region, securityGroupId).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting security group", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
tflog.Info(ctx, "security group deleted")
}
// ImportState imports a resource into the Terraform state on success.
// The expected format of the resource import identifier is: project_id,security_group_id
func (r *securityGroupResource) 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 security group",
fmt.Sprintf("Expected import identifier with format: [project_id],[region],[security_group_id] Got: %q", req.ID),
)
return
}
utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{
"project_id": idParts[0],
"region": idParts[1],
"security_group_id": idParts[2],
})
tflog.Info(ctx, "security group state imported")
}
func mapFields(ctx context.Context, securityGroupResp *iaas.SecurityGroup, model *Model, region string) error {
if securityGroupResp == nil {
return fmt.Errorf("response input is nil")
}
if model == nil {
return fmt.Errorf("model input is nil")
}
var securityGroupId string
if model.SecurityGroupId.ValueString() != "" {
securityGroupId = model.SecurityGroupId.ValueString()
} else if securityGroupResp.Id != nil {
securityGroupId = *securityGroupResp.Id
} else {
return fmt.Errorf("security group id not present")
}
model.Id = utils.BuildInternalTerraformId(model.ProjectId.ValueString(), region, securityGroupId)
model.Region = types.StringValue(region)
labels, err := iaasUtils.MapLabels(ctx, securityGroupResp.Labels, model.Labels)
if err != nil {
return err
}
model.SecurityGroupId = types.StringValue(securityGroupId)
model.Name = types.StringPointerValue(securityGroupResp.Name)
model.Description = types.StringPointerValue(securityGroupResp.Description)
model.Stateful = types.BoolPointerValue(securityGroupResp.Stateful)
model.Labels = labels
return nil
}
func toCreatePayload(ctx context.Context, model *Model) (*iaas.CreateSecurityGroupPayload, error) {
if model == nil {
return nil, fmt.Errorf("nil model")
}
labels, err := conversion.ToStringInterfaceMap(ctx, model.Labels)
if err != nil {
return nil, fmt.Errorf("converting to Go map: %w", err)
}
return &iaas.CreateSecurityGroupPayload{
Stateful: conversion.BoolValueToPointer(model.Stateful),
Description: conversion.StringValueToPointer(model.Description),
Labels: &labels,
Name: conversion.StringValueToPointer(model.Name),
}, nil
}
func toUpdatePayload(ctx context.Context, model *Model, currentLabels types.Map) (*iaas.UpdateSecurityGroupPayload, error) {
if model == nil {
return nil, fmt.Errorf("nil model")
}
labels, err := conversion.ToJSONMapPartialUpdatePayload(ctx, currentLabels, model.Labels)
if err != nil {
return nil, fmt.Errorf("converting to Go map: %w", err)
}
return &iaas.UpdateSecurityGroupPayload{
Description: conversion.StringValueToPointer(model.Description),
Name: conversion.StringValueToPointer(model.Name),
Labels: &labels,
}, nil
}

View file

@ -1,230 +0,0 @@
package securitygroup
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/iaas"
)
func TestMapFields(t *testing.T) {
type args struct {
state Model
input *iaas.SecurityGroup
region string
}
tests := []struct {
description string
args args
expected Model
isValid bool
}{
{
description: "default_values",
args: args{
state: Model{
ProjectId: types.StringValue("pid"),
SecurityGroupId: types.StringValue("sgid"),
},
input: &iaas.SecurityGroup{
Id: utils.Ptr("sgid"),
},
region: "eu01",
},
expected: Model{
Id: types.StringValue("pid,eu01,sgid"),
ProjectId: types.StringValue("pid"),
SecurityGroupId: types.StringValue("sgid"),
Name: types.StringNull(),
Labels: types.MapNull(types.StringType),
Description: types.StringNull(),
Stateful: types.BoolNull(),
Region: types.StringValue("eu01"),
},
isValid: true,
},
{
description: "simple_values",
args: args{
state: Model{
ProjectId: types.StringValue("pid"),
SecurityGroupId: types.StringValue("sgid"),
Region: types.StringValue("eu01"),
},
input: &iaas.SecurityGroup{
Id: utils.Ptr("sgid"),
Name: utils.Ptr("name"),
Stateful: utils.Ptr(true),
Labels: &map[string]interface{}{
"key": "value",
},
Description: utils.Ptr("desc"),
},
region: "eu02",
},
expected: Model{
Id: types.StringValue("pid,eu02,sgid"),
ProjectId: types.StringValue("pid"),
SecurityGroupId: types.StringValue("sgid"),
Name: types.StringValue("name"),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
Description: types.StringValue("desc"),
Stateful: types.BoolValue(true),
Region: types.StringValue("eu02"),
},
isValid: true,
},
{
description: "empty_labels",
args: args{
state: Model{
ProjectId: types.StringValue("pid"),
SecurityGroupId: types.StringValue("sgid"),
},
input: &iaas.SecurityGroup{
Id: utils.Ptr("sgid"),
Labels: &map[string]interface{}{},
},
region: "eu01",
},
expected: Model{
Id: types.StringValue("pid,eu01,sgid"),
ProjectId: types.StringValue("pid"),
SecurityGroupId: types.StringValue("sgid"),
Name: types.StringNull(),
Labels: types.MapNull(types.StringType),
Description: types.StringNull(),
Stateful: types.BoolNull(),
Region: types.StringValue("eu01"),
},
isValid: true,
},
{
description: "response_nil_fail",
},
{
description: "no_resource_id",
args: args{
state: Model{
ProjectId: types.StringValue("pid"),
},
input: &iaas.SecurityGroup{},
},
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
err := mapFields(context.Background(), tt.args.input, &tt.args.state, tt.args.region)
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.args.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 *iaas.CreateSecurityGroupPayload
isValid bool
}{
{
"default_ok",
&Model{
Name: types.StringValue("name"),
Stateful: types.BoolValue(true),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
Description: types.StringValue("desc"),
},
&iaas.CreateSecurityGroupPayload{
Name: utils.Ptr("name"),
Stateful: utils.Ptr(true),
Labels: &map[string]interface{}{
"key": "value",
},
Description: utils.Ptr("desc"),
},
true,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
output, err := toCreatePayload(context.Background(), 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 *iaas.UpdateSecurityGroupPayload
isValid bool
}{
{
"default_ok",
&Model{
Name: types.StringValue("name"),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
Description: types.StringValue("desc"),
},
&iaas.UpdateSecurityGroupPayload{
Name: utils.Ptr("name"),
Labels: &map[string]interface{}{
"key": "value",
},
Description: utils.Ptr("desc"),
},
true,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
output, err := toUpdatePayload(context.Background(), tt.input, types.MapNull(types.StringType))
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,214 +0,0 @@
package securitygrouprule
import (
"context"
"fmt"
"net/http"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
iaasUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/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-log/tflog"
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
"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 = &securityGroupRuleDataSource{}
)
// NewSecurityGroupRuleDataSource is a helper function to simplify the provider implementation.
func NewSecurityGroupRuleDataSource() datasource.DataSource {
return &securityGroupRuleDataSource{}
}
// securityGroupRuleDataSource is the data source implementation.
type securityGroupRuleDataSource struct {
client *iaas.APIClient
providerData core.ProviderData
}
// Metadata returns the data source type name.
func (d *securityGroupRuleDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_security_group_rule"
}
func (d *securityGroupRuleDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
var ok bool
d.providerData, ok = conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics)
if !ok {
return
}
apiClient := iaasUtils.ConfigureClient(ctx, &d.providerData, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}
d.client = apiClient
tflog.Info(ctx, "iaas client configured")
}
// Schema defines the schema for the resource.
func (d *securityGroupRuleDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
directionOptions := []string{"ingress", "egress"}
description := "Security group datasource schema. Must have a `region` specified in the provider configuration."
resp.Schema = schema.Schema{
MarkdownDescription: description,
Description: description,
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: "Terraform's internal datasource ID. It is structured as \"`project_id`,`region`,`security_group_id`,`security_group_rule_id`\".",
Computed: true,
},
"project_id": schema.StringAttribute{
Description: "STACKIT project ID to which the security group rule is associated.",
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"security_group_id": schema.StringAttribute{
Description: "The security group ID.",
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"security_group_rule_id": schema.StringAttribute{
Description: "The security group rule ID.",
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"region": schema.StringAttribute{
Description: "The resource region. If not defined, the provider region is used.",
// the region cannot be found, so it has to be passed
Optional: true,
},
"direction": schema.StringAttribute{
Description: "The direction of the traffic which the rule should match. Some of the possible values are: " + utils.FormatPossibleValues(directionOptions...),
Computed: true,
},
"description": schema.StringAttribute{
Description: "The description of the security group rule.",
Computed: true,
},
"ether_type": schema.StringAttribute{
Description: "The ethertype which the rule should match.",
Computed: true,
},
"icmp_parameters": schema.SingleNestedAttribute{
Description: "ICMP Parameters.",
Computed: true,
Attributes: map[string]schema.Attribute{
"code": schema.Int64Attribute{
Description: "ICMP code. Can be set if the protocol is ICMP.",
Computed: true,
},
"type": schema.Int64Attribute{
Description: "ICMP type. Can be set if the protocol is ICMP.",
Computed: true,
},
},
},
"ip_range": schema.StringAttribute{
Description: "The remote IP range which the rule should match.",
Computed: true,
},
"port_range": schema.SingleNestedAttribute{
Description: "The range of ports.",
Computed: true,
Attributes: map[string]schema.Attribute{
"max": schema.Int64Attribute{
Description: "The maximum port number. Should be greater or equal to the minimum.",
Computed: true,
},
"min": schema.Int64Attribute{
Description: "The minimum port number. Should be less or equal to the minimum.",
Computed: true,
},
},
},
"protocol": schema.SingleNestedAttribute{
Description: "The internet protocol which the rule should match.",
Computed: true,
Attributes: map[string]schema.Attribute{
"name": schema.StringAttribute{
Description: "The protocol name which the rule should match.",
Computed: true,
},
"number": schema.Int64Attribute{
Description: "The protocol number which the rule should match.",
Computed: true,
},
},
},
"remote_security_group_id": schema.StringAttribute{
Description: "The remote security group which the rule should match.",
Computed: true,
},
},
}
}
// Read refreshes the Terraform state with the latest data.
func (d *securityGroupRuleDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform
var model Model
diags := req.Config.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
region := d.providerData.GetRegionWithOverride(model.Region)
securityGroupId := model.SecurityGroupId.ValueString()
securityGroupRuleId := model.SecurityGroupRuleId.ValueString()
ctx = core.InitProviderContext(ctx)
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "region", region)
ctx = tflog.SetField(ctx, "security_group_id", securityGroupId)
ctx = tflog.SetField(ctx, "security_group_rule_id", securityGroupRuleId)
securityGroupRuleResp, err := d.client.GetSecurityGroupRule(ctx, projectId, region, securityGroupId, securityGroupRuleId).Execute()
if err != nil {
utils.LogError(
ctx,
&resp.Diagnostics,
err,
"Reading security group rule",
fmt.Sprintf("Security group rule with ID %q or security group with ID %q does not exist in project %q.", securityGroupRuleId, securityGroupId, 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)
err = mapFields(securityGroupRuleResp, &model, region)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading security group rule", 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, "security group rule read")
}

View file

@ -1,93 +0,0 @@
package securitygrouprule
import (
"context"
"slices"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
)
// UseNullForUnknownBasedOnProtocolModifier returns a plan modifier that sets a null
// value into the planned value, based on the value of the protocol.name attribute.
//
// To prevent Terraform errors, the framework automatically sets unconfigured
// and Computed attributes to an unknown value "(known after apply)" on update.
// To prevent always showing "(known after apply)" on update for an attribute, e.g. port_range, which never changes in case the protocol is a specific one,
// we set the value to null.
// Examples: port_range is only computed if protocol is not icmp and icmp_parameters is only computed if protocol is icmp
func UseNullForUnknownBasedOnProtocolModifier() planmodifier.Object {
return useNullForUnknownBasedOnProtocolModifier{}
}
// useNullForUnknownBasedOnProtocolModifier implements the plan modifier.
type useNullForUnknownBasedOnProtocolModifier struct{}
func (m useNullForUnknownBasedOnProtocolModifier) Description(_ context.Context) string {
return "If protocol.name attribute is set and the value corresponds to an icmp protocol, the value of this attribute in state will be set to null."
}
// MarkdownDescription returns a markdown description of the plan modifier.
func (m useNullForUnknownBasedOnProtocolModifier) MarkdownDescription(_ context.Context) string {
return "Once set, the value of this attribute in state will be set to null if protocol.name attribute is set and the value corresponds to an icmp protocol."
}
// PlanModifyBool implements the plan modification logic.
func (m useNullForUnknownBasedOnProtocolModifier) PlanModifyObject(ctx context.Context, req planmodifier.ObjectRequest, resp *planmodifier.ObjectResponse) { // nolint:gocritic // function signature required by Terraform
// Check if the resource is being created.
if req.State.Raw.IsNull() {
return
}
// Do nothing if there is a known planned value.
if !req.PlanValue.IsUnknown() {
return
}
// Do nothing if there is an unknown configuration value, otherwise interpolation gets messed up.
if req.ConfigValue.IsUnknown() {
return
}
// If there is an unknown configuration value, check if the value of protocol.name attribute corresponds to an icmp protocol. If it does, set the attribute value to null
var model Model
resp.Diagnostics.Append(req.Config.Get(ctx, &model)...)
if resp.Diagnostics.HasError() {
return
}
// If protocol is not configured, return without error.
if model.Protocol.IsNull() || model.Protocol.IsUnknown() {
return
}
protocol := &protocolModel{}
diags := model.Protocol.As(ctx, protocol, basetypes.ObjectAsOptions{})
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
protocolName := conversion.StringValueToPointer(protocol.Name)
if protocolName == nil {
return
}
if slices.Contains(icmpProtocols, *protocolName) {
if model.PortRange.IsUnknown() {
resp.PlanValue = types.ObjectNull(portRangeTypes)
return
}
} else {
if model.IcmpParameters.IsUnknown() {
resp.PlanValue = types.ObjectNull(icmpParametersTypes)
return
}
}
// use state for unknown if the value was not set to null
resp.PlanValue = req.StateValue
}

View file

@ -1,804 +0,0 @@
package securitygrouprule
import (
"context"
"fmt"
"net/http"
"regexp"
"slices"
"strings"
iaasUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/utils"
"github.com/hashicorp/terraform-plugin-framework-validators/int64validator"
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
"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/int64planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/objectplanmodifier"
"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/iaas"
"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/utils"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate"
)
// Ensure the implementation satisfies the expected interfaces.
var (
_ resource.Resource = &securityGroupRuleResource{}
_ resource.ResourceWithConfigure = &securityGroupRuleResource{}
_ resource.ResourceWithImportState = &securityGroupRuleResource{}
_ resource.ResourceWithModifyPlan = &securityGroupRuleResource{}
icmpProtocols = []string{"icmp", "ipv6-icmp"}
protocolsPossibleValues = []string{
"ah", "dccp", "egp", "esp", "gre", "icmp", "igmp", "ipip", "ipv6-encap", "ipv6-frag", "ipv6-icmp",
"ipv6-nonxt", "ipv6-opts", "ipv6-route", "ospf", "pgm", "rsvp", "sctp", "tcp", "udp", "udplite", "vrrp",
}
)
type Model struct {
Id types.String `tfsdk:"id"` // needed by TF
ProjectId types.String `tfsdk:"project_id"`
Region types.String `tfsdk:"region"`
SecurityGroupId types.String `tfsdk:"security_group_id"`
SecurityGroupRuleId types.String `tfsdk:"security_group_rule_id"`
Direction types.String `tfsdk:"direction"`
Description types.String `tfsdk:"description"`
EtherType types.String `tfsdk:"ether_type"`
IcmpParameters types.Object `tfsdk:"icmp_parameters"`
IpRange types.String `tfsdk:"ip_range"`
PortRange types.Object `tfsdk:"port_range"`
Protocol types.Object `tfsdk:"protocol"`
RemoteSecurityGroupId types.String `tfsdk:"remote_security_group_id"`
}
type icmpParametersModel struct {
Code types.Int64 `tfsdk:"code"`
Type types.Int64 `tfsdk:"type"`
}
// Types corresponding to icmpParameters
var icmpParametersTypes = map[string]attr.Type{
"code": basetypes.Int64Type{},
"type": basetypes.Int64Type{},
}
type portRangeModel struct {
Max types.Int64 `tfsdk:"max"`
Min types.Int64 `tfsdk:"min"`
}
// Types corresponding to portRange
var portRangeTypes = map[string]attr.Type{
"max": basetypes.Int64Type{},
"min": basetypes.Int64Type{},
}
type protocolModel struct {
Name types.String `tfsdk:"name"`
Number types.Int64 `tfsdk:"number"`
}
// Types corresponding to protocol
var protocolTypes = map[string]attr.Type{
"name": basetypes.StringType{},
"number": basetypes.Int64Type{},
}
// NewSecurityGroupRuleResource is a helper function to simplify the provider implementation.
func NewSecurityGroupRuleResource() resource.Resource {
return &securityGroupRuleResource{}
}
// securityGroupRuleResource is the resource implementation.
type securityGroupRuleResource struct {
client *iaas.APIClient
providerData core.ProviderData
}
// Metadata returns the resource type name.
func (r *securityGroupRuleResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_security_group_rule"
}
// ModifyPlan implements resource.ResourceWithModifyPlan.
// Use the modifier to set the effective region in the current plan.
func (r *securityGroupRuleResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { // nolint:gocritic // function signature required by Terraform
var configModel Model
// skip initial empty configuration to avoid follow-up errors
if req.Config.Raw.IsNull() {
return
}
resp.Diagnostics.Append(req.Config.Get(ctx, &configModel)...)
if resp.Diagnostics.HasError() {
return
}
var planModel Model
resp.Diagnostics.Append(req.Plan.Get(ctx, &planModel)...)
if resp.Diagnostics.HasError() {
return
}
utils.AdaptRegion(ctx, configModel.Region, &planModel.Region, r.providerData.GetRegion(), resp)
if resp.Diagnostics.HasError() {
return
}
resp.Diagnostics.Append(resp.Plan.Set(ctx, planModel)...)
if resp.Diagnostics.HasError() {
return
}
}
// Configure adds the provider configured client to the resource.
func (r *securityGroupRuleResource) 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
}
apiClient := iaasUtils.ConfigureClient(ctx, &r.providerData, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}
r.client = apiClient
tflog.Info(ctx, "iaas client configured")
}
func (r *securityGroupRuleResource) 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 protocol is not configured, return without error.
if model.Protocol.IsNull() || model.Protocol.IsUnknown() {
return
}
protocol := &protocolModel{}
diags := model.Protocol.As(ctx, protocol, basetypes.ObjectAsOptions{})
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
protocolName := conversion.StringValueToPointer(protocol.Name)
if protocolName == nil {
return
}
if slices.Contains(icmpProtocols, *protocolName) {
if !(model.PortRange.IsNull() || model.PortRange.IsUnknown()) {
resp.Diagnostics.AddAttributeError(
path.Root("port_range"),
"Conflicting attribute configuration",
"`port_range` attribute can't be provided if `protocol.name` is set to `icmp` or `ipv6-icmp`",
)
}
} else {
if !(model.IcmpParameters.IsNull() || model.IcmpParameters.IsUnknown()) {
resp.Diagnostics.AddAttributeError(
path.Root("icmp_parameters"),
"Conflicting attribute configuration",
"`icmp_parameters` attribute can't be provided if `protocol.name` is not `icmp` or `ipv6-icmp`",
)
}
}
}
// Schema defines the schema for the resource.
func (r *securityGroupRuleResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
directionOptions := []string{"ingress", "egress"}
description := "Security group rule resource schema. Must have a `region` specified in the provider configuration."
resp.Schema = schema.Schema{
MarkdownDescription: description,
Description: description,
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: "Terraform's internal resource ID. It is structured as \"`project_id`,`region`,`security_group_id`,`security_group_rule_id`\".",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
stringplanmodifier.RequiresReplace(),
},
},
"project_id": schema.StringAttribute{
Description: "STACKIT project ID to which the security group rule is associated.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"region": schema.StringAttribute{
Description: "The resource region. If not defined, the provider region is used.",
Optional: true,
// must be computed to allow for storing the override value from the provider
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"security_group_id": schema.StringAttribute{
Description: "The security group ID.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"security_group_rule_id": schema.StringAttribute{
Description: "The security group rule ID.",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"description": schema.StringAttribute{
Description: "The rule description.",
Optional: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplaceIfConfigured(),
},
Validators: []validator.String{
stringvalidator.LengthAtMost(127),
},
},
"direction": schema.StringAttribute{
Description: "The direction of the traffic which the rule should match. Some of the possible values are: " + utils.FormatPossibleValues(directionOptions...),
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"ether_type": schema.StringAttribute{
Description: "The ethertype which the rule should match.",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
stringplanmodifier.RequiresReplaceIfConfigured(),
},
},
"icmp_parameters": schema.SingleNestedAttribute{
Description: "ICMP Parameters. These parameters should only be provided if the protocol is ICMP.",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.Object{
UseNullForUnknownBasedOnProtocolModifier(),
objectplanmodifier.RequiresReplaceIfConfigured(),
},
Attributes: map[string]schema.Attribute{
"code": schema.Int64Attribute{
Description: "ICMP code. Can be set if the protocol is ICMP.",
Required: true,
PlanModifiers: []planmodifier.Int64{
int64planmodifier.RequiresReplace(),
},
Validators: []validator.Int64{
int64validator.AtLeast(0),
int64validator.AtMost(255),
},
},
"type": schema.Int64Attribute{
Description: "ICMP type. Can be set if the protocol is ICMP.",
Required: true,
PlanModifiers: []planmodifier.Int64{
int64planmodifier.RequiresReplace(),
},
Validators: []validator.Int64{
int64validator.AtLeast(0),
int64validator.AtMost(255),
},
},
},
},
"ip_range": schema.StringAttribute{
Description: "The remote IP range which the rule should match.",
Optional: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
stringvalidator.RegexMatches(
regexp.MustCompile(`^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\/(3[0-2]|2[0-9]|1[0-9]|[0-9]))$|^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))(\/((1(1[0-9]|2[0-8]))|([0-9][0-9])|([0-9])))?$`),
"must match expression"),
},
},
"port_range": schema.SingleNestedAttribute{
Description: "The range of ports. This should only be provided if the protocol is not ICMP.",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.Object{
objectplanmodifier.RequiresReplaceIfConfigured(),
UseNullForUnknownBasedOnProtocolModifier(),
},
Attributes: map[string]schema.Attribute{
"max": schema.Int64Attribute{
Description: "The maximum port number. Should be greater or equal to the minimum.",
Required: true,
PlanModifiers: []planmodifier.Int64{
int64planmodifier.RequiresReplace(),
},
Validators: []validator.Int64{
int64validator.AtLeast(0),
int64validator.AtMost(65535),
},
},
"min": schema.Int64Attribute{
Description: "The minimum port number. Should be less or equal to the maximum.",
Required: true,
PlanModifiers: []planmodifier.Int64{
int64planmodifier.RequiresReplace(),
},
Validators: []validator.Int64{
int64validator.AtLeast(0),
int64validator.AtMost(65535),
},
},
},
},
"protocol": schema.SingleNestedAttribute{
Description: "The internet protocol which the rule should match.",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.Object{
objectplanmodifier.RequiresReplaceIfConfigured(),
objectplanmodifier.UseStateForUnknown(),
},
Attributes: map[string]schema.Attribute{
"name": schema.StringAttribute{
Description: fmt.Sprintf("The protocol name which the rule should match. Either `name` or `number` must be provided. %s", utils.FormatPossibleValues(protocolsPossibleValues...)),
Optional: true,
Computed: true,
Validators: []validator.String{
stringvalidator.AtLeastOneOf(
path.MatchRoot("protocol").AtName("number"),
),
stringvalidator.ConflictsWith(
path.MatchRoot("protocol").AtName("number"),
),
},
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
stringplanmodifier.RequiresReplaceIfConfigured(),
},
},
"number": schema.Int64Attribute{
Description: "The protocol number which the rule should match. Either `name` or `number` must be provided.",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.Int64{
int64planmodifier.UseStateForUnknown(),
int64planmodifier.RequiresReplaceIfConfigured(),
},
Validators: []validator.Int64{
int64validator.AtLeast(0),
int64validator.AtMost(255),
},
},
},
},
"remote_security_group_id": schema.StringAttribute{
Description: "The remote security group which the rule should match.",
Optional: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
},
}
}
// Create creates the resource and sets the initial Terraform state.
func (r *securityGroupRuleResource) 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()
region := r.providerData.GetRegionWithOverride(model.Region)
securityGroupId := model.SecurityGroupId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "region", region)
ctx = tflog.SetField(ctx, "security_group_id", securityGroupId)
var icmpParameters *icmpParametersModel
if !(model.IcmpParameters.IsNull() || model.IcmpParameters.IsUnknown()) {
icmpParameters = &icmpParametersModel{}
diags = model.IcmpParameters.As(ctx, icmpParameters, basetypes.ObjectAsOptions{})
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
}
var portRange *portRangeModel
if !(model.PortRange.IsNull() || model.PortRange.IsUnknown()) {
portRange = &portRangeModel{}
diags = model.PortRange.As(ctx, portRange, basetypes.ObjectAsOptions{})
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
}
var protocol *protocolModel
if !(model.Protocol.IsNull() || model.Protocol.IsUnknown()) {
protocol = &protocolModel{}
diags = model.Protocol.As(ctx, protocol, basetypes.ObjectAsOptions{})
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
}
// Generate API request body from model
payload, err := toCreatePayload(&model, icmpParameters, portRange, protocol)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating security group rule", fmt.Sprintf("Creating API payload: %v", err))
return
}
// Create new security group rule
securityGroupRule, err := r.client.CreateSecurityGroupRule(ctx, projectId, region, securityGroupId).CreateSecurityGroupRulePayload(*payload).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating security group rule", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
ctx = tflog.SetField(ctx, "security_group_rule_id", *securityGroupRule.Id)
// Map response body to schema
err = mapFields(securityGroupRule, &model, region)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating security group rule", 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, "Security group rule created")
}
// Read refreshes the Terraform state with the latest data.
func (r *securityGroupRuleResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform
var model Model
diags := req.State.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
region := r.providerData.GetRegionWithOverride(model.Region)
securityGroupId := model.SecurityGroupId.ValueString()
securityGroupRuleId := model.SecurityGroupRuleId.ValueString()
ctx = core.InitProviderContext(ctx)
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "region", region)
ctx = tflog.SetField(ctx, "security_group_id", securityGroupId)
ctx = tflog.SetField(ctx, "security_group_rule_id", securityGroupRuleId)
securityGroupRuleResp, err := r.client.GetSecurityGroupRule(ctx, projectId, region, securityGroupId, securityGroupRuleId).Execute()
if err != nil {
oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped
if ok && oapiErr.StatusCode == http.StatusNotFound {
resp.State.RemoveResource(ctx)
return
}
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading security group rule", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
// Map response body to schema
err = mapFields(securityGroupRuleResp, &model, region)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading security group rule", 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, "security group rule read")
}
// Update updates the resource and sets the updated Terraform state on success.
func (r *securityGroupRuleResource) Update(ctx context.Context, _ resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform
// Update shouldn't be called
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating security group rule", "Security group rule can't be updated")
}
// Delete deletes the resource and removes the Terraform state on success.
func (r *securityGroupRuleResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve values from state
var model Model
diags := req.State.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
region := r.providerData.GetRegionWithOverride(model.Region)
securityGroupId := model.SecurityGroupId.ValueString()
securityGroupRuleId := model.SecurityGroupRuleId.ValueString()
ctx = core.InitProviderContext(ctx)
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "region", region)
ctx = tflog.SetField(ctx, "security_group_id", securityGroupId)
ctx = tflog.SetField(ctx, "security_group_rule_id", securityGroupRuleId)
// Delete existing security group rule
err := r.client.DeleteSecurityGroupRule(ctx, projectId, region, securityGroupId, securityGroupRuleId).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting security group rule", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
tflog.Info(ctx, "security group rule deleted")
}
// ImportState imports a resource into the Terraform state on success.
// The expected format of the resource import identifier is: project_id,security_group_id, security_group_rule_id
func (r *securityGroupRuleResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
idParts := strings.Split(req.ID, core.Separator)
if len(idParts) != 4 || idParts[0] == "" || idParts[1] == "" || idParts[2] == "" || idParts[3] == "" {
core.LogAndAddError(ctx, &resp.Diagnostics,
"Error importing security group rule",
fmt.Sprintf("Expected import identifier with format: [project_id],[region],[security_group_id],[security_group_rule_id] Got: %q", req.ID),
)
return
}
utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{
"project_id": idParts[0],
"region": idParts[1],
"security_group_id": idParts[2],
"security_group_rule_id": idParts[3],
})
tflog.Info(ctx, "security group rule state imported")
}
func mapFields(securityGroupRuleResp *iaas.SecurityGroupRule, model *Model, region string) error {
if securityGroupRuleResp == nil {
return fmt.Errorf("response input is nil")
}
if model == nil {
return fmt.Errorf("model input is nil")
}
var securityGroupRuleId string
if model.SecurityGroupRuleId.ValueString() != "" {
securityGroupRuleId = model.SecurityGroupRuleId.ValueString()
} else if securityGroupRuleResp.Id != nil {
securityGroupRuleId = *securityGroupRuleResp.Id
} else {
return fmt.Errorf("security group rule id not present")
}
model.Id = utils.BuildInternalTerraformId(model.ProjectId.ValueString(), region, model.SecurityGroupId.ValueString(), securityGroupRuleId)
model.Region = types.StringValue(region)
model.SecurityGroupRuleId = types.StringValue(securityGroupRuleId)
model.Direction = types.StringPointerValue(securityGroupRuleResp.Direction)
model.Description = types.StringPointerValue(securityGroupRuleResp.Description)
model.EtherType = types.StringPointerValue(securityGroupRuleResp.Ethertype)
model.IpRange = types.StringPointerValue(securityGroupRuleResp.IpRange)
model.RemoteSecurityGroupId = types.StringPointerValue(securityGroupRuleResp.RemoteSecurityGroupId)
err := mapIcmpParameters(securityGroupRuleResp, model)
if err != nil {
return fmt.Errorf("map icmp_parameters: %w", err)
}
err = mapPortRange(securityGroupRuleResp, model)
if err != nil {
return fmt.Errorf("map port_range: %w", err)
}
err = mapProtocol(securityGroupRuleResp, model)
if err != nil {
return fmt.Errorf("map protocol: %w", err)
}
return nil
}
func mapIcmpParameters(securityGroupRuleResp *iaas.SecurityGroupRule, m *Model) error {
if securityGroupRuleResp.IcmpParameters == nil {
m.IcmpParameters = types.ObjectNull(icmpParametersTypes)
return nil
}
icmpParametersValues := map[string]attr.Value{
"type": types.Int64Value(*securityGroupRuleResp.IcmpParameters.Type),
"code": types.Int64Value(*securityGroupRuleResp.IcmpParameters.Code),
}
icmpParametersObject, diags := types.ObjectValue(icmpParametersTypes, icmpParametersValues)
if diags.HasError() {
return fmt.Errorf("create icmpParameters object: %w", core.DiagsToError(diags))
}
m.IcmpParameters = icmpParametersObject
return nil
}
func mapPortRange(securityGroupRuleResp *iaas.SecurityGroupRule, m *Model) error {
if securityGroupRuleResp.PortRange == nil {
m.PortRange = types.ObjectNull(portRangeTypes)
return nil
}
portRangeMax := types.Int64Null()
portRangeMin := types.Int64Null()
if securityGroupRuleResp.PortRange.Max != nil {
portRangeMax = types.Int64Value(*securityGroupRuleResp.PortRange.Max)
}
if securityGroupRuleResp.PortRange.Min != nil {
portRangeMin = types.Int64Value(*securityGroupRuleResp.PortRange.Min)
}
portRangeValues := map[string]attr.Value{
"max": portRangeMax,
"min": portRangeMin,
}
portRangeObject, diags := types.ObjectValue(portRangeTypes, portRangeValues)
if diags.HasError() {
return fmt.Errorf("create portRange object: %w", core.DiagsToError(diags))
}
m.PortRange = portRangeObject
return nil
}
func mapProtocol(securityGroupRuleResp *iaas.SecurityGroupRule, m *Model) error {
if securityGroupRuleResp.Protocol == nil {
m.Protocol = types.ObjectNull(protocolTypes)
return nil
}
protocolNumberValue := types.Int64Null()
if securityGroupRuleResp.Protocol.Number != nil {
protocolNumberValue = types.Int64Value(*securityGroupRuleResp.Protocol.Number)
}
protocolNameValue := types.StringNull()
if securityGroupRuleResp.Protocol.Name != nil {
protocolNameValue = types.StringValue(*securityGroupRuleResp.Protocol.Name)
}
protocolValues := map[string]attr.Value{
"name": protocolNameValue,
"number": protocolNumberValue,
}
protocolObject, diags := types.ObjectValue(protocolTypes, protocolValues)
if diags.HasError() {
return fmt.Errorf("create protocol object: %w", core.DiagsToError(diags))
}
m.Protocol = protocolObject
return nil
}
func toCreatePayload(model *Model, icmpParameters *icmpParametersModel, portRange *portRangeModel, protocol *protocolModel) (*iaas.CreateSecurityGroupRulePayload, error) {
if model == nil {
return nil, fmt.Errorf("nil model")
}
payloadIcmpParameters, err := toIcmpParametersPayload(icmpParameters)
if err != nil {
return nil, fmt.Errorf("converting icmp parameters: %w", err)
}
payloadPortRange, err := toPortRangePayload(portRange)
if err != nil {
return nil, fmt.Errorf("converting port range: %w", err)
}
payloadProtocol, err := toProtocolPayload(protocol)
if err != nil {
return nil, fmt.Errorf("converting protocol: %w", err)
}
return &iaas.CreateSecurityGroupRulePayload{
Description: conversion.StringValueToPointer(model.Description),
Direction: conversion.StringValueToPointer(model.Direction),
Ethertype: conversion.StringValueToPointer(model.EtherType),
IpRange: conversion.StringValueToPointer(model.IpRange),
RemoteSecurityGroupId: conversion.StringValueToPointer(model.RemoteSecurityGroupId),
IcmpParameters: payloadIcmpParameters,
PortRange: payloadPortRange,
Protocol: payloadProtocol,
}, nil
}
func toIcmpParametersPayload(icmpParameters *icmpParametersModel) (*iaas.ICMPParameters, error) {
if icmpParameters == nil {
return nil, nil
}
payloadParams := &iaas.ICMPParameters{}
payloadParams.Code = conversion.Int64ValueToPointer(icmpParameters.Code)
payloadParams.Type = conversion.Int64ValueToPointer(icmpParameters.Type)
return payloadParams, nil
}
func toPortRangePayload(portRange *portRangeModel) (*iaas.PortRange, error) {
if portRange == nil {
return nil, nil
}
payloadPortRange := &iaas.PortRange{}
payloadPortRange.Max = conversion.Int64ValueToPointer(portRange.Max)
payloadPortRange.Min = conversion.Int64ValueToPointer(portRange.Min)
return payloadPortRange, nil
}
func toProtocolPayload(protocol *protocolModel) (*iaas.CreateProtocol, error) {
if protocol == nil {
return nil, nil
}
payloadProtocol := &iaas.CreateProtocol{}
payloadProtocol.String = conversion.StringValueToPointer(protocol.Name)
payloadProtocol.Int64 = conversion.Int64ValueToPointer(protocol.Number)
return payloadProtocol, nil
}

View file

@ -1,322 +0,0 @@
package securitygrouprule
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/hashicorp/terraform-plugin-framework/types/basetypes"
"github.com/stackitcloud/stackit-sdk-go/core/utils"
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
)
var fixtureModelIcmpParameters = types.ObjectValueMust(icmpParametersTypes, map[string]attr.Value{
"code": types.Int64Value(1),
"type": types.Int64Value(2),
})
var fixtureIcmpParameters = iaas.ICMPParameters{
Code: utils.Ptr(int64(1)),
Type: utils.Ptr(int64(2)),
}
var fixtureModelPortRange = types.ObjectValueMust(portRangeTypes, map[string]attr.Value{
"max": types.Int64Value(2),
"min": types.Int64Value(1),
})
var fixturePortRange = iaas.PortRange{
Max: utils.Ptr(int64(2)),
Min: utils.Ptr(int64(1)),
}
var fixtureModelProtocol = types.ObjectValueMust(protocolTypes, map[string]attr.Value{
"name": types.StringValue("name"),
"number": types.Int64Value(1),
})
var fixtureProtocol = iaas.Protocol{
Name: utils.Ptr("name"),
Number: utils.Ptr(int64(1)),
}
var fixtureModelCreateProtocol = types.ObjectValueMust(protocolTypes, map[string]attr.Value{
"name": types.StringValue("name"),
"number": types.Int64Null(),
})
var fixtureCreateProtocol = iaas.CreateProtocol{
String: utils.Ptr("name"),
}
func TestMapFields(t *testing.T) {
type args struct {
state Model
input *iaas.SecurityGroupRule
region string
}
tests := []struct {
description string
args args
expected Model
isValid bool
}{
{
description: "default_values",
args: args{
state: Model{
ProjectId: types.StringValue("pid"),
SecurityGroupId: types.StringValue("sgid"),
SecurityGroupRuleId: types.StringValue("sgrid"),
},
input: &iaas.SecurityGroupRule{
Id: utils.Ptr("sgrid"),
},
region: "eu01",
},
expected: Model{
Id: types.StringValue("pid,eu01,sgid,sgrid"),
ProjectId: types.StringValue("pid"),
SecurityGroupId: types.StringValue("sgid"),
SecurityGroupRuleId: types.StringValue("sgrid"),
Direction: types.StringNull(),
Description: types.StringNull(),
EtherType: types.StringNull(),
IpRange: types.StringNull(),
RemoteSecurityGroupId: types.StringNull(),
IcmpParameters: types.ObjectNull(icmpParametersTypes),
PortRange: types.ObjectNull(portRangeTypes),
Protocol: types.ObjectNull(protocolTypes),
Region: types.StringValue("eu01"),
},
isValid: true,
},
{
description: "simple_values",
args: args{
state: Model{
ProjectId: types.StringValue("pid"),
SecurityGroupId: types.StringValue("sgid"),
SecurityGroupRuleId: types.StringValue("sgrid"),
Region: types.StringValue("eu01"),
},
input: &iaas.SecurityGroupRule{
Id: utils.Ptr("sgrid"),
Description: utils.Ptr("desc"),
Direction: utils.Ptr("ingress"),
Ethertype: utils.Ptr("ether"),
IpRange: utils.Ptr("iprange"),
RemoteSecurityGroupId: utils.Ptr("remote"),
IcmpParameters: &fixtureIcmpParameters,
PortRange: &fixturePortRange,
Protocol: &fixtureProtocol,
},
region: "eu02",
},
expected: Model{
Id: types.StringValue("pid,eu02,sgid,sgrid"),
ProjectId: types.StringValue("pid"),
SecurityGroupId: types.StringValue("sgid"),
SecurityGroupRuleId: types.StringValue("sgrid"),
Direction: types.StringValue("ingress"),
Description: types.StringValue("desc"),
EtherType: types.StringValue("ether"),
IpRange: types.StringValue("iprange"),
RemoteSecurityGroupId: types.StringValue("remote"),
IcmpParameters: fixtureModelIcmpParameters,
PortRange: fixtureModelPortRange,
Protocol: fixtureModelProtocol,
Region: types.StringValue("eu02"),
},
isValid: true,
},
{
description: "protocol_only_with_name",
args: args{
state: Model{
ProjectId: types.StringValue("pid"),
SecurityGroupId: types.StringValue("sgid"),
SecurityGroupRuleId: types.StringValue("sgrid"),
Protocol: types.ObjectValueMust(protocolTypes, map[string]attr.Value{
"name": types.StringValue("name"),
"number": types.Int64Null(),
}),
},
input: &iaas.SecurityGroupRule{
Id: utils.Ptr("sgrid"),
Protocol: &fixtureProtocol,
},
region: "eu01",
},
expected: Model{
Id: types.StringValue("pid,eu01,sgid,sgrid"),
ProjectId: types.StringValue("pid"),
SecurityGroupId: types.StringValue("sgid"),
SecurityGroupRuleId: types.StringValue("sgrid"),
Direction: types.StringNull(),
Description: types.StringNull(),
EtherType: types.StringNull(),
IpRange: types.StringNull(),
RemoteSecurityGroupId: types.StringNull(),
IcmpParameters: types.ObjectNull(icmpParametersTypes),
PortRange: types.ObjectNull(portRangeTypes),
Protocol: fixtureModelProtocol,
Region: types.StringValue("eu01"),
},
isValid: true,
},
{
description: "protocol_only_with_number",
args: args{
state: Model{
ProjectId: types.StringValue("pid"),
SecurityGroupId: types.StringValue("sgid"),
SecurityGroupRuleId: types.StringValue("sgrid"),
Protocol: types.ObjectValueMust(protocolTypes, map[string]attr.Value{
"name": types.StringNull(),
"number": types.Int64Value(1),
}),
},
input: &iaas.SecurityGroupRule{
Id: utils.Ptr("sgrid"),
Protocol: &fixtureProtocol,
},
region: "eu01",
},
expected: Model{
Id: types.StringValue("pid,eu01,sgid,sgrid"),
ProjectId: types.StringValue("pid"),
SecurityGroupId: types.StringValue("sgid"),
SecurityGroupRuleId: types.StringValue("sgrid"),
Direction: types.StringNull(),
Description: types.StringNull(),
EtherType: types.StringNull(),
IpRange: types.StringNull(),
RemoteSecurityGroupId: types.StringNull(),
IcmpParameters: types.ObjectNull(icmpParametersTypes),
PortRange: types.ObjectNull(portRangeTypes),
Protocol: fixtureModelProtocol,
Region: types.StringValue("eu01"),
},
isValid: true,
},
{
description: "response_nil_fail",
},
{
description: "no_resource_id",
args: args{
state: Model{
ProjectId: types.StringValue("pid"),
SecurityGroupId: types.StringValue("sgid"),
},
input: &iaas.SecurityGroupRule{},
},
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
err := mapFields(tt.args.input, &tt.args.state, tt.args.region)
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.args.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 *iaas.CreateSecurityGroupRulePayload
isValid bool
}{
{
"default_values",
&Model{},
&iaas.CreateSecurityGroupRulePayload{},
true,
},
{
"default_ok",
&Model{
Description: types.StringValue("desc"),
Direction: types.StringValue("ingress"),
IcmpParameters: fixtureModelIcmpParameters,
PortRange: fixtureModelPortRange,
Protocol: fixtureModelCreateProtocol,
},
&iaas.CreateSecurityGroupRulePayload{
Description: utils.Ptr("desc"),
Direction: utils.Ptr("ingress"),
IcmpParameters: &fixtureIcmpParameters,
PortRange: &fixturePortRange,
Protocol: &fixtureCreateProtocol,
},
true,
},
{
"nil_model",
nil,
nil,
false,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
var icmpParameters *icmpParametersModel
var portRange *portRangeModel
var protocol *protocolModel
if tt.input != nil {
if !(tt.input.IcmpParameters.IsNull() || tt.input.IcmpParameters.IsUnknown()) {
icmpParameters = &icmpParametersModel{}
diags := tt.input.IcmpParameters.As(context.Background(), icmpParameters, basetypes.ObjectAsOptions{})
if diags.HasError() {
t.Fatalf("Error converting icmp parameters: %v", diags.Errors())
}
}
if !(tt.input.PortRange.IsNull() || tt.input.PortRange.IsUnknown()) {
portRange = &portRangeModel{}
diags := tt.input.PortRange.As(context.Background(), portRange, basetypes.ObjectAsOptions{})
if diags.HasError() {
t.Fatalf("Error converting port range: %v", diags.Errors())
}
}
if !(tt.input.Protocol.IsNull() || tt.input.Protocol.IsUnknown()) {
protocol = &protocolModel{}
diags := tt.input.Protocol.As(context.Background(), protocol, basetypes.ObjectAsOptions{})
if diags.HasError() {
t.Fatalf("Error converting protocol: %v", diags.Errors())
}
}
}
output, err := toCreatePayload(tt.input, icmpParameters, portRange, protocol)
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,176 +0,0 @@
package server
const markdownDescription = `
Server resource schema. Must have a region specified in the provider configuration.` + "\n" + `
## Example Usage` + "\n" + `
### With key pair` + "\n" +
"```terraform" + `
resource "stackit_key_pair" "keypair" {
name = "example-key-pair"
public_key = chomp(file("path/to/id_rsa.pub"))
}
resource "stackit_server" "user-data-from-file" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
boot_volume = {
size = 64
source_type = "image"
source_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
name = "example-server"
machine_type = "g2i.1"
keypair_name = stackit_key_pair.keypair.name
user_data = file("${path.module}/cloud-init.yaml")
}
` + "\n```" + `
### Boot from volume` + "\n" +
"```terraform" + `
resource "stackit_server" "boot-from-volume" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
name = "example-server"
boot_volume = {
size = 64
source_type = "image"
source_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
availability_zone = "eu01-1"
machine_type = "g2i.1"
keypair_name = "example-keypair"
}
` + "\n```" + `
### Boot from existing volume` + "\n" +
"```terraform" + `
resource "stackit_volume" "example-volume" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
size = 12
source = {
type = "image"
id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
name = "example-volume"
availability_zone = "eu01-1"
}
resource "stackit_server" "boot-from-volume" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
name = "example-server"
boot_volume = {
source_type = "volume"
source_id = stackit_volume.example-volume.volume_id
}
availability_zone = "eu01-1"
machine_type = "g2i.1"
keypair_name = stackit_key_pair.keypair.name
}
` + "\n```" + `
### Network setup` + "\n" +
"```terraform" + `
resource "stackit_network" "network" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
name = "example-network"
nameservers = ["192.0.2.0", "198.51.100.0", "203.0.113.0"]
ipv4_prefix_length = 24
}
resource "stackit_security_group" "sec-group" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
name = "example-security-group"
stateful = true
}
resource "stackit_security_group_rule" "rule" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
security_group_id = stackit_security_group.sec-group.security_group_id
direction = "ingress"
ether_type = "IPv4"
}
resource "stackit_network_interface" "nic" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
network_id = stackit_network.network.network_id
security_group_ids = [stackit_security_group.sec-group.security_group_id]
}
resource "stackit_server" "server-with-network" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
name = "example-server"
boot_volume = {
size = 64
source_type = "image"
source_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
machine_type = "g2i.1"
keypair_name = stackit_key_pair.keypair.name
network_interfaces = [
stackit_network_interface.nic.network_interface_id
]
}
resource "stackit_public_ip" "public-ip" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
network_interface_id = stackit_network_interface.nic.network_interface_id
}
` + "\n```" + `
### Server with attached volume` + "\n" +
"```terraform" + `
resource "stackit_volume" "example-volume" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
size = 12
performance_class = "storage_premium_perf6"
name = "example-volume"
availability_zone = "eu01-1"
}
resource "stackit_server" "server-with-volume" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
name = "example-server"
boot_volume = {
size = 64
source_type = "image"
source_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
availability_zone = "eu01-1"
machine_type = "g2i.1"
keypair_name = stackit_key_pair.keypair.name
}
resource "stackit_server_volume_attach" "attach_volume" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
server_id = stackit_server.server-with-volume.server_id
volume_id = stackit_volume.example-volume.volume_id
}
` + "\n```" + `
### Server with user data (cloud-init)` + "\n" +
"```terraform" + `
resource "stackit_server" "user-data" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
boot_volume = {
size = 64
source_type = "image"
source_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
name = "example-server"
machine_type = "g2i.1"
keypair_name = stackit_key_pair.keypair.name
user_data = "#!/bin/bash\n/bin/su"
}
resource "stackit_server" "user-data-from-file" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
boot_volume = {
size = 64
source_type = "image"
source_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
name = "example-server"
machine_type = "g2i.1"
keypair_name = stackit_key_pair.keypair.name
user_data = file("${path.module}/cloud-init.yaml")
}
` + "\n```"

View file

@ -1,325 +0,0 @@
package server
import (
"context"
"fmt"
"net/http"
"time"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
iaasUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/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-framework/types/basetypes"
"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
"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 = &serverDataSource{}
)
type DataSourceModel struct {
Id types.String `tfsdk:"id"` // needed by TF
ProjectId types.String `tfsdk:"project_id"`
Region types.String `tfsdk:"region"`
ServerId types.String `tfsdk:"server_id"`
MachineType types.String `tfsdk:"machine_type"`
Name types.String `tfsdk:"name"`
AvailabilityZone types.String `tfsdk:"availability_zone"`
BootVolume types.Object `tfsdk:"boot_volume"`
ImageId types.String `tfsdk:"image_id"`
NetworkInterfaces types.List `tfsdk:"network_interfaces"`
KeypairName types.String `tfsdk:"keypair_name"`
Labels types.Map `tfsdk:"labels"`
AffinityGroup types.String `tfsdk:"affinity_group"`
UserData types.String `tfsdk:"user_data"`
CreatedAt types.String `tfsdk:"created_at"`
LaunchedAt types.String `tfsdk:"launched_at"`
UpdatedAt types.String `tfsdk:"updated_at"`
}
var bootVolumeDataTypes = map[string]attr.Type{
"id": basetypes.StringType{},
"delete_on_termination": basetypes.BoolType{},
}
// NewServerDataSource is a helper function to simplify the provider implementation.
func NewServerDataSource() datasource.DataSource {
return &serverDataSource{}
}
// serverDataSource is the data source implementation.
type serverDataSource struct {
client *iaas.APIClient
providerData core.ProviderData
}
// Metadata returns the data source type name.
func (d *serverDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_server"
}
func (d *serverDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
var ok bool
d.providerData, ok = conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics)
if !ok {
return
}
apiClient := iaasUtils.ConfigureClient(ctx, &d.providerData, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}
d.client = apiClient
tflog.Info(ctx, "iaas client configured")
}
// Schema defines the schema for the datasource.
func (d *serverDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
description := "Server datasource schema. Must have a `region` specified in the provider configuration."
resp.Schema = schema.Schema{
MarkdownDescription: description,
Description: description,
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: "Terraform's internal resource ID. It is structured as \"`project_id`,`region`,`server_id`\".",
Computed: true,
},
"project_id": schema.StringAttribute{
Description: "STACKIT project ID to which the server is associated.",
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"region": schema.StringAttribute{
Description: "The resource region. If not defined, the provider region is used.",
// the region cannot be found, so it has to be passed
Optional: true,
},
"server_id": schema.StringAttribute{
Description: "The server ID.",
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"name": schema.StringAttribute{
Description: "The name of the server.",
Computed: true,
},
"machine_type": schema.StringAttribute{
MarkdownDescription: "Name of the type of the machine for the server. Possible values are documented in [Virtual machine flavors](https://docs.stackit.cloud/products/compute-engine/server/basics/machine-types/)",
Computed: true,
},
"availability_zone": schema.StringAttribute{
Description: "The availability zone of the server.",
Computed: true,
},
"boot_volume": schema.SingleNestedAttribute{
Description: "The boot volume for the server",
Computed: true,
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: "The ID of the boot volume",
Computed: true,
},
"delete_on_termination": schema.BoolAttribute{
Description: "Delete the volume during the termination of the server.",
Computed: true,
},
},
},
"image_id": schema.StringAttribute{
Description: "The image ID to be used for an ephemeral disk on the server.",
Computed: true,
},
"network_interfaces": schema.ListAttribute{
Description: "The IDs of network interfaces which should be attached to the server. Updating it will recreate the server.",
Computed: true,
ElementType: types.StringType,
},
"keypair_name": schema.StringAttribute{
Description: "The name of the keypair used during server creation.",
Computed: true,
},
"labels": schema.MapAttribute{
Description: "Labels are key-value string pairs which can be attached to a resource container",
ElementType: types.StringType,
Computed: true,
},
"affinity_group": schema.StringAttribute{
Description: "The affinity group the server is assigned to.",
Computed: true,
},
"user_data": schema.StringAttribute{
Description: "User data that is passed via cloud-init to the server.",
Computed: true,
},
"created_at": schema.StringAttribute{
Description: "Date-time when the server was created",
Computed: true,
},
"launched_at": schema.StringAttribute{
Description: "Date-time when the server was launched",
Computed: true,
},
"updated_at": schema.StringAttribute{
Description: "Date-time when the server was updated",
Computed: true,
},
},
}
}
// Read refreshes the Terraform state with the latest data.
func (d *serverDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform
var model DataSourceModel
diags := req.Config.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
region := d.providerData.GetRegionWithOverride(model.Region)
serverId := model.ServerId.ValueString()
ctx = core.InitProviderContext(ctx)
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "region", region)
ctx = tflog.SetField(ctx, "server_id", serverId)
serverReq := d.client.GetServer(ctx, projectId, region, serverId)
serverReq = serverReq.Details(true)
serverResp, err := serverReq.Execute()
if err != nil {
utils.LogError(
ctx,
&resp.Diagnostics,
err,
"Reading server",
fmt.Sprintf("Server with ID %q does not exist in project %q.", serverId, 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)
// Map response body to schema
err = mapDataSourceFields(ctx, serverResp, &model, region)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading server", 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, "server read")
}
func mapDataSourceFields(ctx context.Context, serverResp *iaas.Server, model *DataSourceModel, region string) error {
if serverResp == nil {
return fmt.Errorf("response input is nil")
}
if model == nil {
return fmt.Errorf("model input is nil")
}
var serverId string
if model.ServerId.ValueString() != "" {
serverId = model.ServerId.ValueString()
} else if serverResp.Id != nil {
serverId = *serverResp.Id
} else {
return fmt.Errorf("server id not present")
}
model.Id = utils.BuildInternalTerraformId(model.ProjectId.ValueString(), region, serverId)
model.Region = types.StringValue(region)
labels, err := iaasUtils.MapLabels(ctx, serverResp.Labels, model.Labels)
if err != nil {
return err
}
var createdAt basetypes.StringValue
if serverResp.CreatedAt != nil {
createdAtValue := *serverResp.CreatedAt
createdAt = types.StringValue(createdAtValue.Format(time.RFC3339))
}
var updatedAt basetypes.StringValue
if serverResp.UpdatedAt != nil {
updatedAtValue := *serverResp.UpdatedAt
updatedAt = types.StringValue(updatedAtValue.Format(time.RFC3339))
}
var launchedAt basetypes.StringValue
if serverResp.LaunchedAt != nil {
launchedAtValue := *serverResp.LaunchedAt
launchedAt = types.StringValue(launchedAtValue.Format(time.RFC3339))
}
if serverResp.Nics != nil {
var respNics []string
for _, nic := range *serverResp.Nics {
respNics = append(respNics, *nic.NicId)
}
nicTF, diags := types.ListValueFrom(ctx, types.StringType, respNics)
if diags.HasError() {
return fmt.Errorf("failed to map networkInterfaces: %w", core.DiagsToError(diags))
}
model.NetworkInterfaces = nicTF
} else {
model.NetworkInterfaces = types.ListNull(types.StringType)
}
if serverResp.BootVolume != nil {
bootVolume, diags := types.ObjectValue(bootVolumeDataTypes, map[string]attr.Value{
"id": types.StringPointerValue(serverResp.BootVolume.Id),
"delete_on_termination": types.BoolPointerValue(serverResp.BootVolume.DeleteOnTermination),
})
if diags.HasError() {
return fmt.Errorf("failed to map bootVolume: %w", core.DiagsToError(diags))
}
model.BootVolume = bootVolume
} else {
model.BootVolume = types.ObjectNull(bootVolumeDataTypes)
}
if serverResp.UserData != nil && len(*serverResp.UserData) > 0 {
model.UserData = types.StringValue(string(*serverResp.UserData))
}
model.AvailabilityZone = types.StringPointerValue(serverResp.AvailabilityZone)
model.ServerId = types.StringValue(serverId)
model.MachineType = types.StringPointerValue(serverResp.MachineType)
model.Name = types.StringPointerValue(serverResp.Name)
model.Labels = labels
model.ImageId = types.StringPointerValue(serverResp.ImageId)
model.KeypairName = types.StringPointerValue(serverResp.KeypairName)
model.AffinityGroup = types.StringPointerValue(serverResp.AffinityGroup)
model.CreatedAt = createdAt
model.UpdatedAt = updatedAt
model.LaunchedAt = launchedAt
return nil
}

View file

@ -1,175 +0,0 @@
package server
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/iaas"
)
func TestMapDataSourceFields(t *testing.T) {
type args struct {
state DataSourceModel
input *iaas.Server
region string
}
tests := []struct {
description string
args args
expected DataSourceModel
isValid bool
}{
{
description: "default_values",
args: args{
state: DataSourceModel{
ProjectId: types.StringValue("pid"),
ServerId: types.StringValue("sid"),
},
input: &iaas.Server{
Id: utils.Ptr("sid"),
},
region: "eu01",
},
expected: DataSourceModel{
Id: types.StringValue("pid,eu01,sid"),
ProjectId: types.StringValue("pid"),
ServerId: types.StringValue("sid"),
Name: types.StringNull(),
AvailabilityZone: types.StringNull(),
Labels: types.MapNull(types.StringType),
ImageId: types.StringNull(),
NetworkInterfaces: types.ListNull(types.StringType),
KeypairName: types.StringNull(),
AffinityGroup: types.StringNull(),
UserData: types.StringNull(),
CreatedAt: types.StringNull(),
UpdatedAt: types.StringNull(),
LaunchedAt: types.StringNull(),
Region: types.StringValue("eu01"),
},
isValid: true,
},
{
description: "simple_values",
args: args{
state: DataSourceModel{
ProjectId: types.StringValue("pid"),
ServerId: types.StringValue("sid"),
Region: types.StringValue("eu01"),
},
input: &iaas.Server{
Id: utils.Ptr("sid"),
Name: utils.Ptr("name"),
AvailabilityZone: utils.Ptr("zone"),
Labels: &map[string]interface{}{
"key": "value",
},
ImageId: utils.Ptr("image_id"),
Nics: &[]iaas.ServerNetwork{
{
NicId: utils.Ptr("nic1"),
},
{
NicId: utils.Ptr("nic2"),
},
},
KeypairName: utils.Ptr("keypair_name"),
AffinityGroup: utils.Ptr("group_id"),
CreatedAt: utils.Ptr(testTimestamp()),
UpdatedAt: utils.Ptr(testTimestamp()),
LaunchedAt: utils.Ptr(testTimestamp()),
Status: utils.Ptr("active"),
},
region: "eu02",
},
expected: DataSourceModel{
Id: types.StringValue("pid,eu02,sid"),
ProjectId: types.StringValue("pid"),
ServerId: types.StringValue("sid"),
Name: types.StringValue("name"),
AvailabilityZone: types.StringValue("zone"),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
ImageId: types.StringValue("image_id"),
NetworkInterfaces: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("nic1"),
types.StringValue("nic2"),
}),
KeypairName: types.StringValue("keypair_name"),
AffinityGroup: types.StringValue("group_id"),
CreatedAt: types.StringValue(testTimestampValue),
UpdatedAt: types.StringValue(testTimestampValue),
LaunchedAt: types.StringValue(testTimestampValue),
Region: types.StringValue("eu02"),
},
isValid: true,
},
{
description: "empty_labels",
args: args{
state: DataSourceModel{
ProjectId: types.StringValue("pid"),
ServerId: types.StringValue("sid"),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{}),
},
input: &iaas.Server{
Id: utils.Ptr("sid"),
},
region: "eu01",
},
expected: DataSourceModel{
Id: types.StringValue("pid,eu01,sid"),
ProjectId: types.StringValue("pid"),
ServerId: types.StringValue("sid"),
Name: types.StringNull(),
AvailabilityZone: types.StringNull(),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{}),
ImageId: types.StringNull(),
NetworkInterfaces: types.ListNull(types.StringType),
KeypairName: types.StringNull(),
AffinityGroup: types.StringNull(),
UserData: types.StringNull(),
CreatedAt: types.StringNull(),
UpdatedAt: types.StringNull(),
LaunchedAt: types.StringNull(),
Region: types.StringValue("eu01"),
},
isValid: true,
},
{
description: "response_nil_fail",
},
{
description: "no_resource_id",
args: args{
state: DataSourceModel{
ProjectId: types.StringValue("pid"),
},
input: &iaas.Server{},
},
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
err := mapDataSourceFields(context.Background(), tt.args.input, &tt.args.state, tt.args.region)
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.args.state, tt.expected)
if diff != "" {
t.Fatalf("Data does not match: %s", diff)
}
}
})
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,623 +0,0 @@
package server
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/hashicorp/terraform-plugin-framework/types/basetypes"
"github.com/stackitcloud/stackit-sdk-go/core/utils"
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
"github.com/stackitcloud/stackit-sdk-go/services/iaas/wait"
)
const (
userData = "user_data"
base64EncodedUserData = "dXNlcl9kYXRh"
testTimestampValue = "2006-01-02T15:04:05Z"
)
func testTimestamp() time.Time {
timestamp, _ := time.Parse(time.RFC3339, testTimestampValue)
return timestamp
}
func TestMapFields(t *testing.T) {
type args struct {
state Model
input *iaas.Server
region string
}
tests := []struct {
description string
args args
expected Model
isValid bool
}{
{
description: "default_values",
args: args{
state: Model{
ProjectId: types.StringValue("pid"),
ServerId: types.StringValue("sid"),
},
input: &iaas.Server{
Id: utils.Ptr("sid"),
},
region: "eu01",
},
expected: Model{
Id: types.StringValue("pid,eu01,sid"),
ProjectId: types.StringValue("pid"),
ServerId: types.StringValue("sid"),
Name: types.StringNull(),
AvailabilityZone: types.StringNull(),
Labels: types.MapNull(types.StringType),
ImageId: types.StringNull(),
NetworkInterfaces: types.ListNull(types.StringType),
KeypairName: types.StringNull(),
AffinityGroup: types.StringNull(),
UserData: types.StringNull(),
CreatedAt: types.StringNull(),
UpdatedAt: types.StringNull(),
LaunchedAt: types.StringNull(),
Region: types.StringValue("eu01"),
},
isValid: true,
},
{
description: "simple_values",
args: args{
state: Model{
ProjectId: types.StringValue("pid"),
ServerId: types.StringValue("sid"),
Region: types.StringValue("eu01"),
},
input: &iaas.Server{
Id: utils.Ptr("sid"),
Name: utils.Ptr("name"),
AvailabilityZone: utils.Ptr("zone"),
Labels: &map[string]interface{}{
"key": "value",
},
ImageId: utils.Ptr("image_id"),
Nics: &[]iaas.ServerNetwork{
{
NicId: utils.Ptr("nic1"),
},
{
NicId: utils.Ptr("nic2"),
},
},
KeypairName: utils.Ptr("keypair_name"),
AffinityGroup: utils.Ptr("group_id"),
CreatedAt: utils.Ptr(testTimestamp()),
UpdatedAt: utils.Ptr(testTimestamp()),
LaunchedAt: utils.Ptr(testTimestamp()),
Status: utils.Ptr("active"),
},
region: "eu02",
},
expected: Model{
Id: types.StringValue("pid,eu02,sid"),
ProjectId: types.StringValue("pid"),
ServerId: types.StringValue("sid"),
Name: types.StringValue("name"),
AvailabilityZone: types.StringValue("zone"),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
ImageId: types.StringValue("image_id"),
NetworkInterfaces: types.ListNull(types.StringType),
KeypairName: types.StringValue("keypair_name"),
AffinityGroup: types.StringValue("group_id"),
CreatedAt: types.StringValue(testTimestampValue),
UpdatedAt: types.StringValue(testTimestampValue),
LaunchedAt: types.StringValue(testTimestampValue),
Region: types.StringValue("eu02"),
},
isValid: true,
},
{
description: "empty_labels",
args: args{
state: Model{
ProjectId: types.StringValue("pid"),
ServerId: types.StringValue("sid"),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{}),
},
input: &iaas.Server{
Id: utils.Ptr("sid"),
},
region: "eu01",
},
expected: Model{
Id: types.StringValue("pid,eu01,sid"),
ProjectId: types.StringValue("pid"),
ServerId: types.StringValue("sid"),
Name: types.StringNull(),
AvailabilityZone: types.StringNull(),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{}),
ImageId: types.StringNull(),
NetworkInterfaces: types.ListNull(types.StringType),
KeypairName: types.StringNull(),
AffinityGroup: types.StringNull(),
UserData: types.StringNull(),
CreatedAt: types.StringNull(),
UpdatedAt: types.StringNull(),
LaunchedAt: types.StringNull(),
Region: types.StringValue("eu01"),
},
isValid: true,
},
{
description: "response_nil_fail",
},
{
description: "no_resource_id",
args: args{
state: Model{
ProjectId: types.StringValue("pid"),
},
input: &iaas.Server{},
},
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
err := mapFields(context.Background(), tt.args.input, &tt.args.state, tt.args.region)
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.args.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 *iaas.CreateServerPayload
isValid bool
}{
{
description: "ok",
input: &Model{
Name: types.StringValue("name"),
AvailabilityZone: types.StringValue("zone"),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
BootVolume: types.ObjectValueMust(bootVolumeTypes, map[string]attr.Value{
"performance_class": types.StringValue("class"),
"size": types.Int64Value(1),
"source_type": types.StringValue("type"),
"source_id": types.StringValue("id"),
"delete_on_termination": types.BoolUnknown(),
"id": types.StringValue("id"),
}),
ImageId: types.StringValue("image"),
KeypairName: types.StringValue("keypair"),
MachineType: types.StringValue("machine_type"),
UserData: types.StringValue(userData),
NetworkInterfaces: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("nic1"),
types.StringValue("nic2"),
}),
},
expected: &iaas.CreateServerPayload{
Name: utils.Ptr("name"),
AvailabilityZone: utils.Ptr("zone"),
Labels: &map[string]interface{}{
"key": "value",
},
BootVolume: &iaas.ServerBootVolume{
PerformanceClass: utils.Ptr("class"),
Size: utils.Ptr(int64(1)),
Source: &iaas.BootVolumeSource{
Type: utils.Ptr("type"),
Id: utils.Ptr("id"),
},
},
ImageId: utils.Ptr("image"),
KeypairName: utils.Ptr("keypair"),
MachineType: utils.Ptr("machine_type"),
UserData: utils.Ptr([]byte(base64EncodedUserData)),
Networking: &iaas.CreateServerPayloadAllOfNetworking{
CreateServerNetworkingWithNics: &iaas.CreateServerNetworkingWithNics{
NicIds: &[]string{"nic1", "nic2"},
},
},
},
isValid: true,
},
{
description: "delete on termination is set to true",
input: &Model{
Name: types.StringValue("name"),
AvailabilityZone: types.StringValue("zone"),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
BootVolume: types.ObjectValueMust(bootVolumeTypes, map[string]attr.Value{
"performance_class": types.StringValue("class"),
"size": types.Int64Value(1),
"source_type": types.StringValue("image"),
"source_id": types.StringValue("id"),
"delete_on_termination": types.BoolValue(true),
"id": types.StringValue("id"),
}),
ImageId: types.StringValue("image"),
KeypairName: types.StringValue("keypair"),
MachineType: types.StringValue("machine_type"),
UserData: types.StringValue(userData),
NetworkInterfaces: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("nic1"),
types.StringValue("nic2"),
}),
},
expected: &iaas.CreateServerPayload{
Name: utils.Ptr("name"),
AvailabilityZone: utils.Ptr("zone"),
Labels: &map[string]interface{}{
"key": "value",
},
BootVolume: &iaas.ServerBootVolume{
PerformanceClass: utils.Ptr("class"),
Size: utils.Ptr(int64(1)),
Source: &iaas.BootVolumeSource{
Type: utils.Ptr("image"),
Id: utils.Ptr("id"),
},
DeleteOnTermination: utils.Ptr(true),
},
ImageId: utils.Ptr("image"),
KeypairName: utils.Ptr("keypair"),
MachineType: utils.Ptr("machine_type"),
UserData: utils.Ptr([]byte(base64EncodedUserData)),
Networking: &iaas.CreateServerPayloadAllOfNetworking{
CreateServerNetworkingWithNics: &iaas.CreateServerNetworkingWithNics{
NicIds: &[]string{"nic1", "nic2"},
},
},
},
isValid: true,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
output, err := toCreatePayload(context.Background(), 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 *iaas.UpdateServerPayload
isValid bool
}{
{
"default_ok",
&Model{
Name: types.StringValue("name"),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
},
&iaas.UpdateServerPayload{
Name: utils.Ptr("name"),
Labels: &map[string]interface{}{
"key": "value",
},
},
true,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
output, err := toUpdatePayload(context.Background(), tt.input, types.MapNull(types.StringType))
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)
}
}
})
}
}
var _ serverControlClient = &mockServerControlClient{}
// mockServerControlClient mocks the [serverControlClient] interface with
// pluggable functions
type mockServerControlClient struct {
wait.APIClientInterface
startServerCalled int
startServerExecute func(callNo int, ctx context.Context, projectId, region, serverId string) error
stopServerCalled int
stopServerExecute func(callNo int, ctx context.Context, projectId, region, serverId string) error
deallocateServerCalled int
deallocateServerExecute func(callNo int, ctx context.Context, projectId, region, serverId string) error
getServerCalled int
getServerExecute func(callNo int, ctx context.Context, projectId, region, serverId string) (*iaas.Server, error)
}
// DeallocateServerExecute implements serverControlClient.
func (t *mockServerControlClient) DeallocateServerExecute(ctx context.Context, projectId, region, serverId string) error {
t.deallocateServerCalled++
return t.deallocateServerExecute(t.deallocateServerCalled, ctx, projectId, region, serverId)
}
// GetServerExecute implements serverControlClient.
func (t *mockServerControlClient) GetServerExecute(ctx context.Context, projectId, region, serverId string) (*iaas.Server, error) {
t.getServerCalled++
return t.getServerExecute(t.getServerCalled, ctx, projectId, region, serverId)
}
// StartServerExecute implements serverControlClient.
func (t *mockServerControlClient) StartServerExecute(ctx context.Context, projectId, region, serverId string) error {
t.startServerCalled++
return t.startServerExecute(t.startServerCalled, ctx, projectId, region, serverId)
}
// StopServerExecute implements serverControlClient.
func (t *mockServerControlClient) StopServerExecute(ctx context.Context, projectId, region, serverId string) error {
t.stopServerCalled++
return t.stopServerExecute(t.stopServerCalled, ctx, projectId, region, serverId)
}
func Test_serverResource_updateServerStatus(t *testing.T) {
projectId := basetypes.NewStringValue("projectId")
serverId := basetypes.NewStringValue("serverId")
type fields struct {
client *mockServerControlClient
}
type args struct {
currentState *string
model Model
region string
}
type want struct {
err bool
status types.String
getServerCount int
stopCount int
startCount int
deallocatedCount int
}
tests := []struct {
name string
fields fields
args args
want want
}{
{
name: "no desired status",
fields: fields{
client: &mockServerControlClient{
getServerExecute: func(_ int, _ context.Context, _, _, _ string) (*iaas.Server, error) {
return &iaas.Server{
Id: utils.Ptr(serverId.ValueString()),
Status: utils.Ptr(wait.ServerActiveStatus),
}, nil
},
},
},
args: args{
currentState: utils.Ptr(wait.ServerActiveStatus),
model: Model{
ProjectId: projectId,
ServerId: serverId,
},
},
want: want{
getServerCount: 1,
},
},
{
name: "desired inactive state",
fields: fields{
client: &mockServerControlClient{
getServerExecute: func(no int, _ context.Context, _, _, _ string) (*iaas.Server, error) {
var state string
if no <= 1 {
state = wait.ServerActiveStatus
} else {
state = wait.ServerInactiveStatus
}
return &iaas.Server{
Id: utils.Ptr(serverId.ValueString()),
Status: &state,
}, nil
},
stopServerExecute: func(_ int, _ context.Context, _, _, _ string) error { return nil },
},
},
args: args{
currentState: utils.Ptr(wait.ServerActiveStatus),
model: Model{
ProjectId: projectId,
ServerId: serverId,
DesiredStatus: basetypes.NewStringValue("inactive"),
},
},
want: want{
getServerCount: 2,
stopCount: 1,
status: basetypes.NewStringValue("inactive"),
},
},
{
name: "desired deallocated state",
fields: fields{
client: &mockServerControlClient{
getServerExecute: func(no int, _ context.Context, _, _, _ string) (*iaas.Server, error) {
var state string
switch no {
case 1:
state = wait.ServerActiveStatus
case 2:
state = wait.ServerInactiveStatus
default:
state = wait.ServerDeallocatedStatus
}
return &iaas.Server{
Id: utils.Ptr(serverId.ValueString()),
Status: &state,
}, nil
},
deallocateServerExecute: func(_ int, _ context.Context, _, _, _ string) error { return nil },
},
},
args: args{
currentState: utils.Ptr(wait.ServerActiveStatus),
model: Model{
ProjectId: projectId,
ServerId: serverId,
DesiredStatus: basetypes.NewStringValue("deallocated"),
},
},
want: want{
getServerCount: 3,
deallocatedCount: 1,
status: basetypes.NewStringValue("deallocated"),
},
},
{
name: "don't call start if active",
fields: fields{
client: &mockServerControlClient{
getServerExecute: func(_ int, _ context.Context, _, _, _ string) (*iaas.Server, error) {
return &iaas.Server{
Id: utils.Ptr(serverId.ValueString()),
Status: utils.Ptr(wait.ServerActiveStatus),
}, nil
},
},
},
args: args{
currentState: utils.Ptr(wait.ServerActiveStatus),
model: Model{
ProjectId: projectId,
ServerId: serverId,
DesiredStatus: basetypes.NewStringValue("active"),
},
},
want: want{
status: basetypes.NewStringValue("active"),
getServerCount: 1,
},
},
{
name: "don't call stop if inactive",
fields: fields{
client: &mockServerControlClient{
getServerExecute: func(_ int, _ context.Context, _, _, _ string) (*iaas.Server, error) {
return &iaas.Server{
Id: utils.Ptr(serverId.ValueString()),
Status: utils.Ptr(wait.ServerInactiveStatus),
}, nil
},
},
},
args: args{
currentState: utils.Ptr(wait.ServerInactiveStatus),
model: Model{
ProjectId: projectId,
ServerId: serverId,
DesiredStatus: basetypes.NewStringValue("inactive"),
},
},
want: want{
status: basetypes.NewStringValue("inactive"),
getServerCount: 1,
},
},
{
name: "don't call dealloacate if deallocated",
fields: fields{
client: &mockServerControlClient{
getServerExecute: func(_ int, _ context.Context, _, _, _ string) (*iaas.Server, error) {
return &iaas.Server{
Id: utils.Ptr(serverId.ValueString()),
Status: utils.Ptr(wait.ServerDeallocatedStatus),
}, nil
},
},
},
args: args{
currentState: utils.Ptr(wait.ServerDeallocatedStatus),
model: Model{
ProjectId: projectId,
ServerId: serverId,
DesiredStatus: basetypes.NewStringValue("deallocated"),
},
},
want: want{
status: basetypes.NewStringValue("deallocated"),
getServerCount: 1,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := updateServerStatus(context.Background(), tt.fields.client, tt.args.currentState, &tt.args.model, tt.args.region)
if (err != nil) != tt.want.err {
t.Errorf("inconsistent error, want %v and got %v", tt.want.err, err)
}
if expected, actual := tt.want.status, tt.args.model.DesiredStatus; expected != actual {
t.Errorf("wanted status %s but got %s", expected, actual)
}
if expected, actual := tt.want.getServerCount, tt.fields.client.getServerCalled; expected != actual {
t.Errorf("wrong number of get server calls: Expected %d but got %d", expected, actual)
}
if expected, actual := tt.want.startCount, tt.fields.client.startServerCalled; expected != actual {
t.Errorf("wrong number of start server calls: Expected %d but got %d", expected, actual)
}
if expected, actual := tt.want.stopCount, tt.fields.client.stopServerCalled; expected != actual {
t.Errorf("wrong number of stop server calls: Expected %d but got %d", expected, actual)
}
if expected, actual := tt.want.deallocatedCount, tt.fields.client.deallocateServerCalled; expected != actual {
t.Errorf("wrong number of deallocate server calls: Expected %d but got %d", expected, actual)
}
})
}
}

View file

@ -1,323 +0,0 @@
package serviceaccountattach
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
iaasUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/utils"
"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-log/tflog"
"github.com/stackitcloud/stackit-sdk-go/core/oapierror"
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate"
)
// Ensure the implementation satisfies the expected interfaces.
var (
_ resource.Resource = &serviceAccountAttachResource{}
_ resource.ResourceWithConfigure = &serviceAccountAttachResource{}
_ resource.ResourceWithImportState = &serviceAccountAttachResource{}
_ resource.ResourceWithModifyPlan = &serviceAccountAttachResource{}
)
type Model struct {
Id types.String `tfsdk:"id"` // needed by TF
ProjectId types.String `tfsdk:"project_id"`
Region types.String `tfsdk:"region"`
ServerId types.String `tfsdk:"server_id"`
ServiceAccountEmail types.String `tfsdk:"service_account_email"`
}
// NewServiceAccountAttachResource is a helper function to simplify the provider implementation.
func NewServiceAccountAttachResource() resource.Resource {
return &serviceAccountAttachResource{}
}
// serviceAccountAttachResource is the resource implementation.
type serviceAccountAttachResource struct {
client *iaas.APIClient
providerData core.ProviderData
}
// Metadata returns the resource type name.
func (r *serviceAccountAttachResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_server_service_account_attach"
}
// ModifyPlan implements resource.ResourceWithModifyPlan.
// Use the modifier to set the effective region in the current plan.
func (r *serviceAccountAttachResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { // nolint:gocritic // function signature required by Terraform
var configModel Model
// skip initial empty configuration to avoid follow-up errors
if req.Config.Raw.IsNull() {
return
}
resp.Diagnostics.Append(req.Config.Get(ctx, &configModel)...)
if resp.Diagnostics.HasError() {
return
}
var planModel Model
resp.Diagnostics.Append(req.Plan.Get(ctx, &planModel)...)
if resp.Diagnostics.HasError() {
return
}
utils.AdaptRegion(ctx, configModel.Region, &planModel.Region, r.providerData.GetRegion(), resp)
if resp.Diagnostics.HasError() {
return
}
resp.Diagnostics.Append(resp.Plan.Set(ctx, planModel)...)
if resp.Diagnostics.HasError() {
return
}
}
// Configure adds the provider configured client to the resource.
func (r *serviceAccountAttachResource) 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
}
apiClient := iaasUtils.ConfigureClient(ctx, &r.providerData, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}
r.client = apiClient
tflog.Info(ctx, "iaas client configured")
}
// Schema defines the schema for the resource.
func (r *serviceAccountAttachResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
description := "Service account attachment resource schema. Attaches a service account to a server. Must have a `region` specified in the provider configuration."
resp.Schema = schema.Schema{
MarkdownDescription: description,
Description: description,
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: "Terraform's internal resource ID. It is structured as \"`project_id`,`region`,`server_id`,`service_account_email`\".",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"project_id": schema.StringAttribute{
Description: "STACKIT project ID to which the service account attachment is associated.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"region": schema.StringAttribute{
Description: "The resource region. If not defined, the provider region is used.",
Optional: true,
// must be computed to allow for storing the override value from the provider
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"server_id": schema.StringAttribute{
Description: "The server ID.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"service_account_email": schema.StringAttribute{
Description: "The service account email.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
},
}
}
// Create creates the resource and sets the initial Terraform state.
func (r *serviceAccountAttachResource) 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()
region := r.providerData.GetRegionWithOverride(model.Region)
serverId := model.ServerId.ValueString()
serviceAccountEmail := model.ServiceAccountEmail.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "region", region)
ctx = tflog.SetField(ctx, "server_id", serverId)
ctx = tflog.SetField(ctx, "service_account_email", serviceAccountEmail)
// Create new service account attachment
_, err := r.client.AddServiceAccountToServer(ctx, projectId, region, serverId, serviceAccountEmail).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error attaching service account to server", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
model.Id = utils.BuildInternalTerraformId(projectId, region, serverId, serviceAccountEmail)
model.Region = types.StringValue(region)
// Set state to fully populated data
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "Service account attachment created")
}
// Read refreshes the Terraform state with the latest data.
func (r *serviceAccountAttachResource) 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()
region := r.providerData.GetRegionWithOverride(model.Region)
serverId := model.ServerId.ValueString()
serviceAccountEmail := model.ServiceAccountEmail.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "region", region)
ctx = tflog.SetField(ctx, "server_id", serverId)
ctx = tflog.SetField(ctx, "service_account_email", serviceAccountEmail)
serviceAccounts, err := r.client.ListServerServiceAccounts(ctx, projectId, region, serverId).Execute()
if err != nil {
oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped
if ok && oapiErr.StatusCode == http.StatusNotFound {
resp.State.RemoveResource(ctx)
return
}
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading service account attachment", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
if serviceAccounts == nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading service account attachment", "List of service accounts attached to the server is nil")
return
}
if serviceAccounts.Items != nil {
for _, mail := range *serviceAccounts.Items {
if mail != serviceAccountEmail {
continue
}
model.Id = utils.BuildInternalTerraformId(projectId, region, serverId, serviceAccountEmail)
model.Region = types.StringValue(region)
// Set refreshed state
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "Service account attachment read")
return
}
}
// no matching service account was found, the attachment no longer exists
resp.State.RemoveResource(ctx)
}
// Update updates the resource and sets the updated Terraform state on success.
func (r *serviceAccountAttachResource) Update(_ context.Context, _ resource.UpdateRequest, _ *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform
// Update is not supported, all fields require replace
}
// Delete deletes the resource and removes the Terraform state on success.
func (r *serviceAccountAttachResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve values from state
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()
region := r.providerData.GetRegionWithOverride(model.Region)
serverId := model.ServerId.ValueString()
service_accountId := model.ServiceAccountEmail.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "server_id", serverId)
ctx = tflog.SetField(ctx, "service_account_email", service_accountId)
// Remove service_account from server
_, err := r.client.RemoveServiceAccountFromServer(ctx, projectId, region, serverId, service_accountId).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error removing service account from server", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
tflog.Info(ctx, "Service account attachment deleted")
}
// ImportState imports a resource into the Terraform state on success.
// The expected format of the resource import identifier is: project_id,server_id
func (r *serviceAccountAttachResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
idParts := strings.Split(req.ID, core.Separator)
if len(idParts) != 4 || idParts[0] == "" || idParts[1] == "" || idParts[2] == "" || idParts[3] == "" {
core.LogAndAddError(ctx, &resp.Diagnostics,
"Error importing service_account attachment",
fmt.Sprintf("Expected import identifier with format: [project_id],[region],[server_id],[service_account_email] Got: %q", req.ID),
)
return
}
utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{
"project_id": idParts[0],
"region": idParts[1],
"server_id": idParts[2],
"service_account_email": idParts[3],
})
tflog.Info(ctx, "Service account attachment state imported")
}

View file

@ -1,62 +0,0 @@
variable "project_id" {}
data "stackit_image_v2" "name_match_ubuntu_22_04" {
project_id = var.project_id
name = "Ubuntu 22.04"
}
data "stackit_image_v2" "ubuntu_by_image_id" {
project_id = var.project_id
image_id = data.stackit_image_v2.name_match_ubuntu_22_04.image_id
}
data "stackit_image_v2" "regex_match_ubuntu_22_04" {
project_id = var.project_id
name_regex = "(?i)^ubuntu 22.04$"
}
data "stackit_image_v2" "filter_debian_11" {
project_id = var.project_id
filter = {
distro = "debian"
version = "11"
}
}
data "stackit_image_v2" "filter_uefi_ubuntu" {
project_id = var.project_id
filter = {
distro = "ubuntu"
uefi = true
}
}
data "stackit_image_v2" "name_regex_and_filter_rhel_9_1" {
project_id = var.project_id
name_regex = "^Red Hat Enterprise Linux 9.1$"
filter = {
distro = "rhel"
version = "9.1"
uefi = true
}
}
data "stackit_image_v2" "name_windows_2022_standard" {
project_id = var.project_id
name = "Windows Server 2022 Standard"
}
data "stackit_image_v2" "ubuntu_arm64_latest" {
project_id = var.project_id
filter = {
distro = "ubuntu-arm64"
}
}
data "stackit_image_v2" "ubuntu_arm64_oldest" {
project_id = var.project_id
filter = {
distro = "ubuntu-arm64"
}
sort_ascending = true
}

View file

@ -1,18 +0,0 @@
variable "project_id" {}
data "stackit_machine_type" "two_vcpus_filter" {
project_id = var.project_id
filter = "vcpus==2"
}
data "stackit_machine_type" "filter_sorted_ascending_false" {
project_id = var.project_id
filter = "vcpus >= 2 && ram >= 2048"
sort_ascending = false
}
# returns warning
data "stackit_machine_type" "no_match" {
project_id = var.project_id
filter = "vcpus == 99"
}

View file

@ -1 +0,0 @@
data "stackit_public_ip_ranges" "example" {}

View file

@ -1,9 +0,0 @@
variable "project_id" {}
variable "name" {}
variable "policy" {}
resource "stackit_affinity_group" "affinity_group" {
project_id = var.project_id
name = var.name
policy = var.policy
}

View file

@ -1,48 +0,0 @@
variable "project_id" {}
variable "name" {}
variable "disk_format" {}
variable "local_file_path" {}
variable "min_disk_size" {}
variable "min_ram" {}
variable "label" {}
variable "boot_menu" {}
variable "cdrom_bus" {}
variable "disk_bus" {}
variable "nic_model" {}
variable "operating_system" {}
variable "operating_system_distro" {}
variable "operating_system_version" {}
variable "rescue_bus" {}
variable "rescue_device" {}
variable "secure_boot" {}
variable "uefi" {}
variable "video_model" {}
variable "virtio_scsi" {}
resource "stackit_image" "image" {
project_id = var.project_id
name = var.name
disk_format = var.disk_format
local_file_path = var.local_file_path
min_disk_size = var.min_disk_size
min_ram = var.min_ram
labels = {
"acc-test" : var.label
}
config = {
boot_menu = var.boot_menu
cdrom_bus = var.cdrom_bus
disk_bus = var.disk_bus
nic_model = var.nic_model
operating_system = var.operating_system
operating_system_distro = var.operating_system_distro
operating_system_version = var.operating_system_version
rescue_bus = var.rescue_bus
rescue_device = var.rescue_device
secure_boot = var.secure_boot
uefi = var.uefi
video_model = var.video_model
virtio_scsi = var.virtio_scsi
}
}

View file

@ -1,11 +0,0 @@
variable "project_id" {}
variable "name" {}
variable "disk_format" {}
variable "local_file_path" {}
resource "stackit_image" "image" {
project_id = var.project_id
name = var.name
disk_format = var.disk_format
local_file_path = var.local_file_path
}

View file

@ -1,11 +0,0 @@
variable "name" {}
variable "public_key" {}
variable "label" {}
resource "stackit_key_pair" "key_pair" {
name = var.name
public_key = var.public_key
labels = {
"acc-test" : var.label
}
}

View file

@ -1,7 +0,0 @@
variable "name" {}
variable "public_key" {}
resource "stackit_key_pair" "key_pair" {
name = var.name
public_key = var.public_key
}

View file

@ -1,49 +0,0 @@
variable "organization_id" {}
variable "name" {}
variable "transfer_network" {}
variable "network_ranges_prefix" {}
variable "default_nameservers" {}
variable "default_prefix_length" {}
variable "max_prefix_length" {}
variable "min_prefix_length" {}
variable "route_destination_type" {}
variable "route_destination_value" {}
variable "route_next_hop_type" {}
variable "route_next_hop_value" {}
variable "label" {}
resource "stackit_network_area" "network_area" {
organization_id = var.organization_id
name = var.name
network_ranges = [
{
prefix = var.network_ranges_prefix
}
]
transfer_network = var.transfer_network
default_nameservers = [var.default_nameservers]
default_prefix_length = var.default_prefix_length
max_prefix_length = var.max_prefix_length
min_prefix_length = var.min_prefix_length
labels = {
"acc-test" : var.label
}
}
resource "stackit_network_area_route" "network_area_route" {
organization_id = stackit_network_area.network_area.organization_id
network_area_id = stackit_network_area.network_area.network_area_id
destination = {
type = var.route_destination_type
value = var.route_destination_value
}
next_hop = {
type = var.route_next_hop_type
value = var.route_next_hop_value
}
labels = {
"acc-test" : var.label
}
}

Some files were not shown because too many files have changed in this diff Show more