Files
opentf/internal/backend/remote-state/azure/backend_test.go
2025-10-24 16:11:01 -04:00

519 lines
18 KiB
Go

// Copyright (c) The OpenTofu Authors
// SPDX-License-Identifier: MPL-2.0
// Copyright (c) 2023 HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package azure
import (
"context"
"errors"
"io"
"os"
"testing"
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime"
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob"
"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/container"
"github.com/opentofu/opentofu/internal/backend"
"github.com/opentofu/opentofu/internal/backend/remote-state/azure/auth"
"github.com/opentofu/opentofu/internal/encryption"
"github.com/opentofu/opentofu/internal/httpclient"
"github.com/opentofu/opentofu/internal/legacy/helper/acctest"
)
func TestBackend_impl(t *testing.T) {
var _ backend.Backend = new(Backend)
}
func TestBackendConfig(t *testing.T) {
// This test just instantiates the client. Shouldn't make any actual
// requests nor incur any costs.
config := map[string]interface{}{
"storage_account_name": "tfaccount",
"container_name": "tfcontainer",
"key": "state",
"snapshot": false,
// Access Key must be Base64
"access_key": "QUNDRVNTX0tFWQ0K",
}
b := backend.TestBackendConfig(t, New(encryption.StateEncryptionDisabled()), backend.TestWrapConfig(config)).(*Backend)
if b.containerName != "tfcontainer" {
t.Fatalf("Incorrect bucketName was populated")
}
if b.keyName != "state" {
t.Fatalf("Incorrect keyName was populated")
}
if b.snapshot != false {
t.Fatalf("Incorrect snapshot was populated")
}
}
func TestBackendConfig_Timeout(t *testing.T) {
config := map[string]any{
"storage_account_name": "tfaccount",
"container_name": "tfcontainer",
"key": "state",
"snapshot": false,
// Access Key must be Base64
"access_key": "QUNDRVNTX0tFWQ0K",
}
testCases := []struct {
name string
timeoutSeconds any
expectError bool
}{
{
name: "string timeout",
timeoutSeconds: "Nonsense",
expectError: true,
},
{
name: "negative timeout",
timeoutSeconds: -10,
expectError: true,
},
{
// 0 is a valid timeout value, it disables the timeout
name: "zero timeout",
timeoutSeconds: 0,
},
{
name: "positive timeout",
timeoutSeconds: 10,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
config["timeout_seconds"] = tc.timeoutSeconds
b, _, errors := backend.TestBackendConfigWarningsAndErrors(t, New(encryption.StateEncryptionDisabled()), backend.TestWrapConfig(config))
if tc.expectError {
if len(errors) == 0 {
t.Fatalf("Expected an error")
}
return
}
if !tc.expectError && len(errors) > 0 {
t.Fatalf("Expected no errors, got: %v", errors)
}
be, ok := b.(*Backend)
if !ok || be == nil {
t.Fatalf("Expected initialized Backend, got %T", b)
}
if int(be.timeout.Seconds()) != tc.timeoutSeconds {
t.Fatalf("Expected timeoutSeconds to be %d, got %d", tc.timeoutSeconds, int(be.timeout.Seconds()))
}
})
}
}
type mockClient struct {
marker string
}
func (p mockClient) NewListBlobsFlatPager(params *container.ListBlobsFlatOptions) *runtime.Pager[container.ListBlobsFlatResponse] {
env_name := "env-name"
blobDetails := make([]*container.BlobItem, 5000)
for i := range blobDetails {
blobDetails[i] = &container.BlobItem{}
blobDetails[i].Name = &env_name
}
returnMarker := "next-token"
return runtime.NewPager(runtime.PagingHandler[container.ListBlobsFlatResponse]{
More: func(resp container.ListBlobsFlatResponse) bool {
return *resp.Marker != returnMarker
},
Fetcher: func(context.Context, *container.ListBlobsFlatResponse) (container.ListBlobsFlatResponse, error) {
prevMarker := p.marker
p.marker = returnMarker
return container.ListBlobsFlatResponse{
ListBlobsFlatSegmentResponse: container.ListBlobsFlatSegmentResponse{
Segment: &container.BlobFlatListSegment{
BlobItems: blobDetails,
},
Marker: &prevMarker,
NextMarker: &returnMarker,
},
}, nil
},
})
}
func TestBackendPagination(t *testing.T) {
ctx := context.Background()
client := &mockClient{}
result, err := getPaginatedResults(ctx, client, "env")
if err != nil {
t.Fatalf("error getting paginated results %q", err)
}
// default is always on the list + 10k generated blobs from the mocked ListBlobs
if len(result) != 10001 {
t.Fatalf("expected len 10001, got %d instead", len(result))
}
}
func TestStorageNames(t *testing.T) {
err := checkAccountAndContainerNames("goodaccountname1000", "good-container-name-2")
if err != nil {
t.Fatalf("encountered an error on good storage name: %v", err)
}
err = checkAccountAndContainerNames("bad-accountname1000", "good-container-name-2")
if err == nil {
t.Fatalf("encountered no error on a bad storage name: account names cannot have hyphens")
}
err = checkAccountAndContainerNames("", "good-container-name-2")
if err == nil {
t.Fatalf("encountered no error on a bad storage name: account names must have 24 characters or fewer")
}
err = checkAccountAndContainerNames("aa", "good-container-name-2")
if err == nil {
t.Fatalf("encountered no error on a bad storage name: account names must have 3 characters or more")
}
err = checkAccountAndContainerNames("goodaccountname1000", "-bad-container-2")
if err == nil {
t.Fatalf("encountered no error on a bad container name: container cannot start with a hyphen")
}
err = checkAccountAndContainerNames("goodaccountname1000", "bad-container-2-")
if err == nil {
t.Fatalf("encountered no error on a bad container name: container cannot end with a hyphen")
}
err = checkAccountAndContainerNames("goodaccountname1000", "bad--container-2")
if err == nil {
t.Fatalf("encountered no error on a bad container name: container cannot have consecutive hyphens")
}
err = checkAccountAndContainerNames("goodaccountname1000", "myveryeducatedmotherjustservedusnachoswaitwhathappenedtothe9pies")
if err == nil {
t.Fatalf("encountered no error on a bad container name: containers must have 63 characters or fewer")
}
err = checkAccountAndContainerNames("goodaccountname1000", "x")
if err == nil {
t.Fatalf("encountered no error on a bad container name: containers must have 3 characters or more")
}
}
// TestAccBackendAccessKeyBasic tests if the backend functions when using basic access key.
func TestAccBackendAccessKeyBasic(t *testing.T) {
testAccAzureBackend(t)
rs := acctest.RandString(4)
res := testResourceNames(rs, "testState")
authMethod, err := auth.GetAuthMethod(t.Context(), testAuthConfig())
if err != nil {
t.Fatal(err)
}
authCred, err := authMethod.Construct(t.Context(), testAuthConfig())
if err != nil {
t.Fatal(err)
}
resourceGroupClient, _, err := createTestResources(t, &res, authCred)
t.Cleanup(func() {
destroyTestResources(t, resourceGroupClient, res)
})
if err != nil {
t.Fatal(err)
}
// The call to backend.TestBackendStates tests workspace creation, list and deletion.
b1 := backend.TestBackendConfig(t, New(encryption.StateEncryptionDisabled()), backend.TestWrapConfig(map[string]interface{}{
"storage_account_name": res.storageAccountName,
"container_name": res.storageContainerName,
"key": res.storageKeyName,
"access_key": res.storageAccountAccessKey,
"use_cli": false,
})).(*Backend)
backend.TestBackendStates(t, b1)
b2 := backend.TestBackendConfig(t, New(encryption.StateEncryptionDisabled()), backend.TestWrapConfig(map[string]interface{}{
"storage_account_name": res.storageAccountName,
"container_name": res.storageContainerName,
"key": res.storageKeyName,
"access_key": res.storageAccountAccessKey,
"use_cli": false,
})).(*Backend)
// TestBackendStateForceUnlock runs the both the TestBackendStateLocks test and the --force-unlock tests
backend.TestBackendStateForceUnlock(t, b1, b2)
backend.TestBackendStateLocksInWS(t, b1, b2, "foo")
backend.TestBackendStateForceUnlockInWS(t, b1, b2, "foo")
}
// TestAccBackendSASToken tests if the backend functions when using a SAS token.
func TestAccBackendSASToken(t *testing.T) {
testAccAzureBackend(t)
rs := acctest.RandString(4)
res := testResourceNames(rs, "testState")
authMethod, err := auth.GetAuthMethod(t.Context(), testAuthConfig())
if err != nil {
t.Fatal(err)
}
authCred, err := authMethod.Construct(t.Context(), testAuthConfig())
if err != nil {
t.Fatal(err)
}
resourceGroupClient, _, err := createTestResources(t, &res, authCred)
t.Cleanup(func() {
destroyTestResources(t, resourceGroupClient, res)
})
if err != nil {
t.Fatal(err)
}
keycred, err := azblob.NewSharedKeyCredential(res.storageAccountName, res.storageAccountAccessKey)
if err != nil {
t.Fatal(err)
}
sasToken, err := getSASToken(keycred)
if err != nil {
t.Fatal(err)
}
b1 := backend.TestBackendConfig(t, New(encryption.StateEncryptionDisabled()), backend.TestWrapConfig(map[string]interface{}{
"storage_account_name": res.storageAccountName,
"container_name": res.storageContainerName,
"key": res.storageKeyName,
"sas_token": sasToken,
"use_cli": false,
})).(*Backend)
backend.TestBackendStates(t, b1)
b2 := backend.TestBackendConfig(t, New(encryption.StateEncryptionDisabled()), backend.TestWrapConfig(map[string]interface{}{
"storage_account_name": res.storageAccountName,
"container_name": res.storageContainerName,
"key": res.storageKeyName,
"sas_token": sasToken,
"use_cli": false,
})).(*Backend)
backend.TestBackendStateForceUnlock(t, b1, b2)
backend.TestBackendStateLocksInWS(t, b1, b2, "foo")
backend.TestBackendStateForceUnlockInWS(t, b1, b2, "foo")
}
// TestAccBackendServicePrincipalClientSecret tests if the backend functions when using a client ID and secret.
func TestAccBackendServicePrincipalClientSecret(t *testing.T) {
testAccAzureBackend(t)
rs := acctest.RandString(4)
res := testResourceNames(rs, "testState")
client_id := os.Getenv("TF_AZURE_TEST_CLIENT_ID")
client_secret := os.Getenv("TF_AZURE_TEST_CLIENT_SECRET")
if client_id == "" || client_secret == "" {
t.Skip(`
A client ID or client secret was not provided.
Please set TF_AZURE_TEST_CLIENT_ID and TF_AZURE_TEST_CLIENT_SECRET, either manually or using the terraform plan in the meta-test folder.`)
}
if res.tenantID == "" {
t.Fatal(errors.New("A tenant ID must be provided through ARM_TENANT_ID in order to run this test."))
}
authMethod, err := auth.GetAuthMethod(t.Context(), testAuthConfig())
if err != nil {
t.Fatal(err)
}
authCred, err := authMethod.Construct(t.Context(), testAuthConfig())
if err != nil {
t.Fatal(err)
}
resourceGroupClient, _, err := createTestResources(t, &res, authCred)
t.Cleanup(func() {
destroyTestResources(t, resourceGroupClient, res)
})
if err != nil {
t.Fatal(err)
}
b1 := backend.TestBackendConfig(t, New(encryption.StateEncryptionDisabled()), backend.TestWrapConfig(map[string]interface{}{
"storage_account_name": res.storageAccountName,
"container_name": res.storageContainerName,
"key": res.storageKeyName,
"resource_group_name": res.resourceGroup,
"client_id": client_id,
"client_secret": client_secret,
"use_cli": false,
})).(*Backend)
backend.TestBackendStates(t, b1)
b2 := backend.TestBackendConfig(t, New(encryption.StateEncryptionDisabled()), backend.TestWrapConfig(map[string]interface{}{
"storage_account_name": res.storageAccountName,
"container_name": res.storageContainerName,
"key": res.storageKeyName,
"resource_group_name": res.resourceGroup,
"client_id": client_id,
"client_secret": client_secret,
"use_cli": false,
})).(*Backend)
// TestBackendStateForceUnlock runs the both the TestBackendStateLocks test and the --force-unlock tests
backend.TestBackendStateForceUnlock(t, b1, b2)
backend.TestBackendStateLocksInWS(t, b1, b2, "foo")
backend.TestBackendStateForceUnlockInWS(t, b1, b2, "foo")
}
// TestAccBackendServicePrincipalClientCertificate tests if the backend functions when using a PFX certificate file.
func TestAccBackendServicePrincipalClientCertificate(t *testing.T) {
testAccAzureBackend(t)
rs := acctest.RandString(4)
res := testResourceNames(rs, "testState")
client_id := os.Getenv("TF_AZURE_TEST_CLIENT_ID")
cert_path := os.Getenv("TF_AZURE_TEST_CERT_PATH")
// cert_password may be empty
cert_password := os.Getenv("TF_AZURE_TEST_CERT_PASSWORD")
if client_id == "" || cert_path == "" {
t.Skip("A certificate must be provided through TF_AZURE_TEST_CERT_PATH, and a client_id must be provided through TF_AZURE_TEST_CLIENT_ID")
}
// Make sure we can open and read the file
cert_file, err := os.Open(cert_path)
if err != nil {
t.Fatalf("error opening cert file: %s", err.Error())
}
_, err = io.ReadAll(cert_file)
if err != nil {
t.Fatalf("error reading cert file: %s", err.Error())
}
cert_file.Close()
authMethod, err := auth.GetAuthMethod(t.Context(), testAuthConfig())
if err != nil {
t.Fatal(err)
}
authCred, err := authMethod.Construct(t.Context(), testAuthConfig())
if err != nil {
t.Fatal(err)
}
resourceGroupClient, _, err := createTestResources(t, &res, authCred)
t.Cleanup(func() {
destroyTestResources(t, resourceGroupClient, res)
})
if err != nil {
t.Fatal(err)
}
b1 := backend.TestBackendConfig(t, New(encryption.StateEncryptionDisabled()), backend.TestWrapConfig(map[string]interface{}{
"storage_account_name": res.storageAccountName,
"container_name": res.storageContainerName,
"key": res.storageKeyName,
"resource_group_name": res.resourceGroup,
"client_id": client_id,
"client_certificate_path": cert_path,
"client_certificate_password": cert_password,
"use_cli": false,
})).(*Backend)
backend.TestBackendStates(t, b1)
}
// TestAccBackendManagedServiceIdentity tests if the backend functions when using a managed service identity, like on an Azure VM.
// Note: this test does NOT create its own resource group, storage account, or storage container. You must set up that infrastructure
// manually, as well as the underlying managed service identity which this test depends upon.
func TestAccBackendManagedServiceIdentity(t *testing.T) {
testAccAzureBackend(t)
storageAccountName := os.Getenv("TF_AZURE_TEST_STORAGE_ACCOUNT_NAME")
resourceGroupName := os.Getenv("TF_AZURE_TEST_RESOURCE_GROUP_NAME")
containerName := os.Getenv("TF_AZURE_TEST_CONTAINER_NAME")
if storageAccountName == "" || resourceGroupName == "" || containerName == "" {
t.Skip("For MSI tests, all infrastructure must be set up ahead of time and passed through environment variables.")
}
b := backend.TestBackendConfig(t, New(encryption.StateEncryptionDisabled()), backend.TestWrapConfig(map[string]interface{}{
"storage_account_name": storageAccountName,
"container_name": containerName,
"key": "testState",
"resource_group_name": resourceGroupName,
"use_msi": true,
"use_cli": false,
})).(*Backend)
backend.TestBackendStates(t, b)
// Manually delete all blobs in the container
client := httpclient.New(t.Context())
authCred, err := azidentity.NewManagedIdentityCredential(
&azidentity.ManagedIdentityCredentialOptions{ClientOptions: azcore.ClientOptions{
Telemetry: policy.TelemetryOptions{
Disabled: true,
},
Transport: client,
Cloud: cloud.AzurePublic,
}},
)
if err != nil {
t.Logf("Skipping deleting blobs in container %s due to error obtaining credentials: %v", containerName, err)
return
}
deleteBlobsManually(t, authCred, storageAccountName, resourceGroupName, containerName)
}
// TestAccBackendAKSWorkloadIdentity tests if the backend functions when using workload identity, on Azure AKS (Kubernetes).
// Note: this test does NOT create its own resource group, storage account, or storage container. You must set up that infrastructure
// manually, as well as the kubernetes cluster, workload identity, and managed identity which this test depends upon.
func TestAccBackendAKSWorkloadIdentity(t *testing.T) {
testAccAzureBackend(t)
storageAccountName := os.Getenv("TF_AZURE_TEST_STORAGE_ACCOUNT_NAME")
resourceGroupName := os.Getenv("TF_AZURE_TEST_RESOURCE_GROUP_NAME")
containerName := os.Getenv("TF_AZURE_TEST_CONTAINER_NAME")
if storageAccountName == "" || resourceGroupName == "" || containerName == "" {
t.Skip("For MSI tests, all infrastructure must be set up ahead of time and passed through environment variables.")
}
b := backend.TestBackendConfig(t, New(encryption.StateEncryptionDisabled()), backend.TestWrapConfig(map[string]interface{}{
"storage_account_name": storageAccountName,
"container_name": containerName,
"key": "testState",
"resource_group_name": resourceGroupName,
"use_aks_workload_identity": true,
"use_cli": false,
})).(*Backend)
backend.TestBackendStates(t, b)
client := httpclient.New(t.Context())
authCred, err := azidentity.NewWorkloadIdentityCredential(
&azidentity.WorkloadIdentityCredentialOptions{ClientOptions: azcore.ClientOptions{
Telemetry: policy.TelemetryOptions{
Disabled: true,
},
Transport: client,
Cloud: cloud.AzurePublic,
}},
)
if err != nil {
t.Logf("Skipping deleting blobs in container %s due to error obtaining credentials: %v", containerName, err)
return
}
// Manually delete all blobs in the container
deleteBlobsManually(t, authCred, storageAccountName, resourceGroupName, containerName)
}