From c4182772b99849d5bebc267a94e0eb29c6f8390f Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Thu, 26 Feb 2026 13:31:58 -0500 Subject: [PATCH 1/2] refactor(cloud): introduce CloudCredentialResolver registry pattern Replace the monolithic switch statement in resolveCredentials() with a pluggable registry. Each provider/credType pair is now an independent CloudCredentialResolver registered via init(). Provider-specific logic is extracted into cloud_account_{aws_creds,gcp,azure,do,k8s}.go; the godo import moves to cloud_account_do.go alongside the DO resolvers. cloud_account.go retains only the CloudAccount struct, Init, and the mock/registry dispatch logic. All 22 existing tests pass unchanged. Co-Authored-By: Claude Opus 4.6 --- module/cloud_account.go | 313 ++-------------------------- module/cloud_account_aws_creds.go | 99 +++++++++ module/cloud_account_azure.go | 107 ++++++++++ module/cloud_account_do.go | 74 +++++++ module/cloud_account_gcp.go | 145 +++++++++++++ module/cloud_account_k8s.go | 93 +++++++++ module/cloud_credential_resolver.go | 24 +++ 7 files changed, 561 insertions(+), 294 deletions(-) create mode 100644 module/cloud_account_aws_creds.go create mode 100644 module/cloud_account_azure.go create mode 100644 module/cloud_account_do.go create mode 100644 module/cloud_account_gcp.go create mode 100644 module/cloud_account_k8s.go create mode 100644 module/cloud_credential_resolver.go diff --git a/module/cloud_account.go b/module/cloud_account.go index 04f97544..78515cbe 100644 --- a/module/cloud_account.go +++ b/module/cloud_account.go @@ -3,11 +3,8 @@ package module import ( "context" "fmt" - "os" "github.com/CrisisTextLine/modular" - "github.com/digitalocean/godo" - "golang.org/x/oauth2" ) // CloudCredentialProvider provides cloud credentials to other modules. @@ -49,6 +46,7 @@ type CloudAccount struct { config map[string]any provider string region string + credType string creds *CloudCredentials } @@ -108,6 +106,7 @@ func (m *CloudAccount) GetCredentials(_ context.Context) (*CloudCredentials, err } // resolveCredentials resolves credentials based on provider and credential type config. +// It dispatches to registered CloudCredentialResolvers via the global registry. func (m *CloudAccount) resolveCredentials() (*CloudCredentials, error) { creds := &CloudCredentials{ Provider: m.provider, @@ -132,44 +131,26 @@ func (m *CloudAccount) resolveCredentials() (*CloudCredentials, error) { return creds, nil } - credType, _ := credsMap["type"].(string) - if credType == "" { - credType = "static" + m.credType, _ = credsMap["type"].(string) + if m.credType == "" { + m.credType = "static" } - switch credType { - case "static": - return m.resolveStaticCredentials(creds, credsMap) - case "env": - return m.resolveEnvCredentials(creds) - case "profile": - return m.resolveProfileCredentials(creds, credsMap) - case "role_arn": - return m.resolveRoleARNCredentials(creds, credsMap) - case "kubeconfig": - return m.resolveKubeconfigCredentials(creds, credsMap) - // GCP credential types - case "service_account_json": - return m.resolveGCPServiceAccountJSON(creds, credsMap) - case "service_account_key": - return m.resolveGCPServiceAccountKey(creds, credsMap) - case "workload_identity": - return m.resolveGCPWorkloadIdentity(creds) - case "application_default": - return m.resolveGCPApplicationDefault(creds) - // Azure credential types - case "client_credentials": - return m.resolveAzureClientCredentials(creds, credsMap) - case "managed_identity": - return m.resolveAzureManagedIdentity(creds, credsMap) - case "cli": - return m.resolveAzureCLI(creds) - // DigitalOcean credential types - case "api_token": - return m.resolveDOAPIToken(creds, credsMap) - default: - return nil, fmt.Errorf("unsupported credential type %q", credType) + // Store creds on m so resolvers can write into it directly. + m.creds = creds + + providerResolvers, ok := credentialResolvers[m.provider] + if !ok { + return nil, fmt.Errorf("unknown cloud provider: %s", m.provider) + } + resolver, ok := providerResolvers[m.credType] + if !ok { + return nil, fmt.Errorf("unsupported credential type %q for provider %q", m.credType, m.provider) + } + if err := resolver.Resolve(m); err != nil { + return nil, err } + return m.creds, nil } func (m *CloudAccount) resolveMockCredentials(creds *CloudCredentials) (*CloudCredentials, error) { @@ -190,259 +171,3 @@ func (m *CloudAccount) resolveMockCredentials(creds *CloudCredentials) (*CloudCr } return creds, nil } - -func (m *CloudAccount) resolveStaticCredentials(creds *CloudCredentials, credsMap map[string]any) (*CloudCredentials, error) { - switch m.provider { - case "aws": - creds.AccessKey, _ = credsMap["accessKey"].(string) - creds.SecretKey, _ = credsMap["secretKey"].(string) - creds.SessionToken, _ = credsMap["sessionToken"].(string) - creds.RoleARN, _ = credsMap["roleArn"].(string) - case "gcp": - if pid, ok := credsMap["projectId"].(string); ok { - creds.ProjectID = pid - } - if saJSON, ok := credsMap["serviceAccountJson"].(string); ok { - creds.ServiceAccountJSON = []byte(saJSON) - } - case "azure": - creds.TenantID, _ = credsMap["tenant_id"].(string) - creds.ClientID, _ = credsMap["client_id"].(string) - creds.ClientSecret, _ = credsMap["client_secret"].(string) - if sub, ok := credsMap["subscription_id"].(string); ok { - creds.SubscriptionID = sub - } - case "kubernetes": - if kc, ok := credsMap["kubeconfig"].(string); ok { - creds.Kubeconfig = []byte(kc) - } - creds.Context, _ = credsMap["context"].(string) - default: - creds.Token, _ = credsMap["token"].(string) - } - return creds, nil -} - -func (m *CloudAccount) resolveEnvCredentials(creds *CloudCredentials) (*CloudCredentials, error) { - switch m.provider { - case "aws": - creds.AccessKey = os.Getenv("AWS_ACCESS_KEY_ID") - if creds.AccessKey == "" { - creds.AccessKey = os.Getenv("AWS_ACCESS_KEY") - } - creds.SecretKey = os.Getenv("AWS_SECRET_ACCESS_KEY") - if creds.SecretKey == "" { - creds.SecretKey = os.Getenv("AWS_SECRET_KEY") - } - creds.SessionToken = os.Getenv("AWS_SESSION_TOKEN") - creds.RoleARN = os.Getenv("AWS_ROLE_ARN") - case "gcp": - creds.ProjectID = os.Getenv("GOOGLE_CLOUD_PROJECT") - if creds.ProjectID == "" { - creds.ProjectID = os.Getenv("GCP_PROJECT_ID") - } - saPath := os.Getenv("GOOGLE_APPLICATION_CREDENTIALS") - if saPath != "" { - data, err := os.ReadFile(saPath) //nolint:gosec // G304: path from trusted config data - if err != nil { - return nil, fmt.Errorf("reading GOOGLE_APPLICATION_CREDENTIALS: %w", err) - } - creds.ServiceAccountJSON = data - } - case "azure": - creds.TenantID = os.Getenv("AZURE_TENANT_ID") - creds.ClientID = os.Getenv("AZURE_CLIENT_ID") - creds.ClientSecret = os.Getenv("AZURE_CLIENT_SECRET") - if sub := os.Getenv("AZURE_SUBSCRIPTION_ID"); sub != "" { - creds.SubscriptionID = sub - } - case "kubernetes": - kubeconfigPath := os.Getenv("KUBECONFIG") - if kubeconfigPath == "" { - home, _ := os.UserHomeDir() - kubeconfigPath = home + "/.kube/config" - } - data, err := os.ReadFile(kubeconfigPath) //nolint:gosec // G304: path from trusted config data - if err != nil { - return nil, fmt.Errorf("reading kubeconfig: %w", err) - } - creds.Kubeconfig = data - case "digitalocean": - creds.Token = os.Getenv("DIGITALOCEAN_TOKEN") - if creds.Token == "" { - creds.Token = os.Getenv("DO_TOKEN") - } - default: - creds.Token = os.Getenv("CLOUD_TOKEN") - } - return creds, nil -} - -func (m *CloudAccount) resolveProfileCredentials(creds *CloudCredentials, credsMap map[string]any) (*CloudCredentials, error) { - // AWS named profile from ~/.aws/credentials - // For now: read AWS_PROFILE or the configured profile name from the shared credentials file. - profile, _ := credsMap["profile"].(string) - if profile == "" { - profile = os.Getenv("AWS_PROFILE") - } - if profile == "" { - profile = "default" - } - // Stub: document STS/profile resolution path. - // Production implementation would use aws-sdk-go-v2/config.LoadDefaultConfig - // with config.WithSharedConfigProfile(profile). - creds.Extra = map[string]string{"profile": profile} - return creds, nil -} - -func (m *CloudAccount) resolveRoleARNCredentials(creds *CloudCredentials, credsMap map[string]any) (*CloudCredentials, error) { - // Stub for STS AssumeRole. - // Production implementation: use aws-sdk-go-v2/service/sts AssumeRole with - // the source credentials, then populate AccessKey/SecretKey/SessionToken - // from the returned Credentials. - roleARN, _ := credsMap["roleArn"].(string) - externalID, _ := credsMap["externalId"].(string) - creds.RoleARN = roleARN - creds.Extra = map[string]string{"external_id": externalID} - return creds, nil -} - -func (m *CloudAccount) resolveKubeconfigCredentials(creds *CloudCredentials, credsMap map[string]any) (*CloudCredentials, error) { - path, _ := credsMap["path"].(string) - if path == "" { - path = os.Getenv("KUBECONFIG") - } - if path == "" { - home, _ := os.UserHomeDir() - path = home + "/.kube/config" - } - - if inline, ok := credsMap["inline"].(string); ok && inline != "" { - creds.Kubeconfig = []byte(inline) - } else if path != "" { - data, err := os.ReadFile(path) //nolint:gosec // G304: path from trusted config data - if err != nil { - return nil, fmt.Errorf("reading kubeconfig at %q: %w", path, err) - } - creds.Kubeconfig = data - } - - creds.Context, _ = credsMap["context"].(string) - return creds, nil -} - -// resolveGCPServiceAccountJSON reads a GCP service account JSON key file from the given path. -func (m *CloudAccount) resolveGCPServiceAccountJSON(creds *CloudCredentials, credsMap map[string]any) (*CloudCredentials, error) { - path, _ := credsMap["path"].(string) - if path == "" { - return nil, fmt.Errorf("service_account_json credential requires 'path'") - } - data, err := os.ReadFile(path) //nolint:gosec // G304: path from trusted config data - if err != nil { - return nil, fmt.Errorf("reading service account JSON at %q: %w", path, err) - } - creds.ServiceAccountJSON = data - return creds, nil -} - -// resolveGCPServiceAccountKey uses an inline GCP service account JSON key. -func (m *CloudAccount) resolveGCPServiceAccountKey(creds *CloudCredentials, credsMap map[string]any) (*CloudCredentials, error) { - key, _ := credsMap["key"].(string) - if key == "" { - return nil, fmt.Errorf("service_account_key credential requires 'key'") - } - creds.ServiceAccountJSON = []byte(key) - return creds, nil -} - -// resolveGCPWorkloadIdentity handles GCP Workload Identity (GKE metadata server). -// Production: use golang.org/x/oauth2/google with google.FindDefaultCredentials. -func (m *CloudAccount) resolveGCPWorkloadIdentity(creds *CloudCredentials) (*CloudCredentials, error) { - if creds.Extra == nil { - creds.Extra = map[string]string{} - } - creds.Extra["credential_source"] = "workload_identity" - return creds, nil -} - -// resolveGCPApplicationDefault resolves GCP Application Default Credentials. -// Reads GOOGLE_APPLICATION_CREDENTIALS if set; otherwise records the ADC source. -func (m *CloudAccount) resolveGCPApplicationDefault(creds *CloudCredentials) (*CloudCredentials, error) { - if creds.ProjectID == "" { - creds.ProjectID = os.Getenv("GOOGLE_CLOUD_PROJECT") - if creds.ProjectID == "" { - creds.ProjectID = os.Getenv("GCP_PROJECT_ID") - } - } - saPath := os.Getenv("GOOGLE_APPLICATION_CREDENTIALS") - if saPath != "" { - data, err := os.ReadFile(saPath) //nolint:gosec // G304: path from trusted config data - if err != nil { - return nil, fmt.Errorf("reading GOOGLE_APPLICATION_CREDENTIALS: %w", err) - } - creds.ServiceAccountJSON = data - return creds, nil - } - // No explicit file — production would use the ADC chain (gcloud, metadata server, etc.) - if creds.Extra == nil { - creds.Extra = map[string]string{} - } - creds.Extra["credential_source"] = "application_default" - return creds, nil -} - -// resolveAzureClientCredentials resolves Azure service principal client credentials. -func (m *CloudAccount) resolveAzureClientCredentials(creds *CloudCredentials, credsMap map[string]any) (*CloudCredentials, error) { - creds.TenantID, _ = credsMap["tenant_id"].(string) - creds.ClientID, _ = credsMap["client_id"].(string) - creds.ClientSecret, _ = credsMap["client_secret"].(string) - if creds.TenantID == "" || creds.ClientID == "" || creds.ClientSecret == "" { - return nil, fmt.Errorf("client_credentials requires tenant_id, client_id, and client_secret") - } - return creds, nil -} - -// resolveAzureManagedIdentity handles Azure Managed Identity (VMs, AKS, etc.). -// Optional client_id selects a user-assigned managed identity. -// Production: use github.com/Azure/azure-sdk-for-go/sdk/azidentity ManagedIdentityCredential. -func (m *CloudAccount) resolveAzureManagedIdentity(creds *CloudCredentials, credsMap map[string]any) (*CloudCredentials, error) { - if clientID, ok := credsMap["client_id"].(string); ok { - creds.ClientID = clientID - } - if creds.Extra == nil { - creds.Extra = map[string]string{} - } - creds.Extra["credential_source"] = "managed_identity" - return creds, nil -} - -// resolveAzureCLI handles Azure CLI credentials (az login). -// Production: use github.com/Azure/azure-sdk-for-go/sdk/azidentity AzureCLICredential. -func (m *CloudAccount) resolveAzureCLI(creds *CloudCredentials) (*CloudCredentials, error) { - if creds.Extra == nil { - creds.Extra = map[string]string{} - } - creds.Extra["credential_source"] = "azure_cli" - return creds, nil -} - -// resolveDOAPIToken resolves a DigitalOcean API token from config. -func (m *CloudAccount) resolveDOAPIToken(creds *CloudCredentials, credsMap map[string]any) (*CloudCredentials, error) { - token, _ := credsMap["token"].(string) - if token == "" { - return nil, fmt.Errorf("api_token credential requires 'token'") - } - creds.Token = token - return creds, nil -} - -// doClient returns a configured *godo.Client using the Token credential. -// The caller must have resolved credentials with provider=digitalocean before calling this. -func (m *CloudAccount) doClient() (*godo.Client, error) { - if m.creds == nil || m.creds.Token == "" { - return nil, fmt.Errorf("cloud.account %q: DigitalOcean token not set", m.name) - } - ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: m.creds.Token}) - httpClient := oauth2.NewClient(context.Background(), ts) - return godo.NewClient(httpClient), nil -} diff --git a/module/cloud_account_aws_creds.go b/module/cloud_account_aws_creds.go new file mode 100644 index 00000000..a369c7de --- /dev/null +++ b/module/cloud_account_aws_creds.go @@ -0,0 +1,99 @@ +package module + +import "os" + +func init() { + RegisterCredentialResolver(&awsStaticResolver{}) + RegisterCredentialResolver(&awsEnvResolver{}) + RegisterCredentialResolver(&awsProfileResolver{}) + RegisterCredentialResolver(&awsRoleARNResolver{}) +} + +// awsStaticResolver resolves AWS credentials from static config fields. +type awsStaticResolver struct{} + +func (r *awsStaticResolver) Provider() string { return "aws" } +func (r *awsStaticResolver) CredentialType() string { return "static" } + +func (r *awsStaticResolver) Resolve(m *CloudAccount) error { + credsMap, _ := m.config["credentials"].(map[string]any) + if credsMap != nil { + m.creds.AccessKey, _ = credsMap["accessKey"].(string) + m.creds.SecretKey, _ = credsMap["secretKey"].(string) + m.creds.SessionToken, _ = credsMap["sessionToken"].(string) + m.creds.RoleARN, _ = credsMap["roleArn"].(string) + } + return nil +} + +// awsEnvResolver resolves AWS credentials from environment variables. +type awsEnvResolver struct{} + +func (r *awsEnvResolver) Provider() string { return "aws" } +func (r *awsEnvResolver) CredentialType() string { return "env" } + +func (r *awsEnvResolver) Resolve(m *CloudAccount) error { + m.creds.AccessKey = os.Getenv("AWS_ACCESS_KEY_ID") + if m.creds.AccessKey == "" { + m.creds.AccessKey = os.Getenv("AWS_ACCESS_KEY") + } + m.creds.SecretKey = os.Getenv("AWS_SECRET_ACCESS_KEY") + if m.creds.SecretKey == "" { + m.creds.SecretKey = os.Getenv("AWS_SECRET_KEY") + } + m.creds.SessionToken = os.Getenv("AWS_SESSION_TOKEN") + m.creds.RoleARN = os.Getenv("AWS_ROLE_ARN") + return nil +} + +// awsProfileResolver resolves AWS credentials from a named profile. +type awsProfileResolver struct{} + +func (r *awsProfileResolver) Provider() string { return "aws" } +func (r *awsProfileResolver) CredentialType() string { return "profile" } + +func (r *awsProfileResolver) Resolve(m *CloudAccount) error { + credsMap, _ := m.config["credentials"].(map[string]any) + profile := "" + if credsMap != nil { + profile, _ = credsMap["profile"].(string) + } + if profile == "" { + profile = os.Getenv("AWS_PROFILE") + } + if profile == "" { + profile = "default" + } + // Stub: production implementation would use aws-sdk-go-v2/config.LoadDefaultConfig + // with config.WithSharedConfigProfile(profile). + if m.creds.Extra == nil { + m.creds.Extra = map[string]string{} + } + m.creds.Extra["profile"] = profile + return nil +} + +// awsRoleARNResolver resolves AWS credentials via STS AssumeRole. +type awsRoleARNResolver struct{} + +func (r *awsRoleARNResolver) Provider() string { return "aws" } +func (r *awsRoleARNResolver) CredentialType() string { return "role_arn" } + +func (r *awsRoleARNResolver) Resolve(m *CloudAccount) error { + credsMap, _ := m.config["credentials"].(map[string]any) + if credsMap == nil { + return nil + } + // Stub for STS AssumeRole. + // Production implementation: use aws-sdk-go-v2/service/sts AssumeRole with + // the source credentials, then populate AccessKey/SecretKey/SessionToken + // from the returned Credentials. + roleARN, _ := credsMap["roleArn"].(string) + externalID, _ := credsMap["externalId"].(string) + m.creds.RoleARN = roleARN + if m.creds.Extra == nil { + m.creds.Extra = map[string]string{} + } + m.creds.Extra["external_id"] = externalID + return nil +} diff --git a/module/cloud_account_azure.go b/module/cloud_account_azure.go new file mode 100644 index 00000000..7d2dc492 --- /dev/null +++ b/module/cloud_account_azure.go @@ -0,0 +1,107 @@ +package module + +import ( + "fmt" + "os" +) + +func init() { + RegisterCredentialResolver(&azureStaticResolver{}) + RegisterCredentialResolver(&azureEnvResolver{}) + RegisterCredentialResolver(&azureClientCredentialsResolver{}) + RegisterCredentialResolver(&azureManagedIdentityResolver{}) + RegisterCredentialResolver(&azureCLIResolver{}) +} + +// azureStaticResolver resolves Azure credentials from static config fields. +type azureStaticResolver struct{} + +func (r *azureStaticResolver) Provider() string { return "azure" } +func (r *azureStaticResolver) CredentialType() string { return "static" } + +func (r *azureStaticResolver) Resolve(m *CloudAccount) error { + credsMap, _ := m.config["credentials"].(map[string]any) + if credsMap == nil { + return nil + } + m.creds.TenantID, _ = credsMap["tenant_id"].(string) + m.creds.ClientID, _ = credsMap["client_id"].(string) + m.creds.ClientSecret, _ = credsMap["client_secret"].(string) + if sub, ok := credsMap["subscription_id"].(string); ok { + m.creds.SubscriptionID = sub + } + return nil +} + +// azureEnvResolver resolves Azure credentials from environment variables. +type azureEnvResolver struct{} + +func (r *azureEnvResolver) Provider() string { return "azure" } +func (r *azureEnvResolver) CredentialType() string { return "env" } + +func (r *azureEnvResolver) Resolve(m *CloudAccount) error { + m.creds.TenantID = os.Getenv("AZURE_TENANT_ID") + m.creds.ClientID = os.Getenv("AZURE_CLIENT_ID") + m.creds.ClientSecret = os.Getenv("AZURE_CLIENT_SECRET") + if sub := os.Getenv("AZURE_SUBSCRIPTION_ID"); sub != "" { + m.creds.SubscriptionID = sub + } + return nil +} + +// azureClientCredentialsResolver resolves Azure service principal client credentials. +type azureClientCredentialsResolver struct{} + +func (r *azureClientCredentialsResolver) Provider() string { return "azure" } +func (r *azureClientCredentialsResolver) CredentialType() string { return "client_credentials" } + +func (r *azureClientCredentialsResolver) Resolve(m *CloudAccount) error { + credsMap, _ := m.config["credentials"].(map[string]any) + if credsMap == nil { + return fmt.Errorf("client_credentials requires tenant_id, client_id, and client_secret") + } + m.creds.TenantID, _ = credsMap["tenant_id"].(string) + m.creds.ClientID, _ = credsMap["client_id"].(string) + m.creds.ClientSecret, _ = credsMap["client_secret"].(string) + if m.creds.TenantID == "" || m.creds.ClientID == "" || m.creds.ClientSecret == "" { + return fmt.Errorf("client_credentials requires tenant_id, client_id, and client_secret") + } + return nil +} + +// azureManagedIdentityResolver handles Azure Managed Identity (VMs, AKS, etc.). +// Optional client_id selects a user-assigned managed identity. +// Production: use github.com/Azure/azure-sdk-for-go/sdk/azidentity ManagedIdentityCredential. +type azureManagedIdentityResolver struct{} + +func (r *azureManagedIdentityResolver) Provider() string { return "azure" } +func (r *azureManagedIdentityResolver) CredentialType() string { return "managed_identity" } + +func (r *azureManagedIdentityResolver) Resolve(m *CloudAccount) error { + credsMap, _ := m.config["credentials"].(map[string]any) + if credsMap != nil { + if clientID, ok := credsMap["client_id"].(string); ok { + m.creds.ClientID = clientID + } + } + if m.creds.Extra == nil { + m.creds.Extra = map[string]string{} + } + m.creds.Extra["credential_source"] = "managed_identity" + return nil +} + +// azureCLIResolver handles Azure CLI credentials (az login). +// Production: use github.com/Azure/azure-sdk-for-go/sdk/azidentity AzureCLICredential. +type azureCLIResolver struct{} + +func (r *azureCLIResolver) Provider() string { return "azure" } +func (r *azureCLIResolver) CredentialType() string { return "cli" } + +func (r *azureCLIResolver) Resolve(m *CloudAccount) error { + if m.creds.Extra == nil { + m.creds.Extra = map[string]string{} + } + m.creds.Extra["credential_source"] = "azure_cli" + return nil +} diff --git a/module/cloud_account_do.go b/module/cloud_account_do.go new file mode 100644 index 00000000..a194c3fa --- /dev/null +++ b/module/cloud_account_do.go @@ -0,0 +1,74 @@ +package module + +import ( + "context" + "fmt" + "os" + + "github.com/digitalocean/godo" + "golang.org/x/oauth2" +) + +func init() { + RegisterCredentialResolver(&doStaticResolver{}) + RegisterCredentialResolver(&doEnvResolver{}) + RegisterCredentialResolver(&doAPITokenResolver{}) +} + +// doStaticResolver resolves DigitalOcean credentials from static config fields. +type doStaticResolver struct{} + +func (r *doStaticResolver) Provider() string { return "digitalocean" } +func (r *doStaticResolver) CredentialType() string { return "static" } + +func (r *doStaticResolver) Resolve(m *CloudAccount) error { + credsMap, _ := m.config["credentials"].(map[string]any) + if credsMap != nil { + m.creds.Token, _ = credsMap["token"].(string) + } + return nil +} + +// doEnvResolver resolves DigitalOcean credentials from environment variables. +type doEnvResolver struct{} + +func (r *doEnvResolver) Provider() string { return "digitalocean" } +func (r *doEnvResolver) CredentialType() string { return "env" } + +func (r *doEnvResolver) Resolve(m *CloudAccount) error { + m.creds.Token = os.Getenv("DIGITALOCEAN_TOKEN") + if m.creds.Token == "" { + m.creds.Token = os.Getenv("DO_TOKEN") + } + return nil +} + +// doAPITokenResolver resolves a DigitalOcean API token from explicit config. +type doAPITokenResolver struct{} + +func (r *doAPITokenResolver) Provider() string { return "digitalocean" } +func (r *doAPITokenResolver) CredentialType() string { return "api_token" } + +func (r *doAPITokenResolver) Resolve(m *CloudAccount) error { + credsMap, _ := m.config["credentials"].(map[string]any) + if credsMap == nil { + return fmt.Errorf("api_token credential requires 'token'") + } + token, _ := credsMap["token"].(string) + if token == "" { + return fmt.Errorf("api_token credential requires 'token'") + } + m.creds.Token = token + return nil +} + +// doClient returns a configured *godo.Client using the Token credential. +// The caller must have resolved credentials with provider=digitalocean before calling this. +func (m *CloudAccount) doClient() (*godo.Client, error) { + if m.creds == nil || m.creds.Token == "" { + return nil, fmt.Errorf("cloud.account %q: DigitalOcean token not set", m.name) + } + ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: m.creds.Token}) + httpClient := oauth2.NewClient(context.Background(), ts) + return godo.NewClient(httpClient), nil +} diff --git a/module/cloud_account_gcp.go b/module/cloud_account_gcp.go new file mode 100644 index 00000000..601ba04d --- /dev/null +++ b/module/cloud_account_gcp.go @@ -0,0 +1,145 @@ +package module + +import ( + "fmt" + "os" +) + +func init() { + RegisterCredentialResolver(&gcpStaticResolver{}) + RegisterCredentialResolver(&gcpEnvResolver{}) + RegisterCredentialResolver(&gcpServiceAccountJSONResolver{}) + RegisterCredentialResolver(&gcpServiceAccountKeyResolver{}) + RegisterCredentialResolver(&gcpWorkloadIdentityResolver{}) + RegisterCredentialResolver(&gcpApplicationDefaultResolver{}) +} + +// gcpStaticResolver resolves GCP credentials from static config fields. +type gcpStaticResolver struct{} + +func (r *gcpStaticResolver) Provider() string { return "gcp" } +func (r *gcpStaticResolver) CredentialType() string { return "static" } + +func (r *gcpStaticResolver) Resolve(m *CloudAccount) error { + credsMap, _ := m.config["credentials"].(map[string]any) + if credsMap == nil { + return nil + } + if pid, ok := credsMap["projectId"].(string); ok { + m.creds.ProjectID = pid + } + if saJSON, ok := credsMap["serviceAccountJson"].(string); ok { + m.creds.ServiceAccountJSON = []byte(saJSON) + } + return nil +} + +// gcpEnvResolver resolves GCP credentials from environment variables. +type gcpEnvResolver struct{} + +func (r *gcpEnvResolver) Provider() string { return "gcp" } +func (r *gcpEnvResolver) CredentialType() string { return "env" } + +func (r *gcpEnvResolver) Resolve(m *CloudAccount) error { + m.creds.ProjectID = os.Getenv("GOOGLE_CLOUD_PROJECT") + if m.creds.ProjectID == "" { + m.creds.ProjectID = os.Getenv("GCP_PROJECT_ID") + } + saPath := os.Getenv("GOOGLE_APPLICATION_CREDENTIALS") + if saPath != "" { + data, err := os.ReadFile(saPath) //nolint:gosec // G304: path from trusted config data + if err != nil { + return fmt.Errorf("reading GOOGLE_APPLICATION_CREDENTIALS: %w", err) + } + m.creds.ServiceAccountJSON = data + } + return nil +} + +// gcpServiceAccountJSONResolver reads a GCP service account JSON key file from the given path. +type gcpServiceAccountJSONResolver struct{} + +func (r *gcpServiceAccountJSONResolver) Provider() string { return "gcp" } +func (r *gcpServiceAccountJSONResolver) CredentialType() string { return "service_account_json" } + +func (r *gcpServiceAccountJSONResolver) Resolve(m *CloudAccount) error { + credsMap, _ := m.config["credentials"].(map[string]any) + if credsMap == nil { + return fmt.Errorf("service_account_json credential requires 'path'") + } + path, _ := credsMap["path"].(string) + if path == "" { + return fmt.Errorf("service_account_json credential requires 'path'") + } + data, err := os.ReadFile(path) //nolint:gosec // G304: path from trusted config data + if err != nil { + return fmt.Errorf("reading service account JSON at %q: %w", path, err) + } + m.creds.ServiceAccountJSON = data + return nil +} + +// gcpServiceAccountKeyResolver uses an inline GCP service account JSON key. +type gcpServiceAccountKeyResolver struct{} + +func (r *gcpServiceAccountKeyResolver) Provider() string { return "gcp" } +func (r *gcpServiceAccountKeyResolver) CredentialType() string { return "service_account_key" } + +func (r *gcpServiceAccountKeyResolver) Resolve(m *CloudAccount) error { + credsMap, _ := m.config["credentials"].(map[string]any) + if credsMap == nil { + return fmt.Errorf("service_account_key credential requires 'key'") + } + key, _ := credsMap["key"].(string) + if key == "" { + return fmt.Errorf("service_account_key credential requires 'key'") + } + m.creds.ServiceAccountJSON = []byte(key) + return nil +} + +// gcpWorkloadIdentityResolver handles GCP Workload Identity (GKE metadata server). +// Production: use golang.org/x/oauth2/google with google.FindDefaultCredentials. +type gcpWorkloadIdentityResolver struct{} + +func (r *gcpWorkloadIdentityResolver) Provider() string { return "gcp" } +func (r *gcpWorkloadIdentityResolver) CredentialType() string { return "workload_identity" } + +func (r *gcpWorkloadIdentityResolver) Resolve(m *CloudAccount) error { + if m.creds.Extra == nil { + m.creds.Extra = map[string]string{} + } + m.creds.Extra["credential_source"] = "workload_identity" + return nil +} + +// gcpApplicationDefaultResolver resolves GCP Application Default Credentials. +// Reads GOOGLE_APPLICATION_CREDENTIALS if set; otherwise records the ADC source. +type gcpApplicationDefaultResolver struct{} + +func (r *gcpApplicationDefaultResolver) Provider() string { return "gcp" } +func (r *gcpApplicationDefaultResolver) CredentialType() string { return "application_default" } + +func (r *gcpApplicationDefaultResolver) Resolve(m *CloudAccount) error { + if m.creds.ProjectID == "" { + m.creds.ProjectID = os.Getenv("GOOGLE_CLOUD_PROJECT") + if m.creds.ProjectID == "" { + m.creds.ProjectID = os.Getenv("GCP_PROJECT_ID") + } + } + saPath := os.Getenv("GOOGLE_APPLICATION_CREDENTIALS") + if saPath != "" { + data, err := os.ReadFile(saPath) //nolint:gosec // G304: path from trusted config data + if err != nil { + return fmt.Errorf("reading GOOGLE_APPLICATION_CREDENTIALS: %w", err) + } + m.creds.ServiceAccountJSON = data + return nil + } + // No explicit file — production would use the ADC chain (gcloud, metadata server, etc.) + if m.creds.Extra == nil { + m.creds.Extra = map[string]string{} + } + m.creds.Extra["credential_source"] = "application_default" + return nil +} diff --git a/module/cloud_account_k8s.go b/module/cloud_account_k8s.go new file mode 100644 index 00000000..773cf2fe --- /dev/null +++ b/module/cloud_account_k8s.go @@ -0,0 +1,93 @@ +package module + +import ( + "fmt" + "os" +) + +func init() { + RegisterCredentialResolver(&k8sStaticResolver{}) + RegisterCredentialResolver(&k8sEnvResolver{}) + RegisterCredentialResolver(&k8sKubeconfigResolver{}) +} + +// k8sStaticResolver resolves Kubernetes credentials from static config fields. +type k8sStaticResolver struct{} + +func (r *k8sStaticResolver) Provider() string { return "kubernetes" } +func (r *k8sStaticResolver) CredentialType() string { return "static" } + +func (r *k8sStaticResolver) Resolve(m *CloudAccount) error { + credsMap, _ := m.config["credentials"].(map[string]any) + if credsMap == nil { + return nil + } + if kc, ok := credsMap["kubeconfig"].(string); ok { + m.creds.Kubeconfig = []byte(kc) + } + m.creds.Context, _ = credsMap["context"].(string) + return nil +} + +// k8sEnvResolver resolves Kubernetes credentials from the KUBECONFIG environment variable. +type k8sEnvResolver struct{} + +func (r *k8sEnvResolver) Provider() string { return "kubernetes" } +func (r *k8sEnvResolver) CredentialType() string { return "env" } + +func (r *k8sEnvResolver) Resolve(m *CloudAccount) error { + kubeconfigPath := os.Getenv("KUBECONFIG") + if kubeconfigPath == "" { + home, _ := os.UserHomeDir() + kubeconfigPath = home + "/.kube/config" + } + data, err := os.ReadFile(kubeconfigPath) //nolint:gosec // G304: path from trusted config data + if err != nil { + return fmt.Errorf("reading kubeconfig: %w", err) + } + m.creds.Kubeconfig = data + return nil +} + +// k8sKubeconfigResolver resolves Kubernetes credentials from a kubeconfig file or inline content. +type k8sKubeconfigResolver struct{} + +func (r *k8sKubeconfigResolver) Provider() string { return "kubernetes" } +func (r *k8sKubeconfigResolver) CredentialType() string { return "kubeconfig" } + +func (r *k8sKubeconfigResolver) Resolve(m *CloudAccount) error { + credsMap, _ := m.config["credentials"].(map[string]any) + + path := "" + if credsMap != nil { + path, _ = credsMap["path"].(string) + } + if path == "" { + path = os.Getenv("KUBECONFIG") + } + if path == "" { + home, _ := os.UserHomeDir() + path = home + "/.kube/config" + } + + if credsMap != nil { + if inline, ok := credsMap["inline"].(string); ok && inline != "" { + m.creds.Kubeconfig = []byte(inline) + m.creds.Context, _ = credsMap["context"].(string) + return nil + } + } + + if path != "" { + data, err := os.ReadFile(path) //nolint:gosec // G304: path from trusted config data + if err != nil { + return fmt.Errorf("reading kubeconfig at %q: %w", path, err) + } + m.creds.Kubeconfig = data + } + + if credsMap != nil { + m.creds.Context, _ = credsMap["context"].(string) + } + return nil +} diff --git a/module/cloud_credential_resolver.go b/module/cloud_credential_resolver.go new file mode 100644 index 00000000..6a540459 --- /dev/null +++ b/module/cloud_credential_resolver.go @@ -0,0 +1,24 @@ +package module + +// CloudCredentialResolver resolves credentials for a specific cloud provider and credential type. +type CloudCredentialResolver interface { + // Provider returns the cloud provider name (e.g., "aws", "gcp", "azure", "digitalocean", "kubernetes"). + Provider() string + // CredentialType returns the credential type this resolver handles (e.g., "static", "env", "profile", "role_arn"). + CredentialType() string + // Resolve resolves credentials from the given config and stores them in the CloudAccount. + Resolve(m *CloudAccount) error +} + +// credentialResolvers is the global registry: provider -> credType -> resolver. +var credentialResolvers = map[string]map[string]CloudCredentialResolver{} + +// RegisterCredentialResolver registers a CloudCredentialResolver in the global registry. +// It is safe to call from init() functions. +func RegisterCredentialResolver(r CloudCredentialResolver) { + p := r.Provider() + if credentialResolvers[p] == nil { + credentialResolvers[p] = map[string]CloudCredentialResolver{} + } + credentialResolvers[p][r.CredentialType()] = r +} From 7ea02448de86e05240abc3c2742b3ef78e4a7cfa Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Thu, 26 Feb 2026 13:35:02 -0500 Subject: [PATCH 2/2] refactor(platform): introduce backend factory registries for platform modules Replace switch statements in Init() for platform.networking, platform.dns, platform.autoscaling, platform.apigateway, platform.kubernetes, and platform.ecs with registry lookups. Each module now exposes a public Register*Backend() function and a *BackendFactory type, with mock and real backends registered in init(). New backends can be added by external packages without modifying core module files. All existing tests pass. Co-Authored-By: Claude Opus 4.6 --- module/platform_apigateway.go | 33 ++++++++++++++++++---- module/platform_autoscaling.go | 33 ++++++++++++++++++---- module/platform_dns.go | 33 ++++++++++++++++++---- module/platform_ecs.go | 44 ++++++++++++++++++++++++++---- module/platform_kubernetes.go | 28 ++++++++++++------- module/platform_kubernetes_kind.go | 18 ++++++++++++ module/platform_networking.go | 33 ++++++++++++++++++---- 7 files changed, 183 insertions(+), 39 deletions(-) diff --git a/module/platform_apigateway.go b/module/platform_apigateway.go index 26ad3300..859b8fd1 100644 --- a/module/platform_apigateway.go +++ b/module/platform_apigateway.go @@ -55,6 +55,26 @@ type apigatewayBackend interface { destroy(m *PlatformAPIGateway) error } +// APIGatewayBackendFactory creates an apigatewayBackend for a given provider config. +type APIGatewayBackendFactory func(cfg map[string]any) (apigatewayBackend, error) + +// apigatewayBackendRegistry maps provider name to its factory. +var apigatewayBackendRegistry = map[string]APIGatewayBackendFactory{} + +// RegisterAPIGatewayBackend registers an APIGatewayBackendFactory for the given provider name. +func RegisterAPIGatewayBackend(provider string, factory APIGatewayBackendFactory) { + apigatewayBackendRegistry[provider] = factory +} + +func init() { + RegisterAPIGatewayBackend("mock", func(_ map[string]any) (apigatewayBackend, error) { + return &mockAPIGatewayBackend{}, nil + }) + RegisterAPIGatewayBackend("aws", func(_ map[string]any) (apigatewayBackend, error) { + return &awsAPIGatewayBackend{}, nil + }) +} + // PlatformAPIGateway manages API gateway provisioning via pluggable backends. // Config: // @@ -99,14 +119,15 @@ func (m *PlatformAPIGateway) Init(app modular.Application) error { provider = "mock" } - switch provider { - case "mock": - m.backend = &mockAPIGatewayBackend{} - case "aws": - m.backend = &awsAPIGatewayBackend{} - default: + factory, ok := apigatewayBackendRegistry[provider] + if !ok { return fmt.Errorf("platform.apigateway %q: unsupported provider %q", m.name, provider) } + backend, err := factory(m.config) + if err != nil { + return fmt.Errorf("platform.apigateway %q: creating backend: %w", m.name, err) + } + m.backend = backend gwName, _ := m.config["name"].(string) if gwName == "" { diff --git a/module/platform_autoscaling.go b/module/platform_autoscaling.go index a24e5943..6a307296 100644 --- a/module/platform_autoscaling.go +++ b/module/platform_autoscaling.go @@ -46,6 +46,26 @@ type autoscalingBackend interface { destroy(m *PlatformAutoscaling) error } +// AutoscalingBackendFactory creates an autoscalingBackend for a given provider config. +type AutoscalingBackendFactory func(cfg map[string]any) (autoscalingBackend, error) + +// autoscalingBackendRegistry maps provider name to its factory. +var autoscalingBackendRegistry = map[string]AutoscalingBackendFactory{} + +// RegisterAutoscalingBackend registers an AutoscalingBackendFactory for the given provider name. +func RegisterAutoscalingBackend(provider string, factory AutoscalingBackendFactory) { + autoscalingBackendRegistry[provider] = factory +} + +func init() { + RegisterAutoscalingBackend("mock", func(_ map[string]any) (autoscalingBackend, error) { + return &mockAutoscalingBackend{}, nil + }) + RegisterAutoscalingBackend("aws", func(_ map[string]any) (autoscalingBackend, error) { + return &awsAutoscalingBackend{}, nil + }) +} + // PlatformAutoscaling manages autoscaling policies via pluggable backends. // Config: // @@ -87,14 +107,15 @@ func (m *PlatformAutoscaling) Init(app modular.Application) error { provider = "mock" } - switch provider { - case "mock": - m.backend = &mockAutoscalingBackend{} - case "aws": - m.backend = &awsAutoscalingBackend{} - default: + factory, ok := autoscalingBackendRegistry[provider] + if !ok { return fmt.Errorf("platform.autoscaling %q: unsupported provider %q", m.name, provider) } + backend, err := factory(m.config) + if err != nil { + return fmt.Errorf("platform.autoscaling %q: creating backend: %w", m.name, err) + } + m.backend = backend m.state = &ScalingState{ ID: "", diff --git a/module/platform_dns.go b/module/platform_dns.go index c41125c7..2720f46f 100644 --- a/module/platform_dns.go +++ b/module/platform_dns.go @@ -52,6 +52,26 @@ type dnsBackend interface { destroyDNS(m *PlatformDNS) error } +// DNSBackendFactory creates a dnsBackend for a given provider config. +type DNSBackendFactory func(cfg map[string]any) (dnsBackend, error) + +// dnsBackendRegistry maps provider name to its factory. +var dnsBackendRegistry = map[string]DNSBackendFactory{} + +// RegisterDNSBackend registers a DNSBackendFactory for the given provider name. +func RegisterDNSBackend(provider string, factory DNSBackendFactory) { + dnsBackendRegistry[provider] = factory +} + +func init() { + RegisterDNSBackend("mock", func(_ map[string]any) (dnsBackend, error) { + return &mockDNSBackend{}, nil + }) + RegisterDNSBackend("aws", func(_ map[string]any) (dnsBackend, error) { + return &route53Backend{}, nil + }) +} + // PlatformDNS manages DNS zones and records via pluggable backends. // Config: // @@ -95,14 +115,15 @@ func (m *PlatformDNS) Init(app modular.Application) error { providerType = "mock" } - switch providerType { - case "mock": - m.backend = &mockDNSBackend{} - case "aws": - m.backend = &route53Backend{} - default: + factory, ok := dnsBackendRegistry[providerType] + if !ok { return fmt.Errorf("platform.dns %q: unsupported provider %q", m.name, providerType) } + backend, err := factory(m.config) + if err != nil { + return fmt.Errorf("platform.dns %q: creating backend: %w", m.name, err) + } + m.backend = backend zone := m.zoneConfig() if zone.Name == "" { diff --git a/module/platform_ecs.go b/module/platform_ecs.go index e11440d5..9b3aa9f0 100644 --- a/module/platform_ecs.go +++ b/module/platform_ecs.go @@ -74,6 +74,26 @@ type ecsBackend interface { destroy(e *PlatformECS) error } +// ECSBackendFactory creates an ecsBackend for a given provider config. +type ECSBackendFactory func(cfg map[string]any) (ecsBackend, error) + +// ecsBackendRegistry maps provider name to its factory. +var ecsBackendRegistry = map[string]ECSBackendFactory{} + +// RegisterECSBackend registers an ECSBackendFactory for the given provider name. +func RegisterECSBackend(provider string, factory ECSBackendFactory) { + ecsBackendRegistry[provider] = factory +} + +func init() { + RegisterECSBackend("mock", func(_ map[string]any) (ecsBackend, error) { + return &ecsMockBackend{}, nil + }) + RegisterECSBackend("aws", func(_ map[string]any) (ecsBackend, error) { + return &awsECSBackend{}, nil + }) +} + // NewPlatformECS creates a new PlatformECS module. func NewPlatformECS(name string, cfg map[string]any) *PlatformECS { return &PlatformECS{name: name, config: cfg} @@ -120,12 +140,26 @@ func (m *PlatformECS) Init(app modular.Application) error { Status: "pending", } - // Select backend: use real AWS ECS when account is AWS, otherwise use in-memory mock. - if m.provider != nil && m.provider.Provider() == "aws" { - m.backend = &awsECSBackend{} - } else { - m.backend = &ecsMockBackend{} + // Determine provider type: use explicit "provider" config field if set, + // otherwise fall back to the cloud account's provider name (if available). + providerType, _ := m.config["provider"].(string) + if providerType == "" && m.provider != nil { + providerType = m.provider.Provider() + } + if providerType == "" { + providerType = "mock" + } + + factory, ok := ecsBackendRegistry[providerType] + if !ok { + // Fall back to mock for unknown provider types to preserve backward compatibility. + factory = ecsBackendRegistry["mock"] + } + backend, err := factory(m.config) + if err != nil { + return fmt.Errorf("platform.ecs %q: creating backend: %w", m.name, err) } + m.backend = backend return app.RegisterService(m.name, m) } diff --git a/module/platform_kubernetes.go b/module/platform_kubernetes.go index a60a72b2..f1242edd 100644 --- a/module/platform_kubernetes.go +++ b/module/platform_kubernetes.go @@ -51,6 +51,17 @@ type kubernetesBackend interface { destroy(k *PlatformKubernetes) error } +// KubernetesBackendFactory creates a kubernetesBackend for a given cluster type config. +type KubernetesBackendFactory func(cfg map[string]any) (kubernetesBackend, error) + +// kubernetesBackendRegistry maps cluster type name to its factory. +var kubernetesBackendRegistry = map[string]KubernetesBackendFactory{} + +// RegisterKubernetesBackend registers a KubernetesBackendFactory for the given cluster type. +func RegisterKubernetesBackend(clusterType string, factory KubernetesBackendFactory) { + kubernetesBackendRegistry[clusterType] = factory +} + // NewPlatformKubernetes creates a new PlatformKubernetes module. func NewPlatformKubernetes(name string, cfg map[string]any) *PlatformKubernetes { return &PlatformKubernetes{name: name, config: cfg} @@ -79,18 +90,15 @@ func (m *PlatformKubernetes) Init(app modular.Application) error { clusterType = "kind" } - switch clusterType { - case "kind", "k3s": - m.backend = &kindBackend{} - case "eks": - m.backend = &eksBackend{} - case "gke": - m.backend = &gkeBackend{} - case "aks": - m.backend = &aksBackend{} - default: + factory, ok := kubernetesBackendRegistry[clusterType] + if !ok { return fmt.Errorf("platform.kubernetes %q: unsupported type %q", m.name, clusterType) } + backend, err := factory(m.config) + if err != nil { + return fmt.Errorf("platform.kubernetes %q: creating backend: %w", m.name, err) + } + m.backend = backend version, _ := m.config["version"].(string) m.state = &KubernetesClusterState{ diff --git a/module/platform_kubernetes_kind.go b/module/platform_kubernetes_kind.go index 68090b21..ea1d6991 100644 --- a/module/platform_kubernetes_kind.go +++ b/module/platform_kubernetes_kind.go @@ -825,3 +825,21 @@ func (b *aksBackend) azureToken(creds *CloudCredentials) (string, error) { } return token, nil } + +func init() { + RegisterKubernetesBackend("kind", func(_ map[string]any) (kubernetesBackend, error) { + return &kindBackend{}, nil + }) + RegisterKubernetesBackend("k3s", func(_ map[string]any) (kubernetesBackend, error) { + return &kindBackend{}, nil + }) + RegisterKubernetesBackend("eks", func(_ map[string]any) (kubernetesBackend, error) { + return &eksBackend{}, nil + }) + RegisterKubernetesBackend("gke", func(_ map[string]any) (kubernetesBackend, error) { + return &gkeBackend{}, nil + }) + RegisterKubernetesBackend("aks", func(_ map[string]any) (kubernetesBackend, error) { + return &aksBackend{}, nil + }) +} diff --git a/module/platform_networking.go b/module/platform_networking.go index 823acbde..0722016e 100644 --- a/module/platform_networking.go +++ b/module/platform_networking.go @@ -64,6 +64,26 @@ type networkBackend interface { destroy(m *PlatformNetworking) error } +// NetworkingBackendFactory creates a networkBackend for a given provider config. +type NetworkingBackendFactory func(cfg map[string]any) (networkBackend, error) + +// networkingBackendRegistry maps provider name to its factory. +var networkingBackendRegistry = map[string]NetworkingBackendFactory{} + +// RegisterNetworkingBackend registers a NetworkingBackendFactory for the given provider name. +func RegisterNetworkingBackend(provider string, factory NetworkingBackendFactory) { + networkingBackendRegistry[provider] = factory +} + +func init() { + RegisterNetworkingBackend("mock", func(_ map[string]any) (networkBackend, error) { + return &mockNetworkBackend{}, nil + }) + RegisterNetworkingBackend("aws", func(_ map[string]any) (networkBackend, error) { + return &awsNetworkBackend{}, nil + }) +} + // PlatformNetworking manages VPC/subnet/security-group resources via pluggable backends. // Config: // @@ -115,14 +135,15 @@ func (m *PlatformNetworking) Init(app modular.Application) error { providerType = "mock" } - switch providerType { - case "mock": - m.backend = &mockNetworkBackend{} - case "aws": - m.backend = &awsNetworkBackend{} - default: + factory, ok := networkingBackendRegistry[providerType] + if !ok { return fmt.Errorf("platform.networking %q: unsupported provider %q", m.name, providerType) } + backend, err := factory(m.config) + if err != nil { + return fmt.Errorf("platform.networking %q: creating backend: %w", m.name, err) + } + m.backend = backend m.state = &NetworkState{ SubnetIDs: make(map[string]string),