diff --git a/cli/azd/pkg/infra/provisioning/terraform/terraform_provider.go b/cli/azd/pkg/infra/provisioning/terraform/terraform_provider.go index c3b164f2256..aaf7c77004a 100644 --- a/cli/azd/pkg/infra/provisioning/terraform/terraform_provider.go +++ b/cli/azd/pkg/infra/provisioning/terraform/terraform_provider.go @@ -646,6 +646,9 @@ func (t *TerraformProvider) dataDirPath() string { } // Check terraform file for remote backend provider +// Note: This uses simple string matching rather than full HCL parsing for performance and +// to avoid additional dependencies. While this may have edge cases (e.g., backends mentioned +// in comments), it's sufficient for the common case of detecting actual backend configurations. func (t *TerraformProvider) isRemoteBackendConfig() (bool, error) { modulePath := t.modulePath() infraDir, _ := os.Open(modulePath) @@ -655,6 +658,23 @@ func (t *TerraformProvider) isRemoteBackendConfig() (bool, error) { return false, fmt.Errorf("reading .tf files contents: %w", err) } + // List of currently supported remote backend types that should not use the -state flag. + // This list includes all standard Terraform backends as of Terraform 1.3+ + // (deprecated backends like etcd, swift, artifactory, and manta were removed in 1.3) + // Reference: https://developer.hashicorp.com/terraform/language/backend + remoteBackends := []string{ + `backend "azurerm"`, // Azure Resource Manager + `backend "remote"`, // Terraform Cloud (legacy) + `backend "s3"`, // AWS S3 + `backend "gcs"`, // Google Cloud Storage + `backend "consul"`, // HashiCorp Consul + `backend "cos"`, // Tencent Cloud Object Storage + `backend "http"`, // HTTP/REST + `backend "kubernetes"`, // Kubernetes + `backend "oss"`, // Alibaba Cloud OSS + `backend "pg"`, // PostgreSQL + } + for index := range files { if !files[index].IsDir() && filepath.Ext(files[index].Name()) == ".tf" { fileContent, err := os.ReadFile(filepath.Join(modulePath, files[index].Name())) @@ -663,8 +683,22 @@ func (t *TerraformProvider) isRemoteBackendConfig() (bool, error) { return false, fmt.Errorf("error reading .tf files: %w", err) } - if found := strings.Contains(string(fileContent), `backend "azurerm"`); found { - return true, nil + content := string(fileContent) + + // Quick check: if the file doesn't contain "backend" keyword, skip detailed checks + if !strings.Contains(content, "backend") { + // Still need to check for Terraform Cloud "cloud {}" syntax + if strings.Contains(content, "terraform {") && strings.Contains(content, "cloud {") { + return true, nil + } + continue + } + + // Check for standard backend blocks + for _, backend := range remoteBackends { + if strings.Contains(content, backend) { + return true, nil + } } } } diff --git a/cli/azd/pkg/infra/provisioning/terraform/terraform_provider_test.go b/cli/azd/pkg/infra/provisioning/terraform/terraform_provider_test.go index 7f1a9a98d41..5e696047fef 100644 --- a/cli/azd/pkg/infra/provisioning/terraform/terraform_provider_test.go +++ b/cli/azd/pkg/infra/provisioning/terraform/terraform_provider_test.go @@ -7,6 +7,8 @@ import ( "context" _ "embed" "fmt" + "os" + "path/filepath" "regexp" "strings" "testing" @@ -222,3 +224,142 @@ func (m *mockCurrentPrincipal) CurrentPrincipalId(_ context.Context) (string, er func (m *mockCurrentPrincipal) CurrentPrincipalType(_ context.Context) (provisioning.PrincipalType, error) { return provisioning.UserType, nil } + +func TestIsRemoteBackendConfig(t *testing.T) { + tests := []struct { + name string + backendFile string + expectedRemote bool + }{ + { + name: "azurerm backend", + backendFile: "azurerm.tf", + expectedRemote: true, + }, + { + name: "remote backend (Terraform Cloud legacy)", + backendFile: "remote.tf", + expectedRemote: true, + }, + { + name: "cloud block (Terraform Cloud new syntax)", + backendFile: "cloud.tf", + expectedRemote: true, + }, + { + name: "s3 backend", + backendFile: "s3.tf", + expectedRemote: true, + }, + { + name: "gcs backend", + backendFile: "gcs.tf", + expectedRemote: true, + }, + { + name: "consul backend", + backendFile: "consul.tf", + expectedRemote: true, + }, + { + name: "http backend", + backendFile: "http.tf", + expectedRemote: true, + }, + { + name: "kubernetes backend", + backendFile: "kubernetes.tf", + expectedRemote: true, + }, + { + name: "local backend", + backendFile: "local.tf", + expectedRemote: false, + }, + { + name: "no backend specified", + backendFile: "no_backend.tf", + expectedRemote: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + prepareGenericMocks(mockContext.CommandRunner) + + // Create a temporary directory for the test + tmpDir := t.TempDir() + infraDir := filepath.Join(tmpDir, "infra") + err := os.MkdirAll(infraDir, 0755) + require.NoError(t, err) + + // Copy the test backend file to the temporary infra directory + testDataPath := filepath.Join("testdata", "backend_tests", tt.backendFile) + testContent, err := os.ReadFile(testDataPath) + require.NoError(t, err) + + // #nosec G306 -- test file permissions are intentionally readable + err = os.WriteFile(filepath.Join(infraDir, "main.tf"), testContent, 0644) + require.NoError(t, err) + + // Create a TerraformProvider instance + options := provisioning.Options{ + Module: "main", + } + + env := environment.NewWithValues("test-env", map[string]string{ + "AZURE_LOCATION": "westus2", + "AZURE_SUBSCRIPTION_ID": "00000000-0000-0000-0000-000000000000", + }) + + resourceService := azapi.NewResourceService( + mockContext.SubscriptionCredentialProvider, + mockContext.ArmClientOptions, + ) + accountManager := &mockaccount.MockAccountManager{ + Subscriptions: []account.Subscription{ + { + Id: "00000000-0000-0000-0000-000000000000", + Name: "test", + }, + }, + Locations: []account.Location{ + { + Name: "location", + DisplayName: "Test Location", + RegionalDisplayName: "(US) Test Location", + }, + }, + } + + envManager := &mockenv.MockEnvManager{} + envManager.On("Save", mock.Anything, mock.Anything).Return(nil) + + provider := NewTerraformProvider( + terraformTools.NewCli(mockContext.CommandRunner), + envManager, + env, + mockContext.Console, + &mockCurrentPrincipal{}, + prompt.NewDefaultPrompter( + env, + mockContext.Console, + accountManager, + resourceService, + cloud.AzurePublic(), + ), + ) + + err = provider.Initialize(*mockContext.Context, tmpDir, options) + require.NoError(t, err) + + tfProvider := provider.(*TerraformProvider) + + // Test the isRemoteBackendConfig function + isRemote, err := tfProvider.isRemoteBackendConfig() + require.NoError(t, err) + require.Equal(t, tt.expectedRemote, isRemote, "Expected isRemote=%v for %s", tt.expectedRemote, tt.name) + }) + } +} diff --git a/cli/azd/pkg/infra/provisioning/terraform/testdata/backend_tests/azurerm.tf b/cli/azd/pkg/infra/provisioning/terraform/testdata/backend_tests/azurerm.tf new file mode 100644 index 00000000000..8d6d6a4778f --- /dev/null +++ b/cli/azd/pkg/infra/provisioning/terraform/testdata/backend_tests/azurerm.tf @@ -0,0 +1,9 @@ +terraform { + required_version = ">= 1.1.7" + backend "azurerm" { + resource_group_name = "rg-terraform-state" + storage_account_name = "tfstate" + container_name = "tfstate" + key = "terraform.tfstate" + } +} diff --git a/cli/azd/pkg/infra/provisioning/terraform/testdata/backend_tests/cloud.tf b/cli/azd/pkg/infra/provisioning/terraform/testdata/backend_tests/cloud.tf new file mode 100644 index 00000000000..6269ecbddfc --- /dev/null +++ b/cli/azd/pkg/infra/provisioning/terraform/testdata/backend_tests/cloud.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.1.7" + cloud { + hostname = "app.terraform.io" + organization = "my-org" + workspaces { + name = "my-workspace" + } + } +} diff --git a/cli/azd/pkg/infra/provisioning/terraform/testdata/backend_tests/consul.tf b/cli/azd/pkg/infra/provisioning/terraform/testdata/backend_tests/consul.tf new file mode 100644 index 00000000000..9c7e59a56cc --- /dev/null +++ b/cli/azd/pkg/infra/provisioning/terraform/testdata/backend_tests/consul.tf @@ -0,0 +1,7 @@ +terraform { + required_version = ">= 1.1.7" + backend "consul" { + address = "consul.example.com" + path = "terraform/state" + } +} diff --git a/cli/azd/pkg/infra/provisioning/terraform/testdata/backend_tests/gcs.tf b/cli/azd/pkg/infra/provisioning/terraform/testdata/backend_tests/gcs.tf new file mode 100644 index 00000000000..07cb9f415df --- /dev/null +++ b/cli/azd/pkg/infra/provisioning/terraform/testdata/backend_tests/gcs.tf @@ -0,0 +1,7 @@ +terraform { + required_version = ">= 1.1.7" + backend "gcs" { + bucket = "my-tf-state-bucket" + prefix = "terraform/state" + } +} diff --git a/cli/azd/pkg/infra/provisioning/terraform/testdata/backend_tests/http.tf b/cli/azd/pkg/infra/provisioning/terraform/testdata/backend_tests/http.tf new file mode 100644 index 00000000000..691dcd80e77 --- /dev/null +++ b/cli/azd/pkg/infra/provisioning/terraform/testdata/backend_tests/http.tf @@ -0,0 +1,8 @@ +terraform { + required_version = ">= 1.1.7" + backend "http" { + address = "https://api.example.com/terraform/state" + lock_address = "https://api.example.com/terraform/lock" + unlock_address = "https://api.example.com/terraform/unlock" + } +} diff --git a/cli/azd/pkg/infra/provisioning/terraform/testdata/backend_tests/kubernetes.tf b/cli/azd/pkg/infra/provisioning/terraform/testdata/backend_tests/kubernetes.tf new file mode 100644 index 00000000000..a3189c29b9e --- /dev/null +++ b/cli/azd/pkg/infra/provisioning/terraform/testdata/backend_tests/kubernetes.tf @@ -0,0 +1,7 @@ +terraform { + required_version = ">= 1.1.7" + backend "kubernetes" { + secret_suffix = "state" + config_path = "~/.kube/config" + } +} diff --git a/cli/azd/pkg/infra/provisioning/terraform/testdata/backend_tests/local.tf b/cli/azd/pkg/infra/provisioning/terraform/testdata/backend_tests/local.tf new file mode 100644 index 00000000000..6a4511a02c0 --- /dev/null +++ b/cli/azd/pkg/infra/provisioning/terraform/testdata/backend_tests/local.tf @@ -0,0 +1,6 @@ +terraform { + required_version = ">= 1.1.7" + backend "local" { + path = "terraform.tfstate" + } +} diff --git a/cli/azd/pkg/infra/provisioning/terraform/testdata/backend_tests/no_backend.tf b/cli/azd/pkg/infra/provisioning/terraform/testdata/backend_tests/no_backend.tf new file mode 100644 index 00000000000..ba1f1047643 --- /dev/null +++ b/cli/azd/pkg/infra/provisioning/terraform/testdata/backend_tests/no_backend.tf @@ -0,0 +1,9 @@ +terraform { + required_version = ">= 1.1.7" + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 3.0" + } + } +} diff --git a/cli/azd/pkg/infra/provisioning/terraform/testdata/backend_tests/remote.tf b/cli/azd/pkg/infra/provisioning/terraform/testdata/backend_tests/remote.tf new file mode 100644 index 00000000000..02cf9e508cc --- /dev/null +++ b/cli/azd/pkg/infra/provisioning/terraform/testdata/backend_tests/remote.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.1.7" + backend "remote" { + hostname = "app.terraform.io" + organization = "my-org" + workspaces { + name = "my-workspace" + } + } +} diff --git a/cli/azd/pkg/infra/provisioning/terraform/testdata/backend_tests/s3.tf b/cli/azd/pkg/infra/provisioning/terraform/testdata/backend_tests/s3.tf new file mode 100644 index 00000000000..e494039980c --- /dev/null +++ b/cli/azd/pkg/infra/provisioning/terraform/testdata/backend_tests/s3.tf @@ -0,0 +1,8 @@ +terraform { + required_version = ">= 1.1.7" + backend "s3" { + bucket = "my-tf-state-bucket" + key = "terraform.tfstate" + region = "us-east-1" + } +}