diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8b7c9d6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,67 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.1.0] — 2026-05-15 + +### Added + +- **`aws.credentials` standalone module** (`internal/modules/aws_credentials.go`): + optional DRY module that lets a config declare AWS credentials once and have + many sibling `storage.s3` / `step.s3_upload` modules reference them via + `credentials_ref:`. Backed by a process-local `credref` registry that rejects + duplicate names within a config. +- **`storage.s3` standalone module** (`internal/modules/storage_s3.go`): the + S3-backed storage module, plugin-native via `IaCServeOptions.Modules`. + Credentials inline (`credentials:` sub-block) or `credentials_ref:` a + sibling `aws.credentials` module. Optional `endpoint` override (MinIO / + LocalStack via path-style addressing). +- **`step.s3_upload` standalone step** (`internal/steps/s3_upload.go`): + pipeline step that uploads a base64-encoded body from a dot-path in the + pipeline context to S3 and returns `{url, key, bucket}` as step output. + Supports `{{ .field }}` / `{{ uuid }}` key templates and + `content_type_from` dot-path resolution. Plugin-native via + `IaCServeOptions.Steps`. +- **`internal/awscreds.BuildAWSConfig`** — in-plugin AWS credential + resolution. Handles all 4 source paths from the YAML `credentials.type` + field: `static` (inline keys), `env` / `""` (aws-sdk-go-v2 default chain), + `profile` (shared-config profile via `config.WithSharedConfigProfile`), + `role_arn` (STS `AssumeRole` on top of base creds). Ports the SDK-bearing + resolver bodies from workflow core's `module/cloud_account_aws_creds.go`, + which the matched workflow-core change rewrites to declare-only markers. +- **IaC provider credential path** now routes through `BuildAWSConfig` so a + `credentials:` sub-block on the IaC provider config honours all 4 source + paths (previously only inline static keys at top-level were recognised). +- **`TestPluginJSONCapabilities_ModuleStep_Parity`** — host-conformance test + that asserts plugin.json `capabilities.moduleTypes` / + `capabilities.stepTypes` exactly match the providers wired into + `IaCServeOptions`. + +### Changed + +- **`plugin.json` `version`**: 1.0.0 → 1.1.0 (compatibility-marker minor bump + for the new module / step capabilities). +- **`plugin.json` `minEngineVersion`**: 0.52.0 → 0.53.0 — requires workflow + v0.53.0+ for the `IaCServeOptions.Modules` / `.Steps` bridge wiring (the + plan-2 PR 1 SDK extension). +- **`plugin.json` `capabilities.moduleTypes`**: adds `aws.credentials` and + `storage.s3` alongside the existing `iac.provider`. +- **`plugin.json` `capabilities.stepTypes`**: adds `step.s3_upload`. +- **`go.mod`** pins `github.com/GoCodeAlone/workflow v0.53.0`. + +### Notes + +- Phase-B core PR (workflow plan-2 Task 14/15) deletes in-core + `iac_state_spaces.go` and `s3_storage.go` / `pipeline_step_s3_upload.go`; + it is blocked on this release tag. +- Runtime-launch validation transcript: + `docs/runtime-validation/aws-plugin-v1.1.0.md`. + +## [1.0.0] — earlier + +- Typed-IaC migration; baseline AWS provider surface (ECS / EKS / RDS / + ElastiCache / VPC / ALB / Route53 / ECR / API Gateway / SecurityGroup / + IAM / S3 / ACM / AutoScaling) + `iac.state s3` backend. diff --git a/cmd/workflow-plugin-aws/main.go b/cmd/workflow-plugin-aws/main.go index 5279221..af5fcf7 100644 --- a/cmd/workflow-plugin-aws/main.go +++ b/cmd/workflow-plugin-aws/main.go @@ -16,5 +16,8 @@ import ( ) func main() { - sdk.ServeIaCPlugin(internal.NewIaCServer(), sdk.IaCServeOptions{}) + sdk.ServeIaCPlugin(internal.NewIaCServer(), sdk.IaCServeOptions{ + Modules: internal.ModuleProviders(), + Steps: internal.StepProviders(), + }) } diff --git a/docs/runtime-validation/aws-plugin-v1.1.0.md b/docs/runtime-validation/aws-plugin-v1.1.0.md new file mode 100644 index 0000000..46aaddf --- /dev/null +++ b/docs/runtime-validation/aws-plugin-v1.1.0.md @@ -0,0 +1,87 @@ +# Runtime-launch validation — workflow-plugin-aws v1.1.0 + +**Scope:** plan-2 PR 2 finishing task (Task 7) — wire `IaCServeOptions.Modules` ++ `.Steps` for `aws.credentials`, `storage.s3`, `step.s3_upload`; bump +`plugin.json` `version` / `minEngineVersion`; release v1.1.0. + +**Change class:** plugin-loading path + version pin → runtime-launch +validation per the cross-plan policy. + +## What was validated + +The plugin binary was built fresh from the branch HEAD and exercised as a +go-plugin subprocess. + +### 1. Build + +``` +$ GOWORK=off go build -o /tmp/aws-plugin-v110/workflow-plugin-aws ./cmd/workflow-plugin-aws +$ ls -la /tmp/aws-plugin-v110/workflow-plugin-aws +-rwxr-xr-x ... 184M ... workflow-plugin-aws +``` + +Build is clean (`BUILD_EXIT 0`); the binary is the standard ~184 MiB +linked subprocess artifact. + +### 2. go-plugin handshake guard + +Running the binary outside the host process surfaces the canonical +`go-plugin` self-identification message — proving `sdk.ServeIaCPlugin` +wired the handshake correctly and the binary refuses to operate without a +host-provided cookie/protocol exchange. + +``` +$ /tmp/aws-plugin-v110/workflow-plugin-aws +This binary is a plugin. These are not meant to be executed directly. +Please execute the program that consumes these plugins, which will +load any plugins automatically +``` + +This is the go-plugin library's `ServeConfig`-rejection emission and +demonstrates: (a) `sdk.ServeIaCPlugin` did not panic on the new +`IaCServeOptions.Modules` / `.Steps` fields, (b) the bridge construction +path completed successfully, (c) the host-or-nothing handshake guard is +intact. + +### 3. In-process bridge parity (`go test ./internal/...`) + +The host-conformance parity tests build the same providers `main.go` +wires into `IaCServeOptions` and assert the plugin.json declarations +match exactly: + +- `TestPluginJSONCapabilities_ModuleStep_Parity` — plugin.json + `capabilities.moduleTypes` (minus the implicit `iac.provider`) ↔ + `internal.ModuleProviders()` keys; `capabilities.stepTypes` ↔ + `internal.StepProviders()` keys. Both bidirectional. +- `TestCapabilityParity_IaCStateBackends` — pre-existing parity test + for the iac.state-backend capability surface. + +Both pass under `-race`. + +### 4. Full unit test suite + +``` +ok github.com/GoCodeAlone/workflow-plugin-aws/drivers +ok github.com/GoCodeAlone/workflow-plugin-aws/internal +ok github.com/GoCodeAlone/workflow-plugin-aws/internal/awscreds +ok github.com/GoCodeAlone/workflow-plugin-aws/internal/credref +ok github.com/GoCodeAlone/workflow-plugin-aws/internal/modules +ok github.com/GoCodeAlone/workflow-plugin-aws/internal/statebackend +ok github.com/GoCodeAlone/workflow-plugin-aws/internal/steps +ok github.com/GoCodeAlone/workflow-plugin-aws/provider +``` + +All packages green under `GOWORK=off go test ./... -race`. + +## What was NOT validated here + +A full `wfctl plugin install && wfctl plugin list` end-to-end +exercise was not run in this implementer session because the +workflow-plugin-aws repo's CI does not bundle a wfctl binary — the +shell-level handshake check + the in-process bridge parity tests are the +canonical evidence in this repo. The full `wfctl`-driven host-load path +is exercised by the workflow-core PR 1 integration tests (plan-2 Task 2) +which were verified at the v0.53.0 tag this PR pins. PR 4 of plan-2 +(Phase B core deletion) is blocked on this release tag, so any regression +in the host-load path surfaces at PR 4's CI before any in-core path is +removed. diff --git a/go.mod b/go.mod index b2505f7..bf7d206 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/GoCodeAlone/workflow-plugin-aws go 1.26.0 require ( - github.com/GoCodeAlone/workflow v0.51.11-0.20260514225636-522748f35474 + github.com/GoCodeAlone/workflow v0.53.0 github.com/aws/aws-sdk-go-v2 v1.41.7 github.com/aws/aws-sdk-go-v2/config v1.32.16 github.com/aws/aws-sdk-go-v2/credentials v1.19.15 @@ -21,6 +21,8 @@ require ( github.com/aws/aws-sdk-go-v2/service/rds v1.115.0 github.com/aws/aws-sdk-go-v2/service/route53 v1.62.5 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/google/uuid v1.6.0 google.golang.org/grpc v1.80.0 google.golang.org/protobuf v1.36.11 ) @@ -66,7 +68,6 @@ require ( 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.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bits-and-blooms/bitset v1.24.4 // indirect @@ -105,7 +106,6 @@ require ( github.com/golobby/cast v1.3.3 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/s2a-go v0.1.9 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect github.com/googleapis/gax-go/v2 v2.19.0 // indirect github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect diff --git a/go.sum b/go.sum index 456d6f6..d8ece1e 100644 --- a/go.sum +++ b/go.sum @@ -44,8 +44,8 @@ github.com/GoCodeAlone/modular/modules/jsonschema v1.15.0 h1:xb1mI4NZkzvNKQ2F6nk github.com/GoCodeAlone/modular/modules/jsonschema v1.15.0/go.mod h1:hhGouwAVsonmJ4Lain4jINZ9nZCoc9l9eF3BHbmR8eE= github.com/GoCodeAlone/modular/modules/reverseproxy/v2 v2.8.0 h1:cvdLHbM/vzvygQTcAWSJsy+dAPzzwWyjzKMmTBFcFIo= github.com/GoCodeAlone/modular/modules/reverseproxy/v2 v2.8.0/go.mod h1:/9ipMG4qM2CHQ14BfXKdVlYRJelef6M8MFI5TbZv67M= -github.com/GoCodeAlone/workflow v0.51.11-0.20260514225636-522748f35474 h1:C5Hi9BCtTDDP7k/++++LOfj2LxyaKP4YtgB0h5xgkeQ= -github.com/GoCodeAlone/workflow v0.51.11-0.20260514225636-522748f35474/go.mod h1:L1kIOZqebO1WPriHXcqT7bg/uS7pExR8pOrWvurqhR4= +github.com/GoCodeAlone/workflow v0.53.0 h1:+UjoWNHRB1aPiQfWJUltsXnZfupsqbGmItv9xZto4kY= +github.com/GoCodeAlone/workflow v0.53.0/go.mod h1:L1kIOZqebO1WPriHXcqT7bg/uS7pExR8pOrWvurqhR4= github.com/GoCodeAlone/yaegi v0.17.2 h1:WK6Y6e0t1a6U7r+S2dN3CGWW1PizYD3zO0zneToZPxM= github.com/GoCodeAlone/yaegi v0.17.2/go.mod h1:z5Pr6Wse6QJcQvpgxTxzMAevFarH0N37TG88Y9dprx0= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 h1:rIkQfkCOVKc1OiRCNcSDD8ml5RJlZbH/Xsq7lbpynwc= diff --git a/internal/awscreds/awscreds.go b/internal/awscreds/awscreds.go new file mode 100644 index 0000000..0f8f1f1 --- /dev/null +++ b/internal/awscreds/awscreds.go @@ -0,0 +1,162 @@ +// Package awscreds provides the in-plugin AWS credential resolution path. +// +// BuildAWSConfig is the single entry point: given a CredInput (parsed from +// either a YAML `credentials:` block or a host-delivered CloudCredentials), +// it returns a fully-resolved aws.Config. The 4 source paths are: +// +// - "static": inline AccessKey/SecretKey/SessionToken; +// - "env" (or unset): aws-sdk-go-v2's default credential chain; +// - "profile": shared-config profile via config.WithSharedConfigProfile; +// - "role_arn": STS AssumeRole with optional ExternalID + base creds. +// +// The "profile" and "role_arn" SDK blocks are ported from workflow core's +// module/cloud_account_aws_creds.go (awsProfileResolver, awsRoleARNResolver), +// which Phase-B PR 4 (plan-2 Task 13) rewrites to *declare, don't resolve*. +// The SDK-bearing resolution lives here, in the plugin. +// +// CredInput.Source MUST be populated by the call-site from the YAML +// `credentials.type` field (the value the user wrote in their config). It +// is NOT read from CloudAccount.Extra — that map never crosses the +// host↔plugin gRPC boundary. +package awscreds + +import ( + "context" + "fmt" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/sts" +) + +// CredInput is the parsed-config shape BuildAWSConfig consumes. The call-site +// (a Provider's CreateModule/CreateStep or the IaC provider's Initialize) +// parses the `credentials:` YAML block — or the legacy top-level +// access_key_id/secret_access_key keys — into this struct. +type CredInput struct { + AccessKey string + SecretKey string + SessionToken string + Region string + RoleARN string + ExternalID string + Profile string + // Source mirrors the YAML `credentials.type` field — one of + // "static" | "env" | "profile" | "role_arn" | "" (default chain). + Source string + // SessionName is the STS AssumeRole session name. Defaults to + // "workflow-session" when empty. Honoured only when Source == "role_arn". + SessionName string +} + +// stsAssumeRoleAPI is the subset of *sts.Client BuildAWSConfig calls. It +// exists so tests can inject a fake STS implementation without spinning up +// a real STS endpoint. +type stsAssumeRoleAPI interface { + AssumeRole(ctx context.Context, in *sts.AssumeRoleInput, opts ...func(*sts.Options)) (*sts.AssumeRoleOutput, error) +} + +// newSTSClient builds an STS client from the given base aws.Config. Tests +// override this var to inject a fake STS API. +var newSTSClient = func(cfg aws.Config) stsAssumeRoleAPI { + return sts.NewFromConfig(cfg) +} + +// BuildAWSConfig returns a resolved aws.Config for the given CredInput. +// See package doc for the source-path semantics. +func BuildAWSConfig(ctx context.Context, c CredInput) (aws.Config, error) { + switch c.Source { + case "profile": + return loadProfile(ctx, c) + case "role_arn": + return loadRoleARN(ctx, c) + } + // "static" | "env" | "" — all flow through the default chain with + // optional static-credential override when both keys are supplied. + opts := baseLoadOptions(c) + if c.AccessKey != "" && c.SecretKey != "" { + opts = append(opts, config.WithCredentialsProvider( + credentials.NewStaticCredentialsProvider(c.AccessKey, c.SecretKey, c.SessionToken), + )) + } + cfg, err := config.LoadDefaultConfig(ctx, opts...) + if err != nil { + return aws.Config{}, fmt.Errorf("aws creds: load default config: %w", err) + } + return cfg, nil +} + +// loadProfile loads aws.Config from a named shared-config profile. Ported +// from workflow core's awsProfileResolver (cloud_account_aws_creds.go). +func loadProfile(ctx context.Context, c CredInput) (aws.Config, error) { + profile := c.Profile + if profile == "" { + profile = "default" + } + opts := baseLoadOptions(c) + opts = append(opts, config.WithSharedConfigProfile(profile)) + cfg, err := config.LoadDefaultConfig(ctx, opts...) + if err != nil { + return aws.Config{}, fmt.Errorf("aws creds: load profile %q: %w", profile, err) + } + return cfg, nil +} + +// loadRoleARN obtains temporary credentials via STS AssumeRole on top of +// base credentials (inline static keys when supplied, otherwise the default +// chain). Ported from workflow core's awsRoleARNResolver +// (cloud_account_aws_creds.go). +func loadRoleARN(ctx context.Context, c CredInput) (aws.Config, error) { + if c.RoleARN == "" { + return aws.Config{}, fmt.Errorf("aws creds: role_arn source requires non-empty RoleARN") + } + baseOpts := baseLoadOptions(c) + if c.AccessKey != "" && c.SecretKey != "" { + baseOpts = append(baseOpts, config.WithCredentialsProvider( + credentials.NewStaticCredentialsProvider(c.AccessKey, c.SecretKey, c.SessionToken), + )) + } + baseCfg, err := config.LoadDefaultConfig(ctx, baseOpts...) + if err != nil { + return aws.Config{}, fmt.Errorf("aws creds: load base config for role_arn: %w", err) + } + + sessionName := c.SessionName + if sessionName == "" { + sessionName = "workflow-session" + } + input := &sts.AssumeRoleInput{ + RoleArn: aws.String(c.RoleARN), + RoleSessionName: aws.String(sessionName), + } + if c.ExternalID != "" { + input.ExternalId = aws.String(c.ExternalID) + } + + out, err := newSTSClient(baseCfg).AssumeRole(ctx, input) + if err != nil { + return aws.Config{}, fmt.Errorf("aws creds: AssumeRole %q: %w", c.RoleARN, err) + } + if out == nil || out.Credentials == nil { + return aws.Config{}, fmt.Errorf("aws creds: AssumeRole %q returned no credentials", c.RoleARN) + } + + assumed := baseCfg.Copy() + assumed.Credentials = credentials.NewStaticCredentialsProvider( + aws.ToString(out.Credentials.AccessKeyId), + aws.ToString(out.Credentials.SecretAccessKey), + aws.ToString(out.Credentials.SessionToken), + ) + return assumed, nil +} + +// baseLoadOptions builds the LoadDefaultConfig options common to every +// path — currently just Region when set. +func baseLoadOptions(c CredInput) []func(*config.LoadOptions) error { + var opts []func(*config.LoadOptions) error + if c.Region != "" { + opts = append(opts, config.WithRegion(c.Region)) + } + return opts +} diff --git a/internal/awscreds/awscreds_test.go b/internal/awscreds/awscreds_test.go new file mode 100644 index 0000000..15c65c3 --- /dev/null +++ b/internal/awscreds/awscreds_test.go @@ -0,0 +1,226 @@ +package awscreds + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/sts" + ststypes "github.com/aws/aws-sdk-go-v2/service/sts/types" +) + +func TestBuildAWSConfig_Static(t *testing.T) { + ctx := context.Background() + cfg, err := BuildAWSConfig(ctx, CredInput{ + Source: "static", + Region: "us-west-2", + AccessKey: "AKIDTESTSTATIC", + SecretKey: "SECRETTESTSTATIC", + }) + if err != nil { + t.Fatalf("BuildAWSConfig(static): %v", err) + } + if cfg.Region != "us-west-2" { + t.Errorf("Region = %q, want us-west-2", cfg.Region) + } + creds, err := cfg.Credentials.Retrieve(ctx) + if err != nil { + t.Fatalf("Credentials.Retrieve: %v", err) + } + if creds.AccessKeyID != "AKIDTESTSTATIC" || creds.SecretAccessKey != "SECRETTESTSTATIC" { + t.Errorf("retrieved creds = %q/%q, want AKIDTESTSTATIC/SECRETTESTSTATIC", + creds.AccessKeyID, creds.SecretAccessKey) + } +} + +func TestBuildAWSConfig_EmptySourceUsesDefaultChain(t *testing.T) { + // Isolate the env so the default chain doesn't pick up ambient credentials + // (which would make the test order-dependent and noisy). + isolateAWSEnv(t) + ctx := context.Background() + cfg, err := BuildAWSConfig(ctx, CredInput{Region: "us-east-1"}) + if err != nil { + t.Fatalf("BuildAWSConfig(default chain): %v", err) + } + if cfg.Region != "us-east-1" { + t.Errorf("Region = %q, want us-east-1", cfg.Region) + } + // LoadDefaultConfig itself must not error when there are no creds — the + // chain defers actual credential resolution to Retrieve() time. +} + +func TestBuildAWSConfig_Profile(t *testing.T) { + isolateAWSEnv(t) + // Write a throwaway shared-config + shared-credentials file and point + // AWS_CONFIG_FILE / AWS_SHARED_CREDENTIALS_FILE at them. + dir := t.TempDir() + configPath := filepath.Join(dir, "config") + credsPath := filepath.Join(dir, "credentials") + if err := os.WriteFile(configPath, []byte("[profile dev]\nregion = us-east-2\n"), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + if err := os.WriteFile(credsPath, []byte("[dev]\naws_access_key_id = AKIDTESTPROFILE\naws_secret_access_key = SECRETTESTPROFILE\n"), 0o600); err != nil { + t.Fatalf("write credentials: %v", err) + } + t.Setenv("AWS_CONFIG_FILE", configPath) + t.Setenv("AWS_SHARED_CREDENTIALS_FILE", credsPath) + + ctx := context.Background() + cfg, err := BuildAWSConfig(ctx, CredInput{Source: "profile", Profile: "dev"}) + if err != nil { + t.Fatalf("BuildAWSConfig(profile): %v", err) + } + creds, err := cfg.Credentials.Retrieve(ctx) + if err != nil { + t.Fatalf("Credentials.Retrieve: %v", err) + } + if creds.AccessKeyID != "AKIDTESTPROFILE" { + t.Errorf("profile-loaded AccessKeyID = %q, want AKIDTESTPROFILE", creds.AccessKeyID) + } +} + +func TestBuildAWSConfig_RoleARN_InjectedSTS(t *testing.T) { + // Inject a fake STS client so AssumeRole is exercised without a network + // call. Restore the global after the test. + origNew := newSTSClient + t.Cleanup(func() { newSTSClient = origNew }) + + var captured *sts.AssumeRoleInput + fake := &fakeSTS{ + assume: func(_ context.Context, in *sts.AssumeRoleInput, _ ...func(*sts.Options)) (*sts.AssumeRoleOutput, error) { + captured = in + return &sts.AssumeRoleOutput{ + Credentials: &ststypes.Credentials{ + AccessKeyId: aws.String("AKIDASSUMED"), + SecretAccessKey: aws.String("SECRETASSUMED"), + SessionToken: aws.String("TOKENASSUMED"), + }, + }, nil + }, + } + newSTSClient = func(_ aws.Config) stsAssumeRoleAPI { return fake } + + ctx := context.Background() + cfg, err := BuildAWSConfig(ctx, CredInput{ + Source: "role_arn", + Region: "us-east-1", + RoleARN: "arn:aws:iam::123456789012:role/test-role", + ExternalID: "ext-123", + SessionName: "test-session", + AccessKey: "AKIDBASE", + SecretKey: "SECRETBASE", + }) + if err != nil { + t.Fatalf("BuildAWSConfig(role_arn): %v", err) + } + if captured == nil { + t.Fatal("AssumeRole was not called") + } + if aws.ToString(captured.RoleArn) != "arn:aws:iam::123456789012:role/test-role" { + t.Errorf("RoleArn = %q, want arn:aws:iam::123456789012:role/test-role", aws.ToString(captured.RoleArn)) + } + if aws.ToString(captured.RoleSessionName) != "test-session" { + t.Errorf("RoleSessionName = %q, want test-session", aws.ToString(captured.RoleSessionName)) + } + if aws.ToString(captured.ExternalId) != "ext-123" { + t.Errorf("ExternalId = %q, want ext-123", aws.ToString(captured.ExternalId)) + } + got, err := cfg.Credentials.Retrieve(ctx) + if err != nil { + t.Fatalf("Retrieve assumed creds: %v", err) + } + if got.AccessKeyID != "AKIDASSUMED" || got.SecretAccessKey != "SECRETASSUMED" || got.SessionToken != "TOKENASSUMED" { + t.Errorf("assumed creds = %q/%q/%q, want AKIDASSUMED/SECRETASSUMED/TOKENASSUMED", + got.AccessKeyID, got.SecretAccessKey, got.SessionToken) + } +} + +func TestBuildAWSConfig_RoleARN_DefaultSessionName(t *testing.T) { + origNew := newSTSClient + t.Cleanup(func() { newSTSClient = origNew }) + + var captured *sts.AssumeRoleInput + newSTSClient = func(_ aws.Config) stsAssumeRoleAPI { + return &fakeSTS{ + assume: func(_ context.Context, in *sts.AssumeRoleInput, _ ...func(*sts.Options)) (*sts.AssumeRoleOutput, error) { + captured = in + return &sts.AssumeRoleOutput{ + Credentials: &ststypes.Credentials{ + AccessKeyId: aws.String("AKID"), + SecretAccessKey: aws.String("SECRET"), + }, + }, nil + }, + } + } + + _, err := BuildAWSConfig(context.Background(), CredInput{ + Source: "role_arn", + RoleARN: "arn:aws:iam::000000000000:role/r", + }) + if err != nil { + t.Fatalf("BuildAWSConfig: %v", err) + } + if aws.ToString(captured.RoleSessionName) != "workflow-session" { + t.Errorf("default RoleSessionName = %q, want workflow-session", aws.ToString(captured.RoleSessionName)) + } +} + +func TestBuildAWSConfig_RoleARN_EmptyARNErrors(t *testing.T) { + _, err := BuildAWSConfig(context.Background(), CredInput{Source: "role_arn"}) + if err == nil { + t.Fatal("expected error when role_arn source has empty RoleARN") + } +} + +func TestBuildAWSConfig_RoleARN_AssumeRoleErrorPropagates(t *testing.T) { + origNew := newSTSClient + t.Cleanup(func() { newSTSClient = origNew }) + newSTSClient = func(_ aws.Config) stsAssumeRoleAPI { + return &fakeSTS{ + assume: func(_ context.Context, _ *sts.AssumeRoleInput, _ ...func(*sts.Options)) (*sts.AssumeRoleOutput, error) { + return nil, fmt.Errorf("simulated STS denial") + }, + } + } + + _, err := BuildAWSConfig(context.Background(), CredInput{ + Source: "role_arn", + RoleARN: "arn:aws:iam::000000000000:role/r", + }) + if err == nil { + t.Fatal("expected AssumeRole error to propagate") + } +} + +// isolateAWSEnv clears AWS credential env vars + config-file overrides so +// tests don't inadvertently pick up developer / CI ambient credentials. +func isolateAWSEnv(t *testing.T) { + t.Helper() + for _, v := range []string{ + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_PROFILE", + "AWS_CONFIG_FILE", + "AWS_SHARED_CREDENTIALS_FILE", + "AWS_ROLE_ARN", + "AWS_WEB_IDENTITY_TOKEN_FILE", + } { + t.Setenv(v, "") + } + // Point HOME at an empty dir so a real ~/.aws/{config,credentials} is + // not consulted on the host running the tests. + t.Setenv("HOME", t.TempDir()) +} + +type fakeSTS struct { + assume func(ctx context.Context, in *sts.AssumeRoleInput, opts ...func(*sts.Options)) (*sts.AssumeRoleOutput, error) +} + +func (f *fakeSTS) AssumeRole(ctx context.Context, in *sts.AssumeRoleInput, opts ...func(*sts.Options)) (*sts.AssumeRoleOutput, error) { + return f.assume(ctx, in, opts...) +} diff --git a/internal/contracts/aws_plan2.pb.go b/internal/contracts/aws_plan2.pb.go new file mode 100644 index 0000000..18ddcee --- /dev/null +++ b/internal/contracts/aws_plan2.pb.go @@ -0,0 +1,571 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v7.34.1 +// source: internal/contracts/aws_plan2.proto + +package contracts + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// AWSCredentialsBlock mirrors the YAML `credentials:` sub-block the +// plan-2 modules accept. The `type` field — when non-empty — selects one +// of "static" | "env" | "profile" | "role_arn"; consumed by +// awscreds.BuildAWSConfig (see internal/awscreds/awscreds.go). +type AWSCredentialsBlock struct { + state protoimpl.MessageState `protogen:"open.v1"` + // type selects the credential source path. One of: + // + // "static" — inline access_key / secret_key (default when keys present) + // "env" — aws-sdk-go-v2 default credential chain + // "profile" — shared-config profile via WithSharedConfigProfile + // "role_arn" — STS AssumeRole on top of base credentials + // + // Empty string = same as "env" (default chain). + Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"` + AccessKey string `protobuf:"bytes,2,opt,name=access_key,json=accessKey,proto3" json:"access_key,omitempty"` + SecretKey string `protobuf:"bytes,3,opt,name=secret_key,json=secretKey,proto3" json:"secret_key,omitempty"` + SessionToken string `protobuf:"bytes,4,opt,name=session_token,json=sessionToken,proto3" json:"session_token,omitempty"` + // role_arn / external_id / session_name — honored only when type=="role_arn". + RoleArn string `protobuf:"bytes,5,opt,name=role_arn,json=roleArn,proto3" json:"role_arn,omitempty"` + ExternalId string `protobuf:"bytes,6,opt,name=external_id,json=externalId,proto3" json:"external_id,omitempty"` + // profile — honored only when type=="profile". + Profile string `protobuf:"bytes,7,opt,name=profile,proto3" json:"profile,omitempty"` + SessionName string `protobuf:"bytes,8,opt,name=session_name,json=sessionName,proto3" json:"session_name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AWSCredentialsBlock) Reset() { + *x = AWSCredentialsBlock{} + mi := &file_internal_contracts_aws_plan2_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AWSCredentialsBlock) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AWSCredentialsBlock) ProtoMessage() {} + +func (x *AWSCredentialsBlock) ProtoReflect() protoreflect.Message { + mi := &file_internal_contracts_aws_plan2_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AWSCredentialsBlock.ProtoReflect.Descriptor instead. +func (*AWSCredentialsBlock) Descriptor() ([]byte, []int) { + return file_internal_contracts_aws_plan2_proto_rawDescGZIP(), []int{0} +} + +func (x *AWSCredentialsBlock) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +func (x *AWSCredentialsBlock) GetAccessKey() string { + if x != nil { + return x.AccessKey + } + return "" +} + +func (x *AWSCredentialsBlock) GetSecretKey() string { + if x != nil { + return x.SecretKey + } + return "" +} + +func (x *AWSCredentialsBlock) GetSessionToken() string { + if x != nil { + return x.SessionToken + } + return "" +} + +func (x *AWSCredentialsBlock) GetRoleArn() string { + if x != nil { + return x.RoleArn + } + return "" +} + +func (x *AWSCredentialsBlock) GetExternalId() string { + if x != nil { + return x.ExternalId + } + return "" +} + +func (x *AWSCredentialsBlock) GetProfile() string { + if x != nil { + return x.Profile + } + return "" +} + +func (x *AWSCredentialsBlock) GetSessionName() string { + if x != nil { + return x.SessionName + } + return "" +} + +// AWSCredentialsConfig is the typed configuration for the aws.credentials +// standalone module. The module registers the parsed credentials block in +// the process-local credref registry under the module's own name so +// sibling storage.s3 / step.s3_upload modules can reference it via +// `credentials_ref:`. +type AWSCredentialsConfig struct { + state protoimpl.MessageState `protogen:"open.v1"` + // region is the AWS region the credentials are scoped to (used as the + // default region when sibling modules don't override). + Region string `protobuf:"bytes,1,opt,name=region,proto3" json:"region,omitempty"` + Credentials *AWSCredentialsBlock `protobuf:"bytes,2,opt,name=credentials,proto3" json:"credentials,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AWSCredentialsConfig) Reset() { + *x = AWSCredentialsConfig{} + mi := &file_internal_contracts_aws_plan2_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AWSCredentialsConfig) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AWSCredentialsConfig) ProtoMessage() {} + +func (x *AWSCredentialsConfig) ProtoReflect() protoreflect.Message { + mi := &file_internal_contracts_aws_plan2_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AWSCredentialsConfig.ProtoReflect.Descriptor instead. +func (*AWSCredentialsConfig) Descriptor() ([]byte, []int) { + return file_internal_contracts_aws_plan2_proto_rawDescGZIP(), []int{1} +} + +func (x *AWSCredentialsConfig) GetRegion() string { + if x != nil { + return x.Region + } + return "" +} + +func (x *AWSCredentialsConfig) GetCredentials() *AWSCredentialsBlock { + if x != nil { + return x.Credentials + } + return nil +} + +// S3StorageConfig is the typed configuration for the storage.s3 module. +// The module exposes Put/Get/Delete object operations against the named +// bucket. Credentials come from EITHER the inline `credentials:` block OR +// `credentials_ref:` (a sibling aws.credentials module name) — inline +// wins when both are supplied. +type S3StorageConfig struct { + state protoimpl.MessageState `protogen:"open.v1"` + // bucket is the S3 bucket name (REQUIRED). + Bucket string `protobuf:"bytes,1,opt,name=bucket,proto3" json:"bucket,omitempty"` + // region is the AWS region; if set, overrides the referenced + // credentials' region. + Region string `protobuf:"bytes,2,opt,name=region,proto3" json:"region,omitempty"` + // endpoint is an optional custom S3 endpoint URL (MinIO / LocalStack). + // When set, the client uses path-style addressing. + Endpoint string `protobuf:"bytes,3,opt,name=endpoint,proto3" json:"endpoint,omitempty"` + // credentials_ref names an aws.credentials module to look up in the + // process-local credref registry. Mutually exclusive with `credentials`; + // inline credentials win when both are supplied. + CredentialsRef string `protobuf:"bytes,4,opt,name=credentials_ref,json=credentialsRef,proto3" json:"credentials_ref,omitempty"` + Credentials *AWSCredentialsBlock `protobuf:"bytes,5,opt,name=credentials,proto3" json:"credentials,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *S3StorageConfig) Reset() { + *x = S3StorageConfig{} + mi := &file_internal_contracts_aws_plan2_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *S3StorageConfig) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*S3StorageConfig) ProtoMessage() {} + +func (x *S3StorageConfig) ProtoReflect() protoreflect.Message { + mi := &file_internal_contracts_aws_plan2_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use S3StorageConfig.ProtoReflect.Descriptor instead. +func (*S3StorageConfig) Descriptor() ([]byte, []int) { + return file_internal_contracts_aws_plan2_proto_rawDescGZIP(), []int{2} +} + +func (x *S3StorageConfig) GetBucket() string { + if x != nil { + return x.Bucket + } + return "" +} + +func (x *S3StorageConfig) GetRegion() string { + if x != nil { + return x.Region + } + return "" +} + +func (x *S3StorageConfig) GetEndpoint() string { + if x != nil { + return x.Endpoint + } + return "" +} + +func (x *S3StorageConfig) GetCredentialsRef() string { + if x != nil { + return x.CredentialsRef + } + return "" +} + +func (x *S3StorageConfig) GetCredentials() *AWSCredentialsBlock { + if x != nil { + return x.Credentials + } + return nil +} + +// S3UploadStepConfig is the typed configuration for the step.s3_upload +// pipeline step. The step uploads a base64-encoded body from the pipeline +// context to S3 and returns S3UploadStepOutput. +type S3UploadStepConfig struct { + state protoimpl.MessageState `protogen:"open.v1"` + // bucket is the S3 bucket name (REQUIRED). + Bucket string `protobuf:"bytes,1,opt,name=bucket,proto3" json:"bucket,omitempty"` + // region is the AWS region (REQUIRED). + Region string `protobuf:"bytes,2,opt,name=region,proto3" json:"region,omitempty"` + // endpoint is an optional custom S3 endpoint URL (MinIO / LocalStack). + Endpoint string `protobuf:"bytes,3,opt,name=endpoint,proto3" json:"endpoint,omitempty"` + // key is a Go text/template-rendered object key. Supports + // `{{ .field }}` substitution from the pipeline-context data and a + // `{{ uuid }}` helper. + Key string `protobuf:"bytes,4,opt,name=key,proto3" json:"key,omitempty"` + // body_from is the dot-path into the pipeline context (current + + // step outputs under the "steps" key) that resolves to a + // base64-encoded body string. + BodyFrom string `protobuf:"bytes,5,opt,name=body_from,json=bodyFrom,proto3" json:"body_from,omitempty"` + // content_type is the static Content-Type for the uploaded object. + // Used when content_type_from is empty or unresolvable. + ContentType string `protobuf:"bytes,6,opt,name=content_type,json=contentType,proto3" json:"content_type,omitempty"` + // content_type_from is a dot-path into the pipeline context that + // resolves to a Content-Type string. Beats content_type when present. + ContentTypeFrom string `protobuf:"bytes,7,opt,name=content_type_from,json=contentTypeFrom,proto3" json:"content_type_from,omitempty"` + // credentials_ref / credentials — same semantics as S3StorageConfig. + CredentialsRef string `protobuf:"bytes,8,opt,name=credentials_ref,json=credentialsRef,proto3" json:"credentials_ref,omitempty"` + Credentials *AWSCredentialsBlock `protobuf:"bytes,9,opt,name=credentials,proto3" json:"credentials,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *S3UploadStepConfig) Reset() { + *x = S3UploadStepConfig{} + mi := &file_internal_contracts_aws_plan2_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *S3UploadStepConfig) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*S3UploadStepConfig) ProtoMessage() {} + +func (x *S3UploadStepConfig) ProtoReflect() protoreflect.Message { + mi := &file_internal_contracts_aws_plan2_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use S3UploadStepConfig.ProtoReflect.Descriptor instead. +func (*S3UploadStepConfig) Descriptor() ([]byte, []int) { + return file_internal_contracts_aws_plan2_proto_rawDescGZIP(), []int{3} +} + +func (x *S3UploadStepConfig) GetBucket() string { + if x != nil { + return x.Bucket + } + return "" +} + +func (x *S3UploadStepConfig) GetRegion() string { + if x != nil { + return x.Region + } + return "" +} + +func (x *S3UploadStepConfig) GetEndpoint() string { + if x != nil { + return x.Endpoint + } + return "" +} + +func (x *S3UploadStepConfig) GetKey() string { + if x != nil { + return x.Key + } + return "" +} + +func (x *S3UploadStepConfig) GetBodyFrom() string { + if x != nil { + return x.BodyFrom + } + return "" +} + +func (x *S3UploadStepConfig) GetContentType() string { + if x != nil { + return x.ContentType + } + return "" +} + +func (x *S3UploadStepConfig) GetContentTypeFrom() string { + if x != nil { + return x.ContentTypeFrom + } + return "" +} + +func (x *S3UploadStepConfig) GetCredentialsRef() string { + if x != nil { + return x.CredentialsRef + } + return "" +} + +func (x *S3UploadStepConfig) GetCredentials() *AWSCredentialsBlock { + if x != nil { + return x.Credentials + } + return nil +} + +// S3UploadStepOutput is the typed output map step.s3_upload returns. +type S3UploadStepOutput struct { + state protoimpl.MessageState `protogen:"open.v1"` + // url is the virtual-hosted-style URL of the uploaded object + // (`https://.s3..amazonaws.com/`), or, when a + // custom endpoint is configured, `//`. + Url string `protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"` + // key is the rendered S3 object key after template substitution. + Key string `protobuf:"bytes,2,opt,name=key,proto3" json:"key,omitempty"` + // bucket is the S3 bucket name the object was uploaded to. + Bucket string `protobuf:"bytes,3,opt,name=bucket,proto3" json:"bucket,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *S3UploadStepOutput) Reset() { + *x = S3UploadStepOutput{} + mi := &file_internal_contracts_aws_plan2_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *S3UploadStepOutput) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*S3UploadStepOutput) ProtoMessage() {} + +func (x *S3UploadStepOutput) ProtoReflect() protoreflect.Message { + mi := &file_internal_contracts_aws_plan2_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use S3UploadStepOutput.ProtoReflect.Descriptor instead. +func (*S3UploadStepOutput) Descriptor() ([]byte, []int) { + return file_internal_contracts_aws_plan2_proto_rawDescGZIP(), []int{4} +} + +func (x *S3UploadStepOutput) GetUrl() string { + if x != nil { + return x.Url + } + return "" +} + +func (x *S3UploadStepOutput) GetKey() string { + if x != nil { + return x.Key + } + return "" +} + +func (x *S3UploadStepOutput) GetBucket() string { + if x != nil { + return x.Bucket + } + return "" +} + +var File_internal_contracts_aws_plan2_proto protoreflect.FileDescriptor + +const file_internal_contracts_aws_plan2_proto_rawDesc = "" + + "\n" + + "\"internal/contracts/aws_plan2.proto\x12\x17workflow.plugins.aws.v1\"\x85\x02\n" + + "\x13AWSCredentialsBlock\x12\x12\n" + + "\x04type\x18\x01 \x01(\tR\x04type\x12\x1d\n" + + "\n" + + "access_key\x18\x02 \x01(\tR\taccessKey\x12\x1d\n" + + "\n" + + "secret_key\x18\x03 \x01(\tR\tsecretKey\x12#\n" + + "\rsession_token\x18\x04 \x01(\tR\fsessionToken\x12\x19\n" + + "\brole_arn\x18\x05 \x01(\tR\aroleArn\x12\x1f\n" + + "\vexternal_id\x18\x06 \x01(\tR\n" + + "externalId\x12\x18\n" + + "\aprofile\x18\a \x01(\tR\aprofile\x12!\n" + + "\fsession_name\x18\b \x01(\tR\vsessionName\"~\n" + + "\x14AWSCredentialsConfig\x12\x16\n" + + "\x06region\x18\x01 \x01(\tR\x06region\x12N\n" + + "\vcredentials\x18\x02 \x01(\v2,.workflow.plugins.aws.v1.AWSCredentialsBlockR\vcredentials\"\xd6\x01\n" + + "\x0fS3StorageConfig\x12\x16\n" + + "\x06bucket\x18\x01 \x01(\tR\x06bucket\x12\x16\n" + + "\x06region\x18\x02 \x01(\tR\x06region\x12\x1a\n" + + "\bendpoint\x18\x03 \x01(\tR\bendpoint\x12'\n" + + "\x0fcredentials_ref\x18\x04 \x01(\tR\x0ecredentialsRef\x12N\n" + + "\vcredentials\x18\x05 \x01(\v2,.workflow.plugins.aws.v1.AWSCredentialsBlockR\vcredentials\"\xd7\x02\n" + + "\x12S3UploadStepConfig\x12\x16\n" + + "\x06bucket\x18\x01 \x01(\tR\x06bucket\x12\x16\n" + + "\x06region\x18\x02 \x01(\tR\x06region\x12\x1a\n" + + "\bendpoint\x18\x03 \x01(\tR\bendpoint\x12\x10\n" + + "\x03key\x18\x04 \x01(\tR\x03key\x12\x1b\n" + + "\tbody_from\x18\x05 \x01(\tR\bbodyFrom\x12!\n" + + "\fcontent_type\x18\x06 \x01(\tR\vcontentType\x12*\n" + + "\x11content_type_from\x18\a \x01(\tR\x0fcontentTypeFrom\x12'\n" + + "\x0fcredentials_ref\x18\b \x01(\tR\x0ecredentialsRef\x12N\n" + + "\vcredentials\x18\t \x01(\v2,.workflow.plugins.aws.v1.AWSCredentialsBlockR\vcredentials\"P\n" + + "\x12S3UploadStepOutput\x12\x10\n" + + "\x03url\x18\x01 \x01(\tR\x03url\x12\x10\n" + + "\x03key\x18\x02 \x01(\tR\x03key\x12\x16\n" + + "\x06bucket\x18\x03 \x01(\tR\x06bucketBIZGgithub.com/GoCodeAlone/workflow-plugin-aws/internal/contracts;contractsb\x06proto3" + +var ( + file_internal_contracts_aws_plan2_proto_rawDescOnce sync.Once + file_internal_contracts_aws_plan2_proto_rawDescData []byte +) + +func file_internal_contracts_aws_plan2_proto_rawDescGZIP() []byte { + file_internal_contracts_aws_plan2_proto_rawDescOnce.Do(func() { + file_internal_contracts_aws_plan2_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_internal_contracts_aws_plan2_proto_rawDesc), len(file_internal_contracts_aws_plan2_proto_rawDesc))) + }) + return file_internal_contracts_aws_plan2_proto_rawDescData +} + +var file_internal_contracts_aws_plan2_proto_msgTypes = make([]protoimpl.MessageInfo, 5) +var file_internal_contracts_aws_plan2_proto_goTypes = []any{ + (*AWSCredentialsBlock)(nil), // 0: workflow.plugins.aws.v1.AWSCredentialsBlock + (*AWSCredentialsConfig)(nil), // 1: workflow.plugins.aws.v1.AWSCredentialsConfig + (*S3StorageConfig)(nil), // 2: workflow.plugins.aws.v1.S3StorageConfig + (*S3UploadStepConfig)(nil), // 3: workflow.plugins.aws.v1.S3UploadStepConfig + (*S3UploadStepOutput)(nil), // 4: workflow.plugins.aws.v1.S3UploadStepOutput +} +var file_internal_contracts_aws_plan2_proto_depIdxs = []int32{ + 0, // 0: workflow.plugins.aws.v1.AWSCredentialsConfig.credentials:type_name -> workflow.plugins.aws.v1.AWSCredentialsBlock + 0, // 1: workflow.plugins.aws.v1.S3StorageConfig.credentials:type_name -> workflow.plugins.aws.v1.AWSCredentialsBlock + 0, // 2: workflow.plugins.aws.v1.S3UploadStepConfig.credentials:type_name -> workflow.plugins.aws.v1.AWSCredentialsBlock + 3, // [3:3] is the sub-list for method output_type + 3, // [3:3] is the sub-list for method input_type + 3, // [3:3] is the sub-list for extension type_name + 3, // [3:3] is the sub-list for extension extendee + 0, // [0:3] is the sub-list for field type_name +} + +func init() { file_internal_contracts_aws_plan2_proto_init() } +func file_internal_contracts_aws_plan2_proto_init() { + if File_internal_contracts_aws_plan2_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_internal_contracts_aws_plan2_proto_rawDesc), len(file_internal_contracts_aws_plan2_proto_rawDesc)), + NumEnums: 0, + NumMessages: 5, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_internal_contracts_aws_plan2_proto_goTypes, + DependencyIndexes: file_internal_contracts_aws_plan2_proto_depIdxs, + MessageInfos: file_internal_contracts_aws_plan2_proto_msgTypes, + }.Build() + File_internal_contracts_aws_plan2_proto = out.File + file_internal_contracts_aws_plan2_proto_goTypes = nil + file_internal_contracts_aws_plan2_proto_depIdxs = nil +} diff --git a/internal/contracts/aws_plan2.proto b/internal/contracts/aws_plan2.proto new file mode 100644 index 0000000..2883b54 --- /dev/null +++ b/internal/contracts/aws_plan2.proto @@ -0,0 +1,117 @@ +syntax = "proto3"; + +package workflow.plugins.aws.v1; + +option go_package = "github.com/GoCodeAlone/workflow-plugin-aws/internal/contracts;contracts"; + +// aws_plan2.proto declares the typed config / output messages for the +// plugin-native modules + step introduced in plan-2 PR 2: +// - aws.credentials (module) +// - storage.s3 (module) +// - step.s3_upload (step) +// +// The Provider implementations in this repo stay on the legacy +// sdk.ModuleProvider / sdk.StepProvider config-Struct path per plan-2's +// "TypedModuleProvider out for v1" Non-Goal. The messages below back the +// `mode:"strict"` descriptors in plugin.contracts.json so the +// strict-contracts CI gate is satisfied honestly (consistent with the +// existing `iac.provider` precedent in aws.proto) and act as a +// drop-in target for a future TypedModuleProvider migration. See +// decisions/0039. + +// AWSCredentialsBlock mirrors the YAML `credentials:` sub-block the +// plan-2 modules accept. The `type` field — when non-empty — selects one +// of "static" | "env" | "profile" | "role_arn"; consumed by +// awscreds.BuildAWSConfig (see internal/awscreds/awscreds.go). +message AWSCredentialsBlock { + // type selects the credential source path. One of: + // "static" — inline access_key / secret_key (default when keys present) + // "env" — aws-sdk-go-v2 default credential chain + // "profile" — shared-config profile via WithSharedConfigProfile + // "role_arn" — STS AssumeRole on top of base credentials + // Empty string = same as "env" (default chain). + string type = 1; + string access_key = 2; + string secret_key = 3; + string session_token = 4; + // role_arn / external_id / session_name — honored only when type=="role_arn". + string role_arn = 5; + string external_id = 6; + // profile — honored only when type=="profile". + string profile = 7; + string session_name = 8; +} + +// AWSCredentialsConfig is the typed configuration for the aws.credentials +// standalone module. The module registers the parsed credentials block in +// the process-local credref registry under the module's own name so +// sibling storage.s3 / step.s3_upload modules can reference it via +// `credentials_ref:`. +message AWSCredentialsConfig { + // region is the AWS region the credentials are scoped to (used as the + // default region when sibling modules don't override). + string region = 1; + AWSCredentialsBlock credentials = 2; +} + +// S3StorageConfig is the typed configuration for the storage.s3 module. +// The module exposes Put/Get/Delete object operations against the named +// bucket. Credentials come from EITHER the inline `credentials:` block OR +// `credentials_ref:` (a sibling aws.credentials module name) — inline +// wins when both are supplied. +message S3StorageConfig { + // bucket is the S3 bucket name (REQUIRED). + string bucket = 1; + // region is the AWS region; if set, overrides the referenced + // credentials' region. + string region = 2; + // endpoint is an optional custom S3 endpoint URL (MinIO / LocalStack). + // When set, the client uses path-style addressing. + string endpoint = 3; + // credentials_ref names an aws.credentials module to look up in the + // process-local credref registry. Mutually exclusive with `credentials`; + // inline credentials win when both are supplied. + string credentials_ref = 4; + AWSCredentialsBlock credentials = 5; +} + +// S3UploadStepConfig is the typed configuration for the step.s3_upload +// pipeline step. The step uploads a base64-encoded body from the pipeline +// context to S3 and returns S3UploadStepOutput. +message S3UploadStepConfig { + // bucket is the S3 bucket name (REQUIRED). + string bucket = 1; + // region is the AWS region (REQUIRED). + string region = 2; + // endpoint is an optional custom S3 endpoint URL (MinIO / LocalStack). + string endpoint = 3; + // key is a Go text/template-rendered object key. Supports + // `{{ .field }}` substitution from the pipeline-context data and a + // `{{ uuid }}` helper. + string key = 4; + // body_from is the dot-path into the pipeline context (current + + // step outputs under the "steps" key) that resolves to a + // base64-encoded body string. + string body_from = 5; + // content_type is the static Content-Type for the uploaded object. + // Used when content_type_from is empty or unresolvable. + string content_type = 6; + // content_type_from is a dot-path into the pipeline context that + // resolves to a Content-Type string. Beats content_type when present. + string content_type_from = 7; + // credentials_ref / credentials — same semantics as S3StorageConfig. + string credentials_ref = 8; + AWSCredentialsBlock credentials = 9; +} + +// S3UploadStepOutput is the typed output map step.s3_upload returns. +message S3UploadStepOutput { + // url is the virtual-hosted-style URL of the uploaded object + // (`https://.s3..amazonaws.com/`), or, when a + // custom endpoint is configured, `//`. + string url = 1; + // key is the rendered S3 object key after template substitution. + string key = 2; + // bucket is the S3 bucket name the object was uploaded to. + string bucket = 3; +} diff --git a/internal/credref/registry.go b/internal/credref/registry.go new file mode 100644 index 0000000..184d8e8 --- /dev/null +++ b/internal/credref/registry.go @@ -0,0 +1,57 @@ +// Package credref implements the process-local credentials_ref: registry +// for the aws plugin. An aws.credentials Provider's CreateModule registers +// the parsed CredInput under the module name; sibling modules (storage.s3, +// step.s3_upload, etc.) look the entry up via Resolve when their config +// carries a credentials_ref: instead of an inline credentials: block. +// +// credentials_ref names MUST be unique within a config — a duplicate +// Register returns an error rather than silently clobbering, so two +// aws.credentials modules with the same name (or one shadowing another) +// fail loudly at factory-construction time. +// +// The registry is intentionally process-global. The Reset() helper exists +// for tests only — every test that calls Register MUST also call +// `t.Cleanup(credref.Reset)` so test isolation is preserved. +package credref + +import ( + "fmt" + "sync" + + "github.com/GoCodeAlone/workflow-plugin-aws/internal/awscreds" +) + +var ( + mu sync.RWMutex + registry = map[string]awscreds.CredInput{} +) + +// Register stores c under name. Returns an error if name was already +// registered — credentials_ref: names must be unique within a config. +func Register(name string, c awscreds.CredInput) error { + mu.Lock() + defer mu.Unlock() + if _, exists := registry[name]; exists { + return fmt.Errorf("credref: name %q already registered (credentials_ref names must be unique within a config)", name) + } + registry[name] = c + return nil +} + +// Resolve returns the CredInput registered under name and whether it was +// present. Callers MUST check the bool before using the returned value. +func Resolve(name string) (awscreds.CredInput, bool) { + mu.RLock() + defer mu.RUnlock() + c, ok := registry[name] + return c, ok +} + +// Reset clears the registry. Test-only — production code never calls this. +// Tests that call Register MUST `t.Cleanup(credref.Reset)` to avoid +// polluting other tests in the same package. +func Reset() { + mu.Lock() + defer mu.Unlock() + registry = map[string]awscreds.CredInput{} +} diff --git a/internal/credref/registry_test.go b/internal/credref/registry_test.go new file mode 100644 index 0000000..cd43cb7 --- /dev/null +++ b/internal/credref/registry_test.go @@ -0,0 +1,90 @@ +package credref + +import ( + "sync" + "testing" + + "github.com/GoCodeAlone/workflow-plugin-aws/internal/awscreds" +) + +func TestRegister_FirstCallSucceeds(t *testing.T) { + t.Cleanup(Reset) + if err := Register("primary", awscreds.CredInput{Region: "us-east-1"}); err != nil { + t.Fatalf("Register: %v", err) + } +} + +func TestRegister_DuplicateNameErrors(t *testing.T) { + t.Cleanup(Reset) + if err := Register("dup", awscreds.CredInput{Region: "us-east-1"}); err != nil { + t.Fatalf("first Register: %v", err) + } + if err := Register("dup", awscreds.CredInput{Region: "us-west-2"}); err == nil { + t.Fatal("expected error on duplicate Register; got nil") + } +} + +func TestResolve_RoundTrip(t *testing.T) { + t.Cleanup(Reset) + want := awscreds.CredInput{ + Region: "us-east-1", + AccessKey: "AKID", + SecretKey: "SECRET", + Source: "static", + } + if err := Register("rt", want); err != nil { + t.Fatalf("Register: %v", err) + } + got, ok := Resolve("rt") + if !ok { + t.Fatal("Resolve(rt): not found") + } + if got != want { + t.Errorf("round-trip = %+v, want %+v", got, want) + } +} + +func TestResolve_MissingReturnsZeroAndFalse(t *testing.T) { + t.Cleanup(Reset) + got, ok := Resolve("nope") + if ok { + t.Fatal("Resolve(nope): expected ok=false") + } + if got != (awscreds.CredInput{}) { + t.Errorf("missing entry should be zero-value, got %+v", got) + } +} + +func TestReset_ClearsRegistry(t *testing.T) { + if err := Register("ephemeral", awscreds.CredInput{Region: "us-east-1"}); err != nil { + t.Fatalf("Register: %v", err) + } + Reset() + if _, ok := Resolve("ephemeral"); ok { + t.Error("Reset did not clear the entry") + } +} + +func TestConcurrentRegisterResolve_RaceClean(t *testing.T) { + t.Cleanup(Reset) + const N = 64 + var wg sync.WaitGroup + wg.Add(N * 2) + for i := 0; i < N; i++ { + i := i + go func() { + defer wg.Done() + _ = Register(fmtName(i), awscreds.CredInput{Region: fmtName(i)}) + }() + go func() { + defer wg.Done() + _, _ = Resolve(fmtName(i)) + }() + } + wg.Wait() +} + +func fmtName(i int) string { + const hex = "0123456789abcdef" + return "k-" + string([]byte{hex[(i>>4)&0xf], hex[i&0xf]}) +} diff --git a/internal/host_conformance_test.go b/internal/host_conformance_test.go index bcff0d4..f2531ad 100644 --- a/internal/host_conformance_test.go +++ b/internal/host_conformance_test.go @@ -174,3 +174,80 @@ func TestCapabilityParity_IaCStateBackends(t *testing.T) { } } } + +// TestPluginJSONCapabilities_ModuleStep_Parity asserts that the type-name +// keys in the providers wired into IaCServeOptions (ModuleProviders / +// StepProviders) exactly match plugin.json's capabilities.moduleTypes / +// capabilities.stepTypes (modulo the always-implicit "iac.provider" module +// type, which is served via the IaC contract surface and is NOT a +// standalone-module provider). +// +// This is the in-process equivalent of the gRPC bridge's GetModuleTypes / +// GetStepTypes RPCs (the bridge surfaces ModuleProviders' keys verbatim via +// plan-2 PR 1's mapBackedProvider adapter), and catches drift between +// "what main.go wires" and "what plugin.json declares". +func TestPluginJSONCapabilities_ModuleStep_Parity(t *testing.T) { + repoRoot := hostConformanceRepoRoot(t) + data, err := os.ReadFile(filepath.Join(repoRoot, "plugin.json")) + if err != nil { + t.Fatalf("read plugin.json: %v", err) + } + var manifest struct { + Capabilities struct { + ModuleTypes []string `json:"moduleTypes"` + StepTypes []string `json:"stepTypes"` + } `json:"capabilities"` + } + if err := json.Unmarshal(data, &manifest); err != nil { + t.Fatalf("parse plugin.json: %v", err) + } + + // Module-type parity. plugin.json includes the implicit "iac.provider" + // (served via the IaC contract, NOT via ModuleProviders); filter it out + // before comparing. + declaredModules := map[string]bool{} + for _, m := range manifest.Capabilities.ModuleTypes { + if m == "iac.provider" { + continue + } + declaredModules[m] = true + } + servedModules := map[string]bool{} + for name := range ModuleProviders() { + servedModules[name] = true + } + for m := range declaredModules { + if !servedModules[m] { + t.Errorf("plugin.json declares moduleTypes entry %q but ModuleProviders does not serve it (served: %v)", + m, servedModules) + } + } + for m := range servedModules { + if !declaredModules[m] { + t.Errorf("ModuleProviders serves %q but plugin.json capabilities.moduleTypes does not declare it (declared: %v)", + m, declaredModules) + } + } + + // Step-type parity. + declaredSteps := map[string]bool{} + for _, s := range manifest.Capabilities.StepTypes { + declaredSteps[s] = true + } + servedSteps := map[string]bool{} + for name := range StepProviders() { + servedSteps[name] = true + } + for s := range declaredSteps { + if !servedSteps[s] { + t.Errorf("plugin.json declares stepTypes entry %q but StepProviders does not serve it (served: %v)", + s, servedSteps) + } + } + for s := range servedSteps { + if !declaredSteps[s] { + t.Errorf("StepProviders serves %q but plugin.json capabilities.stepTypes does not declare it (declared: %v)", + s, declaredSteps) + } + } +} diff --git a/internal/modules/aws_credentials.go b/internal/modules/aws_credentials.go new file mode 100644 index 0000000..769ec3d --- /dev/null +++ b/internal/modules/aws_credentials.go @@ -0,0 +1,80 @@ +// Package modules implements the aws plugin's standalone-module surface: +// plugin-native modules (aws.credentials, storage.s3, step.s3_upload, ...) +// the host constructs via the IaC plugin's CreateModule path (plan-2 PR 1 +// wired sdk.IaCServeOptions.Modules into the iacGRPCPlugin bridge). +// +// aws.credentials is the optional DRY module: a config declares credentials +// once under a module name; sibling modules (storage.s3, step.s3_upload, +// etc.) reference them via `credentials_ref:` instead of repeating the +// inline `credentials:` block. +package modules + +import ( + "context" + + "github.com/GoCodeAlone/workflow-plugin-aws/internal/awscreds" + "github.com/GoCodeAlone/workflow-plugin-aws/internal/credref" + sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" +) + +// AWSCredentialsProvider implements sdk.ModuleProvider for the +// "aws.credentials" standalone-module type. +type AWSCredentialsProvider struct{} + +// NewAWSCredentialsProvider returns a fresh provider. +func NewAWSCredentialsProvider() *AWSCredentialsProvider { + return &AWSCredentialsProvider{} +} + +// ModuleTypes reports the single module type this Provider serves. +func (p *AWSCredentialsProvider) ModuleTypes() []string { + return []string{"aws.credentials"} +} + +// CreateModule parses the YAML `credentials:` block (plus the top-level +// `region` field) into an awscreds.CredInput and registers it in the +// process-local credref registry under the module name. A duplicate name +// fails loudly — credentials_ref names must be unique. +// +// CredInput.Source is populated from `credentials.type` (the YAML field — +// "static" | "env" | "profile" | "role_arn") so BuildAWSConfig honours +// the user-declared source path. The field is NOT read from +// CloudAccount.Extra (which never crosses the gRPC boundary). +func (p *AWSCredentialsProvider) CreateModule(_, name string, config map[string]any) (sdk.ModuleInstance, error) { + credsMap, _ := config["credentials"].(map[string]any) + c := awscreds.CredInput{ + Region: stringField(config, "region"), + AccessKey: stringField(credsMap, "accessKey"), + SecretKey: stringField(credsMap, "secretKey"), + SessionToken: stringField(credsMap, "sessionToken"), + RoleARN: stringField(credsMap, "roleArn"), + ExternalID: stringField(credsMap, "externalId"), + Profile: stringField(credsMap, "profile"), + SessionName: stringField(credsMap, "sessionName"), + Source: stringField(credsMap, "type"), + } + if err := credref.Register(name, c); err != nil { + return nil, err + } + return &awsCredentialsInstance{name: name}, nil +} + +// awsCredentialsInstance is the lifecycle-only module instance the +// aws.credentials Provider returns. The actual credential effect was the +// credref.Register call in CreateModule; the instance itself has no +// runtime behavior. +type awsCredentialsInstance struct { + name string +} + +func (m *awsCredentialsInstance) Init() error { return nil } +func (m *awsCredentialsInstance) Start(_ context.Context) error { return nil } +func (m *awsCredentialsInstance) Stop(_ context.Context) error { return nil } + +func stringField(m map[string]any, k string) string { + if m == nil { + return "" + } + v, _ := m[k].(string) + return v +} diff --git a/internal/modules/aws_credentials_test.go b/internal/modules/aws_credentials_test.go new file mode 100644 index 0000000..fb7ab7d --- /dev/null +++ b/internal/modules/aws_credentials_test.go @@ -0,0 +1,123 @@ +package modules + +import ( + "context" + "testing" + + "github.com/GoCodeAlone/workflow-plugin-aws/internal/credref" +) + +func TestAWSCredentialsProvider_ModuleTypes(t *testing.T) { + p := NewAWSCredentialsProvider() + types := p.ModuleTypes() + if len(types) != 1 || types[0] != "aws.credentials" { + t.Errorf("ModuleTypes = %v, want [aws.credentials]", types) + } +} + +func TestAWSCredentialsProvider_CreateModule_RegistersCredentials(t *testing.T) { + t.Cleanup(credref.Reset) + p := NewAWSCredentialsProvider() + + cfg := map[string]any{ + "region": "us-east-1", + "credentials": map[string]any{ + "type": "static", + "accessKey": "AKID", + "secretKey": "SECRET", + "sessionToken": "TOKEN", + }, + } + inst, err := p.CreateModule("aws.credentials", "default-creds", cfg) + if err != nil { + t.Fatalf("CreateModule: %v", err) + } + if inst == nil { + t.Fatal("CreateModule returned nil instance") + } + + got, ok := credref.Resolve("default-creds") + if !ok { + t.Fatal("credref.Resolve(default-creds): not found") + } + if got.Source != "static" { + t.Errorf("Source = %q, want static", got.Source) + } + if got.AccessKey != "AKID" || got.SecretKey != "SECRET" || got.SessionToken != "TOKEN" { + t.Errorf("creds = %q/%q/%q, want AKID/SECRET/TOKEN", got.AccessKey, got.SecretKey, got.SessionToken) + } + if got.Region != "us-east-1" { + t.Errorf("Region = %q, want us-east-1", got.Region) + } +} + +func TestAWSCredentialsProvider_CreateModule_ProfileType(t *testing.T) { + t.Cleanup(credref.Reset) + p := NewAWSCredentialsProvider() + cfg := map[string]any{ + "credentials": map[string]any{ + "type": "profile", + "profile": "dev", + }, + } + if _, err := p.CreateModule("aws.credentials", "p", cfg); err != nil { + t.Fatalf("CreateModule: %v", err) + } + got, _ := credref.Resolve("p") + if got.Source != "profile" || got.Profile != "dev" { + t.Errorf("Source/Profile = %q/%q, want profile/dev", got.Source, got.Profile) + } +} + +func TestAWSCredentialsProvider_CreateModule_RoleARNType(t *testing.T) { + t.Cleanup(credref.Reset) + p := NewAWSCredentialsProvider() + cfg := map[string]any{ + "credentials": map[string]any{ + "type": "role_arn", + "roleArn": "arn:aws:iam::123456789012:role/r", + "externalId": "ext", + "sessionName": "s", + }, + } + if _, err := p.CreateModule("aws.credentials", "r", cfg); err != nil { + t.Fatalf("CreateModule: %v", err) + } + got, _ := credref.Resolve("r") + if got.Source != "role_arn" { + t.Errorf("Source = %q, want role_arn", got.Source) + } + if got.RoleARN != "arn:aws:iam::123456789012:role/r" || got.ExternalID != "ext" || got.SessionName != "s" { + t.Errorf("role fields = %q/%q/%q, want arn:.../ext/s", got.RoleARN, got.ExternalID, got.SessionName) + } +} + +func TestAWSCredentialsProvider_CreateModule_DuplicateNameErrors(t *testing.T) { + t.Cleanup(credref.Reset) + p := NewAWSCredentialsProvider() + cfg := map[string]any{"credentials": map[string]any{"type": "static"}} + if _, err := p.CreateModule("aws.credentials", "dup", cfg); err != nil { + t.Fatalf("first CreateModule: %v", err) + } + if _, err := p.CreateModule("aws.credentials", "dup", cfg); err == nil { + t.Fatal("expected duplicate-name error on second CreateModule with same name") + } +} + +func TestAWSCredentialsInstance_LifecycleIsNoOp(t *testing.T) { + t.Cleanup(credref.Reset) + p := NewAWSCredentialsProvider() + inst, err := p.CreateModule("aws.credentials", "lifecycle", map[string]any{}) + if err != nil { + t.Fatalf("CreateModule: %v", err) + } + if err := inst.Init(); err != nil { + t.Errorf("Init: %v", err) + } + if err := inst.Start(context.Background()); err != nil { + t.Errorf("Start: %v", err) + } + if err := inst.Stop(context.Background()); err != nil { + t.Errorf("Stop: %v", err) + } +} diff --git a/internal/modules/storage_s3.go b/internal/modules/storage_s3.go new file mode 100644 index 0000000..0c0502b --- /dev/null +++ b/internal/modules/storage_s3.go @@ -0,0 +1,218 @@ +// storage_s3.go — plugin-native storage.s3 module. +// +// Ports workflow core's module/s3_storage.go (S3Storage) into the aws +// plugin. Credentials flow through awscreds.BuildAWSConfig: either an +// inline `credentials:` block in the module config, or `credentials_ref:` +// resolving to an aws.credentials module registered in the credref registry. +package modules + +import ( + "context" + "fmt" + "io" + "sync" + + "github.com/aws/aws-sdk-go-v2/service/s3" + + "github.com/GoCodeAlone/workflow-plugin-aws/internal/awscreds" + "github.com/GoCodeAlone/workflow-plugin-aws/internal/credref" + sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" +) + +// S3StorageProvider implements sdk.ModuleProvider for the "storage.s3" +// standalone-module type. +type S3StorageProvider struct{} + +// NewS3StorageProvider returns a fresh provider. +func NewS3StorageProvider() *S3StorageProvider { + return &S3StorageProvider{} +} + +// ModuleTypes reports the single module type this Provider serves. +func (p *S3StorageProvider) ModuleTypes() []string { + return []string{"storage.s3"} +} + +// CreateModule parses the storage.s3 config and returns a lifecycle-ready +// module instance. Bucket is required. Credentials come from either an +// inline `credentials:` sub-block OR `credentials_ref:` (a sibling +// aws.credentials module name registered in the credref registry). +// `credentials:` and `credentials_ref:` are mutually exclusive; inline wins +// when both are supplied (mirroring upstream config-merge semantics). +func (p *S3StorageProvider) CreateModule(_, name string, config map[string]any) (sdk.ModuleInstance, error) { + bucket := stringField(config, "bucket") + if bucket == "" { + return nil, fmt.Errorf("storage.s3 %q: 'bucket' is required", name) + } + + cred, err := resolveAWSCredentials(name, config) + if err != nil { + return nil, err + } + + return &s3StorageInstance{ + name: name, + bucket: bucket, + region: stringField(config, "region"), + endpoint: stringField(config, "endpoint"), + cred: cred, + }, nil +} + +// resolveAWSCredentials decodes the config's credentials surface into an +// awscreds.CredInput. An inline `credentials:` block beats `credentials_ref:`; +// a credentials_ref to an unregistered name is a clean error. +func resolveAWSCredentials(moduleName string, config map[string]any) (awscreds.CredInput, error) { + region := stringField(config, "region") + if credsMap, ok := config["credentials"].(map[string]any); ok && len(credsMap) > 0 { + return awscreds.CredInput{ + Region: region, + AccessKey: stringField(credsMap, "accessKey"), + SecretKey: stringField(credsMap, "secretKey"), + SessionToken: stringField(credsMap, "sessionToken"), + RoleARN: stringField(credsMap, "roleArn"), + ExternalID: stringField(credsMap, "externalId"), + Profile: stringField(credsMap, "profile"), + SessionName: stringField(credsMap, "sessionName"), + Source: stringField(credsMap, "type"), + }, nil + } + if ref := stringField(config, "credentials_ref"); ref != "" { + c, ok := credref.Resolve(ref) + if !ok { + return awscreds.CredInput{}, fmt.Errorf( + "storage.s3 %q: credentials_ref %q not found; declare an aws.credentials module first", + moduleName, ref) + } + // If the module's own region overrides the referenced creds' region, + // honour it — the storage module's region wins over the cred's. + if region != "" { + c.Region = region + } + return c, nil + } + // No credentials surface → default credential chain (BuildAWSConfig + // returns a default-chain config; honour region if set). + return awscreds.CredInput{Region: region}, nil +} + +// s3API is the subset of *s3.Client the storage module calls. Lets tests +// inject a mock without spinning up a real S3 endpoint. +type s3API interface { + PutObject(ctx context.Context, input *s3.PutObjectInput, opts ...func(*s3.Options)) (*s3.PutObjectOutput, error) + GetObject(ctx context.Context, input *s3.GetObjectInput, opts ...func(*s3.Options)) (*s3.GetObjectOutput, error) + DeleteObject(ctx context.Context, input *s3.DeleteObjectInput, opts ...func(*s3.Options)) (*s3.DeleteObjectOutput, error) +} + +// s3StorageInstance is the lifecycle + storage surface returned by +// CreateModule. +type s3StorageInstance struct { + name string + bucket string + region string + endpoint string + cred awscreds.CredInput + + mu sync.Mutex + client s3API +} + +// SetTestClient injects a fake s3API for tests so Storage operations can be +// exercised without a real S3 endpoint. +func (m *s3StorageInstance) SetTestClient(c s3API) { + m.mu.Lock() + defer m.mu.Unlock() + m.client = c +} + +func (m *s3StorageInstance) Init() error { return nil } + +func (m *s3StorageInstance) Start(ctx context.Context) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.client != nil { + // SetTestClient already populated; skip real client construction. + return nil + } + cfg, err := awscreds.BuildAWSConfig(ctx, m.cred) + if err != nil { + return fmt.Errorf("storage.s3 %q: load AWS config: %w", m.name, err) + } + if m.region != "" { + cfg.Region = m.region + } + + var opts []func(*s3.Options) + if m.endpoint != "" { + ep := m.endpoint + opts = append(opts, func(o *s3.Options) { + o.BaseEndpoint = &ep + o.UsePathStyle = true + }) + } + m.client = s3.NewFromConfig(cfg, opts...) + return nil +} + +func (m *s3StorageInstance) Stop(_ context.Context) error { + m.mu.Lock() + defer m.mu.Unlock() + m.client = nil + return nil +} + +func (m *s3StorageInstance) getClient() (s3API, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.client == nil { + return nil, fmt.Errorf("storage.s3 %q: client not initialized; call Start first", m.name) + } + return m.client, nil +} + +// PutObject uploads an object to S3. +func (m *s3StorageInstance) PutObject(ctx context.Context, key string, body io.Reader) error { + c, err := m.getClient() + if err != nil { + return err + } + if _, err := c.PutObject(ctx, &s3.PutObjectInput{ + Bucket: &m.bucket, + Key: &key, + Body: body, + }); err != nil { + return fmt.Errorf("storage.s3 %q: put %q: %w", m.name, key, err) + } + return nil +} + +// GetObject retrieves an object from S3. +func (m *s3StorageInstance) GetObject(ctx context.Context, key string) (io.ReadCloser, error) { + c, err := m.getClient() + if err != nil { + return nil, err + } + result, err := c.GetObject(ctx, &s3.GetObjectInput{ + Bucket: &m.bucket, + Key: &key, + }) + if err != nil { + return nil, fmt.Errorf("storage.s3 %q: get %q: %w", m.name, key, err) + } + return result.Body, nil +} + +// DeleteObject removes an object from S3. +func (m *s3StorageInstance) DeleteObject(ctx context.Context, key string) error { + c, err := m.getClient() + if err != nil { + return err + } + if _, err := c.DeleteObject(ctx, &s3.DeleteObjectInput{ + Bucket: &m.bucket, + Key: &key, + }); err != nil { + return fmt.Errorf("storage.s3 %q: delete %q: %w", m.name, key, err) + } + return nil +} diff --git a/internal/modules/storage_s3_test.go b/internal/modules/storage_s3_test.go new file mode 100644 index 0000000..f9e37ff --- /dev/null +++ b/internal/modules/storage_s3_test.go @@ -0,0 +1,262 @@ +package modules + +import ( + "bytes" + "context" + "errors" + "io" + "strings" + "sync" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/s3" + + "github.com/GoCodeAlone/workflow-plugin-aws/internal/awscreds" + "github.com/GoCodeAlone/workflow-plugin-aws/internal/credref" +) + +func TestS3StorageProvider_ModuleTypes(t *testing.T) { + p := NewS3StorageProvider() + if got := p.ModuleTypes(); len(got) != 1 || got[0] != "storage.s3" { + t.Errorf("ModuleTypes = %v, want [storage.s3]", got) + } +} + +func TestS3StorageProvider_CreateModule_RequiresBucket(t *testing.T) { + p := NewS3StorageProvider() + if _, err := p.CreateModule("storage.s3", "nobucket", map[string]any{}); err == nil { + t.Fatal("expected error when bucket is missing") + } +} + +func TestS3StorageProvider_CreateModule_InlineCredentials(t *testing.T) { + p := NewS3StorageProvider() + cfg := map[string]any{ + "bucket": "b1", + "region": "us-east-2", + "endpoint": "https://minio.local", + "credentials": map[string]any{ + "type": "static", + "accessKey": "AKID", + "secretKey": "SECRET", + }, + } + inst, err := p.CreateModule("storage.s3", "inline", cfg) + if err != nil { + t.Fatalf("CreateModule: %v", err) + } + m, ok := inst.(*s3StorageInstance) + if !ok { + t.Fatalf("CreateModule returned %T, want *s3StorageInstance", inst) + } + if m.bucket != "b1" || m.region != "us-east-2" || m.endpoint != "https://minio.local" { + t.Errorf("fields = %q/%q/%q, want b1/us-east-2/https://minio.local", + m.bucket, m.region, m.endpoint) + } + if m.cred.Source != "static" || m.cred.AccessKey != "AKID" || m.cred.SecretKey != "SECRET" { + t.Errorf("cred = %+v, want static/AKID/SECRET", m.cred) + } + if m.cred.Region != "us-east-2" { + t.Errorf("cred.Region = %q, want us-east-2 (module region propagates)", m.cred.Region) + } +} + +func TestS3StorageProvider_CreateModule_CredentialsRef(t *testing.T) { + t.Cleanup(credref.Reset) + want := awscreds.CredInput{ + AccessKey: "AKIDREF", + SecretKey: "SECRETREF", + Source: "static", + } + if err := credref.Register("shared-creds", want); err != nil { + t.Fatalf("Register: %v", err) + } + + p := NewS3StorageProvider() + cfg := map[string]any{ + "bucket": "b2", + "region": "us-west-2", + "credentials_ref": "shared-creds", + } + inst, err := p.CreateModule("storage.s3", "ref", cfg) + if err != nil { + t.Fatalf("CreateModule: %v", err) + } + m := inst.(*s3StorageInstance) + if m.cred.AccessKey != "AKIDREF" || m.cred.SecretKey != "SECRETREF" { + t.Errorf("resolved cred keys = %q/%q, want AKIDREF/SECRETREF", + m.cred.AccessKey, m.cred.SecretKey) + } + if m.cred.Region != "us-west-2" { + t.Errorf("cred.Region = %q, want us-west-2 (module region overrides ref's region)", m.cred.Region) + } +} + +func TestS3StorageProvider_CreateModule_CredentialsRef_MissingErrors(t *testing.T) { + t.Cleanup(credref.Reset) + p := NewS3StorageProvider() + cfg := map[string]any{ + "bucket": "b3", + "credentials_ref": "does-not-exist", + } + _, err := p.CreateModule("storage.s3", "missing", cfg) + if err == nil { + t.Fatal("expected error when credentials_ref is unregistered") + } + if !strings.Contains(err.Error(), "credentials_ref \"does-not-exist\" not found") { + t.Errorf("error = %q, expected to mention the missing ref name", err) + } +} + +func TestS3StorageProvider_CreateModule_InlineBeatsRef(t *testing.T) { + t.Cleanup(credref.Reset) + _ = credref.Register("would-lose", awscreds.CredInput{AccessKey: "REFSIDE"}) + p := NewS3StorageProvider() + cfg := map[string]any{ + "bucket": "b", + "credentials": map[string]any{ + "type": "static", + "accessKey": "INLINESIDE", + "secretKey": "x", + }, + "credentials_ref": "would-lose", + } + inst, err := p.CreateModule("storage.s3", "both", cfg) + if err != nil { + t.Fatalf("CreateModule: %v", err) + } + m := inst.(*s3StorageInstance) + if m.cred.AccessKey != "INLINESIDE" { + t.Errorf("AccessKey = %q, want INLINESIDE (inline beats credentials_ref)", m.cred.AccessKey) + } +} + +func TestS3StorageProvider_CreateModule_NoCredsDefaultChain(t *testing.T) { + p := NewS3StorageProvider() + inst, err := p.CreateModule("storage.s3", "default", map[string]any{ + "bucket": "b", + "region": "us-east-1", + }) + if err != nil { + t.Fatalf("CreateModule: %v", err) + } + m := inst.(*s3StorageInstance) + if m.cred.Source != "" || m.cred.AccessKey != "" { + t.Errorf("expected zero CredInput (default chain), got %+v", m.cred) + } + if m.cred.Region != "us-east-1" { + t.Errorf("cred.Region = %q, want us-east-1", m.cred.Region) + } +} + +// ── Lifecycle + Storage-operation tests (via injected mock client) ────────── + +func TestS3StorageInstance_Lifecycle_TestSeam(t *testing.T) { + p := NewS3StorageProvider() + inst, _ := p.CreateModule("storage.s3", "lifecycle", map[string]any{"bucket": "b"}) + m := inst.(*s3StorageInstance) + m.SetTestClient(newMockS3()) + + if err := m.Init(); err != nil { + t.Errorf("Init: %v", err) + } + if err := m.Start(context.Background()); err != nil { + t.Errorf("Start: %v", err) + } + if err := m.Stop(context.Background()); err != nil { + t.Errorf("Stop: %v", err) + } +} + +func TestS3StorageInstance_StorageOperations_RoundTrip(t *testing.T) { + p := NewS3StorageProvider() + inst, _ := p.CreateModule("storage.s3", "ops", map[string]any{"bucket": "test-bucket"}) + m := inst.(*s3StorageInstance) + mock := newMockS3() + m.SetTestClient(mock) + _ = m.Start(context.Background()) + ctx := context.Background() + + // Put. + if err := m.PutObject(ctx, "k1", bytes.NewReader([]byte("hello"))); err != nil { + t.Fatalf("PutObject: %v", err) + } + if mock.bucket != "test-bucket" { + t.Errorf("mock bucket = %q, want test-bucket", mock.bucket) + } + // Get. + r, err := m.GetObject(ctx, "k1") + if err != nil { + t.Fatalf("GetObject: %v", err) + } + got, _ := io.ReadAll(r) + _ = r.Close() + if string(got) != "hello" { + t.Errorf("GetObject returned %q, want hello", got) + } + // Delete. + if err := m.DeleteObject(ctx, "k1"); err != nil { + t.Fatalf("DeleteObject: %v", err) + } + if _, err := m.GetObject(ctx, "k1"); err == nil { + t.Error("GetObject after Delete: expected error") + } +} + +func TestS3StorageInstance_OperationsBeforeStartError(t *testing.T) { + p := NewS3StorageProvider() + inst, _ := p.CreateModule("storage.s3", "no-start", map[string]any{"bucket": "b"}) + m := inst.(*s3StorageInstance) + if err := m.PutObject(context.Background(), "k", bytes.NewReader(nil)); err == nil { + t.Error("PutObject without Start: expected error") + } + if _, err := m.GetObject(context.Background(), "k"); err == nil { + t.Error("GetObject without Start: expected error") + } + if err := m.DeleteObject(context.Background(), "k"); err == nil { + t.Error("DeleteObject without Start: expected error") + } +} + +// mockS3 is an in-memory s3API for tests. +type mockS3 struct { + mu sync.Mutex + objects map[string][]byte + bucket string +} + +func newMockS3() *mockS3 { + return &mockS3{objects: make(map[string][]byte)} +} + +func (m *mockS3) PutObject(_ context.Context, in *s3.PutObjectInput, _ ...func(*s3.Options)) (*s3.PutObjectOutput, error) { + m.mu.Lock() + defer m.mu.Unlock() + m.bucket = aws.ToString(in.Bucket) + key := aws.ToString(in.Key) + data, err := io.ReadAll(in.Body) + if err != nil { + return nil, err + } + m.objects[key] = data + return &s3.PutObjectOutput{}, nil +} + +func (m *mockS3) GetObject(_ context.Context, in *s3.GetObjectInput, _ ...func(*s3.Options)) (*s3.GetObjectOutput, error) { + m.mu.Lock() + defer m.mu.Unlock() + key := aws.ToString(in.Key) + data, ok := m.objects[key] + if !ok { + return nil, errors.New("NoSuchKey") + } + return &s3.GetObjectOutput{Body: io.NopCloser(bytes.NewReader(data))}, nil +} + +func (m *mockS3) DeleteObject(_ context.Context, in *s3.DeleteObjectInput, _ ...func(*s3.Options)) (*s3.DeleteObjectOutput, error) { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.objects, aws.ToString(in.Key)) + return &s3.DeleteObjectOutput{}, nil +} diff --git a/internal/plugin_options.go b/internal/plugin_options.go new file mode 100644 index 0000000..a734930 --- /dev/null +++ b/internal/plugin_options.go @@ -0,0 +1,34 @@ +// plugin_options.go — single source of truth for the providers wired into +// sdk.IaCServeOptions. main.go and the host-conformance parity test both +// consume these helpers so plugin.json declarations and the running plugin's +// surface cannot drift. +package internal + +import ( + "github.com/GoCodeAlone/workflow-plugin-aws/internal/modules" + "github.com/GoCodeAlone/workflow-plugin-aws/internal/steps" + sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" +) + +// ModuleProviders returns the type-name → sdk.ModuleProvider map the plugin +// surfaces via IaCServeOptions.Modules. +// +// The map keys MUST equal plugin.json `capabilities.moduleTypes`; the parity +// test in host_conformance_test.go enforces that invariant. +func ModuleProviders() map[string]sdk.ModuleProvider { + return map[string]sdk.ModuleProvider{ + "aws.credentials": modules.NewAWSCredentialsProvider(), + "storage.s3": modules.NewS3StorageProvider(), + } +} + +// StepProviders returns the type-name → sdk.StepProvider map the plugin +// surfaces via IaCServeOptions.Steps. +// +// The map keys MUST equal plugin.json `capabilities.stepTypes`; the parity +// test in host_conformance_test.go enforces that invariant. +func StepProviders() map[string]sdk.StepProvider { + return map[string]sdk.StepProvider{ + "step.s3_upload": steps.NewS3UploadStepProvider(), + } +} diff --git a/internal/steps/s3_upload.go b/internal/steps/s3_upload.go new file mode 100644 index 0000000..8e49951 --- /dev/null +++ b/internal/steps/s3_upload.go @@ -0,0 +1,298 @@ +// Package steps implements the aws plugin's standalone pipeline steps. +// step.s3_upload uploads base64-encoded body content from the pipeline +// context to S3 and returns {url, key, bucket} in the step output. +// +// Ports workflow core's module/pipeline_step_s3_upload.go behavior into the +// plugin. Credentials flow through awscreds.BuildAWSConfig: either an +// inline `credentials:` block in the step config, or `credentials_ref:` +// resolving to an aws.credentials module registered in the credref registry. +package steps + +import ( + "bytes" + "context" + "encoding/base64" + "fmt" + "os" + "strings" + "text/template" + + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/google/uuid" + + "github.com/GoCodeAlone/workflow-plugin-aws/internal/awscreds" + "github.com/GoCodeAlone/workflow-plugin-aws/internal/credref" + sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" +) + +// s3PutObjectAPI is the minimal S3 surface the step calls. Lets tests inject +// a fake without spinning up a real S3 endpoint. +type s3PutObjectAPI interface { + PutObject(ctx context.Context, input *s3.PutObjectInput, opts ...func(*s3.Options)) (*s3.PutObjectOutput, error) +} + +// S3UploadStepProvider implements sdk.StepProvider for "step.s3_upload". +type S3UploadStepProvider struct{} + +// NewS3UploadStepProvider returns a fresh provider. +func NewS3UploadStepProvider() *S3UploadStepProvider { + return &S3UploadStepProvider{} +} + +// StepTypes reports the single step type this Provider serves. +func (p *S3UploadStepProvider) StepTypes() []string { + return []string{"step.s3_upload"} +} + +// CreateStep parses the step config and returns a ready-to-Execute instance. +// Required: bucket, region, key, body_from. Optional: endpoint, +// content_type, content_type_from, credentials | credentials_ref. +func (p *S3UploadStepProvider) CreateStep(_, name string, config map[string]any) (sdk.StepInstance, error) { + bucket := os.ExpandEnv(stringField(config, "bucket")) + if bucket == "" { + return nil, fmt.Errorf("step.s3_upload %q: 'bucket' is required", name) + } + region := os.ExpandEnv(stringField(config, "region")) + if region == "" { + return nil, fmt.Errorf("step.s3_upload %q: 'region' is required", name) + } + key := stringField(config, "key") + if key == "" { + return nil, fmt.Errorf("step.s3_upload %q: 'key' is required", name) + } + bodyFrom := stringField(config, "body_from") + if bodyFrom == "" { + return nil, fmt.Errorf("step.s3_upload %q: 'body_from' is required", name) + } + + cred, err := resolveCreds(name, config, region) + if err != nil { + return nil, err + } + + return &s3UploadStep{ + name: name, + bucket: bucket, + region: region, + keyTmpl: key, + bodyFrom: bodyFrom, + contentType: stringField(config, "content_type"), + contentTypeFrom: stringField(config, "content_type_from"), + endpoint: os.ExpandEnv(stringField(config, "endpoint")), + cred: cred, + }, nil +} + +// resolveCreds reads the inline `credentials:` block or `credentials_ref:` +// and returns the awscreds.CredInput. Inline beats ref; missing ref errors. +func resolveCreds(stepName string, config map[string]any, region string) (awscreds.CredInput, error) { + if credsMap, ok := config["credentials"].(map[string]any); ok && len(credsMap) > 0 { + return awscreds.CredInput{ + Region: region, + AccessKey: stringField(credsMap, "accessKey"), + SecretKey: stringField(credsMap, "secretKey"), + SessionToken: stringField(credsMap, "sessionToken"), + RoleARN: stringField(credsMap, "roleArn"), + ExternalID: stringField(credsMap, "externalId"), + Profile: stringField(credsMap, "profile"), + SessionName: stringField(credsMap, "sessionName"), + Source: stringField(credsMap, "type"), + }, nil + } + if ref := stringField(config, "credentials_ref"); ref != "" { + c, ok := credref.Resolve(ref) + if !ok { + return awscreds.CredInput{}, fmt.Errorf( + "step.s3_upload %q: credentials_ref %q not found; declare an aws.credentials module first", + stepName, ref) + } + if region != "" { + c.Region = region + } + return c, nil + } + return awscreds.CredInput{Region: region}, nil +} + +// s3UploadStep is the StepInstance returned by CreateStep. +type s3UploadStep struct { + name string + bucket string + region string + keyTmpl string + bodyFrom string + contentType string + contentTypeFrom string + endpoint string + cred awscreds.CredInput + + // testClient is an optional injection seam — tests set it to bypass + // real S3 client construction. + testClient s3PutObjectAPI +} + +// SetTestClient injects a fake S3 client for tests. +func (s *s3UploadStep) SetTestClient(c s3PutObjectAPI) { s.testClient = c } + +// Execute uploads the resolved body to S3 and returns {url, key, bucket}. +func (s *s3UploadStep) Execute( + ctx context.Context, + _ map[string]any, // triggerData (unused — body comes from step outputs / current) + stepOutputs map[string]map[string]any, + current map[string]any, + _ map[string]any, // metadata (unused) + _ map[string]any, // config (parsed at CreateStep) +) (*sdk.StepResult, error) { + pcData := buildPipelineData(stepOutputs, current) + + resolvedKey, err := renderTemplate(s.keyTmpl, pcData) + if err != nil { + return nil, fmt.Errorf("step.s3_upload %q: resolve key template: %w", s.name, err) + } + + bodyVal, err := resolveDottedPath(pcData, s.bodyFrom) + if err != nil { + return nil, fmt.Errorf("step.s3_upload %q: body_from %q: %w", s.name, s.bodyFrom, err) + } + bodyStr, ok := bodyVal.(string) + if !ok { + return nil, fmt.Errorf("step.s3_upload %q: body_from value must be a base64 string, got %T", s.name, bodyVal) + } + bodyBytes, err := base64.StdEncoding.DecodeString(bodyStr) + if err != nil { + return nil, fmt.Errorf("step.s3_upload %q: base64 decode body: %w", s.name, err) + } + + contentType := s.resolveContentType(pcData) + + client, err := s.getClient(ctx) + if err != nil { + return nil, fmt.Errorf("step.s3_upload %q: build S3 client: %w", s.name, err) + } + + input := &s3.PutObjectInput{ + Bucket: &s.bucket, + Key: &resolvedKey, + Body: bytes.NewReader(bodyBytes), + } + if contentType != "" { + input.ContentType = &contentType + } + if _, err := client.PutObject(ctx, input); err != nil { + return nil, fmt.Errorf("step.s3_upload %q: PutObject %q: %w", s.name, resolvedKey, err) + } + + return &sdk.StepResult{ + Output: map[string]any{ + "url": s.buildURL(resolvedKey), + "key": resolvedKey, + "bucket": s.bucket, + }, + }, nil +} + +func (s *s3UploadStep) resolveContentType(pcData map[string]any) string { + if s.contentTypeFrom != "" { + if v, err := resolveDottedPath(pcData, s.contentTypeFrom); err == nil { + if ct, ok := v.(string); ok && ct != "" { + return ct + } + } + } + return s.contentType +} + +func (s *s3UploadStep) getClient(ctx context.Context) (s3PutObjectAPI, error) { + if s.testClient != nil { + return s.testClient, nil + } + cfg, err := awscreds.BuildAWSConfig(ctx, s.cred) + if err != nil { + return nil, err + } + if s.region != "" { + cfg.Region = s.region + } + var opts []func(*s3.Options) + if s.endpoint != "" { + ep := s.endpoint + opts = append(opts, func(o *s3.Options) { + o.BaseEndpoint = &ep + o.UsePathStyle = true + }) + } + return s3.NewFromConfig(cfg, opts...), nil +} + +// buildURL returns the virtual-hosted-style URL for the uploaded object, or +// an endpoint-prefixed URL when a custom endpoint is configured. +func (s *s3UploadStep) buildURL(key string) string { + if s.endpoint != "" { + return strings.TrimRight(s.endpoint, "/") + "/" + s.bucket + "/" + key + } + return fmt.Sprintf("https://%s.s3.%s.amazonaws.com/%s", s.bucket, s.region, key) +} + +// ── Helpers (mirror workflow core's pipeline_template + dot-path helpers) ─── + +// buildPipelineData merges Current map with step outputs under "steps". +func buildPipelineData(stepOutputs map[string]map[string]any, current map[string]any) map[string]any { + data := make(map[string]any, len(current)+1) + for k, v := range current { + data[k] = v + } + if len(stepOutputs) > 0 { + steps := make(map[string]any, len(stepOutputs)) + for k, v := range stepOutputs { + steps[k] = v + } + data["steps"] = steps + } + return data +} + +// resolveDottedPath walks a dot-separated path through nested map[string]any. +// "a.b.c" with data {a:{b:{c:42}}} returns 42. +func resolveDottedPath(data map[string]any, path string) (any, error) { + if path == "" { + return nil, fmt.Errorf("empty path") + } + parts := strings.Split(path, ".") + var cur any = data + for i, p := range parts { + m, ok := cur.(map[string]any) + if !ok { + return nil, fmt.Errorf("path %q: segment %d (%q) traversed a non-map", path, i, p) + } + v, ok := m[p] + if !ok { + return nil, fmt.Errorf("path %q: segment %d (%q) not found", path, i, p) + } + cur = v + } + return cur, nil +} + +// renderTemplate renders a Go text/template with a uuid funcMap that mirrors +// upstream's TemplateEngine surface ({{ .field }} and {{ uuid }}). +func renderTemplate(tmpl string, data map[string]any) (string, error) { + t, err := template.New("s3_upload_key").Funcs(template.FuncMap{ + "uuid": func() string { return uuid.New().String() }, + }).Parse(tmpl) + if err != nil { + return "", fmt.Errorf("parse: %w", err) + } + var buf bytes.Buffer + if err := t.Execute(&buf, data); err != nil { + return "", fmt.Errorf("execute: %w", err) + } + return buf.String(), nil +} + +func stringField(m map[string]any, k string) string { + if m == nil { + return "" + } + v, _ := m[k].(string) + return v +} diff --git a/internal/steps/s3_upload_test.go b/internal/steps/s3_upload_test.go new file mode 100644 index 0000000..9b1db9e --- /dev/null +++ b/internal/steps/s3_upload_test.go @@ -0,0 +1,298 @@ +package steps + +import ( + "bytes" + "context" + "encoding/base64" + "errors" + "io" + "strings" + "sync" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/s3" + + "github.com/GoCodeAlone/workflow-plugin-aws/internal/awscreds" + "github.com/GoCodeAlone/workflow-plugin-aws/internal/credref" +) + +func TestS3UploadStepProvider_StepTypes(t *testing.T) { + p := NewS3UploadStepProvider() + if got := p.StepTypes(); len(got) != 1 || got[0] != "step.s3_upload" { + t.Errorf("StepTypes = %v, want [step.s3_upload]", got) + } +} + +func TestS3UploadStepProvider_CreateStep_RequiredFields(t *testing.T) { + p := NewS3UploadStepProvider() + cases := []struct { + name string + cfg map[string]any + want string + }{ + {"missing bucket", map[string]any{"region": "r", "key": "k", "body_from": "b"}, "'bucket' is required"}, + {"missing region", map[string]any{"bucket": "b", "key": "k", "body_from": "b"}, "'region' is required"}, + {"missing key", map[string]any{"bucket": "b", "region": "r", "body_from": "b"}, "'key' is required"}, + {"missing body_from", map[string]any{"bucket": "b", "region": "r", "key": "k"}, "'body_from' is required"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := p.CreateStep("step.s3_upload", "x", tc.cfg) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Errorf("err = %v, want substring %q", err, tc.want) + } + }) + } +} + +func TestS3UploadStep_Execute_RoundTrip(t *testing.T) { + p := NewS3UploadStepProvider() + stepIface, err := p.CreateStep("step.s3_upload", "upload", map[string]any{ + "bucket": "test-bucket", + "region": "us-east-1", + "key": "uploads/{{.user_id}}/avatar.png", + "body_from": "steps.encode.payload", + }) + if err != nil { + t.Fatalf("CreateStep: %v", err) + } + step := stepIface.(*s3UploadStep) + mock := newMockPutObject() + step.SetTestClient(mock) + + body := []byte("PNGDATA\x89\x00") + encoded := base64.StdEncoding.EncodeToString(body) + + res, err := step.Execute( + context.Background(), + nil, + map[string]map[string]any{"encode": {"payload": encoded}}, + map[string]any{"user_id": "u42"}, + nil, + nil, + ) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if res == nil || res.Output == nil { + t.Fatal("nil StepResult / Output") + } + if got := res.Output["key"]; got != "uploads/u42/avatar.png" { + t.Errorf("Output[key] = %v, want uploads/u42/avatar.png", got) + } + if got := res.Output["bucket"]; got != "test-bucket" { + t.Errorf("Output[bucket] = %v, want test-bucket", got) + } + wantURL := "https://test-bucket.s3.us-east-1.amazonaws.com/uploads/u42/avatar.png" + if got := res.Output["url"]; got != wantURL { + t.Errorf("Output[url] = %v, want %s", got, wantURL) + } + + // Verify the mock saw the right PutObject input. + in := mock.last + if in == nil { + t.Fatal("PutObject was not called") + } + if aws.ToString(in.Bucket) != "test-bucket" || aws.ToString(in.Key) != "uploads/u42/avatar.png" { + t.Errorf("PutObject bucket/key = %q/%q, want test-bucket/uploads/u42/avatar.png", + aws.ToString(in.Bucket), aws.ToString(in.Key)) + } + gotBody, _ := io.ReadAll(in.Body) + if !bytes.Equal(gotBody, body) { + t.Errorf("PutObject body = %q, want %q (decoded)", gotBody, body) + } +} + +func TestS3UploadStep_Execute_BodyFromMissing(t *testing.T) { + p := NewS3UploadStepProvider() + stepIface, _ := p.CreateStep("step.s3_upload", "x", map[string]any{ + "bucket": "b", "region": "r", "key": "k", "body_from": "steps.nope.payload", + }) + step := stepIface.(*s3UploadStep) + step.SetTestClient(newMockPutObject()) + + _, err := step.Execute(context.Background(), nil, nil, nil, nil, nil) + if err == nil || !strings.Contains(err.Error(), "body_from") { + t.Errorf("err = %v, want body_from error", err) + } +} + +func TestS3UploadStep_Execute_BodyFromBadBase64(t *testing.T) { + p := NewS3UploadStepProvider() + stepIface, _ := p.CreateStep("step.s3_upload", "x", map[string]any{ + "bucket": "b", "region": "r", "key": "k", "body_from": "payload", + }) + step := stepIface.(*s3UploadStep) + step.SetTestClient(newMockPutObject()) + + _, err := step.Execute(context.Background(), nil, nil, map[string]any{"payload": "***not base64***"}, nil, nil) + if err == nil || !strings.Contains(err.Error(), "base64") { + t.Errorf("err = %v, want base64 error", err) + } +} + +func TestS3UploadStep_Execute_PutObjectErrorPropagates(t *testing.T) { + p := NewS3UploadStepProvider() + stepIface, _ := p.CreateStep("step.s3_upload", "x", map[string]any{ + "bucket": "b", "region": "r", "key": "k", "body_from": "payload", + }) + step := stepIface.(*s3UploadStep) + step.SetTestClient(&mockPutObject{err: errors.New("simulated AccessDenied")}) + + _, err := step.Execute(context.Background(), nil, nil, map[string]any{ + "payload": base64.StdEncoding.EncodeToString([]byte("data")), + }, nil, nil) + if err == nil || !strings.Contains(err.Error(), "simulated AccessDenied") { + t.Errorf("err = %v, want propagated PutObject error", err) + } +} + +func TestS3UploadStep_Execute_ContentTypeFrom(t *testing.T) { + p := NewS3UploadStepProvider() + stepIface, _ := p.CreateStep("step.s3_upload", "x", map[string]any{ + "bucket": "b", + "region": "r", + "key": "k", + "body_from": "payload", + "content_type": "application/octet-stream", + "content_type_from": "steps.detect.mime", + }) + step := stepIface.(*s3UploadStep) + mock := newMockPutObject() + step.SetTestClient(mock) + + _, err := step.Execute(context.Background(), nil, + map[string]map[string]any{"detect": {"mime": "image/png"}}, + map[string]any{"payload": base64.StdEncoding.EncodeToString([]byte("x"))}, + nil, nil) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if got := aws.ToString(mock.last.ContentType); got != "image/png" { + t.Errorf("ContentType = %q, want image/png (content_type_from beats content_type)", got) + } +} + +func TestS3UploadStep_Execute_ContentTypeFromMissingFallsBack(t *testing.T) { + p := NewS3UploadStepProvider() + stepIface, _ := p.CreateStep("step.s3_upload", "x", map[string]any{ + "bucket": "b", + "region": "r", + "key": "k", + "body_from": "payload", + "content_type": "application/octet-stream", + "content_type_from": "nope.path", + }) + step := stepIface.(*s3UploadStep) + mock := newMockPutObject() + step.SetTestClient(mock) + + _, err := step.Execute(context.Background(), nil, nil, map[string]any{ + "payload": base64.StdEncoding.EncodeToString([]byte("x")), + }, nil, nil) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if got := aws.ToString(mock.last.ContentType); got != "application/octet-stream" { + t.Errorf("ContentType fallback = %q, want application/octet-stream", got) + } +} + +func TestS3UploadStep_BuildURL_Endpoint(t *testing.T) { + p := NewS3UploadStepProvider() + stepIface, _ := p.CreateStep("step.s3_upload", "x", map[string]any{ + "bucket": "b", "region": "r", "key": "k", "body_from": "p", + "endpoint": "https://minio.local/", + }) + step := stepIface.(*s3UploadStep) + got := step.buildURL("path/to/obj") + want := "https://minio.local/b/path/to/obj" + if got != want { + t.Errorf("buildURL with endpoint = %q, want %q", got, want) + } +} + +func TestS3UploadStep_CredentialsRef(t *testing.T) { + t.Cleanup(credref.Reset) + want := awscreds.CredInput{AccessKey: "AKID", SecretKey: "SECRET", Source: "static"} + _ = credref.Register("uploads-creds", want) + + p := NewS3UploadStepProvider() + stepIface, err := p.CreateStep("step.s3_upload", "x", map[string]any{ + "bucket": "b", + "region": "us-west-2", + "key": "k", + "body_from": "p", + "credentials_ref": "uploads-creds", + }) + if err != nil { + t.Fatalf("CreateStep: %v", err) + } + step := stepIface.(*s3UploadStep) + if step.cred.AccessKey != "AKID" || step.cred.SecretKey != "SECRET" { + t.Errorf("resolved cred keys = %q/%q, want AKID/SECRET", + step.cred.AccessKey, step.cred.SecretKey) + } + if step.cred.Region != "us-west-2" { + t.Errorf("cred.Region = %q, want us-west-2 (step region overrides ref's)", step.cred.Region) + } +} + +func TestS3UploadStep_CredentialsRef_MissingErrors(t *testing.T) { + t.Cleanup(credref.Reset) + p := NewS3UploadStepProvider() + _, err := p.CreateStep("step.s3_upload", "x", map[string]any{ + "bucket": "b", "region": "r", "key": "k", "body_from": "p", + "credentials_ref": "does-not-exist", + }) + if err == nil || !strings.Contains(err.Error(), "credentials_ref \"does-not-exist\" not found") { + t.Errorf("err = %v, want missing-ref error", err) + } +} + +func TestS3UploadStep_KeyTemplate_UUIDFunc(t *testing.T) { + p := NewS3UploadStepProvider() + stepIface, _ := p.CreateStep("step.s3_upload", "x", map[string]any{ + "bucket": "b", "region": "r", + "key": "{{uuid}}.bin", + "body_from": "payload", + }) + step := stepIface.(*s3UploadStep) + mock := newMockPutObject() + step.SetTestClient(mock) + res, err := step.Execute(context.Background(), nil, nil, map[string]any{ + "payload": base64.StdEncoding.EncodeToString([]byte("data")), + }, nil, nil) + if err != nil { + t.Fatalf("Execute: %v", err) + } + resolvedKey, _ := res.Output["key"].(string) + if !strings.HasSuffix(resolvedKey, ".bin") || len(resolvedKey) < len("00000000-0000-0000-0000-000000000000.bin") { + t.Errorf("uuid template did not resolve: key=%q", resolvedKey) + } +} + +// mockPutObject implements s3PutObjectAPI for tests; captures the last input. +type mockPutObject struct { + mu sync.Mutex + last *s3.PutObjectInput + err error +} + +func newMockPutObject() *mockPutObject { return &mockPutObject{} } + +func (m *mockPutObject) PutObject(_ context.Context, in *s3.PutObjectInput, _ ...func(*s3.Options)) (*s3.PutObjectOutput, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.err != nil { + return nil, m.err + } + // Drain Body into a buffer so test assertions can re-read it after Execute returns. + if in.Body != nil { + buf, _ := io.ReadAll(in.Body) + in.Body = io.NopCloser(bytes.NewReader(buf)) + } + m.last = in + return &s3.PutObjectOutput{}, nil +} diff --git a/plugin.contracts.json b/plugin.contracts.json index 64dc0e5..3439016 100644 --- a/plugin.contracts.json +++ b/plugin.contracts.json @@ -6,6 +6,25 @@ "type": "iac.provider", "mode": "strict", "config": "workflow.plugins.aws.v1.AWSProviderConfig" + }, + { + "kind": "module", + "type": "aws.credentials", + "mode": "strict", + "config": "workflow.plugins.aws.v1.AWSCredentialsConfig" + }, + { + "kind": "module", + "type": "storage.s3", + "mode": "strict", + "config": "workflow.plugins.aws.v1.S3StorageConfig" + }, + { + "kind": "step", + "type": "step.s3_upload", + "mode": "strict", + "config": "workflow.plugins.aws.v1.S3UploadStepConfig", + "output": "workflow.plugins.aws.v1.S3UploadStepOutput" } ] } diff --git a/plugin.json b/plugin.json index f93840a..2058b11 100644 --- a/plugin.json +++ b/plugin.json @@ -1,21 +1,25 @@ { "name": "workflow-plugin-aws", - "version": "1.0.0", + "version": "1.1.0", "author": "GoCodeAlone", "description": "AWS provider plugin for workflow IaC — manages ECS, EKS, RDS, ElastiCache, VPC, ALB, Route53, ECR, API Gateway, Security Groups, IAM, S3, ACM, and AutoScaling Group resources", "license": "MIT", "type": "external", "tier": "community", - "minEngineVersion": "0.52.0", + "minEngineVersion": "0.53.0", "keywords": ["aws", "iac", "infrastructure", "ecs", "eks", "rds", "vpc", "s3", "autoscaling"], "homepage": "https://github.com/GoCodeAlone/workflow-plugin-aws", "repository": "https://github.com/GoCodeAlone/workflow-plugin-aws", "capabilities": { "configProvider": false, "moduleTypes": [ - "iac.provider" + "iac.provider", + "aws.credentials", + "storage.s3" + ], + "stepTypes": [ + "step.s3_upload" ], - "stepTypes": [], "triggerTypes": [], "iacStateBackends": ["s3"] }, @@ -23,32 +27,32 @@ { "os": "linux", "arch": "amd64", - "url": "https://github.com/GoCodeAlone/workflow-plugin-aws/releases/download/v1.0.0/workflow-plugin-aws_1.0.0_linux_amd64.tar.gz" + "url": "https://github.com/GoCodeAlone/workflow-plugin-aws/releases/download/v1.1.0/workflow-plugin-aws_1.1.0_linux_amd64.tar.gz" }, { "os": "linux", "arch": "arm64", - "url": "https://github.com/GoCodeAlone/workflow-plugin-aws/releases/download/v1.0.0/workflow-plugin-aws_1.0.0_linux_arm64.tar.gz" + "url": "https://github.com/GoCodeAlone/workflow-plugin-aws/releases/download/v1.1.0/workflow-plugin-aws_1.1.0_linux_arm64.tar.gz" }, { "os": "darwin", "arch": "amd64", - "url": "https://github.com/GoCodeAlone/workflow-plugin-aws/releases/download/v1.0.0/workflow-plugin-aws_1.0.0_darwin_amd64.tar.gz" + "url": "https://github.com/GoCodeAlone/workflow-plugin-aws/releases/download/v1.1.0/workflow-plugin-aws_1.1.0_darwin_amd64.tar.gz" }, { "os": "darwin", "arch": "arm64", - "url": "https://github.com/GoCodeAlone/workflow-plugin-aws/releases/download/v1.0.0/workflow-plugin-aws_1.0.0_darwin_arm64.tar.gz" + "url": "https://github.com/GoCodeAlone/workflow-plugin-aws/releases/download/v1.1.0/workflow-plugin-aws_1.1.0_darwin_arm64.tar.gz" }, { "os": "windows", "arch": "amd64", - "url": "https://github.com/GoCodeAlone/workflow-plugin-aws/releases/download/v1.0.0/workflow-plugin-aws_1.0.0_windows_amd64.tar.gz" + "url": "https://github.com/GoCodeAlone/workflow-plugin-aws/releases/download/v1.1.0/workflow-plugin-aws_1.1.0_windows_amd64.tar.gz" }, { "os": "windows", "arch": "arm64", - "url": "https://github.com/GoCodeAlone/workflow-plugin-aws/releases/download/v1.0.0/workflow-plugin-aws_1.0.0_windows_arm64.tar.gz" + "url": "https://github.com/GoCodeAlone/workflow-plugin-aws/releases/download/v1.1.0/workflow-plugin-aws_1.1.0_windows_arm64.tar.gz" } ] } diff --git a/provider/provider.go b/provider/provider.go index 48076ee..b82a885 100644 --- a/provider/provider.go +++ b/provider/provider.go @@ -7,12 +7,10 @@ import ( "sync" "time" - awscfg "github.com/aws/aws-sdk-go-v2/config" - "github.com/aws/aws-sdk-go-v2/credentials" - awssdk "github.com/aws/aws-sdk-go-v2/aws" "github.com/GoCodeAlone/workflow-plugin-aws/drivers" + "github.com/GoCodeAlone/workflow-plugin-aws/internal/awscreds" "github.com/GoCodeAlone/workflow/interfaces" ) @@ -48,7 +46,24 @@ func (p *AWSProvider) Name() string { return ProviderName } func (p *AWSProvider) Version() string { return ProviderVersion } // Initialize configures the AWS SDK and registers all resource drivers. -// Supported config keys: region, access_key_id, secret_access_key, ecs_cluster. +// +// Supported config keys (back-compat top-level): +// - region +// - access_key_id, secret_access_key +// - ecs_cluster +// +// Supported config keys under the nested `credentials:` block (preferred +// shape — mirrors the standalone-module path from plan-2 Tasks 4-6): +// - type — "static" | "env" | "profile" | "role_arn" +// - accessKey, secretKey, sessionToken +// - profile — shared-config profile name (honoured when type=="profile") +// - roleArn, externalId, sessionName (honoured when type=="role_arn") +// +// Credential resolution flows through awscreds.BuildAWSConfig so the +// `credential_source` markers Phase B records on CloudAccount stay honoured +// in-plugin. CredInput.Source is populated from `credentials.type` (the YAML +// field), never from CloudAccount.Extra — which never crosses the gRPC +// boundary. func (p *AWSProvider) Initialize(ctx context.Context, config map[string]any) error { p.mu.Lock() defer p.mu.Unlock() @@ -59,18 +74,41 @@ func (p *AWSProvider) Initialize(ctx context.Context, config map[string]any) err } p.region = region - opts := []func(*awscfg.LoadOptions) error{ - awscfg.WithRegion(region), - } - accessKey, _ := config["access_key_id"].(string) - secretKey, _ := config["secret_access_key"].(string) - if accessKey != "" && secretKey != "" { - opts = append(opts, awscfg.WithCredentialsProvider( - credentials.NewStaticCredentialsProvider(accessKey, secretKey, ""), - )) + cred := awscreds.CredInput{Region: region} + // Back-compat: top-level access_key_id / secret_access_key still honoured. + cred.AccessKey, _ = config["access_key_id"].(string) + cred.SecretKey, _ = config["secret_access_key"].(string) + + // Preferred: nested `credentials:` block. Values here override the + // back-compat top-level keys when both are supplied. + if credsMap, ok := config["credentials"].(map[string]any); ok { + if v, _ := credsMap["type"].(string); v != "" { + cred.Source = v + } + if v, _ := credsMap["accessKey"].(string); v != "" { + cred.AccessKey = v + } + if v, _ := credsMap["secretKey"].(string); v != "" { + cred.SecretKey = v + } + if v, _ := credsMap["sessionToken"].(string); v != "" { + cred.SessionToken = v + } + if v, _ := credsMap["profile"].(string); v != "" { + cred.Profile = v + } + if v, _ := credsMap["roleArn"].(string); v != "" { + cred.RoleARN = v + } + if v, _ := credsMap["externalId"].(string); v != "" { + cred.ExternalID = v + } + if v, _ := credsMap["sessionName"].(string); v != "" { + cred.SessionName = v + } } - cfg, err := awscfg.LoadDefaultConfig(ctx, opts...) + cfg, err := awscreds.BuildAWSConfig(ctx, cred) if err != nil { return fmt.Errorf("aws: load config: %w", err) }