Skip to content

Commit 5149b59

Browse files
authored
Merge pull request #174 from GoCodeAlone/fix/ws04-cloud-provider-decoupling
refactor: introduce provider registries for cloud and platform decoupling
2 parents c567b1b + 7ea0244 commit 5149b59

14 files changed

Lines changed: 744 additions & 333 deletions

module/cloud_account.go

Lines changed: 19 additions & 294 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,8 @@ package module
33
import (
44
"context"
55
"fmt"
6-
"os"
76

87
"github.com/CrisisTextLine/modular"
9-
"github.com/digitalocean/godo"
10-
"golang.org/x/oauth2"
118
)
129

1310
// CloudCredentialProvider provides cloud credentials to other modules.
@@ -49,6 +46,7 @@ type CloudAccount struct {
4946
config map[string]any
5047
provider string
5148
region string
49+
credType string
5250
creds *CloudCredentials
5351
}
5452

@@ -108,6 +106,7 @@ func (m *CloudAccount) GetCredentials(_ context.Context) (*CloudCredentials, err
108106
}
109107

110108
// resolveCredentials resolves credentials based on provider and credential type config.
109+
// It dispatches to registered CloudCredentialResolvers via the global registry.
111110
func (m *CloudAccount) resolveCredentials() (*CloudCredentials, error) {
112111
creds := &CloudCredentials{
113112
Provider: m.provider,
@@ -132,44 +131,26 @@ func (m *CloudAccount) resolveCredentials() (*CloudCredentials, error) {
132131
return creds, nil
133132
}
134133

135-
credType, _ := credsMap["type"].(string)
136-
if credType == "" {
137-
credType = "static"
134+
m.credType, _ = credsMap["type"].(string)
135+
if m.credType == "" {
136+
m.credType = "static"
138137
}
139138

140-
switch credType {
141-
case "static":
142-
return m.resolveStaticCredentials(creds, credsMap)
143-
case "env":
144-
return m.resolveEnvCredentials(creds)
145-
case "profile":
146-
return m.resolveProfileCredentials(creds, credsMap)
147-
case "role_arn":
148-
return m.resolveRoleARNCredentials(creds, credsMap)
149-
case "kubeconfig":
150-
return m.resolveKubeconfigCredentials(creds, credsMap)
151-
// GCP credential types
152-
case "service_account_json":
153-
return m.resolveGCPServiceAccountJSON(creds, credsMap)
154-
case "service_account_key":
155-
return m.resolveGCPServiceAccountKey(creds, credsMap)
156-
case "workload_identity":
157-
return m.resolveGCPWorkloadIdentity(creds)
158-
case "application_default":
159-
return m.resolveGCPApplicationDefault(creds)
160-
// Azure credential types
161-
case "client_credentials":
162-
return m.resolveAzureClientCredentials(creds, credsMap)
163-
case "managed_identity":
164-
return m.resolveAzureManagedIdentity(creds, credsMap)
165-
case "cli":
166-
return m.resolveAzureCLI(creds)
167-
// DigitalOcean credential types
168-
case "api_token":
169-
return m.resolveDOAPIToken(creds, credsMap)
170-
default:
171-
return nil, fmt.Errorf("unsupported credential type %q", credType)
139+
// Store creds on m so resolvers can write into it directly.
140+
m.creds = creds
141+
142+
providerResolvers, ok := credentialResolvers[m.provider]
143+
if !ok {
144+
return nil, fmt.Errorf("unknown cloud provider: %s", m.provider)
145+
}
146+
resolver, ok := providerResolvers[m.credType]
147+
if !ok {
148+
return nil, fmt.Errorf("unsupported credential type %q for provider %q", m.credType, m.provider)
149+
}
150+
if err := resolver.Resolve(m); err != nil {
151+
return nil, err
172152
}
153+
return m.creds, nil
173154
}
174155

175156
func (m *CloudAccount) resolveMockCredentials(creds *CloudCredentials) (*CloudCredentials, error) {
@@ -190,259 +171,3 @@ func (m *CloudAccount) resolveMockCredentials(creds *CloudCredentials) (*CloudCr
190171
}
191172
return creds, nil
192173
}
193-
194-
func (m *CloudAccount) resolveStaticCredentials(creds *CloudCredentials, credsMap map[string]any) (*CloudCredentials, error) {
195-
switch m.provider {
196-
case "aws":
197-
creds.AccessKey, _ = credsMap["accessKey"].(string)
198-
creds.SecretKey, _ = credsMap["secretKey"].(string)
199-
creds.SessionToken, _ = credsMap["sessionToken"].(string)
200-
creds.RoleARN, _ = credsMap["roleArn"].(string)
201-
case "gcp":
202-
if pid, ok := credsMap["projectId"].(string); ok {
203-
creds.ProjectID = pid
204-
}
205-
if saJSON, ok := credsMap["serviceAccountJson"].(string); ok {
206-
creds.ServiceAccountJSON = []byte(saJSON)
207-
}
208-
case "azure":
209-
creds.TenantID, _ = credsMap["tenant_id"].(string)
210-
creds.ClientID, _ = credsMap["client_id"].(string)
211-
creds.ClientSecret, _ = credsMap["client_secret"].(string)
212-
if sub, ok := credsMap["subscription_id"].(string); ok {
213-
creds.SubscriptionID = sub
214-
}
215-
case "kubernetes":
216-
if kc, ok := credsMap["kubeconfig"].(string); ok {
217-
creds.Kubeconfig = []byte(kc)
218-
}
219-
creds.Context, _ = credsMap["context"].(string)
220-
default:
221-
creds.Token, _ = credsMap["token"].(string)
222-
}
223-
return creds, nil
224-
}
225-
226-
func (m *CloudAccount) resolveEnvCredentials(creds *CloudCredentials) (*CloudCredentials, error) {
227-
switch m.provider {
228-
case "aws":
229-
creds.AccessKey = os.Getenv("AWS_ACCESS_KEY_ID")
230-
if creds.AccessKey == "" {
231-
creds.AccessKey = os.Getenv("AWS_ACCESS_KEY")
232-
}
233-
creds.SecretKey = os.Getenv("AWS_SECRET_ACCESS_KEY")
234-
if creds.SecretKey == "" {
235-
creds.SecretKey = os.Getenv("AWS_SECRET_KEY")
236-
}
237-
creds.SessionToken = os.Getenv("AWS_SESSION_TOKEN")
238-
creds.RoleARN = os.Getenv("AWS_ROLE_ARN")
239-
case "gcp":
240-
creds.ProjectID = os.Getenv("GOOGLE_CLOUD_PROJECT")
241-
if creds.ProjectID == "" {
242-
creds.ProjectID = os.Getenv("GCP_PROJECT_ID")
243-
}
244-
saPath := os.Getenv("GOOGLE_APPLICATION_CREDENTIALS")
245-
if saPath != "" {
246-
data, err := os.ReadFile(saPath) //nolint:gosec // G304: path from trusted config data
247-
if err != nil {
248-
return nil, fmt.Errorf("reading GOOGLE_APPLICATION_CREDENTIALS: %w", err)
249-
}
250-
creds.ServiceAccountJSON = data
251-
}
252-
case "azure":
253-
creds.TenantID = os.Getenv("AZURE_TENANT_ID")
254-
creds.ClientID = os.Getenv("AZURE_CLIENT_ID")
255-
creds.ClientSecret = os.Getenv("AZURE_CLIENT_SECRET")
256-
if sub := os.Getenv("AZURE_SUBSCRIPTION_ID"); sub != "" {
257-
creds.SubscriptionID = sub
258-
}
259-
case "kubernetes":
260-
kubeconfigPath := os.Getenv("KUBECONFIG")
261-
if kubeconfigPath == "" {
262-
home, _ := os.UserHomeDir()
263-
kubeconfigPath = home + "/.kube/config"
264-
}
265-
data, err := os.ReadFile(kubeconfigPath) //nolint:gosec // G304: path from trusted config data
266-
if err != nil {
267-
return nil, fmt.Errorf("reading kubeconfig: %w", err)
268-
}
269-
creds.Kubeconfig = data
270-
case "digitalocean":
271-
creds.Token = os.Getenv("DIGITALOCEAN_TOKEN")
272-
if creds.Token == "" {
273-
creds.Token = os.Getenv("DO_TOKEN")
274-
}
275-
default:
276-
creds.Token = os.Getenv("CLOUD_TOKEN")
277-
}
278-
return creds, nil
279-
}
280-
281-
func (m *CloudAccount) resolveProfileCredentials(creds *CloudCredentials, credsMap map[string]any) (*CloudCredentials, error) {
282-
// AWS named profile from ~/.aws/credentials
283-
// For now: read AWS_PROFILE or the configured profile name from the shared credentials file.
284-
profile, _ := credsMap["profile"].(string)
285-
if profile == "" {
286-
profile = os.Getenv("AWS_PROFILE")
287-
}
288-
if profile == "" {
289-
profile = "default"
290-
}
291-
// Stub: document STS/profile resolution path.
292-
// Production implementation would use aws-sdk-go-v2/config.LoadDefaultConfig
293-
// with config.WithSharedConfigProfile(profile).
294-
creds.Extra = map[string]string{"profile": profile}
295-
return creds, nil
296-
}
297-
298-
func (m *CloudAccount) resolveRoleARNCredentials(creds *CloudCredentials, credsMap map[string]any) (*CloudCredentials, error) {
299-
// Stub for STS AssumeRole.
300-
// Production implementation: use aws-sdk-go-v2/service/sts AssumeRole with
301-
// the source credentials, then populate AccessKey/SecretKey/SessionToken
302-
// from the returned Credentials.
303-
roleARN, _ := credsMap["roleArn"].(string)
304-
externalID, _ := credsMap["externalId"].(string)
305-
creds.RoleARN = roleARN
306-
creds.Extra = map[string]string{"external_id": externalID}
307-
return creds, nil
308-
}
309-
310-
func (m *CloudAccount) resolveKubeconfigCredentials(creds *CloudCredentials, credsMap map[string]any) (*CloudCredentials, error) {
311-
path, _ := credsMap["path"].(string)
312-
if path == "" {
313-
path = os.Getenv("KUBECONFIG")
314-
}
315-
if path == "" {
316-
home, _ := os.UserHomeDir()
317-
path = home + "/.kube/config"
318-
}
319-
320-
if inline, ok := credsMap["inline"].(string); ok && inline != "" {
321-
creds.Kubeconfig = []byte(inline)
322-
} else if path != "" {
323-
data, err := os.ReadFile(path) //nolint:gosec // G304: path from trusted config data
324-
if err != nil {
325-
return nil, fmt.Errorf("reading kubeconfig at %q: %w", path, err)
326-
}
327-
creds.Kubeconfig = data
328-
}
329-
330-
creds.Context, _ = credsMap["context"].(string)
331-
return creds, nil
332-
}
333-
334-
// resolveGCPServiceAccountJSON reads a GCP service account JSON key file from the given path.
335-
func (m *CloudAccount) resolveGCPServiceAccountJSON(creds *CloudCredentials, credsMap map[string]any) (*CloudCredentials, error) {
336-
path, _ := credsMap["path"].(string)
337-
if path == "" {
338-
return nil, fmt.Errorf("service_account_json credential requires 'path'")
339-
}
340-
data, err := os.ReadFile(path) //nolint:gosec // G304: path from trusted config data
341-
if err != nil {
342-
return nil, fmt.Errorf("reading service account JSON at %q: %w", path, err)
343-
}
344-
creds.ServiceAccountJSON = data
345-
return creds, nil
346-
}
347-
348-
// resolveGCPServiceAccountKey uses an inline GCP service account JSON key.
349-
func (m *CloudAccount) resolveGCPServiceAccountKey(creds *CloudCredentials, credsMap map[string]any) (*CloudCredentials, error) {
350-
key, _ := credsMap["key"].(string)
351-
if key == "" {
352-
return nil, fmt.Errorf("service_account_key credential requires 'key'")
353-
}
354-
creds.ServiceAccountJSON = []byte(key)
355-
return creds, nil
356-
}
357-
358-
// resolveGCPWorkloadIdentity handles GCP Workload Identity (GKE metadata server).
359-
// Production: use golang.org/x/oauth2/google with google.FindDefaultCredentials.
360-
func (m *CloudAccount) resolveGCPWorkloadIdentity(creds *CloudCredentials) (*CloudCredentials, error) {
361-
if creds.Extra == nil {
362-
creds.Extra = map[string]string{}
363-
}
364-
creds.Extra["credential_source"] = "workload_identity"
365-
return creds, nil
366-
}
367-
368-
// resolveGCPApplicationDefault resolves GCP Application Default Credentials.
369-
// Reads GOOGLE_APPLICATION_CREDENTIALS if set; otherwise records the ADC source.
370-
func (m *CloudAccount) resolveGCPApplicationDefault(creds *CloudCredentials) (*CloudCredentials, error) {
371-
if creds.ProjectID == "" {
372-
creds.ProjectID = os.Getenv("GOOGLE_CLOUD_PROJECT")
373-
if creds.ProjectID == "" {
374-
creds.ProjectID = os.Getenv("GCP_PROJECT_ID")
375-
}
376-
}
377-
saPath := os.Getenv("GOOGLE_APPLICATION_CREDENTIALS")
378-
if saPath != "" {
379-
data, err := os.ReadFile(saPath) //nolint:gosec // G304: path from trusted config data
380-
if err != nil {
381-
return nil, fmt.Errorf("reading GOOGLE_APPLICATION_CREDENTIALS: %w", err)
382-
}
383-
creds.ServiceAccountJSON = data
384-
return creds, nil
385-
}
386-
// No explicit file — production would use the ADC chain (gcloud, metadata server, etc.)
387-
if creds.Extra == nil {
388-
creds.Extra = map[string]string{}
389-
}
390-
creds.Extra["credential_source"] = "application_default"
391-
return creds, nil
392-
}
393-
394-
// resolveAzureClientCredentials resolves Azure service principal client credentials.
395-
func (m *CloudAccount) resolveAzureClientCredentials(creds *CloudCredentials, credsMap map[string]any) (*CloudCredentials, error) {
396-
creds.TenantID, _ = credsMap["tenant_id"].(string)
397-
creds.ClientID, _ = credsMap["client_id"].(string)
398-
creds.ClientSecret, _ = credsMap["client_secret"].(string)
399-
if creds.TenantID == "" || creds.ClientID == "" || creds.ClientSecret == "" {
400-
return nil, fmt.Errorf("client_credentials requires tenant_id, client_id, and client_secret")
401-
}
402-
return creds, nil
403-
}
404-
405-
// resolveAzureManagedIdentity handles Azure Managed Identity (VMs, AKS, etc.).
406-
// Optional client_id selects a user-assigned managed identity.
407-
// Production: use github.com/Azure/azure-sdk-for-go/sdk/azidentity ManagedIdentityCredential.
408-
func (m *CloudAccount) resolveAzureManagedIdentity(creds *CloudCredentials, credsMap map[string]any) (*CloudCredentials, error) {
409-
if clientID, ok := credsMap["client_id"].(string); ok {
410-
creds.ClientID = clientID
411-
}
412-
if creds.Extra == nil {
413-
creds.Extra = map[string]string{}
414-
}
415-
creds.Extra["credential_source"] = "managed_identity"
416-
return creds, nil
417-
}
418-
419-
// resolveAzureCLI handles Azure CLI credentials (az login).
420-
// Production: use github.com/Azure/azure-sdk-for-go/sdk/azidentity AzureCLICredential.
421-
func (m *CloudAccount) resolveAzureCLI(creds *CloudCredentials) (*CloudCredentials, error) {
422-
if creds.Extra == nil {
423-
creds.Extra = map[string]string{}
424-
}
425-
creds.Extra["credential_source"] = "azure_cli"
426-
return creds, nil
427-
}
428-
429-
// resolveDOAPIToken resolves a DigitalOcean API token from config.
430-
func (m *CloudAccount) resolveDOAPIToken(creds *CloudCredentials, credsMap map[string]any) (*CloudCredentials, error) {
431-
token, _ := credsMap["token"].(string)
432-
if token == "" {
433-
return nil, fmt.Errorf("api_token credential requires 'token'")
434-
}
435-
creds.Token = token
436-
return creds, nil
437-
}
438-
439-
// doClient returns a configured *godo.Client using the Token credential.
440-
// The caller must have resolved credentials with provider=digitalocean before calling this.
441-
func (m *CloudAccount) doClient() (*godo.Client, error) {
442-
if m.creds == nil || m.creds.Token == "" {
443-
return nil, fmt.Errorf("cloud.account %q: DigitalOcean token not set", m.name)
444-
}
445-
ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: m.creds.Token})
446-
httpClient := oauth2.NewClient(context.Background(), ts)
447-
return godo.NewClient(httpClient), nil
448-
}

0 commit comments

Comments
 (0)