Skip to content

Commit b1068c1

Browse files
shirkevichautofix-ci[bot]aknyshosterman
authored
Support !terraform.state on GCS Backends (#1393)
* feat: implement GCS backend support for YAML function * feat: improve GCS backend with performance optimizations and unified Google Cloud auth Performance Improvements: - Add client caching to avoid recreating GCS clients for repeated operations - Implement retry logic with exponential backoff (up to 3 attempts) - Extend timeouts to 30 seconds to match S3 backend performance - Add comprehensive debug logging for better troubleshooting Unified Google Cloud Authentication: - Create internal/gcp package for unified authentication across all GCP services - Support both JSON credentials and file paths consistently - Leverage Google's Application Default Credentials (ADC) automatically - Unify authentication between GCS backend and Secret Manager store - Remove duplicate authentication logic and improve maintainability Enhanced Error Handling: - Improve resource cleanup (explicit close instead of defer in loops) - Add detailed error context and logging - Better handling of transient failures and missing files Test Coverage: - Add comprehensive test suite for unified authentication (4 test functions) - Enhance GCS backend tests with retry and caching scenarios - Maintain 100% backward compatibility with existing configurations This brings GCS backend performance and reliability in line with S3 backend while establishing a foundation for consistent Google Cloud integrations. * fix: address CodeRabbitAI feedback for GCS backend implementation Critical fixes: - Fix indentation issue in test assertions - Replace len() with SHA256 hash for cache key security - Fix context leak in retry loop by removing defer from loop Code quality improvements: - Move mock client creation outside test loop for performance - Standardize test function naming (Test_* -> Test*) - Use consistent assert.Equal() style instead of manual error checks - Optimize resource management and prevent cache collisions All tests pass with improved performance and security. * docs: fix misleading GCS service account impersonation claims - Remove impersonate_service_account from example configurations - Update authentication methods to remove impersonation reference - Add clear limitation notice that impersonation is not yet implemented - Remove commented impersonation example in configuration This ensures users understand current limitations and prevents confusion about unsupported features. * [autofix.ci] apply automated fixes * Use constants instead of strings for backend type * use errors package * [autofix.ci] apply automated fixes --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Andriy Knysh <aknysh@users.noreply.github.com> Co-authored-by: Erik Osterman (CEO @ Cloud Posse) <erik@cloudposse.com>
1 parent 1eda3a2 commit b1068c1

File tree

11 files changed

+1045
-19
lines changed

11 files changed

+1045
-19
lines changed

errors/errors.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,14 @@ var (
7070
ErrEvaluateTerraformBackendVariable = errors.New("failed to evaluate terraform backend variable")
7171
ErrUnsupportedBackendType = errors.New("unsupported backend type")
7272
ErrProcessTerraformStateFile = errors.New("error processing terraform state file")
73-
74-
ErrLoadAwsConfig = errors.New("failed to load AWS config")
75-
ErrGetObjectFromS3 = errors.New("failed to get object from S3")
76-
ErrReadS3ObjectBody = errors.New("failed to read S3 object body")
73+
ErrLoadAwsConfig = errors.New("failed to load AWS config")
74+
ErrGetObjectFromS3 = errors.New("failed to get object from S3")
75+
ErrReadS3ObjectBody = errors.New("failed to read S3 object body")
76+
ErrCreateGCSClient = errors.New("failed to create GCS client")
77+
ErrGetObjectFromGCS = errors.New("failed to get object from GCS")
78+
ErrReadGCSObjectBody = errors.New("failed to read GCS object body")
79+
ErrGCSBucketRequired = errors.New("bucket is required for gcs backend")
80+
ErrInvalidBackendConfig = errors.New("invalid backend configuration")
7781

7882
// Azure Blob Storage specific errors.
7983
ErrGetBlobFromAzure = errors.New("failed to get blob from Azure Blob Storage")

go.mod

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/exec/terraform_generate_backend.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"github.com/pkg/errors"
88
"github.com/spf13/cobra"
99

10+
errUtils "github.com/cloudposse/atmos/errors"
1011
cfg "github.com/cloudposse/atmos/pkg/config"
1112
log "github.com/cloudposse/atmos/pkg/logger"
1213
"github.com/cloudposse/atmos/pkg/perf"
@@ -81,12 +82,19 @@ func ExecuteTerraformGenerateBackendCmd(cmd *cobra.Command, args []string) error
8182
log.Debug("Component backend", "config", componentBackendConfig)
8283

8384
// Check if the `backend` section has `workspace_key_prefix` when `backend_type` is `s3`
84-
if info.ComponentBackendType == "s3" {
85+
if info.ComponentBackendType == cfg.BackendTypeS3 {
8586
if _, ok := info.ComponentBackendSection["workspace_key_prefix"].(string); !ok {
8687
return fmt.Errorf("backend config for the '%s' component is missing 'workspace_key_prefix'", component)
8788
}
8889
}
8990

91+
// Check if the `backend` section has `bucket` when `backend_type` is `gcs`
92+
if info.ComponentBackendType == cfg.BackendTypeGCS {
93+
if _, ok := info.ComponentBackendSection["bucket"].(string); !ok {
94+
return errUtils.ErrGCSBucketRequired
95+
}
96+
}
97+
9098
// Write the backend config to a file
9199
backendFilePath := filepath.Join(
92100
atmosConfig.BasePath,

internal/gcp/auth.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package gcp
2+
3+
import (
4+
"strings"
5+
6+
"google.golang.org/api/option"
7+
)
8+
9+
// AuthOptions contains configuration for Google Cloud authentication.
10+
type AuthOptions struct {
11+
// Credentials can be either:
12+
// - JSON content (if starts with "{")
13+
// - File path to service account JSON file
14+
// - Empty string to use Application Default Credentials (ADC)
15+
Credentials string
16+
17+
// TODO: Add support for service account impersonation
18+
// ImpersonateServiceAccount string
19+
}
20+
21+
// GetClientOptions returns Google Cloud client options based on the provided authentication configuration.
22+
// This function provides unified authentication handling across all Google Cloud services in Atmos.
23+
//
24+
// Authentication precedence:
25+
// 1. Explicit credentials (JSON content or file path)
26+
// 2. Application Default Credentials (ADC) which automatically handles:
27+
// - GOOGLE_APPLICATION_CREDENTIALS environment variable
28+
// - Compute Engine metadata service
29+
// - Cloud Shell credentials
30+
// - gcloud user credentials (from `gcloud auth application-default login`)
31+
// - Workload Identity (in GKE)
32+
func GetClientOptions(opts AuthOptions) []option.ClientOption {
33+
var clientOpts []option.ClientOption
34+
35+
if opts.Credentials != "" {
36+
// Determine if credentials are JSON content or file path
37+
if strings.HasPrefix(strings.TrimSpace(opts.Credentials), "{") {
38+
// JSON content
39+
clientOpts = append(clientOpts, option.WithCredentialsJSON([]byte(opts.Credentials)))
40+
} else {
41+
// File path
42+
clientOpts = append(clientOpts, option.WithCredentialsFile(opts.Credentials))
43+
}
44+
}
45+
// If no explicit credentials, Google Cloud client libraries will automatically use ADC
46+
47+
return clientOpts
48+
}
49+
50+
// GetCredentialsFromBackend extracts credentials from a Terraform backend configuration.
51+
// This is used by the GCS Terraform backend.
52+
func GetCredentialsFromBackend(backend map[string]any) string {
53+
if credentials, ok := backend["credentials"].(string); ok {
54+
return credentials
55+
}
56+
return ""
57+
}
58+
59+
// GetCredentialsFromStore extracts credentials from a store configuration.
60+
// This is used by the Google Secret Manager store.
61+
func GetCredentialsFromStore(credentials *string) string {
62+
if credentials != nil {
63+
return *credentials
64+
}
65+
return ""
66+
}

internal/gcp/auth_test.go

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
package gcp
2+
3+
import (
4+
"testing"
5+
6+
"google.golang.org/api/option"
7+
)
8+
9+
func TestGetClientOptions(t *testing.T) {
10+
tests := []struct {
11+
name string
12+
opts AuthOptions
13+
expected int // number of client options returned
14+
}{
15+
{
16+
name: "no credentials - use ADC",
17+
opts: AuthOptions{
18+
Credentials: "",
19+
},
20+
expected: 0, // ADC uses no explicit options
21+
},
22+
{
23+
name: "JSON credentials",
24+
opts: AuthOptions{
25+
Credentials: `{"type": "service_account", "project_id": "test"}`,
26+
},
27+
expected: 1, // WithCredentialsJSON
28+
},
29+
{
30+
name: "file path credentials",
31+
opts: AuthOptions{
32+
Credentials: "/path/to/service-account.json",
33+
},
34+
expected: 1, // WithCredentialsFile
35+
},
36+
{
37+
name: "JSON with whitespace",
38+
opts: AuthOptions{
39+
Credentials: ` {"type": "service_account"} `,
40+
},
41+
expected: 1, // WithCredentialsJSON
42+
},
43+
}
44+
45+
for _, tt := range tests {
46+
t.Run(tt.name, func(t *testing.T) {
47+
clientOpts := GetClientOptions(tt.opts)
48+
if len(clientOpts) != tt.expected {
49+
t.Errorf("GetClientOptions() returned %d options, expected %d", len(clientOpts), tt.expected)
50+
}
51+
})
52+
}
53+
}
54+
55+
func TestGetCredentialsFromBackend(t *testing.T) {
56+
tests := []struct {
57+
name string
58+
backend map[string]any
59+
expected string
60+
}{
61+
{
62+
name: "credentials present",
63+
backend: map[string]any{
64+
"credentials": "/path/to/creds.json",
65+
"bucket": "test-bucket",
66+
},
67+
expected: "/path/to/creds.json",
68+
},
69+
{
70+
name: "JSON credentials",
71+
backend: map[string]any{
72+
"credentials": `{"type": "service_account"}`,
73+
},
74+
expected: `{"type": "service_account"}`,
75+
},
76+
{
77+
name: "no credentials",
78+
backend: map[string]any{
79+
"bucket": "test-bucket",
80+
},
81+
expected: "",
82+
},
83+
{
84+
name: "empty backend",
85+
backend: map[string]any{},
86+
expected: "",
87+
},
88+
}
89+
90+
for _, tt := range tests {
91+
t.Run(tt.name, func(t *testing.T) {
92+
result := GetCredentialsFromBackend(tt.backend)
93+
if result != tt.expected {
94+
t.Errorf("GetCredentialsFromBackend() = %v, expected %v", result, tt.expected)
95+
}
96+
})
97+
}
98+
}
99+
100+
func TestGetCredentialsFromStore(t *testing.T) {
101+
tests := []struct {
102+
name string
103+
credentials *string
104+
expected string
105+
}{
106+
{
107+
name: "credentials present",
108+
credentials: stringPtr("/path/to/creds.json"),
109+
expected: "/path/to/creds.json",
110+
},
111+
{
112+
name: "JSON credentials",
113+
credentials: stringPtr(`{"type": "service_account"}`),
114+
expected: `{"type": "service_account"}`,
115+
},
116+
{
117+
name: "empty credentials",
118+
credentials: stringPtr(""),
119+
expected: "",
120+
},
121+
{
122+
name: "nil credentials",
123+
credentials: nil,
124+
expected: "",
125+
},
126+
}
127+
128+
for _, tt := range tests {
129+
t.Run(tt.name, func(t *testing.T) {
130+
result := GetCredentialsFromStore(tt.credentials)
131+
if result != tt.expected {
132+
t.Errorf("GetCredentialsFromStore() = %v, expected %v", result, tt.expected)
133+
}
134+
})
135+
}
136+
}
137+
138+
func TestClientOptionsCreation(t *testing.T) {
139+
// Test that we can actually create valid client options without errors
140+
tests := []struct {
141+
name string
142+
credentials string
143+
expectType string
144+
}{
145+
{
146+
name: "JSON credentials create WithCredentialsJSON option",
147+
credentials: `{"type": "service_account", "project_id": "test"}`,
148+
expectType: "JSON",
149+
},
150+
{
151+
name: "file path creates WithCredentialsFile option",
152+
credentials: "/path/to/service-account.json",
153+
expectType: "File",
154+
},
155+
}
156+
157+
for _, tt := range tests {
158+
t.Run(tt.name, func(t *testing.T) {
159+
opts := GetClientOptions(AuthOptions{
160+
Credentials: tt.credentials,
161+
})
162+
163+
// We can't easily inspect the actual option type without reflection,
164+
// but we can verify that an option was created
165+
if len(opts) != 1 {
166+
t.Errorf("Expected 1 client option, got %d", len(opts))
167+
}
168+
169+
// Verify the option is of type option.ClientOption
170+
var _ option.ClientOption = opts[0]
171+
})
172+
}
173+
}
174+
175+
// Helper function to create string pointers for tests
176+
func stringPtr(s string) *string {
177+
return &s
178+
}

0 commit comments

Comments
 (0)