From 009c60376fb3020937b310bf8605cafdf0d9b7e0 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 20 May 2026 11:28:22 -0400 Subject: [PATCH 1/5] refactor: drop direct AWS SDK use from workflow core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workflow core no longer imports any aws-sdk-go-v2/service/* package directly. The remaining AWS-SDK presence in go.mod is purely indirect (modular/eventbus/v2 pulls sts + kinesis, which is modular's concern). Deleted (all unused by the rest of workflow; workflow-plugin-aws is the in-tree replacement for IaC): - provider/aws/ — full directory (plugin.go, clients.go, deploy.go, registry.go, _test.go). cmd/server/main.go's blank side-effect import dropped along with it. - artifact/s3.go — S3Store impl. No callers (artifact pipeline steps use the Store interface; FS impl in artifact/local.go is the only concrete impl now). - iam/aws.go — AWSIAMProvider. api/router.go's resolver registration call removed; KubernetesProvider + OIDCProvider remain. Plugin-side replacement landed in workflow-plugin-aws. - plugin/rbac/aws.go — dead code; no external importers. plugin/rbac/aws_test.go + the TestAWSIAMProvider_NameCheck test in builtin_test.go also removed. - iam/providers_test.go AWSProvider subtests + AWS branch of the RegisterProvider test (latter rewired to KubernetesProvider). Dependency footprint, before → after (direct AWS imports): github.com/aws/aws-sdk-go-v2 6 direct github.com/aws/aws-sdk-go-v2/config 1 direct github.com/aws/aws-sdk-go-v2/credentials 1 direct github.com/aws/aws-sdk-go-v2/service/cloudwatch 1 direct github.com/aws/aws-sdk-go-v2/service/ecs 1 direct github.com/aws/aws-sdk-go-v2/service/eks 1 direct github.com/aws/aws-sdk-go-v2/service/iam 1 direct github.com/aws/aws-sdk-go-v2/service/s3 1 direct github.com/aws/aws-sdk-go-v2/service/sts 1 direct — every one now indirect. Verification: GOWORK=off go test ./... — all 138 packages green GOWORK=off go mod why aws-sdk-go-v2/service/{cloudwatch,ecs,eks,iam,s3} — "main module does not need package …" for each The kinesis + sts indirect pulls come from modular/modules/eventbus/v2; reducing those is a modular-side concern (the eventbus has multiple backends and pulls every SDK). Co-Authored-By: Claude Opus 4.7 (1M context) --- api/router.go | 3 +- artifact/s3.go | 203 ----------- cmd/server/main.go | 1 - go.mod | 15 +- go.sum | 14 - iam/aws.go | 172 ---------- iam/providers_test.go | 91 +---- plugin/rbac/aws.go | 369 -------------------- plugin/rbac/aws_test.go | 646 ------------------------------------ plugin/rbac/builtin_test.go | 7 - provider/aws/clients.go | 28 -- provider/aws/deploy.go | 153 --------- provider/aws/deploy_test.go | 462 -------------------------- provider/aws/plugin.go | 530 ----------------------------- provider/aws/plugin_test.go | 527 ----------------------------- provider/aws/registry.go | 23 -- 16 files changed, 11 insertions(+), 3233 deletions(-) delete mode 100644 artifact/s3.go delete mode 100644 iam/aws.go delete mode 100644 plugin/rbac/aws.go delete mode 100644 plugin/rbac/aws_test.go delete mode 100644 provider/aws/clients.go delete mode 100644 provider/aws/deploy.go delete mode 100644 provider/aws/deploy_test.go delete mode 100644 provider/aws/plugin.go delete mode 100644 provider/aws/plugin_test.go delete mode 100644 provider/aws/registry.go diff --git a/api/router.go b/api/router.go index c70335dd..242c43c8 100644 --- a/api/router.go +++ b/api/router.go @@ -185,7 +185,8 @@ func NewRouterWithIAM(stores Stores, cfg Config, iamResolver *iam.IAMResolver) h resolver := iamResolver if resolver == nil { resolver = iam.NewIAMResolver(stores.IAM) - resolver.RegisterProvider(&iam.AWSIAMProvider{}) + // AWSIAMProvider extracted to workflow-plugin-aws; register + // it from the plugin side if needed. resolver.RegisterProvider(&iam.KubernetesProvider{}) resolver.RegisterProvider(&iam.OIDCProvider{}) } diff --git a/artifact/s3.go b/artifact/s3.go deleted file mode 100644 index 92cf5ba1..00000000 --- a/artifact/s3.go +++ /dev/null @@ -1,203 +0,0 @@ -package artifact - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "fmt" - "io" - "path" - "time" - - "github.com/aws/aws-sdk-go-v2/service/s3" -) - -// S3Store implements Store using an S3-compatible backend. -// Objects are stored under {prefix}/artifacts/{executionID}/{key}. -type S3Store struct { - client *s3.Client - bucket string - prefix string -} - -// NewS3Store creates a new S3Store. -func NewS3Store(client *s3.Client, bucket, prefix string) *S3Store { - return &S3Store{ - client: client, - bucket: bucket, - prefix: prefix, - } -} - -// objectKey returns the full S3 key for a given artifact. -func (s *S3Store) objectKey(executionID, key string) string { - return path.Join(s.prefix, "artifacts", executionID, key) -} - -// Put uploads an artifact to S3. The reader content is buffered to compute -// the SHA256 checksum before upload, since S3 PutObject requires a seekable body -// or known content length for checksum metadata. -func (s *S3Store) Put(ctx context.Context, executionID, key string, reader io.Reader) error { - // Read all content to compute checksum and size. - data, err := io.ReadAll(reader) - if err != nil { - return fmt.Errorf("failed to read artifact data: %w", err) - } - - hasher := sha256.New() - hasher.Write(data) - checksum := hex.EncodeToString(hasher.Sum(nil)) - size := int64(len(data)) - - objectKey := s.objectKey(executionID, key) - - // Store checksum and metadata as S3 object metadata. - metadata := map[string]string{ - "checksum": checksum, - "size": fmt.Sprintf("%d", size), - "created-at": time.Now().UTC().Format(time.RFC3339), - } - - body := newReadSeekCloser(data) - - _, err = s.client.PutObject(ctx, &s3.PutObjectInput{ - Bucket: &s.bucket, - Key: &objectKey, - Body: body, - Metadata: metadata, - }) - if err != nil { - return fmt.Errorf("failed to put artifact to S3: %w", err) - } - - return nil -} - -// Get retrieves an artifact from S3. -func (s *S3Store) Get(ctx context.Context, executionID, key string) (io.ReadCloser, error) { - objectKey := s.objectKey(executionID, key) - - result, err := s.client.GetObject(ctx, &s3.GetObjectInput{ - Bucket: &s.bucket, - Key: &objectKey, - }) - if err != nil { - return nil, fmt.Errorf("failed to get artifact from S3: %w", err) - } - - return result.Body, nil -} - -// List returns all artifacts for a given execution ID by listing S3 objects -// under the execution prefix. -func (s *S3Store) List(ctx context.Context, executionID string) ([]Artifact, error) { - prefix := path.Join(s.prefix, "artifacts", executionID) + "/" - - result, err := s.client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{ - Bucket: &s.bucket, - Prefix: &prefix, - }) - if err != nil { - return nil, fmt.Errorf("failed to list artifacts from S3: %w", err) - } - - var artifacts []Artifact - for _, obj := range result.Contents { - key := path.Base(*obj.Key) - - // Retrieve object metadata for checksum. - head, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{ - Bucket: &s.bucket, - Key: obj.Key, - }) - if err != nil { - return nil, fmt.Errorf("failed to head artifact %q: %w", key, err) - } - - checksum := "" - if head.Metadata != nil { - checksum = head.Metadata["checksum"] - } - - var createdAt time.Time - if head.Metadata != nil { - if ts, ok := head.Metadata["created-at"]; ok { - createdAt, _ = time.Parse(time.RFC3339, ts) - } - } - if createdAt.IsZero() && obj.LastModified != nil { - createdAt = *obj.LastModified - } - - var size int64 - if obj.Size != nil { - size = *obj.Size - } - - artifacts = append(artifacts, Artifact{ - Key: key, - Size: size, - CreatedAt: createdAt, - Checksum: checksum, - }) - } - - return artifacts, nil -} - -// Delete removes an artifact from S3. -func (s *S3Store) Delete(ctx context.Context, executionID, key string) error { - objectKey := s.objectKey(executionID, key) - - _, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{ - Bucket: &s.bucket, - Key: &objectKey, - }) - if err != nil { - return fmt.Errorf("failed to delete artifact from S3: %w", err) - } - - return nil -} - -// readSeekCloser wraps a byte slice to satisfy io.ReadSeekCloser. -type readSeekCloser struct { - data []byte - offset int -} - -func newReadSeekCloser(data []byte) *readSeekCloser { - return &readSeekCloser{data: data} -} - -func (r *readSeekCloser) Read(p []byte) (int, error) { - if r.offset >= len(r.data) { - return 0, io.EOF - } - n := copy(p, r.data[r.offset:]) - r.offset += n - return n, nil -} - -func (r *readSeekCloser) Seek(offset int64, whence int) (int64, error) { - var abs int64 - switch whence { - case io.SeekStart: - abs = offset - case io.SeekCurrent: - abs = int64(r.offset) + offset - case io.SeekEnd: - abs = int64(len(r.data)) + offset - default: - return 0, fmt.Errorf("invalid whence: %d", whence) - } - if abs < 0 { - return 0, fmt.Errorf("negative seek position: %d", abs) - } - r.offset = int(abs) - return abs, nil -} - -func (r *readSeekCloser) Close() error { - return nil -} diff --git a/cmd/server/main.go b/cmd/server/main.go index ed5a66e1..b7d5dbde 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -44,7 +44,6 @@ import ( allplugins "github.com/GoCodeAlone/workflow/plugins/all" pluginpipeline "github.com/GoCodeAlone/workflow/plugins/pipelinesteps" "github.com/GoCodeAlone/workflow/provider" - _ "github.com/GoCodeAlone/workflow/provider/aws" _ "github.com/GoCodeAlone/workflow/provider/azure" _ "github.com/GoCodeAlone/workflow/provider/digitalocean" _ "github.com/GoCodeAlone/workflow/provider/gcp" diff --git a/go.mod b/go.mod index c07d378d..cfe3514a 100644 --- a/go.mod +++ b/go.mod @@ -15,15 +15,6 @@ require ( github.com/GoCodeAlone/yaegi v0.17.2 github.com/IBM/sarama v1.47.0 github.com/alicebob/miniredis/v2 v2.36.1 - github.com/aws/aws-sdk-go-v2 v1.41.6 - github.com/aws/aws-sdk-go-v2/config v1.32.16 - github.com/aws/aws-sdk-go-v2/credentials v1.19.15 - github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.55.2 - github.com/aws/aws-sdk-go-v2/service/ecs v1.76.0 - github.com/aws/aws-sdk-go-v2/service/eks v1.81.2 - github.com/aws/aws-sdk-go-v2/service/iam v1.53.7 - github.com/aws/aws-sdk-go-v2/service/s3 v1.97.2 - github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 github.com/cucumber/godog v0.15.1 github.com/docker/docker v28.5.2+incompatible github.com/expr-lang/expr v1.17.8 @@ -83,19 +74,21 @@ require ( github.com/Workiva/go-datastructures v1.1.7 // indirect github.com/andybalholm/brotli v1.2.1 // indirect github.com/armon/go-metrics v0.4.1 // indirect + github.com/aws/aws-sdk-go-v2 v1.41.6 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect + github.com/aws/aws-sdk-go-v2/config v1.32.16 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.15 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20 // indirect github.com/aws/aws-sdk-go-v2/service/kinesis v1.43.4 // indirect github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 // indirect github.com/aws/smithy-go v1.25.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect diff --git a/go.sum b/go.sum index 24178697..ad13a711 100644 --- a/go.sum +++ b/go.sum @@ -68,26 +68,12 @@ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 h1:dY4kWZiSaXIzxnKlj1 github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22/go.mod h1:KIpEUx0JuRZLO7U6cbV204cWAEco2iC3l061IxlwLtI= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 h1:FPXsW9+gMuIeKmz7j6ENWcWtBGTe1kH8r9thNt5Uxx4= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23/go.mod h1:7J8iGMdRKk6lw2C+cMIphgAnT8uTwBwNOsGkyOCm80U= -github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.55.2 h1:mleWBVIxwceEzyItUVoqMFiv6TmOP6ECPoN6WB/VWXc= -github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.55.2/go.mod h1:cMApt548kNgu87UsBTNWVv+fpzjbUTFRSFjD1688SBs= -github.com/aws/aws-sdk-go-v2/service/ecs v1.76.0 h1:a5G/TgJNrpuCjZBTf8/PTN0C2B0do/ylaYVynxPSbUQ= -github.com/aws/aws-sdk-go-v2/service/ecs v1.76.0/go.mod h1:QkWmubOYmjj3cHn7A4CoUU7BKJhVeo39Gp6NH7IyhZw= -github.com/aws/aws-sdk-go-v2/service/eks v1.81.2 h1:6c/Jkyx1gYLiZGl6VPjApViaoPiYo7TDWXCMk/ZBq6c= -github.com/aws/aws-sdk-go-v2/service/eks v1.81.2/go.mod h1:xdUh6tdF9A8hc+PE84kmHbF/zsVPNiKnc6oLgulq1Eo= -github.com/aws/aws-sdk-go-v2/service/iam v1.53.7 h1:n9YLiWtX3+6pTLZWvRJmtq5JIB9NA/KFelyCg5fOlTU= -github.com/aws/aws-sdk-go-v2/service/iam v1.53.7/go.mod h1:sP46Vo6MeJcM4s0ZXcG2PFmfiSyixhIuC/74W52yKuk= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 h1:HtOTYcbVcGABLOVuPYaIihj6IlkqubBwFj10K5fxRek= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8/go.mod h1:VsK9abqQeGlzPgUr+isNWzPlK2vKe9INMLWnY65f5Xs= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12 h1:qtJZ70afD3ISKWnoX3xB0J2otEqu3LqicRcDBqsj0hQ= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12/go.mod h1:v2pNpJbRNl4vEUWEh5ytQok0zACAKfdmKS51Hotc3pQ= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 h1:PUmZeJU6Y1Lbvt9WFuJ0ugUK2xn6hIWUBBbKuOWF30s= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22/go.mod h1:nO6egFBoAaoXze24a2C0NjQCvdpk8OueRoYimvEB9jo= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20 h1:siU1A6xjUZ2N8zjTHSXFhB9L/2OY8Dqs0xXiLjF30jA= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20/go.mod h1:4TLZCmVJDM3FOu5P5TJP0zOlu9zWgDWU7aUxWbr+rcw= github.com/aws/aws-sdk-go-v2/service/kinesis v1.43.4 h1:3m9iJtMtLq75jKRAfw0kapoHUlbzi0CRVigysBN/FHA= github.com/aws/aws-sdk-go-v2/service/kinesis v1.43.4/go.mod h1:O2L6vGm4xacEuN2otHFMgn7yXXlgzFKzxrba0fy/yk8= -github.com/aws/aws-sdk-go-v2/service/s3 v1.97.2 h1:MRNiP6nqa20aEl8fQ6PJpEq11b2d40b16sm4WD7QgMU= -github.com/aws/aws-sdk-go-v2/service/s3 v1.97.2/go.mod h1:FrNA56srbsr3WShiaelyWYEo70x80mXnVZ17ZZfbeqg= github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 h1:a1Fq/KXn75wSzoJaPQTgZO0wHGqE9mjFnylnqEPTchA= github.com/aws/aws-sdk-go-v2/service/signin v1.0.10/go.mod h1:p6+MXNxW7IA6dMgHfTAzljuwSKD0NCm/4lbS4t6+7vI= github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 h1:x6bKbmDhsgSZwv6q19wY/u3rLk/3FGjJWyqKcIRufpE= diff --git a/iam/aws.go b/iam/aws.go deleted file mode 100644 index 05aa43b5..00000000 --- a/iam/aws.go +++ /dev/null @@ -1,172 +0,0 @@ -package iam - -import ( - "context" - "encoding/json" - "fmt" - "strings" - - "github.com/GoCodeAlone/workflow/store" - "github.com/aws/aws-sdk-go-v2/aws" - awsconfig "github.com/aws/aws-sdk-go-v2/config" - "github.com/aws/aws-sdk-go-v2/credentials" - iamsdk "github.com/aws/aws-sdk-go-v2/service/iam" - "github.com/aws/aws-sdk-go-v2/service/sts" -) - -// AWSConfig holds configuration for the AWS IAM provider. -type AWSConfig struct { - AccountID string `json:"account_id"` - Region string `json:"region"` - AccessKeyID string `json:"access_key_id,omitempty"` - SecretAccessKey string `json:"secret_access_key,omitempty"` - SessionToken string `json:"session_token,omitempty"` //nolint:gosec // field name, not a credential -} - -// AWSIAMProvider validates AWS IAM ARNs using STS GetCallerIdentity and -// IAM GetUser/GetRole calls. -type AWSIAMProvider struct{} - -func (p *AWSIAMProvider) Type() store.IAMProviderType { - return store.IAMProviderAWS -} - -func (p *AWSIAMProvider) ValidateConfig(cfgRaw json.RawMessage) error { - var c AWSConfig - if err := json.Unmarshal(cfgRaw, &c); err != nil { - return fmt.Errorf("invalid aws config: %w", err) - } - if c.AccountID == "" { - return fmt.Errorf("account_id is required") - } - return nil -} - -// ResolveIdentities resolves an AWS ARN to an ExternalIdentity, using -// STS GetCallerIdentity and IAM GetUser/GetRole to enrich attributes. -// Falls back to ARN-only identity when credentials are unavailable. -func (p *AWSIAMProvider) ResolveIdentities(ctx context.Context, cfgRaw json.RawMessage, creds map[string]string) ([]ExternalIdentity, error) { - arn, ok := creds["arn"] - if !ok || arn == "" { - return nil, fmt.Errorf("arn credential required") - } - - if !strings.HasPrefix(arn, "arn:aws:") { - return nil, fmt.Errorf("invalid AWS ARN format") - } - - var awsCfg AWSConfig - if err := json.Unmarshal(cfgRaw, &awsCfg); err != nil { - return nil, fmt.Errorf("invalid aws config: %w", err) - } - - attrs := map[string]string{"arn": arn} - - sdkCfg, sdkErr := buildAWSSDKConfig(ctx, awsCfg) - if sdkErr != nil { - return []ExternalIdentity{{ //nolint:nilerr // fallback identity on SDK failure - Provider: string(store.IAMProviderAWS), - Identifier: arn, - Attributes: attrs, - }}, nil - } - - // Verify caller identity via STS. - stsClient := sts.NewFromConfig(sdkCfg) - callerOut, err := stsClient.GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{}) - if err == nil { - if callerOut.Arn != nil { - attrs["caller_arn"] = aws.ToString(callerOut.Arn) - } - if callerOut.UserId != nil { - attrs["user_id"] = aws.ToString(callerOut.UserId) - } - if callerOut.Account != nil { - attrs["account"] = aws.ToString(callerOut.Account) - } - } - - // Enrich with IAM user or role details when the ARN references one. - iamClient := iamsdk.NewFromConfig(sdkCfg) - arnParts := strings.Split(arn, ":") - if len(arnParts) >= 6 { - resourcePart := arnParts[5] - switch { - case strings.HasPrefix(resourcePart, "user/"): - userName := strings.TrimPrefix(resourcePart, "user/") - userOut, uErr := iamClient.GetUser(ctx, &iamsdk.GetUserInput{ - UserName: aws.String(userName), - }) - if uErr == nil && userOut.User != nil { - attrs["name"] = aws.ToString(userOut.User.UserName) - attrs["type"] = "user" - if userOut.User.Arn != nil { - attrs["arn"] = aws.ToString(userOut.User.Arn) - } - } - case strings.HasPrefix(resourcePart, "role/"): - roleName := strings.TrimPrefix(resourcePart, "role/") - roleOut, rErr := iamClient.GetRole(ctx, &iamsdk.GetRoleInput{ - RoleName: aws.String(roleName), - }) - if rErr == nil && roleOut.Role != nil { - attrs["name"] = aws.ToString(roleOut.Role.RoleName) - attrs["type"] = "role" - if roleOut.Role.Arn != nil { - attrs["arn"] = aws.ToString(roleOut.Role.Arn) - } - } - } - } - - return []ExternalIdentity{{ - Provider: string(store.IAMProviderAWS), - Identifier: arn, - Attributes: attrs, - }}, nil -} - -// TestConnection calls sts:GetCallerIdentity to verify connectivity and credentials. -func (p *AWSIAMProvider) TestConnection(ctx context.Context, cfgRaw json.RawMessage) error { - if err := p.ValidateConfig(cfgRaw); err != nil { - return err - } - - var awsCfg AWSConfig - if err := json.Unmarshal(cfgRaw, &awsCfg); err != nil { - return fmt.Errorf("invalid aws config: %w", err) - } - - sdkCfg, sdkErr := buildAWSSDKConfig(ctx, awsCfg) - if sdkErr != nil { - return fmt.Errorf("aws iam: building SDK config: %w", sdkErr) - } - - stsClient := sts.NewFromConfig(sdkCfg) - out, err := stsClient.GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{}) - if err != nil { - return fmt.Errorf("aws iam: GetCallerIdentity failed: %w", err) - } - - if awsCfg.AccountID != "" && out.Account != nil && aws.ToString(out.Account) != awsCfg.AccountID { - return fmt.Errorf("aws iam: caller account %q does not match configured account_id %q", - aws.ToString(out.Account), awsCfg.AccountID) - } - - return nil -} - -// buildAWSSDKConfig builds an aws.Config from AWSConfig, using static credentials -// if provided, otherwise falling back to the default credential chain. -func buildAWSSDKConfig(ctx context.Context, c AWSConfig) (aws.Config, error) { - var opts []func(*awsconfig.LoadOptions) error - if c.Region != "" { - opts = append(opts, awsconfig.WithRegion(c.Region)) - } - if c.AccessKeyID != "" && c.SecretAccessKey != "" { - opts = append(opts, awsconfig.WithCredentialsProvider( - credentials.NewStaticCredentialsProvider(c.AccessKeyID, c.SecretAccessKey, c.SessionToken), - )) - } - return awsconfig.LoadDefaultConfig(ctx, opts...) -} diff --git a/iam/providers_test.go b/iam/providers_test.go index e93bacad..f0de570e 100644 --- a/iam/providers_test.go +++ b/iam/providers_test.go @@ -9,88 +9,7 @@ import ( "github.com/google/uuid" ) -// --- AWS Provider Tests --- - -func TestAWSProvider_Type(t *testing.T) { - p := &AWSIAMProvider{} - if p.Type() != store.IAMProviderAWS { - t.Errorf("expected %s, got %s", store.IAMProviderAWS, p.Type()) - } -} - -func TestAWSProvider_ValidateConfig_Valid(t *testing.T) { - p := &AWSIAMProvider{} - cfg := json.RawMessage(`{"account_id":"123456789012","region":"us-east-1"}`) - if err := p.ValidateConfig(cfg); err != nil { - t.Fatalf("expected valid config, got %v", err) - } -} - -func TestAWSProvider_ValidateConfig_MissingAccountID(t *testing.T) { - p := &AWSIAMProvider{} - cfg := json.RawMessage(`{"region":"us-east-1"}`) - if err := p.ValidateConfig(cfg); err == nil { - t.Fatal("expected error for missing account_id") - } -} - -func TestAWSProvider_ValidateConfig_InvalidJSON(t *testing.T) { - p := &AWSIAMProvider{} - cfg := json.RawMessage(`{invalid}`) - if err := p.ValidateConfig(cfg); err == nil { - t.Fatal("expected error for invalid JSON") - } -} - -func TestAWSProvider_ResolveIdentities_ValidARN(t *testing.T) { - p := &AWSIAMProvider{} - cfg := json.RawMessage(`{"account_id":"123456789012"}`) - creds := map[string]string{"arn": "arn:aws:iam::123456789012:role/MyRole"} - - ids, err := p.ResolveIdentities(context.Background(), cfg, creds) - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if len(ids) != 1 { - t.Fatalf("expected 1 identity, got %d", len(ids)) - } - if ids[0].Provider != string(store.IAMProviderAWS) { - t.Errorf("expected provider %s, got %s", store.IAMProviderAWS, ids[0].Provider) - } - if ids[0].Identifier != "arn:aws:iam::123456789012:role/MyRole" { - t.Errorf("unexpected identifier: %s", ids[0].Identifier) - } -} - -func TestAWSProvider_ResolveIdentities_MissingARN(t *testing.T) { - p := &AWSIAMProvider{} - cfg := json.RawMessage(`{"account_id":"123456789012"}`) - - _, err := p.ResolveIdentities(context.Background(), cfg, map[string]string{}) - if err == nil { - t.Fatal("expected error for missing ARN") - } -} - -func TestAWSProvider_ResolveIdentities_InvalidARN(t *testing.T) { - p := &AWSIAMProvider{} - cfg := json.RawMessage(`{"account_id":"123456789012"}`) - creds := map[string]string{"arn": "not-an-arn"} - - _, err := p.ResolveIdentities(context.Background(), cfg, creds) - if err == nil { - t.Fatal("expected error for invalid ARN format") - } -} - -func TestAWSProvider_TestConnection(t *testing.T) { - t.Skip("requires real AWS credentials") - p := &AWSIAMProvider{} - cfg := json.RawMessage(`{"account_id":"123456789012","region":"us-east-1"}`) - if err := p.TestConnection(context.Background(), cfg); err != nil { - t.Fatalf("expected no error, got %v", err) - } -} +// AWS IAM provider tests deleted with iam/aws.go (extracted to workflow-plugin-aws). // --- Kubernetes Provider Tests --- @@ -323,13 +242,13 @@ func TestIAMResolver_RegisterProvider(t *testing.T) { is := newMockIAMStore() resolver := NewIAMResolver(is) - resolver.RegisterProvider(&AWSIAMProvider{}) - p, ok := resolver.GetProvider(store.IAMProviderAWS) + resolver.RegisterProvider(&KubernetesProvider{}) + p, ok := resolver.GetProvider(store.IAMProviderKubernetes) if !ok { t.Fatal("expected provider to be registered") } - if p.Type() != store.IAMProviderAWS { - t.Errorf("expected %s, got %s", store.IAMProviderAWS, p.Type()) + if p.Type() != store.IAMProviderKubernetes { + t.Errorf("expected %s, got %s", store.IAMProviderKubernetes, p.Type()) } } diff --git a/plugin/rbac/aws.go b/plugin/rbac/aws.go deleted file mode 100644 index 25430334..00000000 --- a/plugin/rbac/aws.go +++ /dev/null @@ -1,369 +0,0 @@ -package rbac - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "net/url" - "strings" - - awsv2 "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/config" - "github.com/aws/aws-sdk-go-v2/service/iam" - iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types" - - "github.com/GoCodeAlone/workflow/auth" -) - -// IAMClient defines the AWS IAM operations used by AWSIAMProvider. -type IAMClient interface { - SimulatePrincipalPolicy(ctx context.Context, params *iam.SimulatePrincipalPolicyInput, optFns ...func(*iam.Options)) (*iam.SimulatePrincipalPolicyOutput, error) - ListAttachedRolePolicies(ctx context.Context, params *iam.ListAttachedRolePoliciesInput, optFns ...func(*iam.Options)) (*iam.ListAttachedRolePoliciesOutput, error) - ListAttachedUserPolicies(ctx context.Context, params *iam.ListAttachedUserPoliciesInput, optFns ...func(*iam.Options)) (*iam.ListAttachedUserPoliciesOutput, error) - GetPolicy(ctx context.Context, params *iam.GetPolicyInput, optFns ...func(*iam.Options)) (*iam.GetPolicyOutput, error) - GetPolicyVersion(ctx context.Context, params *iam.GetPolicyVersionInput, optFns ...func(*iam.Options)) (*iam.GetPolicyVersionOutput, error) - CreatePolicy(ctx context.Context, params *iam.CreatePolicyInput, optFns ...func(*iam.Options)) (*iam.CreatePolicyOutput, error) - CreatePolicyVersion(ctx context.Context, params *iam.CreatePolicyVersionInput, optFns ...func(*iam.Options)) (*iam.CreatePolicyVersionOutput, error) - AttachRolePolicy(ctx context.Context, params *iam.AttachRolePolicyInput, optFns ...func(*iam.Options)) (*iam.AttachRolePolicyOutput, error) -} - -// AWSIAMProvider implements PermissionProvider via AWS IAM policy simulation. -type AWSIAMProvider struct { - region string - roleARN string - client IAMClient - initErr error -} - -// NewAWSIAMProvider creates an AWSIAMProvider for the given region and role ARN. -// It loads the default AWS configuration for the region. Use -// NewAWSIAMProviderWithClient to inject a custom IAM client (e.g. in tests). -func NewAWSIAMProvider(region, roleARN string) *AWSIAMProvider { - cfg, err := config.LoadDefaultConfig(context.Background(), - config.WithRegion(region), - ) - if err != nil { - return &AWSIAMProvider{region: region, roleARN: roleARN, initErr: err} - } - return &AWSIAMProvider{ - region: region, - roleARN: roleARN, - client: iam.NewFromConfig(cfg), - } -} - -// NewAWSIAMProviderWithClient creates an AWSIAMProvider with an injectable IAM -// client, useful for testing. -func NewAWSIAMProviderWithClient(region, roleARN string, client IAMClient) *AWSIAMProvider { - return &AWSIAMProvider{region: region, roleARN: roleARN, client: client} -} - -// Name returns the provider identifier. -func (a *AWSIAMProvider) Name() string { return "aws-iam" } - -// CheckPermission evaluates whether the subject (IAM principal ARN) is allowed -// to perform action on resource by calling SimulatePrincipalPolicy. -func (a *AWSIAMProvider) CheckPermission(ctx context.Context, subject, resource, action string) (bool, error) { - if a.initErr != nil { - return false, a.initErr - } - // Map workflow resource:action to IAM action format. - iamAction := fmt.Sprintf("%s:%s", resource, action) - out, err := a.client.SimulatePrincipalPolicy(ctx, &iam.SimulatePrincipalPolicyInput{ - PolicySourceArn: awsv2.String(subject), - ActionNames: []string{iamAction}, - ResourceArns: []string{"*"}, - }) - if err != nil { - return false, fmt.Errorf("iam simulate principal policy: %w", err) - } - for i := range out.EvaluationResults { - if out.EvaluationResults[i].EvalDecision == iamtypes.PolicyEvaluationDecisionTypeAllowed { - return true, nil - } - } - return false, nil -} - -// iamPolicyStatement represents a single IAM policy statement. -type iamPolicyStatement struct { - Effect string `json:"Effect"` - Action json.RawMessage `json:"Action"` - Resource json.RawMessage `json:"Resource"` -} - -// iamPolicyDocument represents an IAM policy document. -type iamPolicyDocument struct { - Version string `json:"Version"` - Statement []iamPolicyStatement `json:"Statement"` -} - -// ListPermissions lists IAM permissions for the subject by inspecting attached -// policies. The subject must be a user ARN (containing ":user/") or a role ARN. -func (a *AWSIAMProvider) ListPermissions(ctx context.Context, subject string) ([]auth.Permission, error) { - if a.initErr != nil { - return nil, a.initErr - } - var attached []iamtypes.AttachedPolicy - if strings.Contains(subject, ":user/") { - userName := subject[strings.LastIndex(subject, ":user/")+len(":user/"):] - var marker *string - for { - out, err := a.client.ListAttachedUserPolicies(ctx, &iam.ListAttachedUserPoliciesInput{ - UserName: awsv2.String(userName), - Marker: marker, - }) - if err != nil { - return nil, fmt.Errorf("list attached user policies: %w", err) - } - attached = append(attached, out.AttachedPolicies...) - if !out.IsTruncated { - break - } - marker = out.Marker - } - } else { - roleName := roleNameFromARN(subject) - var marker *string - for { - out, err := a.client.ListAttachedRolePolicies(ctx, &iam.ListAttachedRolePoliciesInput{ - RoleName: awsv2.String(roleName), - Marker: marker, - }) - if err != nil { - return nil, fmt.Errorf("list attached role policies: %w", err) - } - attached = append(attached, out.AttachedPolicies...) - if !out.IsTruncated { - break - } - marker = out.Marker - } - } - - var perms []auth.Permission - for _, p := range attached { - if p.PolicyArn == nil { - continue - } - policyOut, err := a.client.GetPolicy(ctx, &iam.GetPolicyInput{ - PolicyArn: p.PolicyArn, - }) - if err != nil { - return nil, fmt.Errorf("get policy %s: %w", awsv2.ToString(p.PolicyArn), err) - } - if policyOut.Policy == nil || policyOut.Policy.DefaultVersionId == nil { - continue - } - versionOut, err := a.client.GetPolicyVersion(ctx, &iam.GetPolicyVersionInput{ - PolicyArn: p.PolicyArn, - VersionId: policyOut.Policy.DefaultVersionId, - }) - if err != nil { - return nil, fmt.Errorf("get policy version %s for policy %s: %w", - awsv2.ToString(policyOut.Policy.DefaultVersionId), awsv2.ToString(p.PolicyArn), err) - } - if versionOut.PolicyVersion == nil || versionOut.PolicyVersion.Document == nil { - continue - } - // Policy documents are URL-encoded per RFC 3986. - decoded, err := url.QueryUnescape(*versionOut.PolicyVersion.Document) - if err != nil { - return nil, fmt.Errorf("decode policy document for policy %s: %w", awsv2.ToString(p.PolicyArn), err) - } - parsed, err := parseIAMPolicyDocument(decoded) - if err != nil { - return nil, fmt.Errorf("parse policy document for policy %s: %w", awsv2.ToString(p.PolicyArn), err) - } - perms = append(perms, parsed...) - } - return perms, nil -} - -// SyncRoles creates or updates IAM managed policies for each RoleDefinition and -// attaches them to the configured IAM role. -func (a *AWSIAMProvider) SyncRoles(ctx context.Context, roles []auth.RoleDefinition) error { - if a.initErr != nil { - return a.initErr - } - accountID := accountIDFromARN(a.roleARN) - roleName := roleNameFromARN(a.roleARN) - - for _, rd := range roles { - doc, err := buildPolicyDocument(rd) - if err != nil { - return fmt.Errorf("build policy document for %q: %w", rd.Name, err) - } - policyName := "workflow-" + rd.Name - - var policyARN string - createOut, err := a.client.CreatePolicy(ctx, &iam.CreatePolicyInput{ - PolicyName: awsv2.String(policyName), - PolicyDocument: awsv2.String(doc), - Description: awsv2.String(rd.Description), - }) - if err != nil { - var entityExists *iamtypes.EntityAlreadyExistsException - if !errors.As(err, &entityExists) { - return fmt.Errorf("create policy %q: %w", policyName, err) - } - // Policy already exists: create a new default version. - // Require a valid account ID to build the policy ARN. - if !isValidAccountID(accountID) { - return fmt.Errorf("cannot update existing policy %q: unable to determine policy ARN from role ARN %q", policyName, a.roleARN) - } - policyARN = fmt.Sprintf("arn:aws:iam::%s:policy/%s", accountID, policyName) - if _, err := a.client.CreatePolicyVersion(ctx, &iam.CreatePolicyVersionInput{ - PolicyArn: awsv2.String(policyARN), - PolicyDocument: awsv2.String(doc), - SetAsDefault: true, - }); err != nil { - return fmt.Errorf("create policy version for %q: %w", policyName, err) - } - } else if createOut.Policy != nil && createOut.Policy.Arn != nil { - policyARN = *createOut.Policy.Arn - } - - if policyARN == "" { - continue - } - if _, err := a.client.AttachRolePolicy(ctx, &iam.AttachRolePolicyInput{ - RoleName: awsv2.String(roleName), - PolicyArn: awsv2.String(policyARN), - }); err != nil { - return fmt.Errorf("attach policy %q to role %q: %w", policyARN, roleName, err) - } - } - return nil -} - -// parseIAMPolicyDocument converts an IAM policy document JSON string into -// a slice of auth.Permission values. It supports Statement as either a JSON -// array or a single JSON object, both of which are valid per the IAM spec. -func parseIAMPolicyDocument(doc string) ([]auth.Permission, error) { - // Use a wrapper so we can detect the Statement form before unmarshalling. - var wrapper struct { - Statement json.RawMessage `json:"Statement"` - } - if err := json.Unmarshal([]byte(doc), &wrapper); err != nil { - return nil, err - } - if len(wrapper.Statement) == 0 { - return nil, nil - } - - var statements []iamPolicyStatement - switch wrapper.Statement[0] { - case '[': - if err := json.Unmarshal(wrapper.Statement, &statements); err != nil { - return nil, err - } - case '{': - var stmt iamPolicyStatement - if err := json.Unmarshal(wrapper.Statement, &stmt); err != nil { - return nil, err - } - statements = []iamPolicyStatement{stmt} - default: - return nil, fmt.Errorf("unexpected Statement format in IAM policy document") - } - - var perms []auth.Permission - for _, stmt := range statements { - effect := strings.ToLower(stmt.Effect) - actions := parseStringOrSlice(stmt.Action) - for _, act := range actions { - resource, action := splitIAMAction(act) - perms = append(perms, auth.Permission{ - Resource: resource, - Action: action, - Effect: effect, - }) - } - } - return perms, nil -} - -// parseStringOrSlice unmarshals a JSON field that may be a string or []string. -func parseStringOrSlice(raw json.RawMessage) []string { - if raw == nil { - return nil - } - var s string - if err := json.Unmarshal(raw, &s); err == nil { - return []string{s} - } - var ss []string - _ = json.Unmarshal(raw, &ss) - return ss -} - -// splitIAMAction splits an IAM action like "s3:GetObject" into ("s3", "GetObject"). -func splitIAMAction(action string) (resource, act string) { - if i := strings.IndexByte(action, ':'); i >= 0 { - return action[:i], action[i+1:] - } - return action, "" -} - -// roleNameFromARN extracts the role name from an ARN like -// "arn:aws:iam::123:role/my-role" → "my-role", or returns the input unchanged. -func roleNameFromARN(arn string) string { - if i := strings.Index(arn, ":role/"); i >= 0 { - return arn[i+len(":role/"):] - } - return arn -} - -// accountIDFromARN extracts the account ID from an ARN like -// "arn:aws:iam::123456789012:role/my-role" → "123456789012". -func accountIDFromARN(arn string) string { - parts := strings.Split(arn, ":") - if len(parts) >= 6 { - return parts[4] - } - return "" -} - -// isValidAccountID returns true if id is a valid 12-digit AWS account ID. -func isValidAccountID(id string) bool { - if len(id) != 12 { - return false - } - for _, c := range id { - if c < '0' || c > '9' { - return false - } - } - return true -} - -// buildPolicyDocument creates an IAM policy document JSON granting all -// permissions in the RoleDefinition. -func buildPolicyDocument(rd auth.RoleDefinition) (string, error) { - type statement struct { - Effect string `json:"Effect"` - Action []string `json:"Action"` - Resource string `json:"Resource"` - } - actions := make([]string, 0, len(rd.Permissions)) - for _, p := range rd.Permissions { - actions = append(actions, fmt.Sprintf("%s:%s", p.Resource, p.Action)) - } - doc := map[string]interface{}{ - "Version": "2012-10-17", - "Statement": []statement{ - { - Effect: "Allow", - Action: actions, - Resource: "*", - }, - }, - } - data, err := json.Marshal(doc) - if err != nil { - return "", err - } - return string(data), nil -} diff --git a/plugin/rbac/aws_test.go b/plugin/rbac/aws_test.go deleted file mode 100644 index 93882b00..00000000 --- a/plugin/rbac/aws_test.go +++ /dev/null @@ -1,646 +0,0 @@ -package rbac - -import ( - "context" - "encoding/json" - "fmt" - "net/url" - "testing" - - awsv2 "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/service/iam" - iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types" - - "github.com/GoCodeAlone/workflow/auth" -) - -// mockIAMClient is a test double for IAMClient. -type mockIAMClient struct { - simulateFunc func(ctx context.Context, params *iam.SimulatePrincipalPolicyInput, optFns ...func(*iam.Options)) (*iam.SimulatePrincipalPolicyOutput, error) - listAttachedRolesFunc func(ctx context.Context, params *iam.ListAttachedRolePoliciesInput, optFns ...func(*iam.Options)) (*iam.ListAttachedRolePoliciesOutput, error) - listAttachedUsersFunc func(ctx context.Context, params *iam.ListAttachedUserPoliciesInput, optFns ...func(*iam.Options)) (*iam.ListAttachedUserPoliciesOutput, error) - getPolicyFunc func(ctx context.Context, params *iam.GetPolicyInput, optFns ...func(*iam.Options)) (*iam.GetPolicyOutput, error) - getPolicyVersionFunc func(ctx context.Context, params *iam.GetPolicyVersionInput, optFns ...func(*iam.Options)) (*iam.GetPolicyVersionOutput, error) - createPolicyFunc func(ctx context.Context, params *iam.CreatePolicyInput, optFns ...func(*iam.Options)) (*iam.CreatePolicyOutput, error) - createPolicyVerFunc func(ctx context.Context, params *iam.CreatePolicyVersionInput, optFns ...func(*iam.Options)) (*iam.CreatePolicyVersionOutput, error) - attachRolePolicyFunc func(ctx context.Context, params *iam.AttachRolePolicyInput, optFns ...func(*iam.Options)) (*iam.AttachRolePolicyOutput, error) -} - -func (m *mockIAMClient) SimulatePrincipalPolicy(ctx context.Context, params *iam.SimulatePrincipalPolicyInput, optFns ...func(*iam.Options)) (*iam.SimulatePrincipalPolicyOutput, error) { - if m.simulateFunc != nil { - return m.simulateFunc(ctx, params, optFns...) - } - return &iam.SimulatePrincipalPolicyOutput{}, nil -} - -func (m *mockIAMClient) ListAttachedRolePolicies(ctx context.Context, params *iam.ListAttachedRolePoliciesInput, optFns ...func(*iam.Options)) (*iam.ListAttachedRolePoliciesOutput, error) { - if m.listAttachedRolesFunc != nil { - return m.listAttachedRolesFunc(ctx, params, optFns...) - } - return &iam.ListAttachedRolePoliciesOutput{}, nil -} - -func (m *mockIAMClient) ListAttachedUserPolicies(ctx context.Context, params *iam.ListAttachedUserPoliciesInput, optFns ...func(*iam.Options)) (*iam.ListAttachedUserPoliciesOutput, error) { - if m.listAttachedUsersFunc != nil { - return m.listAttachedUsersFunc(ctx, params, optFns...) - } - return &iam.ListAttachedUserPoliciesOutput{}, nil -} - -func (m *mockIAMClient) GetPolicy(ctx context.Context, params *iam.GetPolicyInput, optFns ...func(*iam.Options)) (*iam.GetPolicyOutput, error) { - if m.getPolicyFunc != nil { - return m.getPolicyFunc(ctx, params, optFns...) - } - return &iam.GetPolicyOutput{}, nil -} - -func (m *mockIAMClient) GetPolicyVersion(ctx context.Context, params *iam.GetPolicyVersionInput, optFns ...func(*iam.Options)) (*iam.GetPolicyVersionOutput, error) { - if m.getPolicyVersionFunc != nil { - return m.getPolicyVersionFunc(ctx, params, optFns...) - } - return &iam.GetPolicyVersionOutput{}, nil -} - -func (m *mockIAMClient) CreatePolicy(ctx context.Context, params *iam.CreatePolicyInput, optFns ...func(*iam.Options)) (*iam.CreatePolicyOutput, error) { - if m.createPolicyFunc != nil { - return m.createPolicyFunc(ctx, params, optFns...) - } - return &iam.CreatePolicyOutput{ - Policy: &iamtypes.Policy{ - Arn: awsv2.String("arn:aws:iam::123456789012:policy/" + *params.PolicyName), - PolicyName: params.PolicyName, - }, - }, nil -} - -func (m *mockIAMClient) CreatePolicyVersion(ctx context.Context, params *iam.CreatePolicyVersionInput, optFns ...func(*iam.Options)) (*iam.CreatePolicyVersionOutput, error) { - if m.createPolicyVerFunc != nil { - return m.createPolicyVerFunc(ctx, params, optFns...) - } - return &iam.CreatePolicyVersionOutput{}, nil -} - -func (m *mockIAMClient) AttachRolePolicy(ctx context.Context, params *iam.AttachRolePolicyInput, optFns ...func(*iam.Options)) (*iam.AttachRolePolicyOutput, error) { - if m.attachRolePolicyFunc != nil { - return m.attachRolePolicyFunc(ctx, params, optFns...) - } - return &iam.AttachRolePolicyOutput{}, nil -} - -// --- AWSIAMProvider.Name --- - -func TestAWSIAMProvider_Name(t *testing.T) { - p := NewAWSIAMProviderWithClient("us-east-1", "arn:aws:iam::123:role/test", &mockIAMClient{}) - if p.Name() != "aws-iam" { - t.Errorf("Name() = %q, want aws-iam", p.Name()) - } -} - -// --- AWSIAMProvider.CheckPermission --- - -func TestAWSIAMProvider_CheckPermission_Allowed(t *testing.T) { - mock := &mockIAMClient{ - simulateFunc: func(_ context.Context, params *iam.SimulatePrincipalPolicyInput, _ ...func(*iam.Options)) (*iam.SimulatePrincipalPolicyOutput, error) { - return &iam.SimulatePrincipalPolicyOutput{ - EvaluationResults: []iamtypes.EvaluationResult{ - { - EvalActionName: awsv2.String("workflows:execute"), - EvalDecision: iamtypes.PolicyEvaluationDecisionTypeAllowed, - }, - }, - }, nil - }, - } - p := NewAWSIAMProviderWithClient("us-east-1", "arn:aws:iam::123:role/test", mock) - allowed, err := p.CheckPermission(context.Background(), "arn:aws:iam::123:user/alice", "workflows", "execute") - if err != nil { - t.Fatalf("CheckPermission unexpected error: %v", err) - } - if !allowed { - t.Error("expected allowed=true when decision is 'allowed'") - } -} - -func TestAWSIAMProvider_CheckPermission_ImplicitDeny(t *testing.T) { - mock := &mockIAMClient{ - simulateFunc: func(_ context.Context, _ *iam.SimulatePrincipalPolicyInput, _ ...func(*iam.Options)) (*iam.SimulatePrincipalPolicyOutput, error) { - return &iam.SimulatePrincipalPolicyOutput{ - EvaluationResults: []iamtypes.EvaluationResult{ - { - EvalActionName: awsv2.String("workflows:execute"), - EvalDecision: iamtypes.PolicyEvaluationDecisionType("implicitDeny"), - }, - }, - }, nil - }, - } - p := NewAWSIAMProviderWithClient("us-east-1", "arn:aws:iam::123:role/test", mock) - allowed, err := p.CheckPermission(context.Background(), "arn:aws:iam::123:user/alice", "workflows", "execute") - if err != nil { - t.Fatalf("CheckPermission unexpected error: %v", err) - } - if allowed { - t.Error("expected allowed=false when decision is 'implicitDeny'") - } -} - -func TestAWSIAMProvider_CheckPermission_APIError(t *testing.T) { - mock := &mockIAMClient{ - simulateFunc: func(_ context.Context, _ *iam.SimulatePrincipalPolicyInput, _ ...func(*iam.Options)) (*iam.SimulatePrincipalPolicyOutput, error) { - return nil, fmt.Errorf("no credentials") - }, - } - p := NewAWSIAMProviderWithClient("us-east-1", "arn:aws:iam::123:role/test", mock) - allowed, err := p.CheckPermission(context.Background(), "arn:aws:iam::123:user/alice", "workflows", "execute") - if err == nil { - t.Fatal("expected error from API failure") - } - if allowed { - t.Error("expected allowed=false on API error") - } -} - -func TestAWSIAMProvider_CheckPermission_ActionFormat(t *testing.T) { - var capturedAction string - mock := &mockIAMClient{ - simulateFunc: func(_ context.Context, params *iam.SimulatePrincipalPolicyInput, _ ...func(*iam.Options)) (*iam.SimulatePrincipalPolicyOutput, error) { - if len(params.ActionNames) > 0 { - capturedAction = params.ActionNames[0] - } - return &iam.SimulatePrincipalPolicyOutput{}, nil - }, - } - p := NewAWSIAMProviderWithClient("us-east-1", "arn:aws:iam::123:role/test", mock) - _, _ = p.CheckPermission(context.Background(), "arn:aws:iam::123:user/alice", "workflows", "execute") - if capturedAction != "workflows:execute" { - t.Errorf("expected IAM action 'workflows:execute', got %q", capturedAction) - } -} - -// --- AWSIAMProvider.ListPermissions --- - -func urlEncodePolicy(doc interface{}) string { - data, _ := json.Marshal(doc) - return url.QueryEscape(string(data)) -} - -func TestAWSIAMProvider_ListPermissions_RoleARN(t *testing.T) { - policyDoc := map[string]interface{}{ - "Version": "2012-10-17", - "Statement": []map[string]interface{}{ - {"Effect": "Allow", "Action": []string{"workflows:read", "workflows:write"}, "Resource": "*"}, - }, - } - encoded := urlEncodePolicy(policyDoc) - - mock := &mockIAMClient{ - listAttachedRolesFunc: func(_ context.Context, params *iam.ListAttachedRolePoliciesInput, _ ...func(*iam.Options)) (*iam.ListAttachedRolePoliciesOutput, error) { - return &iam.ListAttachedRolePoliciesOutput{ - AttachedPolicies: []iamtypes.AttachedPolicy{ - {PolicyArn: awsv2.String("arn:aws:iam::123:policy/workflow-editor"), PolicyName: awsv2.String("workflow-editor")}, - }, - }, nil - }, - getPolicyFunc: func(_ context.Context, _ *iam.GetPolicyInput, _ ...func(*iam.Options)) (*iam.GetPolicyOutput, error) { - return &iam.GetPolicyOutput{ - Policy: &iamtypes.Policy{ - Arn: awsv2.String("arn:aws:iam::123:policy/workflow-editor"), - DefaultVersionId: awsv2.String("v1"), - }, - }, nil - }, - getPolicyVersionFunc: func(_ context.Context, _ *iam.GetPolicyVersionInput, _ ...func(*iam.Options)) (*iam.GetPolicyVersionOutput, error) { - return &iam.GetPolicyVersionOutput{ - PolicyVersion: &iamtypes.PolicyVersion{ - Document: awsv2.String(encoded), - VersionId: awsv2.String("v1"), - }, - }, nil - }, - } - p := NewAWSIAMProviderWithClient("us-east-1", "arn:aws:iam::123:role/my-role", mock) - perms, err := p.ListPermissions(context.Background(), "arn:aws:iam::123:role/my-role") - if err != nil { - t.Fatalf("ListPermissions unexpected error: %v", err) - } - if len(perms) != 2 { - t.Fatalf("expected 2 permissions, got %d: %v", len(perms), perms) - } - for _, perm := range perms { - if perm.Resource != "workflows" { - t.Errorf("expected resource 'workflows', got %q", perm.Resource) - } - if perm.Effect != "allow" { - t.Errorf("expected effect 'allow', got %q", perm.Effect) - } - } -} - -func TestAWSIAMProvider_ListPermissions_UserARN(t *testing.T) { - policyDoc := map[string]interface{}{ - "Version": "2012-10-17", - "Statement": []map[string]interface{}{ - {"Effect": "Allow", "Action": "s3:GetObject", "Resource": "*"}, - }, - } - encoded := urlEncodePolicy(policyDoc) - - var capturedUser string - mock := &mockIAMClient{ - listAttachedUsersFunc: func(_ context.Context, params *iam.ListAttachedUserPoliciesInput, _ ...func(*iam.Options)) (*iam.ListAttachedUserPoliciesOutput, error) { - capturedUser = *params.UserName - return &iam.ListAttachedUserPoliciesOutput{ - AttachedPolicies: []iamtypes.AttachedPolicy{ - {PolicyArn: awsv2.String("arn:aws:iam::123:policy/s3-read"), PolicyName: awsv2.String("s3-read")}, - }, - }, nil - }, - getPolicyFunc: func(_ context.Context, _ *iam.GetPolicyInput, _ ...func(*iam.Options)) (*iam.GetPolicyOutput, error) { - return &iam.GetPolicyOutput{ - Policy: &iamtypes.Policy{DefaultVersionId: awsv2.String("v1")}, - }, nil - }, - getPolicyVersionFunc: func(_ context.Context, _ *iam.GetPolicyVersionInput, _ ...func(*iam.Options)) (*iam.GetPolicyVersionOutput, error) { - return &iam.GetPolicyVersionOutput{ - PolicyVersion: &iamtypes.PolicyVersion{ - Document: awsv2.String(encoded), - }, - }, nil - }, - } - p := NewAWSIAMProviderWithClient("us-east-1", "arn:aws:iam::123:role/my-role", mock) - perms, err := p.ListPermissions(context.Background(), "arn:aws:iam::123:user/alice") - if err != nil { - t.Fatalf("ListPermissions unexpected error: %v", err) - } - if capturedUser != "alice" { - t.Errorf("expected username 'alice', got %q", capturedUser) - } - if len(perms) != 1 || perms[0].Action != "GetObject" || perms[0].Resource != "s3" { - t.Errorf("unexpected permissions: %v", perms) - } -} - -func TestAWSIAMProvider_ListPermissions_APIError(t *testing.T) { - mock := &mockIAMClient{ - listAttachedRolesFunc: func(_ context.Context, _ *iam.ListAttachedRolePoliciesInput, _ ...func(*iam.Options)) (*iam.ListAttachedRolePoliciesOutput, error) { - return nil, fmt.Errorf("access denied") - }, - } - p := NewAWSIAMProviderWithClient("us-east-1", "arn:aws:iam::123:role/my-role", mock) - _, err := p.ListPermissions(context.Background(), "arn:aws:iam::123:role/my-role") - if err == nil { - t.Fatal("expected error on API failure") - } -} - -func TestAWSIAMProvider_ListPermissions_ParseSingleActionString(t *testing.T) { - policyDoc := map[string]interface{}{ - "Version": "2012-10-17", - "Statement": []map[string]interface{}{ - {"Effect": "Deny", "Action": "iam:*", "Resource": "*"}, - }, - } - encoded := urlEncodePolicy(policyDoc) - mock := &mockIAMClient{ - listAttachedRolesFunc: func(_ context.Context, _ *iam.ListAttachedRolePoliciesInput, _ ...func(*iam.Options)) (*iam.ListAttachedRolePoliciesOutput, error) { - return &iam.ListAttachedRolePoliciesOutput{ - AttachedPolicies: []iamtypes.AttachedPolicy{ - {PolicyArn: awsv2.String("arn:aws:iam::123:policy/deny-iam")}, - }, - }, nil - }, - getPolicyFunc: func(_ context.Context, _ *iam.GetPolicyInput, _ ...func(*iam.Options)) (*iam.GetPolicyOutput, error) { - return &iam.GetPolicyOutput{Policy: &iamtypes.Policy{DefaultVersionId: awsv2.String("v1")}}, nil - }, - getPolicyVersionFunc: func(_ context.Context, _ *iam.GetPolicyVersionInput, _ ...func(*iam.Options)) (*iam.GetPolicyVersionOutput, error) { - return &iam.GetPolicyVersionOutput{PolicyVersion: &iamtypes.PolicyVersion{Document: awsv2.String(encoded)}}, nil - }, - } - p := NewAWSIAMProviderWithClient("us-east-1", "arn:aws:iam::123:role/my-role", mock) - perms, err := p.ListPermissions(context.Background(), "arn:aws:iam::123:role/my-role") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(perms) != 1 { - t.Fatalf("expected 1 permission, got %d", len(perms)) - } - if perms[0].Effect != "deny" || perms[0].Resource != "iam" || perms[0].Action != "*" { - t.Errorf("unexpected permission: %+v", perms[0]) - } -} - -// --- AWSIAMProvider.SyncRoles --- - -func TestAWSIAMProvider_SyncRoles_CreatesPoliciesAndAttaches(t *testing.T) { - var createdPolicy, attachedPolicy, attachedRole string - mock := &mockIAMClient{ - createPolicyFunc: func(_ context.Context, params *iam.CreatePolicyInput, _ ...func(*iam.Options)) (*iam.CreatePolicyOutput, error) { - createdPolicy = *params.PolicyName - return &iam.CreatePolicyOutput{ - Policy: &iamtypes.Policy{ - Arn: awsv2.String("arn:aws:iam::123456789012:policy/" + *params.PolicyName), - PolicyName: params.PolicyName, - }, - }, nil - }, - attachRolePolicyFunc: func(_ context.Context, params *iam.AttachRolePolicyInput, _ ...func(*iam.Options)) (*iam.AttachRolePolicyOutput, error) { - attachedPolicy = *params.PolicyArn - attachedRole = *params.RoleName - return &iam.AttachRolePolicyOutput{}, nil - }, - } - p := NewAWSIAMProviderWithClient("us-east-1", "arn:aws:iam::123456789012:role/my-role", mock) - - roles := []auth.RoleDefinition{ - { - Name: "editor", - Description: "Can edit workflows", - Permissions: []auth.Permission{ - {Resource: "workflows", Action: "read", Effect: "allow"}, - {Resource: "workflows", Action: "write", Effect: "allow"}, - }, - }, - } - if err := p.SyncRoles(context.Background(), roles); err != nil { - t.Fatalf("SyncRoles error: %v", err) - } - if createdPolicy != "workflow-editor" { - t.Errorf("expected policy name 'workflow-editor', got %q", createdPolicy) - } - if attachedRole != "my-role" { - t.Errorf("expected role 'my-role', got %q", attachedRole) - } - if attachedPolicy != "arn:aws:iam::123456789012:policy/workflow-editor" { - t.Errorf("unexpected attached policy ARN: %q", attachedPolicy) - } -} - -func TestAWSIAMProvider_SyncRoles_UpdatesExistingPolicy(t *testing.T) { - var updatedVersionARN string - var versionSetAsDefault bool - mock := &mockIAMClient{ - createPolicyFunc: func(_ context.Context, params *iam.CreatePolicyInput, _ ...func(*iam.Options)) (*iam.CreatePolicyOutput, error) { - return nil, &iamtypes.EntityAlreadyExistsException{ - Message: awsv2.String("policy already exists"), - } - }, - createPolicyVerFunc: func(_ context.Context, params *iam.CreatePolicyVersionInput, _ ...func(*iam.Options)) (*iam.CreatePolicyVersionOutput, error) { - updatedVersionARN = *params.PolicyArn - versionSetAsDefault = params.SetAsDefault - return &iam.CreatePolicyVersionOutput{}, nil - }, - attachRolePolicyFunc: func(_ context.Context, _ *iam.AttachRolePolicyInput, _ ...func(*iam.Options)) (*iam.AttachRolePolicyOutput, error) { - return &iam.AttachRolePolicyOutput{}, nil - }, - } - p := NewAWSIAMProviderWithClient("us-east-1", "arn:aws:iam::123456789012:role/my-role", mock) - - roles := []auth.RoleDefinition{ - { - Name: "editor", - Description: "Updated editor", - Permissions: []auth.Permission{ - {Resource: "workflows", Action: "read", Effect: "allow"}, - }, - }, - } - if err := p.SyncRoles(context.Background(), roles); err != nil { - t.Fatalf("SyncRoles error: %v", err) - } - if updatedVersionARN != "arn:aws:iam::123456789012:policy/workflow-editor" { - t.Errorf("unexpected policy ARN for version update: %q", updatedVersionARN) - } - if !versionSetAsDefault { - t.Error("expected SetAsDefault=true when updating policy version") - } -} - -func TestAWSIAMProvider_SyncRoles_EmptyRoles(t *testing.T) { - mock := &mockIAMClient{} - p := NewAWSIAMProviderWithClient("us-east-1", "arn:aws:iam::123:role/my-role", mock) - if err := p.SyncRoles(context.Background(), nil); err != nil { - t.Errorf("SyncRoles(nil) unexpected error: %v", err) - } - if err := p.SyncRoles(context.Background(), []auth.RoleDefinition{}); err != nil { - t.Errorf("SyncRoles([]) unexpected error: %v", err) - } -} - -func TestAWSIAMProvider_SyncRoles_CreatePolicyError(t *testing.T) { - mock := &mockIAMClient{ - createPolicyFunc: func(_ context.Context, _ *iam.CreatePolicyInput, _ ...func(*iam.Options)) (*iam.CreatePolicyOutput, error) { - return nil, fmt.Errorf("access denied") - }, - } - p := NewAWSIAMProviderWithClient("us-east-1", "arn:aws:iam::123:role/my-role", mock) - roles := []auth.RoleDefinition{{Name: "reader", Permissions: []auth.Permission{{Resource: "r", Action: "a"}}}} - if err := p.SyncRoles(context.Background(), roles); err == nil { - t.Fatal("expected error when CreatePolicy fails with non-exists error") - } -} - -// --- helper unit tests --- - -func TestParseIAMPolicyDocument(t *testing.T) { - doc := `{ - "Version": "2012-10-17", - "Statement": [ - {"Effect": "Allow", "Action": ["s3:GetObject", "s3:PutObject"], "Resource": "*"}, - {"Effect": "Deny", "Action": "iam:*", "Resource": "*"} - ] - }` - perms, err := parseIAMPolicyDocument(doc) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(perms) != 3 { - t.Fatalf("expected 3 permissions, got %d: %v", len(perms), perms) - } -} - -func TestBuildPolicyDocument(t *testing.T) { - rd := auth.RoleDefinition{ - Name: "test-role", - Permissions: []auth.Permission{ - {Resource: "workflows", Action: "read"}, - {Resource: "workflows", Action: "write"}, - }, - } - doc, err := buildPolicyDocument(rd) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - var pd iamPolicyDocument - if err := json.Unmarshal([]byte(doc), &pd); err != nil { - t.Fatalf("invalid JSON: %v", err) - } - if pd.Version != "2012-10-17" { - t.Errorf("expected version '2012-10-17', got %q", pd.Version) - } - if len(pd.Statement) != 1 { - t.Fatalf("expected 1 statement, got %d", len(pd.Statement)) - } - actions := parseStringOrSlice(pd.Statement[0].Action) - if len(actions) != 2 { - t.Errorf("expected 2 actions, got %d", len(actions)) - } -} - -func TestRoleNameFromARN(t *testing.T) { - tests := []struct { - input string - want string - }{ - {"arn:aws:iam::123:role/my-role", "my-role"}, - {"my-role", "my-role"}, - {"arn:aws:iam::123:user/alice", "arn:aws:iam::123:user/alice"}, - } - for _, tt := range tests { - got := roleNameFromARN(tt.input) - if got != tt.want { - t.Errorf("roleNameFromARN(%q) = %q, want %q", tt.input, got, tt.want) - } - } -} - -func TestAccountIDFromARN(t *testing.T) { - tests := []struct { - input string - want string - }{ - {"arn:aws:iam::123456789012:role/my-role", "123456789012"}, - {"arn:aws:iam::000000000000:user/alice", "000000000000"}, - {"not-an-arn", ""}, - // 5-part ARN (missing account ID) returns empty - {"arn:aws:iam::role/test", ""}, - } - for _, tt := range tests { - got := accountIDFromARN(tt.input) - if got != tt.want { - t.Errorf("accountIDFromARN(%q) = %q, want %q", tt.input, got, tt.want) - } - } -} - -func TestIsValidAccountID(t *testing.T) { - tests := []struct { - input string - want bool - }{ - {"123456789012", true}, - {"000000000000", true}, - {"12345678901", false}, // 11 digits - {"1234567890123", false}, // 13 digits - {"role/test", false}, - {"", false}, - {"12345678901a", false}, - } - for _, tt := range tests { - got := isValidAccountID(tt.input) - if got != tt.want { - t.Errorf("isValidAccountID(%q) = %v, want %v", tt.input, got, tt.want) - } - } -} - -func TestParseIAMPolicyDocument_SingleObjectStatement(t *testing.T) { - // IAM allows Statement as a single object (not an array). - doc := `{ - "Version": "2012-10-17", - "Statement": {"Effect": "Allow", "Action": "s3:GetObject", "Resource": "*"} - }` - perms, err := parseIAMPolicyDocument(doc) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(perms) != 1 { - t.Fatalf("expected 1 permission, got %d", len(perms)) - } - if perms[0].Resource != "s3" || perms[0].Action != "GetObject" || perms[0].Effect != "allow" { - t.Errorf("unexpected permission: %+v", perms[0]) - } -} - -func TestAWSIAMProvider_ListPermissions_Pagination(t *testing.T) { - callCount := 0 - mock := &mockIAMClient{ - listAttachedRolesFunc: func(_ context.Context, params *iam.ListAttachedRolePoliciesInput, _ ...func(*iam.Options)) (*iam.ListAttachedRolePoliciesOutput, error) { - callCount++ - if callCount == 1 { - return &iam.ListAttachedRolePoliciesOutput{ - AttachedPolicies: []iamtypes.AttachedPolicy{ - {PolicyArn: awsv2.String("arn:aws:iam::123:policy/pol-1")}, - }, - IsTruncated: true, - Marker: awsv2.String("next-marker"), - }, nil - } - return &iam.ListAttachedRolePoliciesOutput{ - AttachedPolicies: []iamtypes.AttachedPolicy{ - {PolicyArn: awsv2.String("arn:aws:iam::123:policy/pol-2")}, - }, - IsTruncated: false, - }, nil - }, - getPolicyFunc: func(_ context.Context, params *iam.GetPolicyInput, _ ...func(*iam.Options)) (*iam.GetPolicyOutput, error) { - return &iam.GetPolicyOutput{ - Policy: &iamtypes.Policy{DefaultVersionId: awsv2.String("v1")}, - }, nil - }, - getPolicyVersionFunc: func(_ context.Context, _ *iam.GetPolicyVersionInput, _ ...func(*iam.Options)) (*iam.GetPolicyVersionOutput, error) { - doc := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}` - return &iam.GetPolicyVersionOutput{ - PolicyVersion: &iamtypes.PolicyVersion{Document: awsv2.String(doc)}, - }, nil - }, - } - p := NewAWSIAMProviderWithClient("us-east-1", "arn:aws:iam::123:role/my-role", mock) - perms, err := p.ListPermissions(context.Background(), "arn:aws:iam::123:role/my-role") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if callCount != 2 { - t.Errorf("expected 2 calls to ListAttachedRolePolicies, got %d", callCount) - } - // Two policies, each with one permission. - if len(perms) != 2 { - t.Errorf("expected 2 permissions (one per page), got %d", len(perms)) - } -} - -func TestAWSIAMProvider_ListPermissions_GetPolicyError(t *testing.T) { - mock := &mockIAMClient{ - listAttachedRolesFunc: func(_ context.Context, _ *iam.ListAttachedRolePoliciesInput, _ ...func(*iam.Options)) (*iam.ListAttachedRolePoliciesOutput, error) { - return &iam.ListAttachedRolePoliciesOutput{ - AttachedPolicies: []iamtypes.AttachedPolicy{ - {PolicyArn: awsv2.String("arn:aws:iam::123:policy/p1")}, - }, - }, nil - }, - getPolicyFunc: func(_ context.Context, _ *iam.GetPolicyInput, _ ...func(*iam.Options)) (*iam.GetPolicyOutput, error) { - return nil, fmt.Errorf("get policy failed") - }, - } - p := NewAWSIAMProviderWithClient("us-east-1", "arn:aws:iam::123:role/my-role", mock) - _, err := p.ListPermissions(context.Background(), "arn:aws:iam::123:role/my-role") - if err == nil { - t.Fatal("expected error when GetPolicy fails") - } -} - -func TestAWSIAMProvider_SyncRoles_InvalidARNReturnsError(t *testing.T) { - mock := &mockIAMClient{ - createPolicyFunc: func(_ context.Context, _ *iam.CreatePolicyInput, _ ...func(*iam.Options)) (*iam.CreatePolicyOutput, error) { - return nil, &iamtypes.EntityAlreadyExistsException{ - Message: awsv2.String("policy already exists"), - } - }, - } - // Role ARN without a valid 12-digit account ID. - p := NewAWSIAMProviderWithClient("us-east-1", "arn:aws:iam::role/test", mock) - roles := []auth.RoleDefinition{{Name: "reader", Permissions: []auth.Permission{{Resource: "r", Action: "a"}}}} - if err := p.SyncRoles(context.Background(), roles); err == nil { - t.Fatal("expected error when role ARN is missing a valid account ID") - } -} diff --git a/plugin/rbac/builtin_test.go b/plugin/rbac/builtin_test.go index 507eeca9..500146f9 100644 --- a/plugin/rbac/builtin_test.go +++ b/plugin/rbac/builtin_test.go @@ -223,10 +223,3 @@ func TestBuiltinProvider_WithPermissionManager(t *testing.T) { t.Error("expected non-empty permissions for editor") } } - -func TestAWSIAMProvider_NameCheck(t *testing.T) { - aws := NewAWSIAMProviderWithClient("us-east-1", "arn:aws:iam::role/test", &mockIAMClient{}) - if aws.Name() != "aws-iam" { - t.Errorf("expected name 'aws-iam', got %q", aws.Name()) - } -} diff --git a/provider/aws/clients.go b/provider/aws/clients.go deleted file mode 100644 index 4ca5eac5..00000000 --- a/provider/aws/clients.go +++ /dev/null @@ -1,28 +0,0 @@ -package aws - -import ( - "context" - - "github.com/aws/aws-sdk-go-v2/service/cloudwatch" - "github.com/aws/aws-sdk-go-v2/service/ecs" - "github.com/aws/aws-sdk-go-v2/service/eks" -) - -// ECSClient defines the ECS operations used by AWSProvider. -type ECSClient interface { - RegisterTaskDefinition(ctx context.Context, params *ecs.RegisterTaskDefinitionInput, optFns ...func(*ecs.Options)) (*ecs.RegisterTaskDefinitionOutput, error) - UpdateService(ctx context.Context, params *ecs.UpdateServiceInput, optFns ...func(*ecs.Options)) (*ecs.UpdateServiceOutput, error) - DescribeServices(ctx context.Context, params *ecs.DescribeServicesInput, optFns ...func(*ecs.Options)) (*ecs.DescribeServicesOutput, error) - DescribeClusters(ctx context.Context, params *ecs.DescribeClustersInput, optFns ...func(*ecs.Options)) (*ecs.DescribeClustersOutput, error) - DescribeTaskDefinition(ctx context.Context, params *ecs.DescribeTaskDefinitionInput, optFns ...func(*ecs.Options)) (*ecs.DescribeTaskDefinitionOutput, error) -} - -// EKSClientIface defines the EKS operations used by AWSProvider. -type EKSClientIface interface { - DescribeCluster(ctx context.Context, params *eks.DescribeClusterInput, optFns ...func(*eks.Options)) (*eks.DescribeClusterOutput, error) -} - -// CloudWatchClient defines the CloudWatch operations used by AWSProvider. -type CloudWatchClient interface { - GetMetricData(ctx context.Context, params *cloudwatch.GetMetricDataInput, optFns ...func(*cloudwatch.Options)) (*cloudwatch.GetMetricDataOutput, error) -} diff --git a/provider/aws/deploy.go b/provider/aws/deploy.go deleted file mode 100644 index 03864120..00000000 --- a/provider/aws/deploy.go +++ /dev/null @@ -1,153 +0,0 @@ -package aws - -import ( - "context" - "fmt" - "time" - - awsv2 "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/service/ecs" - ecstypes "github.com/aws/aws-sdk-go-v2/service/ecs/types" - "github.com/aws/aws-sdk-go-v2/service/eks" - "github.com/google/uuid" - - "github.com/GoCodeAlone/workflow/provider" -) - -// deployECS handles deployment to AWS ECS (Fargate or EC2 launch type). -// It registers a new task definition with the provided image and updates the ECS service. -// The returned DeployID has the format "|". -func (p *AWSProvider) deployECS(ctx context.Context, req provider.DeployRequest) (*provider.DeployResult, error) { - client, err := p.ensureECSClient(ctx) - if err != nil { - return nil, err - } - - // Determine cluster and service name, preferring request config over provider config. - // Both are validated early to avoid registering a task definition only to fail later. - cluster := p.config.ECSCluster - if c, ok := req.Config["cluster"].(string); ok && c != "" { - cluster = c - } - if cluster == "" { - return nil, fmt.Errorf("aws: ECS cluster name is required (set ecs_cluster in config or pass cluster in request config)") - } - - service := p.config.Service - if s, ok := req.Config["service"].(string); ok && s != "" { - service = s - } - if service == "" { - return nil, fmt.Errorf("aws: ECS service name is required (set service in config or pass service in request config)") - } - - // Determine task family from config or service name. - family := service - if f, ok := req.Config["task_family"].(string); ok && f != "" { - family = f - } - - // Build container definition with Essential=true and optional environment variables. - containerDef := ecstypes.ContainerDefinition{ - Name: awsv2.String(family), - Image: awsv2.String(req.Image), - Essential: awsv2.Bool(true), - } - // Map req.Config["env_vars"] (map[string]any{"KEY": "value"}) to ECS key-value pairs. - if envVars, ok := req.Config["env_vars"].(map[string]any); ok { - for k, v := range envVars { - if sv, ok := v.(string); ok { - containerDef.Environment = append(containerDef.Environment, ecstypes.KeyValuePair{ - Name: awsv2.String(k), - Value: awsv2.String(sv), - }) - } - } - } - - // Build the task definition input. - taskInput := &ecs.RegisterTaskDefinitionInput{ - Family: awsv2.String(family), - ContainerDefinitions: []ecstypes.ContainerDefinition{containerDef}, - NetworkMode: ecstypes.NetworkModeAwsvpc, - RequiresCompatibilities: []ecstypes.Compatibility{ecstypes.CompatibilityFargate}, - Cpu: awsv2.String("256"), - Memory: awsv2.String("512"), - } - if cpu, ok := req.Config["cpu"].(string); ok && cpu != "" { - taskInput.Cpu = awsv2.String(cpu) - } - if mem, ok := req.Config["memory"].(string); ok && mem != "" { - taskInput.Memory = awsv2.String(mem) - } - // Tag the task definition with the deployment environment for traceability. - if req.Environment != "" { - taskInput.Tags = append(taskInput.Tags, ecstypes.Tag{ - Key: awsv2.String("environment"), - Value: awsv2.String(req.Environment), - }) - } - - taskOut, err := client.RegisterTaskDefinition(ctx, taskInput) - if err != nil { - return nil, fmt.Errorf("aws: register task definition: %w", err) - } - taskDefARN := awsv2.ToString(taskOut.TaskDefinition.TaskDefinitionArn) - - // Update the ECS service to use the new task definition. - svcOut, err := client.UpdateService(ctx, &ecs.UpdateServiceInput{ - Cluster: awsv2.String(cluster), - Service: awsv2.String(service), - TaskDefinition: awsv2.String(taskDefARN), - }) - if err != nil { - return nil, fmt.Errorf("aws: update service: %w", err) - } - - serviceARN := awsv2.ToString(svcOut.Service.ServiceArn) - return &provider.DeployResult{ - DeployID: serviceARN + "|" + taskDefARN, - Status: "in_progress", - Message: fmt.Sprintf("ECS deployment initiated: service=%s task_definition=%s", service, taskDefARN), - StartedAt: time.Now(), - }, nil -} - -// deployEKS handles deployment to AWS EKS (Elastic Kubernetes Service). -// It verifies the cluster is accessible via DescribeCluster and returns a pending -// deployment result. Full Kubernetes rollout requires kubectl or a k8s API client. -func (p *AWSProvider) deployEKS(ctx context.Context, req provider.DeployRequest) (*provider.DeployResult, error) { - client, err := p.ensureEKSClient(ctx) - if err != nil { - return nil, err - } - - clusterName := p.config.EKSCluster - if c, ok := req.Config["cluster"].(string); ok && c != "" { - clusterName = c - } - if clusterName == "" { - return nil, fmt.Errorf("aws: EKS cluster name is required (set eks_cluster in config or pass cluster in request config)") - } - - out, err := client.DescribeCluster(ctx, &eks.DescribeClusterInput{ - Name: awsv2.String(clusterName), - }) - if err != nil { - return nil, fmt.Errorf("aws: describe EKS cluster %s: %w", clusterName, err) - } - - namespace := "default" - if ns, ok := req.Config["namespace"].(string); ok && ns != "" { - namespace = ns - } - - // Use a UUID to guarantee a unique, unambiguous deploy ID regardless of the image name. - deployID := fmt.Sprintf("eks:%s:%s", clusterName, uuid.New().String()) - return &provider.DeployResult{ - DeployID: deployID, - Status: "pending", - Message: fmt.Sprintf("EKS cluster %s (endpoint: %s) is ready; deploy image %s to namespace %s via kubectl or your CD tool", clusterName, awsv2.ToString(out.Cluster.Endpoint), req.Image, namespace), - StartedAt: time.Now(), - }, nil -} diff --git a/provider/aws/deploy_test.go b/provider/aws/deploy_test.go deleted file mode 100644 index 2454ddc7..00000000 --- a/provider/aws/deploy_test.go +++ /dev/null @@ -1,462 +0,0 @@ -package aws - -import ( - "context" - "testing" - - awsv2 "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/service/ecs" - ecstypes "github.com/aws/aws-sdk-go-v2/service/ecs/types" - "github.com/aws/aws-sdk-go-v2/service/eks" - ekstypes "github.com/aws/aws-sdk-go-v2/service/eks/types" - - "github.com/GoCodeAlone/workflow/provider" -) - -// --------------------------------------------------------------------------- -// Mock ECS client -// --------------------------------------------------------------------------- - -type mockECSClient struct { - registerTaskDefFunc func(ctx context.Context, params *ecs.RegisterTaskDefinitionInput, optFns ...func(*ecs.Options)) (*ecs.RegisterTaskDefinitionOutput, error) - updateServiceFunc func(ctx context.Context, params *ecs.UpdateServiceInput, optFns ...func(*ecs.Options)) (*ecs.UpdateServiceOutput, error) - describeServicesFunc func(ctx context.Context, params *ecs.DescribeServicesInput, optFns ...func(*ecs.Options)) (*ecs.DescribeServicesOutput, error) - describeClustersFunc func(ctx context.Context, params *ecs.DescribeClustersInput, optFns ...func(*ecs.Options)) (*ecs.DescribeClustersOutput, error) - describeTaskDefFunc func(ctx context.Context, params *ecs.DescribeTaskDefinitionInput, optFns ...func(*ecs.Options)) (*ecs.DescribeTaskDefinitionOutput, error) -} - -func (m *mockECSClient) RegisterTaskDefinition(ctx context.Context, params *ecs.RegisterTaskDefinitionInput, optFns ...func(*ecs.Options)) (*ecs.RegisterTaskDefinitionOutput, error) { - if m.registerTaskDefFunc != nil { - return m.registerTaskDefFunc(ctx, params, optFns...) - } - return &ecs.RegisterTaskDefinitionOutput{ - TaskDefinition: &ecstypes.TaskDefinition{ - TaskDefinitionArn: awsv2.String("arn:aws:ecs:us-east-1:123456789012:task-definition/my-task:5"), - Family: awsv2.String("my-task"), - Revision: 5, - }, - }, nil -} - -func (m *mockECSClient) UpdateService(ctx context.Context, params *ecs.UpdateServiceInput, optFns ...func(*ecs.Options)) (*ecs.UpdateServiceOutput, error) { - if m.updateServiceFunc != nil { - return m.updateServiceFunc(ctx, params, optFns...) - } - return &ecs.UpdateServiceOutput{ - Service: &ecstypes.Service{ - ServiceArn: awsv2.String("arn:aws:ecs:us-east-1:123456789012:service/my-cluster/my-service"), - }, - }, nil -} - -func (m *mockECSClient) DescribeServices(ctx context.Context, params *ecs.DescribeServicesInput, optFns ...func(*ecs.Options)) (*ecs.DescribeServicesOutput, error) { - if m.describeServicesFunc != nil { - return m.describeServicesFunc(ctx, params, optFns...) - } - return &ecs.DescribeServicesOutput{}, nil -} - -func (m *mockECSClient) DescribeClusters(ctx context.Context, params *ecs.DescribeClustersInput, optFns ...func(*ecs.Options)) (*ecs.DescribeClustersOutput, error) { - if m.describeClustersFunc != nil { - return m.describeClustersFunc(ctx, params, optFns...) - } - return &ecs.DescribeClustersOutput{}, nil -} - -func (m *mockECSClient) DescribeTaskDefinition(ctx context.Context, params *ecs.DescribeTaskDefinitionInput, optFns ...func(*ecs.Options)) (*ecs.DescribeTaskDefinitionOutput, error) { - if m.describeTaskDefFunc != nil { - return m.describeTaskDefFunc(ctx, params, optFns...) - } - return &ecs.DescribeTaskDefinitionOutput{ - TaskDefinition: &ecstypes.TaskDefinition{ - Family: awsv2.String("my-task"), - Revision: 5, - }, - }, nil -} - -// --------------------------------------------------------------------------- -// Mock EKS client -// --------------------------------------------------------------------------- - -type mockEKSClient struct { - describeClusterFunc func(ctx context.Context, params *eks.DescribeClusterInput, optFns ...func(*eks.Options)) (*eks.DescribeClusterOutput, error) -} - -func (m *mockEKSClient) DescribeCluster(ctx context.Context, params *eks.DescribeClusterInput, optFns ...func(*eks.Options)) (*eks.DescribeClusterOutput, error) { - if m.describeClusterFunc != nil { - return m.describeClusterFunc(ctx, params, optFns...) - } - return &eks.DescribeClusterOutput{ - Cluster: &ekstypes.Cluster{ - Name: awsv2.String("my-eks-cluster"), - Endpoint: awsv2.String("https://eks.example.com"), - }, - }, nil -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -func TestDeployECS_Success(t *testing.T) { - cfg := AWSConfig{ECSCluster: "my-cluster", Service: "my-service"} - ecsClient := &mockECSClient{} - p := NewAWSProviderWithClients(cfg, ecsClient, nil, nil) - - req := provider.DeployRequest{ - Image: "myrepo/myapp:latest", - Environment: "production", - Config: map[string]any{}, - } - result, err := p.deployECS(context.Background(), req) - if err != nil { - t.Fatalf("expected no error, got: %v", err) - } - if result == nil { - t.Fatal("expected non-nil result") - } - if result.Status != "in_progress" { - t.Errorf("expected status=in_progress, got %q", result.Status) - } - if result.DeployID == "" { - t.Error("expected non-empty DeployID") - } -} - -func TestDeployECS_RegistersTaskDefinitionWithImage(t *testing.T) { - var capturedImage string - cfg := AWSConfig{ECSCluster: "my-cluster", Service: "my-service"} - ecsClient := &mockECSClient{ - registerTaskDefFunc: func(_ context.Context, params *ecs.RegisterTaskDefinitionInput, _ ...func(*ecs.Options)) (*ecs.RegisterTaskDefinitionOutput, error) { - if len(params.ContainerDefinitions) > 0 { - capturedImage = awsv2.ToString(params.ContainerDefinitions[0].Image) - } - return &ecs.RegisterTaskDefinitionOutput{ - TaskDefinition: &ecstypes.TaskDefinition{ - TaskDefinitionArn: awsv2.String("arn:aws:ecs:us-east-1:123456789012:task-definition/my-service:3"), - Family: awsv2.String("my-service"), - Revision: 3, - }, - }, nil - }, - } - p := NewAWSProviderWithClients(cfg, ecsClient, nil, nil) - - req := provider.DeployRequest{ - Image: "myrepo/myapp:v2", - Config: map[string]any{}, - } - _, err := p.deployECS(context.Background(), req) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if capturedImage != "myrepo/myapp:v2" { - t.Errorf("expected image %q, got %q", "myrepo/myapp:v2", capturedImage) - } -} - -func TestDeployECS_UpdatesService(t *testing.T) { - var capturedTaskDef string - cfg := AWSConfig{ECSCluster: "my-cluster", Service: "my-service"} - ecsClient := &mockECSClient{ - updateServiceFunc: func(_ context.Context, params *ecs.UpdateServiceInput, _ ...func(*ecs.Options)) (*ecs.UpdateServiceOutput, error) { - capturedTaskDef = awsv2.ToString(params.TaskDefinition) - return &ecs.UpdateServiceOutput{ - Service: &ecstypes.Service{ - ServiceArn: awsv2.String("arn:aws:ecs:us-east-1:123456789012:service/my-cluster/my-service"), - }, - }, nil - }, - } - p := NewAWSProviderWithClients(cfg, ecsClient, nil, nil) - - req := provider.DeployRequest{ - Image: "myrepo/myapp:v2", - Config: map[string]any{}, - } - _, err := p.deployECS(context.Background(), req) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if capturedTaskDef == "" { - t.Error("expected UpdateService to be called with a task definition ARN") - } -} - -func TestDeployECS_DeployIDContainsARNs(t *testing.T) { - const serviceARN = "arn:aws:ecs:us-east-1:123456789012:service/my-cluster/my-service" - const taskDefARN = "arn:aws:ecs:us-east-1:123456789012:task-definition/my-service:7" - - ecsClient := &mockECSClient{ - registerTaskDefFunc: func(_ context.Context, _ *ecs.RegisterTaskDefinitionInput, _ ...func(*ecs.Options)) (*ecs.RegisterTaskDefinitionOutput, error) { - return &ecs.RegisterTaskDefinitionOutput{ - TaskDefinition: &ecstypes.TaskDefinition{ - TaskDefinitionArn: awsv2.String(taskDefARN), - Family: awsv2.String("my-service"), - Revision: 7, - }, - }, nil - }, - updateServiceFunc: func(_ context.Context, _ *ecs.UpdateServiceInput, _ ...func(*ecs.Options)) (*ecs.UpdateServiceOutput, error) { - return &ecs.UpdateServiceOutput{ - Service: &ecstypes.Service{ServiceArn: awsv2.String(serviceARN)}, - }, nil - }, - } - cfg := AWSConfig{ECSCluster: "my-cluster", Service: "my-service"} - p := NewAWSProviderWithClients(cfg, ecsClient, nil, nil) - - result, err := p.deployECS(context.Background(), provider.DeployRequest{ - Image: "img:latest", - Config: map[string]any{}, - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - expectedID := serviceARN + "|" + taskDefARN - if result.DeployID != expectedID { - t.Errorf("DeployID mismatch\ngot: %q\nwant: %q", result.DeployID, expectedID) - } -} - -func TestDeployECS_MissingCluster(t *testing.T) { - cfg := AWSConfig{Service: "my-service"} // no ECSCluster - ecsClient := &mockECSClient{} - p := NewAWSProviderWithClients(cfg, ecsClient, nil, nil) - - _, err := p.deployECS(context.Background(), provider.DeployRequest{ - Image: "img:latest", - Config: map[string]any{}, // no cluster override either - }) - if err == nil { - t.Fatal("expected an error when cluster name is missing") - } -} - -func TestDeployECS_MissingService(t *testing.T) { - cfg := AWSConfig{ECSCluster: "my-cluster"} // no Service - ecsClient := &mockECSClient{} - p := NewAWSProviderWithClients(cfg, ecsClient, nil, nil) - - _, err := p.deployECS(context.Background(), provider.DeployRequest{ - Image: "img:latest", - Config: map[string]any{}, // no service override either - }) - if err == nil { - t.Fatal("expected an error when service name is missing") - } -} - -func TestDeployECS_SetsContainerEssential(t *testing.T) { - var capturedEssential *bool - cfg := AWSConfig{ECSCluster: "my-cluster", Service: "my-service"} - ecsClient := &mockECSClient{ - registerTaskDefFunc: func(_ context.Context, params *ecs.RegisterTaskDefinitionInput, _ ...func(*ecs.Options)) (*ecs.RegisterTaskDefinitionOutput, error) { - if len(params.ContainerDefinitions) > 0 { - capturedEssential = params.ContainerDefinitions[0].Essential - } - return &ecs.RegisterTaskDefinitionOutput{ - TaskDefinition: &ecstypes.TaskDefinition{ - TaskDefinitionArn: awsv2.String("arn:aws:ecs:us-east-1:123456789012:task-definition/my-service:1"), - Family: awsv2.String("my-service"), - Revision: 1, - }, - }, nil - }, - } - p := NewAWSProviderWithClients(cfg, ecsClient, nil, nil) - - _, err := p.deployECS(context.Background(), provider.DeployRequest{ - Image: "img:latest", - Config: map[string]any{}, - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if capturedEssential == nil || !*capturedEssential { - t.Error("expected Essential=true on the container definition") - } -} - -func TestDeployECS_SetsEnvironmentTag(t *testing.T) { - var capturedTags []ecstypes.Tag - cfg := AWSConfig{ECSCluster: "my-cluster", Service: "my-service"} - ecsClient := &mockECSClient{ - registerTaskDefFunc: func(_ context.Context, params *ecs.RegisterTaskDefinitionInput, _ ...func(*ecs.Options)) (*ecs.RegisterTaskDefinitionOutput, error) { - capturedTags = params.Tags - return &ecs.RegisterTaskDefinitionOutput{ - TaskDefinition: &ecstypes.TaskDefinition{ - TaskDefinitionArn: awsv2.String("arn:aws:ecs:us-east-1:123456789012:task-definition/my-service:1"), - Family: awsv2.String("my-service"), - Revision: 1, - }, - }, nil - }, - } - p := NewAWSProviderWithClients(cfg, ecsClient, nil, nil) - - _, err := p.deployECS(context.Background(), provider.DeployRequest{ - Image: "img:latest", - Environment: "production", - Config: map[string]any{}, - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - found := false - for _, tag := range capturedTags { - if awsv2.ToString(tag.Key) == "environment" && awsv2.ToString(tag.Value) == "production" { - found = true - } - } - if !found { - t.Errorf("expected environment=production tag, got: %v", capturedTags) - } -} - -func TestDeployECS_SetsEnvVarsFromConfig(t *testing.T) { - var capturedEnv []ecstypes.KeyValuePair - cfg := AWSConfig{ECSCluster: "my-cluster", Service: "my-service"} - ecsClient := &mockECSClient{ - registerTaskDefFunc: func(_ context.Context, params *ecs.RegisterTaskDefinitionInput, _ ...func(*ecs.Options)) (*ecs.RegisterTaskDefinitionOutput, error) { - if len(params.ContainerDefinitions) > 0 { - capturedEnv = params.ContainerDefinitions[0].Environment - } - return &ecs.RegisterTaskDefinitionOutput{ - TaskDefinition: &ecstypes.TaskDefinition{ - TaskDefinitionArn: awsv2.String("arn:aws:ecs:us-east-1:123456789012:task-definition/my-service:1"), - Family: awsv2.String("my-service"), - Revision: 1, - }, - }, nil - }, - } - p := NewAWSProviderWithClients(cfg, ecsClient, nil, nil) - - _, err := p.deployECS(context.Background(), provider.DeployRequest{ - Image: "img:latest", - Config: map[string]any{ - "env_vars": map[string]any{ - "APP_ENV": "prod", - }, - }, - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - found := false - for _, kv := range capturedEnv { - if awsv2.ToString(kv.Name) == "APP_ENV" && awsv2.ToString(kv.Value) == "prod" { - found = true - } - } - if !found { - t.Errorf("expected APP_ENV=prod env var, got: %v", capturedEnv) - } -} - -func TestDeployEKS_DeployIDIsUnique(t *testing.T) { - cfg := AWSConfig{EKSCluster: "my-eks-cluster"} - eksClient := &mockEKSClient{} - p := NewAWSProviderWithClients(cfg, nil, eksClient, nil) - - req := provider.DeployRequest{ - Image: "myrepo/myapp:v1", - Config: map[string]any{}, - } - result1, err := p.deployEKS(context.Background(), req) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - result2, err := p.deployEKS(context.Background(), req) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if result1.DeployID == result2.DeployID { - t.Error("expected unique deploy IDs for distinct EKS deployments of the same image") - } -} -func TestDeployEKS_Success(t *testing.T) { - cfg := AWSConfig{EKSCluster: "my-eks-cluster"} - eksClient := &mockEKSClient{} - p := NewAWSProviderWithClients(cfg, nil, eksClient, nil) - - req := provider.DeployRequest{ - Image: "myrepo/myapp:v1", - Config: map[string]any{}, - } - result, err := p.deployEKS(context.Background(), req) - if err != nil { - t.Fatalf("expected no error, got: %v", err) - } - if result.Status != "pending" { - t.Errorf("expected status=pending, got %q", result.Status) - } - if result.DeployID == "" { - t.Error("expected non-empty DeployID") - } -} -func TestDeployEKS_MissingCluster(t *testing.T) { - cfg := AWSConfig{} // no EKSCluster set - eksClient := &mockEKSClient{} - p := NewAWSProviderWithClients(cfg, nil, eksClient, nil) - - _, err := p.deployEKS(context.Background(), provider.DeployRequest{ - Image: "img:latest", - Config: map[string]any{}, - }) - if err == nil { - t.Fatal("expected an error when cluster name is missing") - } -} - -func TestDeployEKS_ClusterFromRequestConfig(t *testing.T) { - var capturedCluster string - cfg := AWSConfig{} - eksClient := &mockEKSClient{ - describeClusterFunc: func(_ context.Context, params *eks.DescribeClusterInput, _ ...func(*eks.Options)) (*eks.DescribeClusterOutput, error) { - capturedCluster = awsv2.ToString(params.Name) - return &eks.DescribeClusterOutput{ - Cluster: &ekstypes.Cluster{ - Name: params.Name, - Endpoint: awsv2.String("https://eks.example.com"), - }, - }, nil - }, - } - p := NewAWSProviderWithClients(cfg, nil, eksClient, nil) - - req := provider.DeployRequest{ - Image: "img:latest", - Config: map[string]any{"cluster": "override-cluster"}, - } - _, err := p.deployEKS(context.Background(), req) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if capturedCluster != "override-cluster" { - t.Errorf("expected cluster %q, got %q", "override-cluster", capturedCluster) - } -} - -func TestDeployDispatch_EKS(t *testing.T) { - cfg := AWSConfig{EKSCluster: "my-eks"} - eksClient := &mockEKSClient{} - p := NewAWSProviderWithClients(cfg, nil, eksClient, nil) - - req := provider.DeployRequest{ - Image: "img:latest", - Config: map[string]any{"service_type": "eks"}, - } - result, err := p.Deploy(context.Background(), req) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if result.Status != "pending" { - t.Errorf("expected EKS status=pending, got %q", result.Status) - } -} diff --git a/provider/aws/plugin.go b/provider/aws/plugin.go deleted file mode 100644 index a3bdeab1..00000000 --- a/provider/aws/plugin.go +++ /dev/null @@ -1,530 +0,0 @@ -package aws - -import ( - "context" - "database/sql" - "fmt" - "net/http" - "strings" - "sync" - "time" - - awsv2 "github.com/aws/aws-sdk-go-v2/aws" - awscfg "github.com/aws/aws-sdk-go-v2/config" - "github.com/aws/aws-sdk-go-v2/credentials" - "github.com/aws/aws-sdk-go-v2/credentials/stscreds" - "github.com/aws/aws-sdk-go-v2/service/cloudwatch" - cwtypes "github.com/aws/aws-sdk-go-v2/service/cloudwatch/types" - "github.com/aws/aws-sdk-go-v2/service/ecs" - ecstypes "github.com/aws/aws-sdk-go-v2/service/ecs/types" - "github.com/aws/aws-sdk-go-v2/service/eks" - "github.com/aws/aws-sdk-go-v2/service/sts" - - "github.com/GoCodeAlone/workflow/plugin" - "github.com/GoCodeAlone/workflow/provider" -) - -func init() { - plugin.RegisterNativePluginFactory(func(_ *sql.DB, _ map[string]any) plugin.NativePlugin { - return NewAWSProvider(AWSConfig{}) - }) -} - -// AWSConfig holds configuration for the AWS cloud provider. -type AWSConfig struct { - Region string `json:"region" yaml:"region"` - AccessKeyID string `json:"access_key_id" yaml:"access_key_id"` - SecretAccessKey string `json:"secret_access_key" yaml:"secret_access_key"` - RoleARN string `json:"role_arn" yaml:"role_arn"` - ECSCluster string `json:"ecs_cluster" yaml:"ecs_cluster"` - EKSCluster string `json:"eks_cluster" yaml:"eks_cluster"` - Service string `json:"service" yaml:"service"` -} - -// AWSProvider implements CloudProvider for Amazon Web Services. -type AWSProvider struct { - config AWSConfig - mu sync.Mutex // guards lazy client initialisation - ecsClient ECSClient - eksClient EKSClientIface - cwClient CloudWatchClient -} - -// Compile-time interface check. -var _ provider.CloudProvider = (*AWSProvider)(nil) - -// NewAWSProvider creates a new AWSProvider with the given configuration. -// AWS SDK clients are initialized lazily from the config on first use. -func NewAWSProvider(config AWSConfig) *AWSProvider { - return &AWSProvider{config: config} -} - -// NewAWSProviderWithClients creates an AWSProvider with pre-built clients (useful for testing). -func NewAWSProviderWithClients(config AWSConfig, ecsClient ECSClient, eksClient EKSClientIface, cwClient CloudWatchClient) *AWSProvider { - return &AWSProvider{ - config: config, - ecsClient: ecsClient, - eksClient: eksClient, - cwClient: cwClient, - } -} - -// buildAWSConfig builds an AWS SDK config from the provider configuration. -// If RoleARN is set, assumes the role via STS for cross-account access. -func (p *AWSProvider) buildAWSConfig(ctx context.Context) (awsv2.Config, error) { - opts := []func(*awscfg.LoadOptions) error{ - awscfg.WithRegion(p.config.Region), - } - if p.config.AccessKeyID != "" && p.config.SecretAccessKey != "" { - opts = append(opts, awscfg.WithCredentialsProvider( - credentials.NewStaticCredentialsProvider(p.config.AccessKeyID, p.config.SecretAccessKey, ""), - )) - } - cfg, err := awscfg.LoadDefaultConfig(ctx, opts...) - if err != nil { - return cfg, err - } - - if p.config.RoleARN != "" { - stsClient := sts.NewFromConfig(cfg) - provider := stscreds.NewAssumeRoleProvider(stsClient, p.config.RoleARN) - cfg.Credentials = awsv2.NewCredentialsCache(provider) - } - - return cfg, nil -} - -// ensureECSClient lazily initializes the ECS client if not already set. -// It is safe to call concurrently. -func (p *AWSProvider) ensureECSClient(ctx context.Context) (ECSClient, error) { - p.mu.Lock() - defer p.mu.Unlock() - if p.ecsClient != nil { - return p.ecsClient, nil - } - cfg, err := p.buildAWSConfig(ctx) - if err != nil { - return nil, fmt.Errorf("aws: build config: %w", err) - } - p.ecsClient = ecs.NewFromConfig(cfg) - return p.ecsClient, nil -} - -// ensureEKSClient lazily initializes the EKS client if not already set. -// It is safe to call concurrently. -func (p *AWSProvider) ensureEKSClient(ctx context.Context) (EKSClientIface, error) { - p.mu.Lock() - defer p.mu.Unlock() - if p.eksClient != nil { - return p.eksClient, nil - } - cfg, err := p.buildAWSConfig(ctx) - if err != nil { - return nil, fmt.Errorf("aws: build config: %w", err) - } - p.eksClient = eks.NewFromConfig(cfg) - return p.eksClient, nil -} - -// ensureCWClient lazily initializes the CloudWatch client if not already set. -// It is safe to call concurrently. -func (p *AWSProvider) ensureCWClient(ctx context.Context) (CloudWatchClient, error) { - p.mu.Lock() - defer p.mu.Unlock() - if p.cwClient != nil { - return p.cwClient, nil - } - cfg, err := p.buildAWSConfig(ctx) - if err != nil { - return nil, fmt.Errorf("aws: build config: %w", err) - } - p.cwClient = cloudwatch.NewFromConfig(cfg) - return p.cwClient, nil -} - -func (p *AWSProvider) Name() string { return "aws" } -func (p *AWSProvider) Version() string { return "1.0.0" } -func (p *AWSProvider) Description() string { return "AWS Cloud Provider (EC2, ECS, EKS, ECR)" } - -func (p *AWSProvider) UIPages() []plugin.UIPageDef { - return []plugin.UIPageDef{ - { - ID: "aws-settings", - Label: "AWS Settings", - Icon: "cloud", - Category: "cloud-providers", - }, - } -} - -func (p *AWSProvider) Dependencies() []plugin.PluginDependency { return nil } -func (p *AWSProvider) OnEnable(_ plugin.PluginContext) error { return nil } -func (p *AWSProvider) OnDisable(_ plugin.PluginContext) error { return nil } - -func (p *AWSProvider) RegisterRoutes(mux *http.ServeMux) { - mux.HandleFunc("/api/v1/providers/aws/status", p.handleStatus) - mux.HandleFunc("/api/v1/providers/aws/regions", p.handleListRegions) -} - -func (p *AWSProvider) handleStatus(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.Write([]byte(`{"provider":"aws","status":"available","version":"1.0.0"}`)) -} - -func (p *AWSProvider) handleListRegions(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.Write([]byte(`{"regions":["us-east-1","us-west-2","eu-west-1","ap-southeast-1"]}`)) -} - -func (p *AWSProvider) Deploy(ctx context.Context, req provider.DeployRequest) (*provider.DeployResult, error) { - serviceType, _ := req.Config["service_type"].(string) - switch serviceType { - case "eks": - return p.deployEKS(ctx, req) - default: - return p.deployECS(ctx, req) - } -} - -// GetDeploymentStatus returns the current status of an ECS service deployment. -// The deployID must be in the format "|" (as returned by Deploy). -// EKS deploy IDs (prefix "eks:") return a static pending status. -func (p *AWSProvider) GetDeploymentStatus(ctx context.Context, deployID string) (*provider.DeployStatus, error) { - // EKS deployments do not have an ECS-backed status. - if strings.HasPrefix(deployID, "eks:") { - return &provider.DeployStatus{ - DeployID: deployID, - Status: "pending", - Progress: 0, - Message: "EKS deployment status requires kubectl integration", - }, nil - } - - client, err := p.ensureECSClient(ctx) - if err != nil { - return nil, err - } - - parts := strings.SplitN(deployID, "|", 2) - if len(parts) != 2 { - return nil, fmt.Errorf("aws: invalid deploy ID format: %q (expected \"|\")", deployID) - } - serviceARN := parts[0] - - cluster, serviceName, err := parseServiceARN(serviceARN) - if err != nil { - return nil, err - } - // For old-format ARNs (no cluster segment), fall back to the configured cluster. - if cluster == "" { - cluster = p.config.ECSCluster - } - // DescribeServices accepts both ARNs and names; use the full ARN for precision. - out, err := client.DescribeServices(ctx, &ecs.DescribeServicesInput{ - Cluster: awsv2.String(cluster), - Services: []string{serviceARN}, - }) - if err != nil { - return nil, fmt.Errorf("aws: describe services (cluster=%s, service=%s): %w", cluster, serviceName, err) - } - if len(out.Services) == 0 { - return nil, fmt.Errorf("aws: service not found: %s", serviceARN) - } - - svc := out.Services[0] - status, progress, message := ecsServiceDeployStatus(svc.Deployments) - - instances := make([]provider.InstanceStatus, 0, len(svc.Deployments)) - for i := range svc.Deployments { - d := &svc.Deployments[i] - if d.Id == nil { - continue - } - instances = append(instances, provider.InstanceStatus{ - ID: awsv2.ToString(d.Id), - Status: ecsDeploymentRoleToInstanceStatus(d.Status), - }) - } - - return &provider.DeployStatus{ - DeployID: deployID, - Status: status, - Progress: progress, - Message: message, - Instances: instances, - }, nil -} - -// Rollback reverts an ECS service to the previous task definition revision. -// The deployID must be in the format "|". -func (p *AWSProvider) Rollback(ctx context.Context, deployID string) error { - if strings.HasPrefix(deployID, "eks:") { - return fmt.Errorf("aws: EKS rollback requires kubectl integration") - } - - client, err := p.ensureECSClient(ctx) - if err != nil { - return err - } - - parts := strings.SplitN(deployID, "|", 2) - if len(parts) != 2 { - return fmt.Errorf("aws: invalid deploy ID format: %q (expected \"|\")", deployID) - } - serviceARN := parts[0] - taskDefARN := parts[1] - - cluster, service, err := parseServiceARN(serviceARN) - if err != nil { - return err - } - // For old-format ARNs (no cluster segment), fall back to the configured cluster. - if cluster == "" { - cluster = p.config.ECSCluster - } - - prevTaskDef, err := p.getPreviousTaskDef(ctx, client, taskDefARN) - if err != nil { - return err - } - - _, err = client.UpdateService(ctx, &ecs.UpdateServiceInput{ - Cluster: awsv2.String(cluster), - Service: awsv2.String(service), - TaskDefinition: awsv2.String(prevTaskDef), - }) - if err != nil { - return fmt.Errorf("aws: rollback service %s to %s: %w", service, prevTaskDef, err) - } - return nil -} - -// TestConnection verifies connectivity to the configured ECS cluster. -func (p *AWSProvider) TestConnection(ctx context.Context, config map[string]any) (*provider.ConnectionResult, error) { - client, err := p.ensureECSClient(ctx) - if err != nil { - return &provider.ConnectionResult{ - Success: false, - Message: fmt.Sprintf("failed to create ECS client: %v", err), - }, nil - } - - cluster := p.config.ECSCluster - if c, ok := config["cluster"].(string); ok && c != "" { - cluster = c - } - - start := time.Now() - _, err = client.DescribeClusters(ctx, &ecs.DescribeClustersInput{ - Clusters: []string{cluster}, - }) - latency := time.Since(start) - - if err != nil { - return &provider.ConnectionResult{ - Success: false, - Message: fmt.Sprintf("failed to describe ECS cluster %q: %v", cluster, err), - Latency: latency, - }, nil - } - - return &provider.ConnectionResult{ - Success: true, - Message: fmt.Sprintf("successfully connected to ECS cluster %q", cluster), - Latency: latency, - Details: map[string]any{ - "cluster": cluster, - "region": p.config.Region, - }, - }, nil -} - -// GetMetrics fetches CPU and memory utilisation for an ECS service from CloudWatch. -// The deployID must be in the format "|". -// EKS deploy IDs are not supported; use a dedicated EKS metrics integration instead. -func (p *AWSProvider) GetMetrics(ctx context.Context, deployID string, window time.Duration) (*provider.Metrics, error) { - if strings.HasPrefix(deployID, "eks:") { - return nil, fmt.Errorf("aws: EKS metrics are not available via the CloudWatch ECS namespace; use a dedicated EKS metrics integration") - } - - client, err := p.ensureCWClient(ctx) - if err != nil { - return nil, err - } - - clusterName, serviceName, err := deployIDToClusterService(deployID) - if err != nil { - return nil, err - } - - now := time.Now() - start := now.Add(-window) - // CloudWatch standard-resolution metrics require Period to be between 1 and 3600 seconds. - // For short windows (< 5 minutes) we keep 60s granularity for better resolution. - // For longer windows we target roughly 5 data points (period ≈ window/5), - // clamped to the valid [60, 3600] second range to avoid invalid requests. - period := int32(60) - if window >= 5*time.Minute { - period = int32(window.Seconds() / 5) - if period < 60 { - period = 60 - } else if period > 3600 { - period = 3600 - } - } - - queries := []cwtypes.MetricDataQuery{ - { - Id: awsv2.String("cpu"), - MetricStat: &cwtypes.MetricStat{ - Metric: &cwtypes.Metric{ - Namespace: awsv2.String("AWS/ECS"), - MetricName: awsv2.String("CPUUtilization"), - Dimensions: []cwtypes.Dimension{ - {Name: awsv2.String("ClusterName"), Value: awsv2.String(clusterName)}, - {Name: awsv2.String("ServiceName"), Value: awsv2.String(serviceName)}, - }, - }, - Period: awsv2.Int32(period), - Stat: awsv2.String("Average"), - }, - }, - { - Id: awsv2.String("memory"), - MetricStat: &cwtypes.MetricStat{ - Metric: &cwtypes.Metric{ - Namespace: awsv2.String("AWS/ECS"), - MetricName: awsv2.String("MemoryUtilization"), - Dimensions: []cwtypes.Dimension{ - {Name: awsv2.String("ClusterName"), Value: awsv2.String(clusterName)}, - {Name: awsv2.String("ServiceName"), Value: awsv2.String(serviceName)}, - }, - }, - Period: awsv2.Int32(period), - Stat: awsv2.String("Average"), - }, - }, - } - - out, err := client.GetMetricData(ctx, &cloudwatch.GetMetricDataInput{ - StartTime: awsv2.Time(start), - EndTime: awsv2.Time(now), - MetricDataQueries: queries, - }) - if err != nil { - return nil, fmt.Errorf("aws: get metric data (cluster=%s, service=%s): %w", clusterName, serviceName, err) - } - - metrics := &provider.Metrics{CustomMetrics: make(map[string]any)} - for _, result := range out.MetricDataResults { - if result.Id == nil || len(result.Values) == 0 { - continue - } - switch awsv2.ToString(result.Id) { - case "cpu": - metrics.CPU = result.Values[0] - case "memory": - metrics.Memory = result.Values[0] - } - } - return metrics, nil -} - -// parseServiceARN extracts the cluster and service name from an ECS service ARN. -// New format: arn:aws:ecs:::service// -// Old format: arn:aws:ecs:::service/ -func parseServiceARN(serviceARN string) (cluster, service string, err error) { - // Everything after the last colon contains "service//" or "service/". - idx := strings.LastIndex(serviceARN, ":") - if idx < 0 { - return "", "", fmt.Errorf("aws: invalid service ARN: %q", serviceARN) - } - path := serviceARN[idx+1:] // e.g. "service/my-cluster/my-service" - parts := strings.SplitN(path, "/", 3) // ["service", "my-cluster", "my-service"] - if len(parts) == 3 && parts[0] == "service" { - return parts[1], parts[2], nil - } - if len(parts) == 2 && parts[0] == "service" { - // Old ARN format — no cluster segment; return empty cluster. - return "", parts[1], nil - } - return "", "", fmt.Errorf("aws: invalid service ARN path %q in ARN %q", path, serviceARN) -} - -// getPreviousTaskDef returns the family:revision identifier for the revision immediately -// before the one described by taskDefARN. -func (p *AWSProvider) getPreviousTaskDef(ctx context.Context, client ECSClient, taskDefARN string) (string, error) { - out, err := client.DescribeTaskDefinition(ctx, &ecs.DescribeTaskDefinitionInput{ - TaskDefinition: awsv2.String(taskDefARN), - }) - if err != nil { - return "", fmt.Errorf("aws: describe task definition %s: %w", taskDefARN, err) - } - family := awsv2.ToString(out.TaskDefinition.Family) - revision := out.TaskDefinition.Revision - if revision <= 1 { - return "", fmt.Errorf("aws: no previous revision available for task definition %s", taskDefARN) - } - return fmt.Sprintf("%s:%d", family, revision-1), nil -} - -// ecsServiceDeployStatus derives a unified status from the list of ECS service deployments. -func ecsServiceDeployStatus(deployments []ecstypes.Deployment) (status string, progress int, message string) { - for i := range deployments { - d := &deployments[i] - if awsv2.ToString(d.Status) != "PRIMARY" { - continue - } - switch d.RolloutState { - case ecstypes.DeploymentRolloutStateCompleted: - return "succeeded", 100, "deployment completed" - case ecstypes.DeploymentRolloutStateFailed: - msg := awsv2.ToString(d.RolloutStateReason) - if msg == "" { - msg = "deployment failed" - } - return "failed", 0, msg - default: // IN_PROGRESS or unset - desired := d.DesiredCount - running := d.RunningCount - pct := 0 - if desired > 0 { - pct = int(running * 100 / desired) - } - return "in_progress", pct, fmt.Sprintf("running %d/%d tasks", running, desired) - } - } - return "unknown", 0, "no PRIMARY deployment found" -} - -// ecsDeploymentRoleToInstanceStatus maps an ECS deployment role (PRIMARY/ACTIVE/INACTIVE) -// to a provider instance status string. -func ecsDeploymentRoleToInstanceStatus(role *string) string { - switch strings.ToUpper(awsv2.ToString(role)) { - case "PRIMARY": - return "running" - case "ACTIVE": - return "pending" - default: - return "stopped" - } -} - -// deployIDToClusterService parses a deploy ID and returns the ECS cluster and service names. -func deployIDToClusterService(deployID string) (cluster, service string, err error) { - if strings.HasPrefix(deployID, "eks:") { - // "eks::" - parts := strings.SplitN(deployID, ":", 3) - if len(parts) < 2 || parts[1] == "" { - return "", "", fmt.Errorf("aws: cannot parse EKS deploy ID for metrics: %q", deployID) - } - return parts[1], "", nil - } - // ECS: "|" - parts := strings.SplitN(deployID, "|", 2) - if len(parts) != 2 { - return "", "", fmt.Errorf("aws: invalid deploy ID format: %q", deployID) - } - cluster, service, err = parseServiceARN(parts[0]) - return cluster, service, err -} diff --git a/provider/aws/plugin_test.go b/provider/aws/plugin_test.go deleted file mode 100644 index f2223ff8..00000000 --- a/provider/aws/plugin_test.go +++ /dev/null @@ -1,527 +0,0 @@ -package aws - -import ( - "context" - "fmt" - "testing" - "time" - - awsv2 "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/service/cloudwatch" - cwtypes "github.com/aws/aws-sdk-go-v2/service/cloudwatch/types" - "github.com/aws/aws-sdk-go-v2/service/ecs" - ecstypes "github.com/aws/aws-sdk-go-v2/service/ecs/types" -) - -// --------------------------------------------------------------------------- -// Mock CloudWatch client -// --------------------------------------------------------------------------- - -type mockCWClient struct { - getMetricDataFunc func(ctx context.Context, params *cloudwatch.GetMetricDataInput, optFns ...func(*cloudwatch.Options)) (*cloudwatch.GetMetricDataOutput, error) -} - -func (m *mockCWClient) GetMetricData(ctx context.Context, params *cloudwatch.GetMetricDataInput, optFns ...func(*cloudwatch.Options)) (*cloudwatch.GetMetricDataOutput, error) { - if m.getMetricDataFunc != nil { - return m.getMetricDataFunc(ctx, params, optFns...) - } - return &cloudwatch.GetMetricDataOutput{}, nil -} - -// --------------------------------------------------------------------------- -// GetDeploymentStatus tests -// --------------------------------------------------------------------------- - -func TestGetDeploymentStatus_ECSInProgress(t *testing.T) { - const deployID = "arn:aws:ecs:us-east-1:123456789012:service/my-cluster/my-svc|arn:aws:ecs:us-east-1:123456789012:task-definition/my-task:5" - ecsClient := &mockECSClient{ - describeServicesFunc: func(_ context.Context, params *ecs.DescribeServicesInput, _ ...func(*ecs.Options)) (*ecs.DescribeServicesOutput, error) { - return &ecs.DescribeServicesOutput{ - Services: []ecstypes.Service{ - { - ServiceArn: awsv2.String("arn:aws:ecs:us-east-1:123456789012:service/my-cluster/my-svc"), - Deployments: []ecstypes.Deployment{ - { - Id: awsv2.String("ecs-deploy-1"), - Status: awsv2.String("PRIMARY"), - RolloutState: ecstypes.DeploymentRolloutStateInProgress, - DesiredCount: 3, - RunningCount: 1, - }, - }, - }, - }, - }, nil - }, - } - cfg := AWSConfig{} - p := NewAWSProviderWithClients(cfg, ecsClient, nil, nil) - - status, err := p.GetDeploymentStatus(context.Background(), deployID) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if status.Status != "in_progress" { - t.Errorf("expected status=in_progress, got %q", status.Status) - } - if status.Progress < 0 || status.Progress > 100 { - t.Errorf("progress out of range: %d", status.Progress) - } - if len(status.Instances) == 0 { - t.Error("expected at least one instance") - } -} - -func TestGetDeploymentStatus_ECSCompleted(t *testing.T) { - const deployID = "arn:aws:ecs:us-east-1:123456789012:service/my-cluster/my-svc|arn:aws:ecs:us-east-1:123456789012:task-definition/my-task:5" - ecsClient := &mockECSClient{ - describeServicesFunc: func(_ context.Context, _ *ecs.DescribeServicesInput, _ ...func(*ecs.Options)) (*ecs.DescribeServicesOutput, error) { - return &ecs.DescribeServicesOutput{ - Services: []ecstypes.Service{ - { - Deployments: []ecstypes.Deployment{ - { - Id: awsv2.String("ecs-deploy-1"), - Status: awsv2.String("PRIMARY"), - RolloutState: ecstypes.DeploymentRolloutStateCompleted, - DesiredCount: 3, - RunningCount: 3, - }, - }, - }, - }, - }, nil - }, - } - p := NewAWSProviderWithClients(AWSConfig{}, ecsClient, nil, nil) - - status, err := p.GetDeploymentStatus(context.Background(), deployID) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if status.Status != "succeeded" { - t.Errorf("expected status=succeeded, got %q", status.Status) - } - if status.Progress != 100 { - t.Errorf("expected progress=100, got %d", status.Progress) - } -} - -func TestGetDeploymentStatus_ECSFailed(t *testing.T) { - const deployID = "arn:aws:ecs:us-east-1:123456789012:service/my-cluster/my-svc|arn:aws:ecs:us-east-1:123456789012:task-definition/my-task:5" - ecsClient := &mockECSClient{ - describeServicesFunc: func(_ context.Context, _ *ecs.DescribeServicesInput, _ ...func(*ecs.Options)) (*ecs.DescribeServicesOutput, error) { - return &ecs.DescribeServicesOutput{ - Services: []ecstypes.Service{ - { - Deployments: []ecstypes.Deployment{ - { - Id: awsv2.String("ecs-deploy-1"), - Status: awsv2.String("PRIMARY"), - RolloutState: ecstypes.DeploymentRolloutStateFailed, - RolloutStateReason: awsv2.String("ECS tasks failed health check"), - }, - }, - }, - }, - }, nil - }, - } - p := NewAWSProviderWithClients(AWSConfig{}, ecsClient, nil, nil) - - status, err := p.GetDeploymentStatus(context.Background(), deployID) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if status.Status != "failed" { - t.Errorf("expected status=failed, got %q", status.Status) - } -} - -func TestGetDeploymentStatus_EKSDeployID(t *testing.T) { - p := NewAWSProviderWithClients(AWSConfig{}, nil, nil, nil) - - status, err := p.GetDeploymentStatus(context.Background(), "eks:my-cluster:myapp-v1") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if status.Status != "pending" { - t.Errorf("expected status=pending for EKS, got %q", status.Status) - } -} - -func TestGetDeploymentStatus_InvalidDeployID(t *testing.T) { - p := NewAWSProviderWithClients(AWSConfig{}, &mockECSClient{}, nil, nil) - - _, err := p.GetDeploymentStatus(context.Background(), "invalid-id-without-pipe") - if err == nil { - t.Error("expected error for invalid deploy ID") - } -} - -func TestGetDeploymentStatus_OldFormatARN(t *testing.T) { - // Old-format ARN: no cluster segment – cluster should fall back to p.config.ECSCluster. - const deployID = "arn:aws:ecs:us-east-1:123456789012:service/my-svc|arn:aws:ecs:us-east-1:123456789012:task-definition/my-task:5" - var capturedCluster string - ecsClient := &mockECSClient{ - describeServicesFunc: func(_ context.Context, params *ecs.DescribeServicesInput, _ ...func(*ecs.Options)) (*ecs.DescribeServicesOutput, error) { - capturedCluster = awsv2.ToString(params.Cluster) - return &ecs.DescribeServicesOutput{ - Services: []ecstypes.Service{ - { - Deployments: []ecstypes.Deployment{ - { - Id: awsv2.String("deploy-1"), - Status: awsv2.String("PRIMARY"), - RolloutState: ecstypes.DeploymentRolloutStateCompleted, - DesiredCount: 2, - RunningCount: 2, - }, - }, - }, - }, - }, nil - }, - } - cfg := AWSConfig{ECSCluster: "fallback-cluster"} - p := NewAWSProviderWithClients(cfg, ecsClient, nil, nil) - - status, err := p.GetDeploymentStatus(context.Background(), deployID) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if status.Status != "succeeded" { - t.Errorf("expected status=succeeded, got %q", status.Status) - } - if capturedCluster != "fallback-cluster" { - t.Errorf("expected cluster=%q from config fallback, got %q", "fallback-cluster", capturedCluster) - } -} - -func TestRollback_OldFormatARN(t *testing.T) { - // Old-format ARN: no cluster segment – cluster should fall back to p.config.ECSCluster. - const deployID = "arn:aws:ecs:us-east-1:123456789012:service/my-svc|arn:aws:ecs:us-east-1:123456789012:task-definition/my-task:3" - var capturedCluster string - ecsClient := &mockECSClient{ - describeTaskDefFunc: func(_ context.Context, _ *ecs.DescribeTaskDefinitionInput, _ ...func(*ecs.Options)) (*ecs.DescribeTaskDefinitionOutput, error) { - return &ecs.DescribeTaskDefinitionOutput{ - TaskDefinition: &ecstypes.TaskDefinition{ - Family: awsv2.String("my-task"), - Revision: 3, - }, - }, nil - }, - updateServiceFunc: func(_ context.Context, params *ecs.UpdateServiceInput, _ ...func(*ecs.Options)) (*ecs.UpdateServiceOutput, error) { - capturedCluster = awsv2.ToString(params.Cluster) - return &ecs.UpdateServiceOutput{ - Service: &ecstypes.Service{ - ServiceArn: awsv2.String("arn:aws:ecs:us-east-1:123456789012:service/my-svc"), - }, - }, nil - }, - } - cfg := AWSConfig{ECSCluster: "fallback-cluster"} - p := NewAWSProviderWithClients(cfg, ecsClient, nil, nil) - - if err := p.Rollback(context.Background(), deployID); err != nil { - t.Fatalf("unexpected error: %v", err) - } - if capturedCluster != "fallback-cluster" { - t.Errorf("expected cluster=%q from config fallback, got %q", "fallback-cluster", capturedCluster) - } -} - -func TestRollback_Success(t *testing.T) { - const deployID = "arn:aws:ecs:us-east-1:123456789012:service/my-cluster/my-svc|arn:aws:ecs:us-east-1:123456789012:task-definition/my-task:5" - - var updatedTaskDef string - ecsClient := &mockECSClient{ - describeTaskDefFunc: func(_ context.Context, _ *ecs.DescribeTaskDefinitionInput, _ ...func(*ecs.Options)) (*ecs.DescribeTaskDefinitionOutput, error) { - return &ecs.DescribeTaskDefinitionOutput{ - TaskDefinition: &ecstypes.TaskDefinition{ - Family: awsv2.String("my-task"), - Revision: 5, - }, - }, nil - }, - updateServiceFunc: func(_ context.Context, params *ecs.UpdateServiceInput, _ ...func(*ecs.Options)) (*ecs.UpdateServiceOutput, error) { - updatedTaskDef = awsv2.ToString(params.TaskDefinition) - return &ecs.UpdateServiceOutput{ - Service: &ecstypes.Service{ - ServiceArn: awsv2.String("arn:aws:ecs:us-east-1:123456789012:service/my-cluster/my-svc"), - }, - }, nil - }, - } - p := NewAWSProviderWithClients(AWSConfig{}, ecsClient, nil, nil) - - if err := p.Rollback(context.Background(), deployID); err != nil { - t.Fatalf("unexpected error: %v", err) - } - if updatedTaskDef != "my-task:4" { - t.Errorf("expected rollback to revision 4, got %q", updatedTaskDef) - } -} - -func TestRollback_NoRevisionAvailable(t *testing.T) { - const deployID = "arn:aws:ecs:us-east-1:123456789012:service/my-cluster/my-svc|arn:aws:ecs:us-east-1:123456789012:task-definition/my-task:1" - ecsClient := &mockECSClient{ - describeTaskDefFunc: func(_ context.Context, _ *ecs.DescribeTaskDefinitionInput, _ ...func(*ecs.Options)) (*ecs.DescribeTaskDefinitionOutput, error) { - return &ecs.DescribeTaskDefinitionOutput{ - TaskDefinition: &ecstypes.TaskDefinition{ - Family: awsv2.String("my-task"), - Revision: 1, - }, - }, nil - }, - } - p := NewAWSProviderWithClients(AWSConfig{}, ecsClient, nil, nil) - - err := p.Rollback(context.Background(), deployID) - if err == nil { - t.Error("expected error when there is no previous revision") - } -} - -func TestRollback_EKSReturnsError(t *testing.T) { - p := NewAWSProviderWithClients(AWSConfig{}, nil, nil, nil) - - err := p.Rollback(context.Background(), "eks:my-cluster:myapp-v1") - if err == nil { - t.Error("expected error for EKS rollback") - } -} - -func TestRollback_InvalidDeployID(t *testing.T) { - p := NewAWSProviderWithClients(AWSConfig{}, &mockECSClient{}, nil, nil) - - err := p.Rollback(context.Background(), "no-pipe-here") - if err == nil { - t.Error("expected error for invalid deploy ID") - } -} - -// --------------------------------------------------------------------------- -// TestConnection tests -// --------------------------------------------------------------------------- - -func TestTestConnection_Success(t *testing.T) { - ecsClient := &mockECSClient{} - cfg := AWSConfig{ECSCluster: "my-cluster", Region: "us-east-1"} - p := NewAWSProviderWithClients(cfg, ecsClient, nil, nil) - - result, err := p.TestConnection(context.Background(), map[string]any{}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !result.Success { - t.Errorf("expected Success=true, got false: %s", result.Message) - } - if result.Latency <= 0 { - t.Error("expected positive latency") - } -} - -func TestTestConnection_ClusterOverride(t *testing.T) { - var capturedCluster string - ecsClient := &mockECSClient{ - describeClustersFunc: func(_ context.Context, params *ecs.DescribeClustersInput, _ ...func(*ecs.Options)) (*ecs.DescribeClustersOutput, error) { - if len(params.Clusters) > 0 { - capturedCluster = params.Clusters[0] - } - return &ecs.DescribeClustersOutput{}, nil - }, - } - p := NewAWSProviderWithClients(AWSConfig{ECSCluster: "default-cluster"}, ecsClient, nil, nil) - - _, err := p.TestConnection(context.Background(), map[string]any{"cluster": "override-cluster"}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if capturedCluster != "override-cluster" { - t.Errorf("expected cluster %q, got %q", "override-cluster", capturedCluster) - } -} - -func TestTestConnection_Failure(t *testing.T) { - ecsClient := &mockECSClient{ - describeClustersFunc: func(_ context.Context, _ *ecs.DescribeClustersInput, _ ...func(*ecs.Options)) (*ecs.DescribeClustersOutput, error) { - return nil, fmt.Errorf("connection refused") - }, - } - p := NewAWSProviderWithClients(AWSConfig{ECSCluster: "my-cluster"}, ecsClient, nil, nil) - - result, err := p.TestConnection(context.Background(), map[string]any{}) - if err != nil { - t.Fatalf("unexpected error (TestConnection should not propagate errors): %v", err) - } - if result.Success { - t.Error("expected Success=false on client error") - } -} - -// --------------------------------------------------------------------------- -// GetMetrics tests -// --------------------------------------------------------------------------- - -func TestGetMetrics_ReturnsCloudWatchData(t *testing.T) { - const deployID = "arn:aws:ecs:us-east-1:123456789012:service/my-cluster/my-svc|arn:aws:ecs:us-east-1:123456789012:task-definition/my-task:5" - - cwClient := &mockCWClient{ - getMetricDataFunc: func(_ context.Context, _ *cloudwatch.GetMetricDataInput, _ ...func(*cloudwatch.Options)) (*cloudwatch.GetMetricDataOutput, error) { - return &cloudwatch.GetMetricDataOutput{ - MetricDataResults: []cwtypes.MetricDataResult{ - {Id: awsv2.String("cpu"), Values: []float64{42.5}}, - {Id: awsv2.String("memory"), Values: []float64{68.0}}, - }, - }, nil - }, - } - p := NewAWSProviderWithClients(AWSConfig{}, nil, nil, cwClient) - - metrics, err := p.GetMetrics(context.Background(), deployID, 5*time.Minute) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if metrics.CPU != 42.5 { - t.Errorf("expected CPU=42.5, got %v", metrics.CPU) - } - if metrics.Memory != 68.0 { - t.Errorf("expected Memory=68.0, got %v", metrics.Memory) - } -} - -func TestGetMetrics_EmptyResults(t *testing.T) { - const deployID = "arn:aws:ecs:us-east-1:123456789012:service/my-cluster/my-svc|arn:aws:ecs:us-east-1:123456789012:task-definition/my-task:5" - - cwClient := &mockCWClient{} // returns empty results - p := NewAWSProviderWithClients(AWSConfig{}, nil, nil, cwClient) - - metrics, err := p.GetMetrics(context.Background(), deployID, time.Minute) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if metrics.CPU != 0 || metrics.Memory != 0 { - t.Errorf("expected zero metrics for empty CloudWatch response, got CPU=%v Memory=%v", metrics.CPU, metrics.Memory) - } -} - -func TestGetMetrics_EKSDeployID(t *testing.T) { - cwClient := &mockCWClient{} - p := NewAWSProviderWithClients(AWSConfig{}, nil, nil, cwClient) - - _, err := p.GetMetrics(context.Background(), "eks:my-cluster:myapp-v1", 5*time.Minute) - if err == nil { - t.Error("expected error: EKS metrics are not available via CloudWatch ECS namespace") - } -} - -func TestGetMetrics_InvalidDeployID(t *testing.T) { - cwClient := &mockCWClient{} - p := NewAWSProviderWithClients(AWSConfig{}, nil, nil, cwClient) - - _, err := p.GetMetrics(context.Background(), "bad-deploy-id", time.Minute) - if err == nil { - t.Error("expected error for invalid deploy ID") - } -} - -// --------------------------------------------------------------------------- -// Helper function tests -// --------------------------------------------------------------------------- - -func TestParseServiceARN_NewFormat(t *testing.T) { - arn := "arn:aws:ecs:us-east-1:123456789012:service/my-cluster/my-service" - cluster, service, err := parseServiceARN(arn) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if cluster != "my-cluster" { - t.Errorf("expected cluster=%q, got %q", "my-cluster", cluster) - } - if service != "my-service" { - t.Errorf("expected service=%q, got %q", "my-service", service) - } -} - -func TestParseServiceARN_OldFormat(t *testing.T) { - arn := "arn:aws:ecs:us-east-1:123456789012:service/my-service" - cluster, service, err := parseServiceARN(arn) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if cluster != "" { - t.Errorf("expected empty cluster for old ARN format, got %q", cluster) - } - if service != "my-service" { - t.Errorf("expected service=%q, got %q", "my-service", service) - } -} - -func TestParseServiceARN_Invalid(t *testing.T) { - _, _, err := parseServiceARN("not-an-arn") - if err == nil { - t.Error("expected error for invalid ARN") - } -} - -func TestECSServiceDeployStatus_InProgress(t *testing.T) { - deployments := []ecstypes.Deployment{ - { - Status: awsv2.String("PRIMARY"), - RolloutState: ecstypes.DeploymentRolloutStateInProgress, - DesiredCount: 4, - RunningCount: 2, - }, - } - status, progress, _ := ecsServiceDeployStatus(deployments) - if status != "in_progress" { - t.Errorf("expected in_progress, got %q", status) - } - if progress != 50 { - t.Errorf("expected progress=50, got %d", progress) - } -} - -func TestECSServiceDeployStatus_NoPrimary(t *testing.T) { - deployments := []ecstypes.Deployment{ - {Status: awsv2.String("ACTIVE"), RolloutState: ecstypes.DeploymentRolloutStateInProgress}, - } - status, _, _ := ecsServiceDeployStatus(deployments) - if status != "unknown" { - t.Errorf("expected unknown, got %q", status) - } -} - -func TestECSDeploymentRoleToInstanceStatus(t *testing.T) { - tests := []struct { - role string - want string - }{ - {"PRIMARY", "running"}, - {"primary", "running"}, - {"ACTIVE", "pending"}, - {"active", "pending"}, - {"INACTIVE", "stopped"}, - {"", "stopped"}, - } - for _, tc := range tests { - got := ecsDeploymentRoleToInstanceStatus(awsv2.String(tc.role)) - if got != tc.want { - t.Errorf("ecsDeploymentRoleToInstanceStatus(%q) = %q, want %q", tc.role, got, tc.want) - } - } -} - -func TestProviderMetadata(t *testing.T) { - p := NewAWSProvider(AWSConfig{}) - if p.Name() != "aws" { - t.Errorf("expected Name()=aws, got %q", p.Name()) - } - if p.Version() == "" { - t.Error("expected non-empty Version()") - } - if p.Description() == "" { - t.Error("expected non-empty Description()") - } -} diff --git a/provider/aws/registry.go b/provider/aws/registry.go deleted file mode 100644 index 53d523dc..00000000 --- a/provider/aws/registry.go +++ /dev/null @@ -1,23 +0,0 @@ -package aws - -import ( - "context" - "fmt" - - "github.com/GoCodeAlone/workflow/provider" -) - -// PushImage pushes a container image to Amazon ECR. -func (p *AWSProvider) PushImage(ctx context.Context, image string, auth provider.RegistryAuth) error { - return fmt.Errorf("aws: ECR PushImage not yet implemented (image=%s)", image) -} - -// PullImage pulls a container image from Amazon ECR. -func (p *AWSProvider) PullImage(ctx context.Context, image string, auth provider.RegistryAuth) error { - return fmt.Errorf("aws: ECR PullImage not yet implemented (image=%s)", image) -} - -// ListImages lists container images in an Amazon ECR repository. -func (p *AWSProvider) ListImages(ctx context.Context, repo string) ([]provider.ImageTag, error) { - return nil, fmt.Errorf("aws: ECR ListImages not yet implemented (repo=%s)", repo) -} From a5bf4f9273c90bafe242fa28b7c3a0edb273e4e9 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 20 May 2026 12:03:13 -0400 Subject: [PATCH 2/5] docs(api): clarify AWS IAM removal note in NewRouterWithIAM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot flagged the prior comment as misleading — it suggested registration "from the plugin side" without naming the integration path. Replace with an honest note: there is no in-process AWS provider after this PR; an embedder that needs AWS IAM must construct its own iam.IAMResolver, register an AWS-aware iam.Provider impl on it, and pass it as the iamResolver argument to NewRouterWithIAM (rather than relying on the default-resolver branch). No behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) --- api/router.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/api/router.go b/api/router.go index 242c43c8..daf4f582 100644 --- a/api/router.go +++ b/api/router.go @@ -185,8 +185,12 @@ func NewRouterWithIAM(stores Stores, cfg Config, iamResolver *iam.IAMResolver) h resolver := iamResolver if resolver == nil { resolver = iam.NewIAMResolver(stores.IAM) - // AWSIAMProvider extracted to workflow-plugin-aws; register - // it from the plugin side if needed. + // AWS IAM provider removed from workflow core; no in-process + // replacement is wired here. To re-enable AWS IAM, the + // embedder must construct an iam.IAMResolver, register an + // AWS-aware iam.Provider impl on it (e.g., from + // workflow-plugin-aws), and pass it as iamResolver above + // rather than relying on this default branch. resolver.RegisterProvider(&iam.KubernetesProvider{}) resolver.RegisterProvider(&iam.OIDCProvider{}) } From 947b56becfadc39097d6d16a96a863bda767ce34 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 20 May 2026 12:37:45 -0400 Subject: [PATCH 3/5] docs(api): use correct interface name iam.IAMProvider Copilot flagged the prior comment using `iam.Provider`; the actual type in `iam/provider.go` is `iam.IAMProvider`. Co-Authored-By: Claude Opus 4.7 (1M context) --- api/router.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/router.go b/api/router.go index daf4f582..446ec216 100644 --- a/api/router.go +++ b/api/router.go @@ -188,7 +188,7 @@ func NewRouterWithIAM(stores Stores, cfg Config, iamResolver *iam.IAMResolver) h // AWS IAM provider removed from workflow core; no in-process // replacement is wired here. To re-enable AWS IAM, the // embedder must construct an iam.IAMResolver, register an - // AWS-aware iam.Provider impl on it (e.g., from + // AWS-aware iam.IAMProvider impl on it (e.g., from // workflow-plugin-aws), and pass it as iamResolver above // rather than relying on this default branch. resolver.RegisterProvider(&iam.KubernetesProvider{}) From c2067a2907fa86ff9df911e54449aa3b9fe5bce1 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 20 May 2026 13:03:16 -0400 Subject: [PATCH 4/5] chore: run go mod tidy in example/ after AWS SDK removal The example/ sub-module had stale indirect entries for aws-sdk-go-v2/service/iam, internal/checksum, internal/s3shared, and service/s3 that were only pulled transitively via the deleted provider/aws + iam/aws.go + artifact/s3.go. Tidy now that those direct uses are gone. Co-Authored-By: Claude Opus 4.7 (1M context) --- example/go.mod | 3 --- example/go.sum | 8 -------- 2 files changed, 11 deletions(-) diff --git a/example/go.mod b/example/go.mod index 74548c46..d13ad102 100644 --- a/example/go.mod +++ b/example/go.mod @@ -33,11 +33,8 @@ require ( github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect github.com/aws/aws-sdk-go-v2/service/kinesis v1.43.5 // indirect - github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0 // indirect github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 // indirect diff --git a/example/go.sum b/example/go.sum index 24b46087..d0851f94 100644 --- a/example/go.sum +++ b/example/go.sum @@ -60,20 +60,12 @@ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 h1:dY4kWZiSaXIzxnKlj1 github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22/go.mod h1:KIpEUx0JuRZLO7U6cbV204cWAEco2iC3l061IxlwLtI= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 h1:FPXsW9+gMuIeKmz7j6ENWcWtBGTe1kH8r9thNt5Uxx4= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23/go.mod h1:7J8iGMdRKk6lw2C+cMIphgAnT8uTwBwNOsGkyOCm80U= -github.com/aws/aws-sdk-go-v2/service/iam v1.53.7 h1:n9YLiWtX3+6pTLZWvRJmtq5JIB9NA/KFelyCg5fOlTU= -github.com/aws/aws-sdk-go-v2/service/iam v1.53.7/go.mod h1:sP46Vo6MeJcM4s0ZXcG2PFmfiSyixhIuC/74W52yKuk= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 h1:HtOTYcbVcGABLOVuPYaIihj6IlkqubBwFj10K5fxRek= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8/go.mod h1:VsK9abqQeGlzPgUr+isNWzPlK2vKe9INMLWnY65f5Xs= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 h1:PUmZeJU6Y1Lbvt9WFuJ0ugUK2xn6hIWUBBbKuOWF30s= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22/go.mod h1:nO6egFBoAaoXze24a2C0NjQCvdpk8OueRoYimvEB9jo= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ= github.com/aws/aws-sdk-go-v2/service/kinesis v1.43.5 h1:LxgRVyuY+5DEPSX7kmin/V7toE8MWZ9U8n2dqRtX+RE= github.com/aws/aws-sdk-go-v2/service/kinesis v1.43.5/go.mod h1:eUebEBEqVfOwEyDDDbGauH4PNqDCuepRvTaNbJeWr5w= -github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0 h1:hlSuz394kV0vhv9drL5lhuEFbEOEP1VyQpy15qWh1Pk= -github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM= github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 h1:a1Fq/KXn75wSzoJaPQTgZO0wHGqE9mjFnylnqEPTchA= github.com/aws/aws-sdk-go-v2/service/signin v1.0.10/go.mod h1:p6+MXNxW7IA6dMgHfTAzljuwSKD0NCm/4lbS4t6+7vI= github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 h1:x6bKbmDhsgSZwv6q19wY/u3rLk/3FGjJWyqKcIRufpE= From e7aead74db4ff5aa1a35b84b723ee43b10dfa1ff Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 20 May 2026 13:20:38 -0400 Subject: [PATCH 5/5] docs: note provider/aws/ removal in two historical planning docs Copilot round-4 flagged that: - CICD_PLAN.md still listed provider/aws/* under "Provider Plugins" and "Files to create (Phase 6)" without acknowledging the extraction. - docs/migrations/2026-05-15-plugin-modules-on-iac.md described provider/aws/ as a deliberate AWS SDK retention seam and a go.mod-anchor for aws-sdk-go-v2. Both docs are historical (plan + phase-B/C migration); leaving them stale would mislead future readers. Added inline "Update 2026-05-20 (PR #744)" callouts that: - redirect CICD_PLAN.md readers to the migration doc for current state, - update the Phase B verification claim to say aws-sdk-go-v2 is now indirect-only (via modular/eventbus/v2 sts+kinesis), - update the final invariant statement to drop provider/aws/ from the list of retained provider-specific surfaces. No code change. Co-Authored-By: Claude Opus 4.7 (1M context) --- CICD_PLAN.md | 8 ++++++- .../2026-05-15-plugin-modules-on-iac.md | 21 ++++++++++++------- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/CICD_PLAN.md b/CICD_PLAN.md index b88aee5e..ef2640b1 100644 --- a/CICD_PLAN.md +++ b/CICD_PLAN.md @@ -444,7 +444,13 @@ type CloudProvider interface { ### 4 Provider Plugins -**`provider/aws/plugin.go`** — AWS: EC2, ECS, EKS, ECR, CloudWatch +> **Update 2026-05-20 (PR #744):** `provider/aws/*` has been deleted from +> workflow-core; AWS IaC now lives in the external `workflow-plugin-aws` +> repo. The references below are kept as the historical phase-6 plan; +> see the migration doc at +> `docs/migrations/2026-05-15-plugin-modules-on-iac.md` for current state. + +**`provider/aws/plugin.go`** — AWS: EC2, ECS, EKS, ECR, CloudWatch (extracted to workflow-plugin-aws) **`provider/gcp/plugin.go`** — GCP: GKE, Cloud Run, GCR, Cloud Monitoring **`provider/azure/plugin.go`** — Azure: AKS, ACI, ACR, Azure Monitor **`provider/digitalocean/plugin.go`** — DO: DOKS, App Platform, Container Registry diff --git a/docs/migrations/2026-05-15-plugin-modules-on-iac.md b/docs/migrations/2026-05-15-plugin-modules-on-iac.md index 8dfe86d5..b2fdc8a1 100644 --- a/docs/migrations/2026-05-15-plugin-modules-on-iac.md +++ b/docs/migrations/2026-05-15-plugin-modules-on-iac.md @@ -178,11 +178,14 @@ this single transitive path** and **fail CI on any other** `cloud.google.com/go/ dep. Any new GCP SDK package (e.g. `cloud.google.com/go/storage`, `google.golang.org/api/*`) belongs in `workflow-plugin-gcp`, not core. -This is the GCP-side mirror of Phase B's `aws-sdk-go-v2`-retention paragraph: -`provider/aws/` legitimately uses the AWS SDK for its deploy pipeline, -`provider/gcp/` legitimately uses OAuth2 ADC for service-account auth, and -both arrangements are intentional — the audit gate just guards against -scope creep beyond those known seams. +This is the GCP-side mirror of Phase B's `aws-sdk-go-v2`-retention paragraph. +**Update 2026-05-20 (PR #744):** `provider/aws/` has since been deleted along +with the remaining direct AWS SDK uses in workflow core (`iam/aws.go`, +`artifact/s3.go`, `plugin/rbac/aws.go`); `workflow-plugin-aws` is now the +in-tree home for AWS IaC. `aws-sdk-go-v2` remains in `go.mod` purely as an +indirect dep (pulled by `modular/eventbus/v2` for sts/kinesis), which is +a modular-side concern. `provider/gcp/` continues to use OAuth2 ADC for +service-account auth — that arrangement is unchanged. ## Rollback @@ -208,6 +211,9 @@ non-test consumers. - `go mod tidy` against the merged tree makes no net change to AWS SDK service modules — `aws-sdk-go-v2` stays in `go.mod` because `provider/aws/`, `plugin/rbac/aws.go`, `iam/aws.go`, and `artifact/s3.go` still import it. + **Update (PR #744, 2026-05-20):** all four of those files have since + been deleted; `aws-sdk-go-v2` now remains in `go.mod` only as an indirect + dep via `modular/eventbus/v2` (sts + kinesis). - The `.phase-b-complete` marker arms `scripts/audit-cloud-symbols.sh --check`'s zero-`aws-sdk-go-v2` invariant on `module/cloud_account_aws_creds.go`. @@ -256,8 +262,9 @@ Plan-1 and plan-2 manifests + per-task spec records live under `2026-05-15-plugin-modules-on-iac.md`). **Final invariant statement:** workflow-core now imports zero cloud-provider -SDK clients in `module/`; provider-specific surfaces (`provider/aws/`, -`provider/gcp/`'s OAuth2-only path) retain only what's needed for the +SDK clients in `module/`; provider-specific surfaces (`provider/gcp/`'s +OAuth2-only path; `provider/aws/` was removed in PR #744 and replaced by +`workflow-plugin-aws`) retain only what's needed for the out-of-scope deploy-pipeline / credential-resolution work that #653 + decisions/0034 explicitly carve out. Every other cloud-provider integration crosses the engine ↔ plugin gRPC boundary.