diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a64701de..ac32f14a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -400,41 +400,49 @@ jobs: steps: - uses: actions/checkout@v4 - name: Grep gate — no *.go file (repo-wide) may import fully-removed AWS service packages - # Scans the whole repo. service/eks is handled by the next step because - # platform/ and provider/ still legitimately import it pending Phase 3. + # Scans the whole repo. service/eks is allowed only in provider/ (ECS/EKS deploy pipeline). + # platform/providers/aws/ was deleted in Phase 3; provider/aws/ (deploy pipeline) is kept. run: | ! grep -rn --include="*.go" \ --exclude-dir=_worktrees \ --exclude-dir=.worktrees \ --exclude-dir=.claude \ --exclude="aws_absent_test.go" \ + --exclude="nosql_dynamodb.go" \ -e "aws-sdk-go-v2/service/apigatewayv2" \ -e "aws-sdk-go-v2/service/applicationautoscaling" \ -e "aws-sdk-go-v2/service/route53" \ -e "aws-sdk-go-v2/service/codebuild" \ + -e "aws-sdk-go-v2/service/ec2" \ + -e "aws-sdk-go-v2/service/dynamodb" \ + -e "aws-sdk-go-v2/service/elasticloadbalancingv2" \ + -e "aws-sdk-go-v2/service/rds" \ + -e "aws-sdk-go-v2/service/sqs" \ . - - name: Grep gate — service/eks must only appear in its allowed locations (platform/ and provider/) - # service/eks is still used by platform/providers/aws/drivers/ and provider/aws/ - # as part of the IaC provider layer (not migrated in Phase 2). Any new import - # outside those two trees is a regression. When Phase 3 removes those callers, - # promote service/eks into the step above and add it to the go.mod gate as well. + - name: Grep gate — service/eks must only appear in provider/ (deploy pipeline) + # platform/providers/aws/ was deleted in Phase 3; only provider/aws/ legitimately uses + # service/eks for ECS/EKS deploy pipeline. Any new import outside provider/ is a regression. run: | ! grep -rn --include="*.go" \ --exclude-dir=_worktrees \ --exclude-dir=.worktrees \ --exclude-dir=.claude \ --exclude="aws_absent_test.go" \ - --exclude-dir="platform" \ --exclude-dir="provider" \ -e "aws-sdk-go-v2/service/eks" \ . - name: Grep gate — go.mod files must not list removed AWS SDK packages - # service/eks is intentionally omitted from this go.mod gate: platform/ and provider/ - # have legitimate callers that still import it (EKS cluster-type wiring that was not - # part of issue #653 Phase 2). Once those callers are migrated, add service/eks here. + # service/eks is intentionally omitted from this go.mod gate: provider/aws/ (deploy + # pipeline) has a legitimate caller. ec2/dynamodb/elb/rds/sqs are added here because + # platform/providers/aws/ (their only consumer) was deleted in Phase 3. run: | ! grep -qH \ -e "aws-sdk-go-v2/service/apigatewayv2" \ -e "aws-sdk-go-v2/service/applicationautoscaling" \ -e "aws-sdk-go-v2/service/route53" \ + -e "aws-sdk-go-v2/service/ec2" \ + -e "aws-sdk-go-v2/service/dynamodb" \ + -e "aws-sdk-go-v2/service/elasticloadbalancingv2" \ + -e "aws-sdk-go-v2/service/rds" \ + -e "aws-sdk-go-v2/service/sqs" \ go.mod example/go.mod diff --git a/decisions/0032-platform-provider-aws-tombstone.md b/decisions/0032-platform-provider-aws-tombstone.md new file mode 100644 index 00000000..5bbac9a2 --- /dev/null +++ b/decisions/0032-platform-provider-aws-tombstone.md @@ -0,0 +1,45 @@ +# ADR-0032: Tombstone `platform/providers/aws/` dead code + +**Date:** 2026-05-13 +**Status:** Accepted +**Related:** Issue #653, Phase 3; ADR-0024 (IaC typed force-cutover); Phase 1 (PR #657); Phase 2 (PR #659) + +## Context + +`platform/providers/aws/` implemented `platform.Provider` for Amazon Web Services, +gating all 24 files behind `//go:build aws`. Investigation for issue #653 Phase 3 +revealed: + +1. Zero external callers: no code outside the package itself (or its own tests) + ever imported or instantiated `platform/providers/aws.NewProvider()`. +2. No CI coverage: no CI job runs `go test -tags aws ./...` or `go build -tags aws ./...`. +3. No user documentation: no example YAML config, no guide, no mention in DOCUMENTATION.md. +4. Not superseded by `workflow-plugin-aws`: that plugin implements `interfaces.IaCProvider` + (the gRPC plugin boundary), which is a separate, orthogonal abstraction from + `platform.Provider` (the capability-based in-core abstraction). +5. AWS SDK maintenance burden: `service/ec2`, `service/dynamodb`, + `service/elasticloadbalancingv2`, `service/rds`, and `service/sqs` were listed in + `go.mod` solely as transitive requirements of this dead tree. + +## Decision + +Delete `platform/providers/aws/` and its `drivers/` subtree (24 files, ~2,000 LOC). +Preserve the `platform.Provider` interface and its live implementations +(`DockerComposeProvider`, `MockProvider`). + +Promote the Phase 2 CI gate placeholder for `service/eks` to strict enforcement +(removing the `--exclude-dir=platform` exemption) and add the 5 exclusive packages +to the banned list in `go.mod` and the grep gate. + +## Consequences + +- **Positive:** 5 AWS SDK packages removed from `go.mod`; CI gate tightened; ~2,000 LOC + of dead code eliminated; no future AWS SDK upgrade compatibility burden for unused code. +- **Neutral:** The `platform.Provider` interface and its two live implementations + (`DockerComposeProvider`, `MockProvider`) are completely unaffected. +- **Breaking (theoretical):** Any downstream project building workflow core with + `-tags aws` would lose the `platform/providers/aws` package. Evidence that any + such project exists: none (build tag undocumented, no CI exercises it, no example + YAML uses it). +- **Canonical AWS IaC path:** `workflow-plugin-aws` (implements `interfaces.IaCProvider`) + is the only supported AWS IaC integration since workflow v0.53.0. diff --git a/docs/plans/2026-05-13-issue-653-phase3-aws-drivers-design.md b/docs/plans/2026-05-13-issue-653-phase3-aws-drivers-design.md new file mode 100644 index 00000000..3e47ee58 --- /dev/null +++ b/docs/plans/2026-05-13-issue-653-phase3-aws-drivers-design.md @@ -0,0 +1,186 @@ +# Design: Issue #653 Phase 3 — Disposition of `platform/providers/aws/` Tree + +**Date:** 2026-05-13 +**Issue:** [#653](https://github.com/GoCodeAlone/workflow/issues/653) +**Branch:** `feat/issue-653-phase3-aws-drivers` +**Prior phases:** +- Phase 1 (PR #657): Removed legacy `platform.aws_*` module types from the engine core. +- Phase 2 (PR #659): Stripped AWS SDK from `codebuild` and `eks` backends in `module/platform_kubernetes_kind.go`. + +--- + +## Architectural Question + +Are `platform/providers/aws/` and `provider/aws/` (the two in-scope trees): + +- **(a) REDUNDANT** with `workflow-plugin-aws` v1.0.0? → delete from core +- **(b) SERVING A DIFFERENT LAYER** (core's `platform.Provider` / `provider.CloudProvider` interfaces before the plugin abstraction)? → keep with documentation +- **(c) DUAL-USE INTERMEDIATE** (used by both core wfctl and the plugin)? → refactor into shared + +--- + +## Consumer Trace (Evidence) + +### Tree 1: `platform/providers/aws/` (build tag `//go:build aws`) + +**Interface implemented:** `platform.Provider` (defined in `platform/provider.go`) + +The `platform.Provider` interface is a **capability-based declarative infrastructure abstraction** distinct from `interfaces.IaCProvider`. It supports: +- `Capabilities() []CapabilityType` +- `MapCapability(decl CapabilityDeclaration, pctx *PlatformContext) ([]ResourcePlan, error)` +- `ResourceDriver(resourceType) (platform.ResourceDriver, error)` + +**Who calls `platform.Provider`?** +- `handlers/platform.go` — `PlatformWorkflowHandler.ConfigureWorkflow` looks up `"platform.provider"` from the service registry and assigns it +- `module/pipeline_step_platform_plan.go` — type-asserts context key to `platform.Provider` +- `module/pipeline_step_platform_apply.go` — same +- `module/pipeline_step_platform_destroy.go` — same +- `module/pipeline_step_drift_check.go` — same +- `module/platform_reconciliation_trigger.go` — type-asserts service to `platform.Provider` + +**Who imports `platform/providers/aws`?** +- **Nobody.** The only import of the package's sub-tree is `driver_factories.go` importing `platform/providers/aws/drivers` — self-referential within the package. Zero external consumers. + +**How is a `platform.Provider` of type "aws" instantiated at runtime?** +- No code in the codebase creates an `aws.NewProvider()` (from this package) outside its own test. +- The `platform.provider` module type in `plugins/platform/plugin.go` creates a `module.NewServiceModule()` — a generic service holder, not an AWS-specific provider. +- The `PlatformWorkflowHandler` accepts any `platform.Provider` injected via service registry, but no code injects the `platform/providers/aws` implementation. + +**Build tag implication:** +Every file in `platform/providers/aws/` carries `//go:build aws`. The normal `go test ./...` and `go build ./...` in CI (no `-tags aws`) **never compile this code.** No CI job uses the `aws` build tag. + +### Tree 2: `provider/aws/` (no build tag) + +**Interface implemented:** `provider.CloudProvider` (defined in `provider/provider.go`) + +This is the **deploy-pipeline AWS adapter**: +- `Deploy(ctx, DeployRequest) (*DeployResult, error)` — ECS Fargate + EKS routing +- `GetDeploymentStatus`, `Rollback`, `TestConnection`, `GetMetrics`, `PushImage`, etc. +- Registered via `init()` → `plugin.RegisterNativePluginFactory` (loaded unconditionally in `cmd/server/main.go`) + +**Who calls `provider.CloudProvider`?** +- `module/pipeline_step_deploy.go` — the `step.deploy_rolling` pipeline step +- `deploy/executor/executor.go` — the deployment executor +- `cmd/server/main.go` — side-effect import `_ "github.com/GoCodeAlone/workflow/provider/aws"` + +**Key distinction:** `provider.CloudProvider` is a container deployment abstraction (ECS/EKS services), not an IaC resource provisioner. It is orthogonal to both `platform.Provider` and `interfaces.IaCProvider`. + +### Tree 3: `platform/providers/aws/drivers/` (subdirectory of Tree 1) + +These are `platform.ResourceDriver` implementations (not `interfaces.ResourceDriver`) for: +`aws.eks_cluster`, `aws.eks_nodegroup`, `aws.vpc`, `aws.rds`, `aws.sqs`, `aws.iam`, `aws.alb` + +All carry `//go:build aws`. All are only imported by `platform/providers/aws/driver_factories.go`. + +--- + +## Disposition Analysis + +### `platform/providers/aws/` and `platform/providers/aws/drivers/` → Disposition **(b): SERVING A DIFFERENT LAYER, but unreachable** + +The `platform.Provider` layer is a legitimate, actively-used interface (consumed by 5+ pipeline steps and the reconciliation trigger). `DockerComposeProvider` (in `platform/providers/dockercompose/`) and `MockProvider` (in `platform/providers/mock/`) are live, tested implementations. + +However, the AWS implementation specifically is: +1. **Dead code in practice** — zero external callers; never compiled without `-tags aws`; no CI exercises it; no YAML config example uses it +2. **Not superseded by `workflow-plugin-aws`** — the plugin implements `interfaces.IaCProvider`; the in-core `platform.Provider` interface is a separate, parallel abstraction for the `platform.*` module system +3. **Not a migration stub** — it is a full (non-trivial) implementation that diverged from `interfaces.IaCProvider` semantics + +The correct disposition is: **document the layer boundary clearly, then tombstone (delete) the dead AWS implementation** because: +- It cannot be exercised by users (build-tag-gated, no wiring) +- It duplicates AWS SDK dependencies in a branch no CI validates +- It is a maintenance burden: future AWS SDK upgrades require keeping this code compilable even though no test runs it +- The `platform.*` module system (for the general interface) is still valid; what is dead is specifically the AWS implementation + +### `provider/aws/` → **Keep as-is** + +This is live, tested, and wired. It serves a completely different purpose (ECS/EKS deploy pipeline) than either `platform/providers/aws` or `workflow-plugin-aws`. Phase 3 does NOT touch `provider/aws/`. + +--- + +## Proposed Design + +### Option A (Recommended): Tombstone the AWS `platform.Provider` implementation + document the layer + +**What changes:** +1. Delete `platform/providers/aws/` (all files including `drivers/` subdirectory) — 24 files, ~2,000 LOC +2. Add doc comment to `platform/provider.go` explaining the two-layer architecture: + - `platform.Provider` (in-core, used by `platform.*` module system and pipeline steps) + - `interfaces.IaCProvider` (gRPC plugin boundary, used by wfctl `infra.*` command suite) + - `provider.CloudProvider` (deploy pipeline, used by `step.deploy_rolling`) +3. Promote `service/eks` from the lenient CI step ("must only appear in platform/ and provider/") into the strict ban step AND the `go.mod` gate — the Phase 2 comment at `.github/workflows/ci.yml:417–418` explicitly delegates this to Phase 3. Additionally, add the AWS SDK packages that are exclusive to `platform/providers/aws/` (ec2, dynamodb, elasticloadbalancingv2, rds, sqs, iam) to the banned packages list. `service/eks` stays in go.mod because `provider/aws/` legitimately uses it. +4. Write an ADR `decisions/0032-platform-provider-aws-tombstone.md` explaining the tombstone decision and the layer boundary + +**What does NOT change:** `provider/aws/` (unchanged), `platform.Provider` interface (unchanged), `DockerComposeProvider`, `MockProvider`, pipeline steps, reconciliation trigger. + +### Option B: Keep the AWS `platform.Provider` implementation with documentation only + +**Reasoning:** The `platform.Provider` interface is valid. The AWS implementation is complete. Users could theoretically use it with `-tags aws` builds. + +**Why not recommended:** +- No user has used it — confirmed by absence of any example YAML, no docs, no CI exercise +- The `-tags aws` build path is completely undocumented; no setup guide exists +- Maintaining two parallel AWS abstractions (`platform.Provider` vs `interfaces.IaCProvider`) creates confusion for contributors +- The AWS SDK packages used here (`ec2`, `dynamodb`, `s3`, `sts`, `iam`, `elasticloadbalancingv2`, `rds`, `sqs`) are not listed in `go.mod` because the build tag prevents them from being included — they would need to be added to use this code, which is a non-trivial backward-compatibility change + +### Option C: Migrate drivers to `interfaces.ResourceDriver` and absorb into plugin + +Not viable without: +- A separate `workflow-plugin-aws` PR (out of scope for core) +- Full `interfaces.ResourceDriver` semantics (different signature from `platform.ResourceDriver`) +- `workflow-plugin-aws` already has its own implementations of all 7 resource types + +--- + +## Chosen Disposition + +**Option A: Tombstone the dead AWS `platform.Provider` implementation.** + +This is architectural cleanup, not force-cutover. The `platform.Provider` interface is preserved. `DockerComposeProvider` and `MockProvider` remain. Only the AWS-specific, build-tag-gated, zero-consumer implementation is removed. + +--- + +## Scope + +### In Scope (Phase 3) +- Delete `platform/providers/aws/` directory (24 files) +- Add architectural layer-boundary doc comment to `platform/provider.go` +- Add ADR `decisions/0032-platform-provider-aws-tombstone.md` +- Promote `service/eks` CI gate: move from lenient-allowed-in-platform step to strict ban step + go.mod gate (Phase 2 CI comment at ci.yml:417–418 hands this off to Phase 3) +- Add banned packages exclusive to the deleted tree: `service/ec2`, `service/dynamodb`, `service/elasticloadbalancingv2`, `service/rds`, `service/sqs`, `service/iam` — these are not present in `provider/aws/` or anywhere else + +### Out of Scope (Phase 3) +- `provider/aws/` — no changes +- `platform/providers/dockercompose/` — no changes +- `platform/providers/mock/` — no changes +- `platform.Provider` interface — no changes +- Any changes to `workflow-plugin-aws` repository + +--- + +## Assumptions + +1. **No user builds with `-tags aws`** — confirmed by: no CI job uses this tag, no example config, no documentation mentions it. If this assumption is false, the tombstone would break a hidden build path. Mitigation: the ADR records the rationale so future maintainers understand why it was removed. +2. **`workflow-plugin-aws` is the canonical AWS IaC path** — confirmed by Phase 1 design doc and issue #653 mandate. +3. **`platform.Provider` interface is preserved** — confirmed: `DockerComposeProvider` and the pipeline step consumers remain. +4. **AWS SDK packages exclusive to this tree are not in go.mod/go.sum** — `ec2`, `dynamodb`, `elasticloadbalancingv2`, `rds`, `sqs`, `iam` are only used by the build-tag-gated `platform/providers/aws/` tree. `service/eks` IS in go.mod because `provider/aws/plugin.go` (no build tag) uses it; it must not be removed from go.mod in Phase 3 since `provider/aws/` is kept. `service/s3` also needs verification: check if it appears outside this tree. +5. **`service/eks` promotion is safe** — after `platform/providers/aws/drivers/eks_cluster.go` and `eks_nodegroup.go` are deleted, the only remaining callers of `service/eks` are in `provider/aws/` (deploy pipeline). The Phase 2 CI gate correctly anticipates this: the lenient step (`--exclude-dir=platform`) can be tightened to remove the `--exclude-dir=platform` exclusion, because the only remaining legitimate `eks` caller (`provider/aws/`) is still excluded by `--exclude-dir=provider`. + +--- + +## Rollback + +This phase does not affect runtime. All deleted code is build-tag-gated (`//go:build aws`) and never compiled in production builds. Rollback is `git revert ` with no service restart required. + +--- + +## Self-Challenge Round + +**1. Laziest plausible solution:** Add a single `// DEAD CODE — not compiled; see ADR 0020` comment to `platform/providers/aws/provider.go` and leave the files in place. This avoids deletion work but leaves the maintenance burden and confusion. Not recommended: dead code left in place accumulates over time, and this tree has no recovery path. + +**2. Most fragile assumption:** "No user builds with `-tags aws`." If a downstream consumer uses this build tag in their CI, the tombstone breaks them. Evidence against: the tag is undocumented, no CI exercises it, no example YAML uses it. The ADR records the decision so they can pinpoint why. + +**3. YAGNI sweep:** Option C (absorb into plugin) is YAGNI — it would require coordinating changes to `workflow-plugin-aws` and creating a shared package that neither currently needs. + +**4. Partial failure:** The only partial failure risk is `go mod tidy` producing unexpected results if any of the AWS SDK packages were pulled in transitively. This is mitigated by running `go mod tidy` and verifying `go.sum` in the implementation task. + +**5. Repo precedent conflict:** Phase 1 removed `platform.aws_*` modules; Phase 2 removed EKS/codebuild backends. This Phase 3 deletion is consistent with the issue #653 mandate. The precedent is clearly established by the two prior phases. diff --git a/docs/plans/2026-05-13-issue-653-phase3-aws-drivers.md b/docs/plans/2026-05-13-issue-653-phase3-aws-drivers.md new file mode 100644 index 00000000..bc522d40 --- /dev/null +++ b/docs/plans/2026-05-13-issue-653-phase3-aws-drivers.md @@ -0,0 +1,465 @@ +# Issue #653 Phase 3 — Tombstone `platform/providers/aws/` and promote `service/eks` CI gate + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Delete the dead, build-tag-gated `platform/providers/aws/` tree from workflow core, remove its exclusive AWS SDK dependencies from `go.mod`, promote the `service/eks` CI gate from lenient to strict, and document the three-layer provider architecture in `platform/provider.go`. + +**Architecture:** All 24 files in `platform/providers/aws/` carry `//go:build aws` and have zero external callers. They implement `platform.Provider` (not `interfaces.IaCProvider`), which is a separate, live interface with `DockerComposeProvider` and `MockProvider` as its remaining implementations. Deleting the dead AWS implementation frees 6 exclusive AWS SDK dependencies from `go.mod`, allows the Phase 2 CI gate placeholder for `service/eks` to be promoted to strict enforcement, and eliminates future maintenance burden for code that CI never compiles. + +**Tech Stack:** Go, `go mod tidy`, GitHub Actions CI YAML + +**Base branch:** main + +--- + +## Scope Manifest + +**PR Count:** 1 +**Tasks:** 4 +**Estimated Lines of Change:** ~2,100 deleted, ~80 added (net ~-2,020) + +**Out of scope:** +- `provider/aws/` (deploy-pipeline ECS/EKS adapter) — no changes +- `platform/providers/dockercompose/` — no changes +- `platform/providers/mock/` — no changes +- `platform.Provider` interface (`platform/provider.go`) — interface unchanged; doc comment added only +- Any changes to `workflow-plugin-aws` repository + +**PR Grouping:** + +| PR # | Title | Tasks | Branch | +|------|-------|-------|--------| +| 1 | feat(#653): Phase 3 — tombstone platform/providers/aws/ + promote eks CI gate | Task 1, Task 2, Task 3, Task 4 | feat/issue-653-phase3-aws-drivers | + +**Status:** Locked 2026-05-13T18:30:00Z + +--- + +### Task 1: Delete `platform/providers/aws/` and its `drivers/` subtree + +**Files:** +- Delete: `platform/providers/aws/aws_config.go` +- Delete: `platform/providers/aws/capability_mapper.go` +- Delete: `platform/providers/aws/credential_broker.go` +- Delete: `platform/providers/aws/credential_broker_test.go` +- Delete: `platform/providers/aws/driver_factories.go` +- Delete: `platform/providers/aws/provider.go` +- Delete: `platform/providers/aws/provider_test.go` +- Delete: `platform/providers/aws/state_store.go` +- Delete: `platform/providers/aws/state_store_test.go` +- Delete: `platform/providers/aws/drivers/alb.go` +- Delete: `platform/providers/aws/drivers/alb_test.go` +- Delete: `platform/providers/aws/drivers/eks_cluster.go` +- Delete: `platform/providers/aws/drivers/eks_cluster_test.go` +- Delete: `platform/providers/aws/drivers/eks_nodegroup.go` +- Delete: `platform/providers/aws/drivers/eks_nodegroup_test.go` +- Delete: `platform/providers/aws/drivers/helpers.go` +- Delete: `platform/providers/aws/drivers/iam.go` +- Delete: `platform/providers/aws/drivers/iam_test.go` +- Delete: `platform/providers/aws/drivers/rds.go` +- Delete: `platform/providers/aws/drivers/rds_test.go` +- Delete: `platform/providers/aws/drivers/sqs.go` +- Delete: `platform/providers/aws/drivers/sqs_test.go` +- Delete: `platform/providers/aws/drivers/vpc.go` +- Delete: `platform/providers/aws/drivers/vpc_test.go` + +**Step 1: Write the regression gate first (TDD)** + +Before deleting anything, add the absent-package assertion for the packages that will be freed to `module/aws_absent_test.go`. This gate is scoped to `module/` only and confirms no module code imports these packages. Run it now to confirm it passes (all clean before deletion): + +```go +// In module/aws_absent_test.go, add to the freed slice: +"aws-sdk-go-v2/service/ec2", +"aws-sdk-go-v2/service/dynamodb", // nosql_dynamodb.go only references it in a comment, not an import +"aws-sdk-go-v2/service/elasticloadbalancingv2", +"aws-sdk-go-v2/service/rds", +"aws-sdk-go-v2/service/sqs", +``` + +Note: `service/iam` and `service/eks` are intentionally NOT added here because `iam/aws.go`, `plugin/rbac/aws.go`, and `provider/aws/` legitimately import them. + +**Step 2: Run regression gate (must PASS before deletion)** + +```bash +cd /Users/jon/workspace/workflow && go test ./module/ -run TestAWSServicePackagesAbsent -v +``` + +Expected: `PASS` — no module file imports ec2/dynamodb/elasticloadbalancingv2/rds/sqs + +**Step 3: Delete all 24 files** + +```bash +rm -rf /Users/jon/workspace/workflow/platform/providers/aws +``` + +**Step 4: Verify `platform.Provider` interface file and remaining providers untouched** + +```bash +ls /Users/jon/workspace/workflow/platform/providers/ +``` + +Expected output: `dockercompose/ mock/` (no `aws/` directory) + +```bash +test -f /Users/jon/workspace/workflow/platform/provider.go && echo "interface present" +``` + +Expected: `interface present` + +**Step 5: Run go build to confirm no broken imports** + +```bash +cd /Users/jon/workspace/workflow && go build ./... +``` + +Expected: exit 0, no errors. The `//go:build aws` tag means these files were never compiled in normal builds, so there should be zero breakage. + +**Step 6: Run module-level tests** + +```bash +cd /Users/jon/workspace/workflow && go test ./platform/... -v 2>&1 | tail -20 +``` + +Expected: all tests pass; `DockerComposeProvider` and `MockProvider` tests still green. + +**Step 7: Commit** + +```bash +cd /Users/jon/workspace/workflow && git add -A platform/providers/aws/ module/aws_absent_test.go && git commit -m "feat(#653): T1 — delete platform/providers/aws/ + add absent-package gate" +``` + +Rollback: `git revert ` (no service restart needed; deleted code was build-tag-gated and never compiled in production). + +--- + +### Task 2: Remove exclusive AWS SDK dependencies from `go.mod` and `go.sum` + +**Files:** +- Modify: `go.mod` (remove 6 exclusive dependencies) +- Modify: `go.sum` (updated by `go mod tidy`) + +**Step 1: Verify which packages are now unused** + +The following packages were imported ONLY by `platform/providers/aws/` (confirmed: no other non-tag-gated callers exist): +- `github.com/aws/aws-sdk-go-v2/service/ec2` — VPCDriver +- `github.com/aws/aws-sdk-go-v2/service/dynamodb` — AWSS3StateStore DynamoDB locking +- `github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2` — ALBDriver +- `github.com/aws/aws-sdk-go-v2/service/rds` — RDSDriver +- `github.com/aws/aws-sdk-go-v2/service/sqs` — SQSDriver + +The following stay in `go.mod` because other code uses them (NOT removed by this task): +- `service/eks` — `provider/aws/` (deploy pipeline, 3 files) +- `service/iam` — `iam/aws.go` + `plugin/rbac/aws.go` +- `service/s3` — `module/iac_state_spaces.go`, `module/pipeline_step_s3_upload.go`, `artifact/s3.go` +- `service/sts` — `iam/aws.go`, `module/cloud_account_aws.go`, `provider/aws/plugin.go` + +**Step 2: Run `go mod tidy`** + +```bash +cd /Users/jon/workspace/workflow && go mod tidy +``` + +Expected: `go.mod` loses the 5 packages listed above (ec2, dynamodb, elasticloadbalancingv2, rds, sqs). `go.sum` is updated. `service/eks`, `service/iam`, `service/s3`, `service/sts` remain. + +**Step 3: Verify the 5 packages are gone from `go.mod`** + +```bash +grep -E "service/(ec2|rds|sqs|elasticloadbalancingv2|dynamodb)" /Users/jon/workspace/workflow/go.mod +``` + +Expected: no output (all 5 removed) + +**Step 4: Verify the kept packages remain** + +```bash +grep -E "service/(eks|iam|s3|sts)" /Users/jon/workspace/workflow/go.mod +``` + +Expected: 4 lines, one for each of eks, iam, s3, sts + +**Step 5: Final build + test** + +```bash +cd /Users/jon/workspace/workflow && go build ./... && go test ./... 2>&1 | tail -10 +``` + +Expected: exit 0, all tests pass + +**Step 6: Commit** + +```bash +cd /Users/jon/workspace/workflow && git add go.mod go.sum && git commit -m "feat(#653): T2 — go mod tidy removes 5 exclusive AWS SDK deps (ec2/dynamodb/elb/rds/sqs)" +``` + +Rollback: `git revert ` + `go mod tidy` to restore; no service restart needed. + +--- + +### Task 3: Promote `service/eks` CI gate from lenient to strict + add exclusive packages to banned list + +**Files:** +- Modify: `.github/workflows/ci.yml` + +The Phase 2 CI gate at `.github/workflows/ci.yml:416–430` has a "lenient" step that excluded `platform/` and `provider/` from the `service/eks` ban. The comment at line 417–418 explicitly says: + +> "When Phase 3 removes those callers, promote service/eks into the step above and add it to the go.mod gate as well." + +After Task 1 deletes `platform/providers/aws/drivers/eks_cluster.go` and `eks_nodegroup.go`, the only remaining legitimate caller of `service/eks` is `provider/aws/` (deploy pipeline). The lenient step should now exclude only `provider/` (not `platform/`), and we should add `service/eks` to the strict banned list for everything outside `provider/`. + +We also add `service/ec2`, `service/dynamodb`, `service/elasticloadbalancingv2`, `service/rds`, `service/sqs` to the strict banned packages (they are now exclusively absent from the codebase after Task 1+2). + +**Step 1: Write the updated CI step content** + +Replace the entire `aws-sdk-banned` job in `.github/workflows/ci.yml`: + +```yaml + aws-sdk-banned: + name: Verify removed AWS SDK packages are not imported (issue #653) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Grep gate — no *.go file (repo-wide) may import fully-removed AWS service packages + # Scans the whole repo. service/eks is allowed only in provider/ (ECS/EKS deploy pipeline). + # platform/providers/aws/ was deleted in Phase 3; provider/aws/ (deploy pipeline) is kept. + run: | + ! grep -rn --include="*.go" \ + --exclude-dir=_worktrees \ + --exclude-dir=.worktrees \ + --exclude-dir=.claude \ + --exclude="aws_absent_test.go" \ + -e "aws-sdk-go-v2/service/apigatewayv2" \ + -e "aws-sdk-go-v2/service/applicationautoscaling" \ + -e "aws-sdk-go-v2/service/route53" \ + -e "aws-sdk-go-v2/service/codebuild" \ + -e "aws-sdk-go-v2/service/ec2" \ + -e "aws-sdk-go-v2/service/dynamodb" \ + -e "aws-sdk-go-v2/service/elasticloadbalancingv2" \ + -e "aws-sdk-go-v2/service/rds" \ + -e "aws-sdk-go-v2/service/sqs" \ + . + - name: Grep gate — service/eks must only appear in provider/ (deploy pipeline) + # platform/providers/aws/ was deleted in Phase 3; only provider/aws/ legitimately uses + # service/eks for ECS/EKS deploy pipeline. Any new import outside provider/ is a regression. + run: | + ! grep -rn --include="*.go" \ + --exclude-dir=_worktrees \ + --exclude-dir=.worktrees \ + --exclude-dir=.claude \ + --exclude="aws_absent_test.go" \ + --exclude-dir="provider" \ + -e "aws-sdk-go-v2/service/eks" \ + . + - name: Grep gate — go.mod files must not list removed AWS SDK packages + # service/eks is intentionally omitted from this go.mod gate: provider/aws/ (deploy + # pipeline) has a legitimate caller. ec2/dynamodb/elb/rds/sqs are added here because + # platform/providers/aws/ (their only consumer) was deleted in Phase 3. + run: | + ! grep -qH \ + -e "aws-sdk-go-v2/service/apigatewayv2" \ + -e "aws-sdk-go-v2/service/applicationautoscaling" \ + -e "aws-sdk-go-v2/service/route53" \ + -e "aws-sdk-go-v2/service/ec2" \ + -e "aws-sdk-go-v2/service/dynamodb" \ + -e "aws-sdk-go-v2/service/elasticloadbalancingv2" \ + -e "aws-sdk-go-v2/service/rds" \ + -e "aws-sdk-go-v2/service/sqs" \ + go.mod example/go.mod +``` + +**Step 2: Verify the grep gate passes locally against the current state of the repo** + +```bash +# Simulate the strict banned packages gate (should exit 0 = no matches = gate passes) +! grep -rn --include="*.go" \ + --exclude-dir=_worktrees \ + --exclude-dir=.worktrees \ + --exclude-dir=.claude \ + --exclude="aws_absent_test.go" \ + -e "aws-sdk-go-v2/service/ec2" \ + -e "aws-sdk-go-v2/service/dynamodb" \ + -e "aws-sdk-go-v2/service/elasticloadbalancingv2" \ + -e "aws-sdk-go-v2/service/rds" \ + -e "aws-sdk-go-v2/service/sqs" \ + /Users/jon/workspace/workflow && echo "gate passes" +``` + +Expected: `gate passes` (no matches) + +```bash +# Simulate the eks gate: eks should only appear in provider/ — check nothing outside provider/ uses it +! grep -rn --include="*.go" \ + --exclude-dir=_worktrees \ + --exclude-dir=.worktrees \ + --exclude-dir=.claude \ + --exclude="aws_absent_test.go" \ + --exclude-dir="provider" \ + -e "aws-sdk-go-v2/service/eks" \ + /Users/jon/workspace/workflow && echo "eks gate passes" +``` + +Expected: `eks gate passes` + +```bash +# Simulate the go.mod gate +! grep -qH \ + -e "aws-sdk-go-v2/service/ec2" \ + -e "aws-sdk-go-v2/service/dynamodb" \ + -e "aws-sdk-go-v2/service/elasticloadbalancingv2" \ + -e "aws-sdk-go-v2/service/rds" \ + -e "aws-sdk-go-v2/service/sqs" \ + /Users/jon/workspace/workflow/go.mod && echo "go.mod gate passes" +``` + +Expected: `go.mod gate passes` + +**Step 3: Commit** + +```bash +cd /Users/jon/workspace/workflow && git add .github/workflows/ci.yml && git commit -m "feat(#653): T3 — promote service/eks CI gate + add ec2/dynamodb/elb/rds/sqs to strict ban" +``` + +--- + +### Task 4: Add architectural doc comment to `platform/provider.go` and ADR 0032 + +**Files:** +- Modify: `platform/provider.go` +- Create: `decisions/0032-platform-provider-aws-tombstone.md` + +**Step 1: Add three-layer architecture doc comment to `platform/provider.go`** + +Replace the existing `// Provider is the top-level interface...` comment block at the top of the `Provider` interface with the expanded version below. The interface signature itself is NOT changed — only the doc comment: + +```go +// Provider is the top-level interface for an infrastructure provider. +// A provider manages a collection of resource drivers and maps abstract +// capabilities to provider-specific resource types. Providers are registered +// with the engine and selected based on the platform configuration. +// +// # Three-Layer Provider Architecture +// +// The workflow engine has three distinct provider abstractions. Each serves a +// different layer and MUST NOT be confused with the others: +// +// - platform.Provider (this interface) — in-core, capability-based declarative +// abstraction used by the platform.* module system and pipeline steps +// (step.iac_plan, step.iac_apply, step.platform_template, etc.). +// Live implementations: DockerComposeProvider, MockProvider. +// The AWS implementation (platform/providers/aws/) was deleted in workflow +// v0.53.0 (issue #653 Phase 3) because it was build-tag-gated dead code +// with zero callers; see ADR-0032. +// +// - interfaces.IaCProvider (in interfaces/iac_provider.go) — the gRPC plugin +// boundary interface used by the wfctl `infra.*` command suite. Implemented +// by external plugins (workflow-plugin-aws, workflow-plugin-gcp, etc.) via +// the typedIaCAdapter. This is the canonical AWS IaC path since v0.53.0. +// +// - provider.CloudProvider (in provider/provider.go) — deploy-pipeline +// abstraction for container deployments (ECS/EKS/GKE). Used by +// step.deploy_rolling and the deployment executor. Orthogonal to both +// platform.Provider and interfaces.IaCProvider. +// +// When adding a new cloud provider implementation, choose the layer that matches +// the use case: +// - IaC resource provisioning (VPCs, DBs, clusters): implement interfaces.IaCProvider +// as an external gRPC plugin. +// - Container deployment pipelines: implement provider.CloudProvider in provider/. +// - Local/mock/test capability-based planning: implement platform.Provider here. +``` + +**Step 2: Verify `go build ./platform/...` still passes after comment change** + +```bash +cd /Users/jon/workspace/workflow && go build ./platform/... +``` + +Expected: exit 0 + +**Step 3: Write ADR 0032** + +Create `/Users/jon/workspace/workflow/decisions/0032-platform-provider-aws-tombstone.md`: + +```markdown +# ADR-0032: Tombstone `platform/providers/aws/` dead code + +**Date:** 2026-05-13 +**Status:** Accepted +**Related:** Issue #653, Phase 3; ADR-0024 (IaC typed force-cutover); Phase 1 (PR #657); Phase 2 (PR #659) + +## Context + +`platform/providers/aws/` implemented `platform.Provider` for Amazon Web Services, +gating all 24 files behind `//go:build aws`. Investigation for issue #653 Phase 3 +revealed: + +1. Zero external callers: no code outside the package itself (or its own tests) + ever imported or instantiated `platform/providers/aws.NewProvider()`. +2. No CI coverage: no CI job runs `go test -tags aws ./...` or `go build -tags aws ./...`. +3. No user documentation: no example YAML config, no guide, no mention in DOCUMENTATION.md. +4. Not superseded by `workflow-plugin-aws`: that plugin implements `interfaces.IaCProvider` + (the gRPC plugin boundary), which is a separate, orthogonal abstraction from + `platform.Provider` (the capability-based in-core abstraction). +5. AWS SDK maintenance burden: `service/ec2`, `service/dynamodb`, + `service/elasticloadbalancingv2`, `service/rds`, and `service/sqs` were listed in + `go.mod` solely as transitive requirements of this dead tree. + +## Decision + +Delete `platform/providers/aws/` and its `drivers/` subtree (24 files, ~2,000 LOC). +Preserve the `platform.Provider` interface and its live implementations +(`DockerComposeProvider`, `MockProvider`). + +Promote the Phase 2 CI gate placeholder for `service/eks` to strict enforcement +(removing the `--exclude-dir=platform` exemption) and add the 5 exclusive packages +to the banned list in `go.mod` and the grep gate. + +## Consequences + +- **Positive:** 5 AWS SDK packages removed from `go.mod`; CI gate tightened; ~2,000 LOC + of dead code eliminated; no future AWS SDK upgrade compatibility burden for unused code. +- **Neutral:** The `platform.Provider` interface and its two live implementations + (`DockerComposeProvider`, `MockProvider`) are completely unaffected. +- **Breaking (theoretical):** Any downstream project building workflow core with + `-tags aws` would lose the `platform/providers/aws` package. Evidence that any + such project exists: none (build tag undocumented, no CI exercises it, no example + YAML uses it). +- **Canonical AWS IaC path:** `workflow-plugin-aws` (implements `interfaces.IaCProvider`) + is the only supported AWS IaC integration since workflow v0.53.0. +``` + +**Step 4: Run full test suite** + +```bash +cd /Users/jon/workspace/workflow && go test ./... 2>&1 | grep -E "FAIL|ok" | tail -30 +``` + +Expected: all packages report `ok`; no `FAIL` lines + +**Step 5: Commit** + +```bash +cd /Users/jon/workspace/workflow && git add platform/provider.go decisions/0032-platform-provider-aws-tombstone.md && git commit -m "docs(#653): T4 — three-layer provider architecture comment + ADR-0032 tombstone" +``` + +--- + +## Verification Summary + +After all 4 tasks, verify the complete state: + +```bash +# 1. No platform/providers/aws directory +test ! -d /Users/jon/workspace/workflow/platform/providers/aws && echo "PASS: tree deleted" + +# 2. Exclusive packages removed from go.mod +! grep -E "service/(ec2|rds|sqs|elasticloadbalancingv2|dynamodb)" /Users/jon/workspace/workflow/go.mod && echo "PASS: exclusive deps removed" + +# 3. Kept packages still present +grep -c "service/\(eks\|iam\|s3\|sts\)" /Users/jon/workspace/workflow/go.mod | grep -q "4" && echo "PASS: 4 kept packages present" + +# 4. Full build clean +cd /Users/jon/workspace/workflow && go build ./... && echo "PASS: build clean" + +# 5. All tests pass +cd /Users/jon/workspace/workflow && go test ./... 2>&1 | grep -c "^ok" | grep -v "^0$" && echo "PASS: tests green" +``` diff --git a/docs/plans/2026-05-13-issue-653-phase3-aws-drivers.md.scope-lock b/docs/plans/2026-05-13-issue-653-phase3-aws-drivers.md.scope-lock new file mode 100644 index 00000000..c161c50c --- /dev/null +++ b/docs/plans/2026-05-13-issue-653-phase3-aws-drivers.md.scope-lock @@ -0,0 +1 @@ +dfe4130a073ce48b84ad9ac8586d31083398d6b7250db41a004df68ee9387d2e diff --git a/go.mod b/go.mod index d5a9d938..3e93da17 100644 --- a/go.mod +++ b/go.mod @@ -22,15 +22,10 @@ require ( github.com/aws/aws-sdk-go-v2/config v1.32.16 github.com/aws/aws-sdk-go-v2/credentials v1.19.15 github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.55.2 - github.com/aws/aws-sdk-go-v2/service/dynamodb v1.57.0 - github.com/aws/aws-sdk-go-v2/service/ec2 v1.296.0 github.com/aws/aws-sdk-go-v2/service/ecs v1.76.0 github.com/aws/aws-sdk-go-v2/service/eks v1.81.2 - github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2 v1.54.6 github.com/aws/aws-sdk-go-v2/service/iam v1.53.7 - github.com/aws/aws-sdk-go-v2/service/rds v1.115.0 github.com/aws/aws-sdk-go-v2/service/s3 v1.97.2 - github.com/aws/aws-sdk-go-v2/service/sqs v1.42.21 github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 github.com/cucumber/godog v0.15.1 github.com/docker/docker v28.5.2+incompatible @@ -110,7 +105,6 @@ require ( github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 // indirect github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.20 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20 // indirect github.com/aws/aws-sdk-go-v2/service/kinesis v1.43.4 // indirect diff --git a/go.sum b/go.sum index f649afc2..4b4a6fdb 100644 --- a/go.sum +++ b/go.sum @@ -110,38 +110,26 @@ github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 h1:FPXsW9+gMuIeKmz7j6ENWcWtBGT github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23/go.mod h1:7J8iGMdRKk6lw2C+cMIphgAnT8uTwBwNOsGkyOCm80U= github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.55.2 h1:mleWBVIxwceEzyItUVoqMFiv6TmOP6ECPoN6WB/VWXc= github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.55.2/go.mod h1:cMApt548kNgu87UsBTNWVv+fpzjbUTFRSFjD1688SBs= -github.com/aws/aws-sdk-go-v2/service/dynamodb v1.57.0 h1:lQmHdyl1ZzNxImTGMkzPTnXEYGd16GaiNU61J02gt5w= -github.com/aws/aws-sdk-go-v2/service/dynamodb v1.57.0/go.mod h1:dLREOeW66eVaaGIOi2ZlLHDgkR3nuJ02rd00j0YSlBE= -github.com/aws/aws-sdk-go-v2/service/ec2 v1.296.0 h1:98Miqj16un1WLNyM1RjVDhXYumhqZrQfAeG8i4jPG6o= -github.com/aws/aws-sdk-go-v2/service/ec2 v1.296.0/go.mod h1:T6ndRfdhnXLIY5oKBHjYZDVj706los2zGdpThppquvA= github.com/aws/aws-sdk-go-v2/service/ecs v1.76.0 h1:a5G/TgJNrpuCjZBTf8/PTN0C2B0do/ylaYVynxPSbUQ= github.com/aws/aws-sdk-go-v2/service/ecs v1.76.0/go.mod h1:QkWmubOYmjj3cHn7A4CoUU7BKJhVeo39Gp6NH7IyhZw= github.com/aws/aws-sdk-go-v2/service/eks v1.81.2 h1:6c/Jkyx1gYLiZGl6VPjApViaoPiYo7TDWXCMk/ZBq6c= github.com/aws/aws-sdk-go-v2/service/eks v1.81.2/go.mod h1:xdUh6tdF9A8hc+PE84kmHbF/zsVPNiKnc6oLgulq1Eo= -github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2 v1.54.6 h1:fQR1aeZKaiPkNPya0JMy2nhsoqoSgIWc3/QTiTiL1K0= -github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2 v1.54.6/go.mod h1:oJRLDix51wqBDlP9dv+blFkvvf7HESolQz5cdhdmV4A= github.com/aws/aws-sdk-go-v2/service/iam v1.53.7 h1:n9YLiWtX3+6pTLZWvRJmtq5JIB9NA/KFelyCg5fOlTU= github.com/aws/aws-sdk-go-v2/service/iam v1.53.7/go.mod h1:sP46Vo6MeJcM4s0ZXcG2PFmfiSyixhIuC/74W52yKuk= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 h1:HtOTYcbVcGABLOVuPYaIihj6IlkqubBwFj10K5fxRek= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8/go.mod h1:VsK9abqQeGlzPgUr+isNWzPlK2vKe9INMLWnY65f5Xs= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12 h1:qtJZ70afD3ISKWnoX3xB0J2otEqu3LqicRcDBqsj0hQ= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12/go.mod h1:v2pNpJbRNl4vEUWEh5ytQok0zACAKfdmKS51Hotc3pQ= -github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.20 h1:ru+seMuylHiNZlvgZei83eD8h37hRjm1XIMOEmcV0BU= -github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.20/go.mod h1:ihZMtPTKoX/ugQRHbui6zNdSgVYN1KY2Dgwb2d3hXlc= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 h1:PUmZeJU6Y1Lbvt9WFuJ0ugUK2xn6hIWUBBbKuOWF30s= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22/go.mod h1:nO6egFBoAaoXze24a2C0NjQCvdpk8OueRoYimvEB9jo= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20 h1:siU1A6xjUZ2N8zjTHSXFhB9L/2OY8Dqs0xXiLjF30jA= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20/go.mod h1:4TLZCmVJDM3FOu5P5TJP0zOlu9zWgDWU7aUxWbr+rcw= github.com/aws/aws-sdk-go-v2/service/kinesis v1.43.4 h1:3m9iJtMtLq75jKRAfw0kapoHUlbzi0CRVigysBN/FHA= github.com/aws/aws-sdk-go-v2/service/kinesis v1.43.4/go.mod h1:O2L6vGm4xacEuN2otHFMgn7yXXlgzFKzxrba0fy/yk8= -github.com/aws/aws-sdk-go-v2/service/rds v1.115.0 h1:oNl6YghOtxu3MiFk1tQ86QlrYMIEJazGUDbBCg9nxLA= -github.com/aws/aws-sdk-go-v2/service/rds v1.115.0/go.mod h1:JBRYWpz5oXQtHgQC+X8LX9lh0FBCwRHJlWEIT+TTLaE= github.com/aws/aws-sdk-go-v2/service/s3 v1.97.2 h1:MRNiP6nqa20aEl8fQ6PJpEq11b2d40b16sm4WD7QgMU= github.com/aws/aws-sdk-go-v2/service/s3 v1.97.2/go.mod h1:FrNA56srbsr3WShiaelyWYEo70x80mXnVZ17ZZfbeqg= github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 h1:a1Fq/KXn75wSzoJaPQTgZO0wHGqE9mjFnylnqEPTchA= github.com/aws/aws-sdk-go-v2/service/signin v1.0.10/go.mod h1:p6+MXNxW7IA6dMgHfTAzljuwSKD0NCm/4lbS4t6+7vI= -github.com/aws/aws-sdk-go-v2/service/sqs v1.42.21 h1:Oa0IhwDLVrcBHDlNo1aosG4CxO4HyvzDV5xUWqWcBc0= -github.com/aws/aws-sdk-go-v2/service/sqs v1.42.21/go.mod h1:t98Ssq+qtXKXl2SFtaSkuT6X42FSM//fnO6sfq5RqGM= github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 h1:x6bKbmDhsgSZwv6q19wY/u3rLk/3FGjJWyqKcIRufpE= github.com/aws/aws-sdk-go-v2/service/sso v1.30.16/go.mod h1:CudnEVKRtLn0+3uMV0yEXZ+YZOKnAtUJ5DmDhilVnIw= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 h1:oK/njaL8GtyEihkWMD4k3VgHCT64RQKkZwh0DG5j8ak= diff --git a/module/aws_absent_test.go b/module/aws_absent_test.go index dffd35b3..7f703ed8 100644 --- a/module/aws_absent_test.go +++ b/module/aws_absent_test.go @@ -19,6 +19,12 @@ func TestAWSServicePackagesAbsent(t *testing.T) { "aws-sdk-go-v2/service/route53", "aws-sdk-go-v2/service/codebuild", "aws-sdk-go-v2/service/eks", + // Phase 3 (#653): packages exclusive to platform/providers/aws/ (now deleted) + "aws-sdk-go-v2/service/ec2", + "aws-sdk-go-v2/service/dynamodb", + "aws-sdk-go-v2/service/elasticloadbalancingv2", + "aws-sdk-go-v2/service/rds", + "aws-sdk-go-v2/service/sqs", } fset := token.NewFileSet() diff --git a/platform/provider.go b/platform/provider.go index ff3b3781..2d8fd466 100644 --- a/platform/provider.go +++ b/platform/provider.go @@ -6,6 +6,36 @@ import "context" // A provider manages a collection of resource drivers and maps abstract // capabilities to provider-specific resource types. Providers are registered // with the engine and selected based on the platform configuration. +// +// # Three-Layer Provider Architecture +// +// The workflow engine has three distinct provider abstractions. Each serves a +// different layer and MUST NOT be confused with the others: +// +// - platform.Provider (this interface) — in-core, capability-based declarative +// abstraction used by the platform.* module system and pipeline steps +// (step.iac_plan, step.iac_apply, step.platform_template, etc.). +// Live implementations: DockerComposeProvider, MockProvider. +// The AWS implementation (platform/providers/aws/) was deleted in workflow +// v0.53.0 (issue #653 Phase 3) because it was build-tag-gated dead code +// with zero callers; see ADR-0032. +// +// - interfaces.IaCProvider (in interfaces/iac_provider.go) — the gRPC plugin +// boundary interface used by the wfctl `infra.*` command suite. Implemented +// by external plugins (workflow-plugin-aws, workflow-plugin-gcp, etc.) via +// the typedIaCAdapter. This is the canonical AWS IaC path since v0.53.0. +// +// - provider.CloudProvider (in provider/provider.go) — deploy-pipeline +// abstraction for container deployments (ECS/EKS/GKE). Used by +// step.deploy_rolling and the deployment executor. Orthogonal to both +// platform.Provider and interfaces.IaCProvider. +// +// When adding a new cloud provider implementation, choose the layer that matches +// the use case: +// - IaC resource provisioning (VPCs, DBs, clusters): implement interfaces.IaCProvider +// as an external gRPC plugin. +// - Container deployment pipelines: implement provider.CloudProvider in provider/. +// - Local/mock/test capability-based planning: implement platform.Provider here. type Provider interface { // Name returns the provider identifier (e.g., "aws", "docker-compose", "gcp"). Name() string diff --git a/platform/providers/aws/aws_config.go b/platform/providers/aws/aws_config.go deleted file mode 100644 index bfd17a8a..00000000 --- a/platform/providers/aws/aws_config.go +++ /dev/null @@ -1,11 +0,0 @@ -//go:build aws - -package aws - -import ( - awsv2 "github.com/aws/aws-sdk-go-v2/aws" -) - -// awsSDKConfig is an alias for the AWS SDK v2 config type used throughout -// the provider. This allows tests to construct configs with mock credentials. -type awsSDKConfig = awsv2.Config diff --git a/platform/providers/aws/capability_mapper.go b/platform/providers/aws/capability_mapper.go deleted file mode 100644 index 1a7e57b7..00000000 --- a/platform/providers/aws/capability_mapper.go +++ /dev/null @@ -1,305 +0,0 @@ -//go:build aws - -package aws - -import ( - "fmt" - - "github.com/GoCodeAlone/workflow/platform" -) - -// AWSCapabilityMapper maps abstract capability declarations to AWS resource plans. -type AWSCapabilityMapper struct{} - -// NewAWSCapabilityMapper creates a new capability mapper for AWS. -func NewAWSCapabilityMapper() *AWSCapabilityMapper { - return &AWSCapabilityMapper{} -} - -func (m *AWSCapabilityMapper) CanMap(capabilityType string) bool { - switch capabilityType { - case "kubernetes_cluster", "network", "database", "message_queue", - "container_runtime", "load_balancer": - return true - } - return false -} - -func (m *AWSCapabilityMapper) Map(decl platform.CapabilityDeclaration, pctx *platform.PlatformContext) ([]platform.ResourcePlan, error) { - switch decl.Type { - case "kubernetes_cluster": - return m.mapKubernetesCluster(decl) - case "network": - return m.mapNetwork(decl) - case "database": - return m.mapDatabase(decl) - case "message_queue": - return m.mapMessageQueue(decl) - case "container_runtime": - return m.mapContainerRuntime(decl, pctx) - case "load_balancer": - return m.mapLoadBalancer(decl, pctx) - default: - return nil, &platform.CapabilityUnsupportedError{ - Capability: decl.Type, - Provider: ProviderName, - } - } -} - -func (m *AWSCapabilityMapper) ValidateConstraints(decl platform.CapabilityDeclaration, constraints []platform.Constraint) []platform.ConstraintViolation { - var violations []platform.ConstraintViolation - for _, c := range constraints { - val, ok := decl.Properties[c.Field] - if !ok { - continue - } - if !checkConstraint(c.Operator, val, c.Value) { - violations = append(violations, platform.ConstraintViolation{ - Constraint: c, - Actual: val, - Message: fmt.Sprintf("property %q value %v violates constraint %s %v from %s", c.Field, val, c.Operator, c.Value, c.Source), - }) - } - } - return violations -} - -func (m *AWSCapabilityMapper) mapKubernetesCluster(decl platform.CapabilityDeclaration) ([]platform.ResourcePlan, error) { - version, _ := decl.Properties["version"].(string) - if version == "" { - version = "1.29" - } - nodeCount := intProp(decl.Properties, "node_count", 2) - instanceType, _ := decl.Properties["instance_type"].(string) - if instanceType == "" { - instanceType = "t3.medium" - } - - clusterName := decl.Name + "-eks" - nodeGroupName := decl.Name + "-nodes" - - return []platform.ResourcePlan{ - { - ResourceType: "aws.eks_cluster", - Name: clusterName, - Properties: map[string]any{ - "version": version, - "name": clusterName, - }, - DependsOn: decl.DependsOn, - }, - { - ResourceType: "aws.eks_nodegroup", - Name: nodeGroupName, - Properties: map[string]any{ - "cluster_name": clusterName, - "node_count": nodeCount, - "instance_type": instanceType, - }, - DependsOn: []string{clusterName}, - }, - }, nil -} - -func (m *AWSCapabilityMapper) mapNetwork(decl platform.CapabilityDeclaration) ([]platform.ResourcePlan, error) { - cidr, _ := decl.Properties["cidr"].(string) - if cidr == "" { - return nil, fmt.Errorf("aws: network capability requires 'cidr' property") - } - enableNAT := boolProp(decl.Properties, "enable_nat", true) - - return []platform.ResourcePlan{ - { - ResourceType: "aws.vpc", - Name: decl.Name + "-vpc", - Properties: map[string]any{ - "cidr": cidr, - "enable_nat": enableNAT, - "name": decl.Name + "-vpc", - }, - DependsOn: decl.DependsOn, - }, - }, nil -} - -func (m *AWSCapabilityMapper) mapDatabase(decl platform.CapabilityDeclaration) ([]platform.ResourcePlan, error) { - engine, _ := decl.Properties["engine"].(string) - if engine == "" { - return nil, fmt.Errorf("aws: database capability requires 'engine' property") - } - engineVersion, _ := decl.Properties["engine_version"].(string) - instanceClass, _ := decl.Properties["instance_class"].(string) - if instanceClass == "" { - instanceClass = "db.t3.micro" - } - allocatedStorage := intProp(decl.Properties, "allocated_storage", 20) - multiAZ := boolProp(decl.Properties, "multi_az", false) - - return []platform.ResourcePlan{ - { - ResourceType: "aws.rds", - Name: decl.Name + "-rds", - Properties: map[string]any{ - "engine": engine, - "engine_version": engineVersion, - "instance_class": instanceClass, - "allocated_storage": allocatedStorage, - "multi_az": multiAZ, - "name": decl.Name + "-rds", - }, - DependsOn: decl.DependsOn, - }, - }, nil -} - -func (m *AWSCapabilityMapper) mapMessageQueue(decl platform.CapabilityDeclaration) ([]platform.ResourcePlan, error) { - fifo := boolProp(decl.Properties, "fifo", false) - visibilityTimeout := intProp(decl.Properties, "visibility_timeout", 30) - retentionPeriod := intProp(decl.Properties, "retention_period", 345600) - - return []platform.ResourcePlan{ - { - ResourceType: "aws.sqs", - Name: decl.Name + "-sqs", - Properties: map[string]any{ - "fifo": fifo, - "visibility_timeout": visibilityTimeout, - "retention_period": retentionPeriod, - "name": decl.Name + "-sqs", - }, - DependsOn: decl.DependsOn, - }, - }, nil -} - -func (m *AWSCapabilityMapper) mapContainerRuntime(decl platform.CapabilityDeclaration, pctx *platform.PlatformContext) ([]platform.ResourcePlan, error) { - image, _ := decl.Properties["image"].(string) - if image == "" { - return nil, fmt.Errorf("aws: container_runtime capability requires 'image' property") - } - replicas := intProp(decl.Properties, "replicas", 1) - memory, _ := decl.Properties["memory"].(string) - cpu, _ := decl.Properties["cpu"].(string) - - // container_runtime maps to an EKS deployment, which needs a cluster - deps := decl.DependsOn - if pctx != nil { - for name, out := range pctx.ParentOutputs { - if out.Type == "kubernetes_cluster" { - deps = append(deps, name) - break - } - } - } - - return []platform.ResourcePlan{ - { - ResourceType: "aws.eks_nodegroup", - Name: decl.Name + "-deployment", - Properties: map[string]any{ - "image": image, - "replicas": replicas, - "memory": memory, - "cpu": cpu, - "name": decl.Name + "-deployment", - }, - DependsOn: deps, - }, - }, nil -} - -func (m *AWSCapabilityMapper) mapLoadBalancer(decl platform.CapabilityDeclaration, pctx *platform.PlatformContext) ([]platform.ResourcePlan, error) { - scheme, _ := decl.Properties["scheme"].(string) - if scheme == "" { - scheme = "internet-facing" - } - - deps := decl.DependsOn - if pctx != nil { - for name, out := range pctx.ParentOutputs { - if out.Type == "network" { - deps = append(deps, name) - break - } - } - } - - return []platform.ResourcePlan{ - { - ResourceType: "aws.alb", - Name: decl.Name + "-alb", - Properties: map[string]any{ - "scheme": scheme, - "name": decl.Name + "-alb", - }, - DependsOn: deps, - }, - }, nil -} - -// Helper functions - -func intProp(props map[string]any, key string, def int) int { - v, ok := props[key] - if !ok { - return def - } - switch n := v.(type) { - case int: - return n - case float64: - return int(n) - case int64: - return int(n) - default: - return def - } -} - -func boolProp(props map[string]any, key string, def bool) bool { - v, ok := props[key] - if !ok { - return def - } - b, ok := v.(bool) - if !ok { - return def - } - return b -} - -func checkConstraint(op string, actual, limit any) bool { - actualF, aOK := toFloat(actual) - limitF, lOK := toFloat(limit) - if !aOK || !lOK { - // For non-numeric, only == is supported - if op == "==" { - return fmt.Sprintf("%v", actual) == fmt.Sprintf("%v", limit) - } - return true // cannot evaluate, assume satisfied - } - switch op { - case "<=": - return actualF <= limitF - case ">=": - return actualF >= limitF - case "==": - return actualF == limitF - default: - return true - } -} - -func toFloat(v any) (float64, bool) { - switch n := v.(type) { - case int: - return float64(n), true - case int64: - return float64(n), true - case float64: - return n, true - default: - return 0, false - } -} diff --git a/platform/providers/aws/credential_broker.go b/platform/providers/aws/credential_broker.go deleted file mode 100644 index d2d45f1f..00000000 --- a/platform/providers/aws/credential_broker.go +++ /dev/null @@ -1,162 +0,0 @@ -//go:build aws - -package aws - -import ( - "context" - "fmt" - "time" - - "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" - "github.com/google/uuid" - - "github.com/GoCodeAlone/workflow/platform" -) - -// STSClient defines the STS operations used by the credential broker. -type STSClient interface { - AssumeRole(ctx context.Context, params *sts.AssumeRoleInput, optFns ...func(*sts.Options)) (*sts.AssumeRoleOutput, error) -} - -// AWSCredentialBroker implements platform.CredentialBroker using AWS STS. -type AWSCredentialBroker struct { - stsClient STSClient - roleARN string -} - -// NewAWSCredentialBroker creates a credential broker backed by STS AssumeRole. -func NewAWSCredentialBroker(cfg awsSDKConfig, roleARN string) *AWSCredentialBroker { - return &AWSCredentialBroker{ - stsClient: sts.NewFromConfig(cfg), - roleARN: roleARN, - } -} - -func (b *AWSCredentialBroker) IssueCredential(ctx context.Context, pctx *platform.PlatformContext, request platform.CredentialRequest) (*platform.CredentialRef, error) { - sessionName := fmt.Sprintf("wf-%s-%s", pctx.ContextPath(), request.Name) - // Truncate session name to 64 chars (AWS limit) - if len(sessionName) > 64 { - sessionName = sessionName[:64] - } - - ttl := request.TTL - if ttl == 0 { - ttl = time.Hour - } - durationSecs := int32(ttl.Seconds()) - - input := &sts.AssumeRoleInput{ - RoleArn: aws.String(b.roleARN), - RoleSessionName: aws.String(sessionName), - DurationSeconds: aws.Int32(durationSecs), - } - - // Scope by resource names if provided - if len(request.Scope) > 0 { - policy := buildScopePolicy(request.Scope) - input.Policy = aws.String(policy) - } - - out, err := b.stsClient.AssumeRole(ctx, input) - if err != nil { - return nil, fmt.Errorf("aws: assume role: %w", err) - } - - credID := uuid.New().String() - expiresAt := time.Now().Add(ttl) - if out.Credentials != nil && out.Credentials.Expiration != nil { - expiresAt = *out.Credentials.Expiration - } - - return &platform.CredentialRef{ - ID: credID, - Name: request.Name, - SecretPath: fmt.Sprintf("aws/sts/%s", credID), - Provider: ProviderName, - ExpiresAt: expiresAt, - Tier: pctx.Tier, - ContextPath: pctx.ContextPath(), - }, nil -} - -func (b *AWSCredentialBroker) RevokeCredential(_ context.Context, _ *platform.CredentialRef) error { - // STS sessions cannot be directly revoked. They expire naturally. - // In production, you would attach a revocation policy to the role. - return nil -} - -func (b *AWSCredentialBroker) ResolveCredential(_ context.Context, ref *platform.CredentialRef) (string, error) { - // In a real implementation, this would retrieve the cached credential value - // from an in-memory store or secrets backend. - return fmt.Sprintf("sts-session:%s", ref.ID), nil -} - -func (b *AWSCredentialBroker) RotateCredential(ctx context.Context, ref *platform.CredentialRef) (*platform.CredentialRef, error) { - pctx := &platform.PlatformContext{ - Tier: ref.Tier, - } - // Parse context path back to org/env/app - parts := splitContextPath(ref.ContextPath) - if len(parts) >= 2 { - pctx.Org = parts[0] - pctx.Environment = parts[1] - } - if len(parts) >= 3 { - pctx.Application = parts[2] - } - - request := platform.CredentialRequest{ - Name: ref.Name, - Type: "token", - TTL: time.Hour, - } - - return b.IssueCredential(ctx, pctx, request) -} - -func (b *AWSCredentialBroker) ListCredentials(_ context.Context, _ *platform.PlatformContext) ([]*platform.CredentialRef, error) { - // In a real implementation, this would query a credential store. - return nil, nil -} - -func buildScopePolicy(scope []string) string { - // Simplified IAM policy that restricts to specific resource names. - _ = scope - return `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"*","Resource":"*"}]}` -} - -func splitContextPath(path string) []string { - var parts []string - current := "" - for _, c := range path { - if c == '/' { - if current != "" { - parts = append(parts, current) - } - current = "" - } else { - current += string(c) - } - } - if current != "" { - parts = append(parts, current) - } - return parts -} - -// Ensure compile-time interface check is in provider_test.go since we need the -// mock. The real STS client from sts.NewFromConfig satisfies STSClient. -var _ STSClient = (*sts.Client)(nil) - -// Verify that AWSCredentialBroker satisfies platform.CredentialBroker. -var _ platform.CredentialBroker = (*AWSCredentialBroker)(nil) - -// Verify this struct field doesn't panic for nil credentials -func credentialExpiration(creds *ststypes.Credentials) time.Time { - if creds != nil && creds.Expiration != nil { - return *creds.Expiration - } - return time.Time{} -} diff --git a/platform/providers/aws/credential_broker_test.go b/platform/providers/aws/credential_broker_test.go deleted file mode 100644 index e648225e..00000000 --- a/platform/providers/aws/credential_broker_test.go +++ /dev/null @@ -1,285 +0,0 @@ -//go:build aws - -package aws - -import ( - "context" - "fmt" - "testing" - "time" - - "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" - - "github.com/GoCodeAlone/workflow/platform" -) - -type mockSTSClient struct { - assumeRoleFunc func(ctx context.Context, params *sts.AssumeRoleInput, optFns ...func(*sts.Options)) (*sts.AssumeRoleOutput, error) -} - -func (m *mockSTSClient) AssumeRole(ctx context.Context, params *sts.AssumeRoleInput, optFns ...func(*sts.Options)) (*sts.AssumeRoleOutput, error) { - if m.assumeRoleFunc != nil { - return m.assumeRoleFunc(ctx, params, optFns...) - } - exp := time.Now().Add(time.Hour) - return &sts.AssumeRoleOutput{ - Credentials: &ststypes.Credentials{ - AccessKeyId: aws.String("AKIAIOSFODNN7EXAMPLE"), - SecretAccessKey: aws.String("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"), - SessionToken: aws.String("FwoGZX..."), - Expiration: &exp, - }, - }, nil -} - -func newTestCredBroker(client STSClient) *AWSCredentialBroker { - return &AWSCredentialBroker{ - stsClient: client, - roleARN: "arn:aws:iam::123456789:role/test-role", - } -} - -func TestCredentialBroker_IssueCredential(t *testing.T) { - broker := newTestCredBroker(&mockSTSClient{}) - ctx := context.Background() - - pctx := &platform.PlatformContext{ - Org: "acme", - Environment: "prod", - Tier: platform.TierInfrastructure, - } - req := platform.CredentialRequest{ - Name: "db-creds", - Type: "database", - TTL: 30 * time.Minute, - } - - ref, err := broker.IssueCredential(ctx, pctx, req) - if err != nil { - t.Fatalf("IssueCredential() error: %v", err) - } - if ref == nil { - t.Fatal("IssueCredential() returned nil") - } - if ref.Name != "db-creds" { - t.Errorf("Name = %q, want db-creds", ref.Name) - } - if ref.Provider != "aws" { - t.Errorf("Provider = %q, want aws", ref.Provider) - } - if ref.Tier != platform.TierInfrastructure { - t.Errorf("Tier = %v, want TierInfrastructure", ref.Tier) - } - if ref.ContextPath != "acme/prod" { - t.Errorf("ContextPath = %q, want acme/prod", ref.ContextPath) - } - if ref.ID == "" { - t.Error("ID is empty") - } - if ref.SecretPath == "" { - t.Error("SecretPath is empty") - } -} - -func TestCredentialBroker_IssueCredentialWithScope(t *testing.T) { - var receivedPolicy *string - broker := newTestCredBroker(&mockSTSClient{ - assumeRoleFunc: func(ctx context.Context, params *sts.AssumeRoleInput, optFns ...func(*sts.Options)) (*sts.AssumeRoleOutput, error) { - receivedPolicy = params.Policy - exp := time.Now().Add(time.Hour) - return &sts.AssumeRoleOutput{ - Credentials: &ststypes.Credentials{Expiration: &exp}, - }, nil - }, - }) - ctx := context.Background() - - pctx := &platform.PlatformContext{Org: "acme", Environment: "prod"} - req := platform.CredentialRequest{ - Name: "scoped", - Scope: []string{"resource-a", "resource-b"}, - } - - _, err := broker.IssueCredential(ctx, pctx, req) - if err != nil { - t.Fatalf("IssueCredential() error: %v", err) - } - if receivedPolicy == nil { - t.Error("expected policy to be set when scope is provided") - } -} - -func TestCredentialBroker_IssueCredentialDefaultTTL(t *testing.T) { - var receivedDuration *int32 - broker := newTestCredBroker(&mockSTSClient{ - assumeRoleFunc: func(ctx context.Context, params *sts.AssumeRoleInput, optFns ...func(*sts.Options)) (*sts.AssumeRoleOutput, error) { - receivedDuration = params.DurationSeconds - exp := time.Now().Add(time.Hour) - return &sts.AssumeRoleOutput{ - Credentials: &ststypes.Credentials{Expiration: &exp}, - }, nil - }, - }) - ctx := context.Background() - - pctx := &platform.PlatformContext{Org: "acme", Environment: "prod"} - req := platform.CredentialRequest{Name: "test"} - - _, err := broker.IssueCredential(ctx, pctx, req) - if err != nil { - t.Fatalf("IssueCredential() error: %v", err) - } - if receivedDuration == nil || *receivedDuration != 3600 { - t.Errorf("DurationSeconds = %v, want 3600 (default 1 hour)", receivedDuration) - } -} - -func TestCredentialBroker_IssueCredentialError(t *testing.T) { - broker := newTestCredBroker(&mockSTSClient{ - assumeRoleFunc: func(ctx context.Context, params *sts.AssumeRoleInput, optFns ...func(*sts.Options)) (*sts.AssumeRoleOutput, error) { - return nil, fmt.Errorf("access denied") - }, - }) - ctx := context.Background() - - pctx := &platform.PlatformContext{Org: "acme", Environment: "prod"} - req := platform.CredentialRequest{Name: "test"} - - _, err := broker.IssueCredential(ctx, pctx, req) - if err == nil { - t.Fatal("expected error from STS failure") - } -} - -func TestCredentialBroker_IssueCredentialLongSessionName(t *testing.T) { - broker := newTestCredBroker(&mockSTSClient{ - assumeRoleFunc: func(ctx context.Context, params *sts.AssumeRoleInput, optFns ...func(*sts.Options)) (*sts.AssumeRoleOutput, error) { - if len(*params.RoleSessionName) > 64 { - t.Errorf("session name too long: %d chars", len(*params.RoleSessionName)) - } - exp := time.Now().Add(time.Hour) - return &sts.AssumeRoleOutput{ - Credentials: &ststypes.Credentials{Expiration: &exp}, - }, nil - }, - }) - ctx := context.Background() - - pctx := &platform.PlatformContext{ - Org: "very-long-organization-name-that-exceeds-normal-lengths", - Environment: "production-environment", - Application: "my-super-long-application-name", - } - req := platform.CredentialRequest{Name: "a-long-credential-name-here"} - - _, err := broker.IssueCredential(ctx, pctx, req) - if err != nil { - t.Fatalf("IssueCredential() error: %v", err) - } -} - -func TestCredentialBroker_IssueCredentialNilExpiration(t *testing.T) { - broker := newTestCredBroker(&mockSTSClient{ - assumeRoleFunc: func(ctx context.Context, params *sts.AssumeRoleInput, optFns ...func(*sts.Options)) (*sts.AssumeRoleOutput, error) { - return &sts.AssumeRoleOutput{ - Credentials: nil, - }, nil - }, - }) - ctx := context.Background() - - pctx := &platform.PlatformContext{Org: "acme", Environment: "prod"} - req := platform.CredentialRequest{Name: "test", TTL: time.Hour} - - ref, err := broker.IssueCredential(ctx, pctx, req) - if err != nil { - t.Fatalf("IssueCredential() error: %v", err) - } - // Should use TTL-based expiration as fallback - if ref.ExpiresAt.IsZero() { - t.Error("ExpiresAt should not be zero") - } -} - -func TestCredentialBroker_RevokeCredential(t *testing.T) { - broker := newTestCredBroker(&mockSTSClient{}) - ctx := context.Background() - - ref := &platform.CredentialRef{ID: "test-id"} - err := broker.RevokeCredential(ctx, ref) - if err != nil { - t.Fatalf("RevokeCredential() error: %v", err) - } -} - -func TestCredentialBroker_ResolveCredential(t *testing.T) { - broker := newTestCredBroker(&mockSTSClient{}) - ctx := context.Background() - - ref := &platform.CredentialRef{ID: "test-id-123"} - val, err := broker.ResolveCredential(ctx, ref) - if err != nil { - t.Fatalf("ResolveCredential() error: %v", err) - } - if val != "sts-session:test-id-123" { - t.Errorf("resolved value = %q, want sts-session:test-id-123", val) - } -} - -func TestCredentialBroker_RotateCredential(t *testing.T) { - broker := newTestCredBroker(&mockSTSClient{}) - ctx := context.Background() - - ref := &platform.CredentialRef{ - ID: "old-id", - Name: "db-creds", - ContextPath: "acme/prod/api", - Tier: platform.TierSharedPrimitive, - } - - newRef, err := broker.RotateCredential(ctx, ref) - if err != nil { - t.Fatalf("RotateCredential() error: %v", err) - } - if newRef.Name != "db-creds" { - t.Errorf("Name = %q, want db-creds", newRef.Name) - } - if newRef.ID == "old-id" { - t.Error("expected new ID after rotation") - } -} - -func TestCredentialBroker_ListCredentials(t *testing.T) { - broker := newTestCredBroker(&mockSTSClient{}) - ctx := context.Background() - - refs, err := broker.ListCredentials(ctx, &platform.PlatformContext{}) - if err != nil { - t.Fatalf("ListCredentials() error: %v", err) - } - if refs != nil { - t.Errorf("expected nil list, got %v", refs) - } -} - -func TestCredentialExpiration(t *testing.T) { - // nil credentials - if !credentialExpiration(nil).IsZero() { - t.Error("expected zero time for nil credentials") - } - - // nil expiration - creds := &ststypes.Credentials{} - if !credentialExpiration(creds).IsZero() { - t.Error("expected zero time for nil expiration") - } - - // valid expiration - exp := time.Now() - creds.Expiration = &exp - if credentialExpiration(creds) != exp { - t.Error("expected matching expiration time") - } -} diff --git a/platform/providers/aws/driver_factories.go b/platform/providers/aws/driver_factories.go deleted file mode 100644 index 9bb286d5..00000000 --- a/platform/providers/aws/driver_factories.go +++ /dev/null @@ -1,40 +0,0 @@ -//go:build aws - -package aws - -import ( - "github.com/GoCodeAlone/workflow/platform" - "github.com/GoCodeAlone/workflow/platform/providers/aws/drivers" -) - -// Driver factory functions bridge between the provider (aws package) and the -// drivers sub-package so that registerDrivers can create all drivers from a -// single AWS config. - -func NewEKSClusterDriver(cfg awsSDKConfig) platform.ResourceDriver { - return drivers.NewEKSClusterDriver(cfg) -} - -func NewEKSNodeGroupDriver(cfg awsSDKConfig) platform.ResourceDriver { - return drivers.NewEKSNodeGroupDriver(cfg) -} - -func NewVPCDriver(cfg awsSDKConfig) platform.ResourceDriver { - return drivers.NewVPCDriver(cfg) -} - -func NewRDSDriver(cfg awsSDKConfig) platform.ResourceDriver { - return drivers.NewRDSDriver(cfg) -} - -func NewSQSDriver(cfg awsSDKConfig) platform.ResourceDriver { - return drivers.NewSQSDriver(cfg) -} - -func NewIAMDriver(cfg awsSDKConfig) platform.ResourceDriver { - return drivers.NewIAMDriver(cfg) -} - -func NewALBDriver(cfg awsSDKConfig) platform.ResourceDriver { - return drivers.NewALBDriver(cfg) -} diff --git a/platform/providers/aws/drivers/alb.go b/platform/providers/aws/drivers/alb.go deleted file mode 100644 index 9a13bdc8..00000000 --- a/platform/providers/aws/drivers/alb.go +++ /dev/null @@ -1,224 +0,0 @@ -//go:build aws - -package drivers - -import ( - "context" - "fmt" - "time" - - awsv2 "github.com/aws/aws-sdk-go-v2/aws" - elbv2 "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2" - elbtypes "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2/types" - - "github.com/GoCodeAlone/workflow/platform" -) - -// ELBv2Client defines the ELBv2 operations used by the ALB driver. -type ELBv2Client interface { - CreateLoadBalancer(ctx context.Context, params *elbv2.CreateLoadBalancerInput, optFns ...func(*elbv2.Options)) (*elbv2.CreateLoadBalancerOutput, error) - DescribeLoadBalancers(ctx context.Context, params *elbv2.DescribeLoadBalancersInput, optFns ...func(*elbv2.Options)) (*elbv2.DescribeLoadBalancersOutput, error) - DeleteLoadBalancer(ctx context.Context, params *elbv2.DeleteLoadBalancerInput, optFns ...func(*elbv2.Options)) (*elbv2.DeleteLoadBalancerOutput, error) - ModifyLoadBalancerAttributes(ctx context.Context, params *elbv2.ModifyLoadBalancerAttributesInput, optFns ...func(*elbv2.Options)) (*elbv2.ModifyLoadBalancerAttributesOutput, error) -} - -// ALBDriver manages Application Load Balancer resources. -type ALBDriver struct { - client ELBv2Client -} - -// NewALBDriver creates a new ALB driver. -func NewALBDriver(cfg awsv2.Config) *ALBDriver { - return &ALBDriver{ - client: elbv2.NewFromConfig(cfg), - } -} - -// NewALBDriverWithClient creates an ALB driver with a custom client. -func NewALBDriverWithClient(client ELBv2Client) *ALBDriver { - return &ALBDriver{client: client} -} - -func (d *ALBDriver) ResourceType() string { return "aws.alb" } - -func (d *ALBDriver) Create(ctx context.Context, name string, properties map[string]any) (*platform.ResourceOutput, error) { - scheme, _ := properties["scheme"].(string) - if scheme == "" { - scheme = "internet-facing" - } - subnetIDs := stringSliceProp(properties, "subnet_ids") - securityGroups := stringSliceProp(properties, "security_group_ids") - - lbScheme := elbtypes.LoadBalancerSchemeEnumInternetFacing - if scheme == "internal" { - lbScheme = elbtypes.LoadBalancerSchemeEnumInternal - } - - input := &elbv2.CreateLoadBalancerInput{ - Name: awsv2.String(name), - Type: elbtypes.LoadBalancerTypeEnumApplication, - Scheme: lbScheme, - Subnets: subnetIDs, - SecurityGroups: securityGroups, - } - - out, err := d.client.CreateLoadBalancer(ctx, input) - if err != nil { - return nil, fmt.Errorf("alb: create %q: %w", name, err) - } - - if len(out.LoadBalancers) == 0 { - return nil, fmt.Errorf("alb: create %q returned no load balancers", name) - } - - return albToOutput(&out.LoadBalancers[0]), nil -} - -func (d *ALBDriver) Read(ctx context.Context, name string) (*platform.ResourceOutput, error) { - out, err := d.client.DescribeLoadBalancers(ctx, &elbv2.DescribeLoadBalancersInput{ - Names: []string{name}, - }) - if err != nil { - return nil, fmt.Errorf("alb: describe %q: %w", name, err) - } - if len(out.LoadBalancers) == 0 { - return nil, &platform.ResourceNotFoundError{Name: name, Provider: "aws"} - } - return albToOutput(&out.LoadBalancers[0]), nil -} - -func (d *ALBDriver) Update(ctx context.Context, name string, _, desired map[string]any) (*platform.ResourceOutput, error) { - // Read current to get the ARN - current, err := d.Read(ctx, name) - if err != nil { - return nil, err - } - arn, _ := current.Properties["arn"].(string) - if arn == "" { - return nil, fmt.Errorf("alb: update %q: missing ARN", name) - } - - // Modify attributes if provided - var attrs []elbtypes.LoadBalancerAttribute - if idleTimeout, ok := desired["idle_timeout"].(string); ok { - attrs = append(attrs, elbtypes.LoadBalancerAttribute{ - Key: awsv2.String("idle_timeout.timeout_seconds"), - Value: awsv2.String(idleTimeout), - }) - } - - if len(attrs) > 0 { - _, err := d.client.ModifyLoadBalancerAttributes(ctx, &elbv2.ModifyLoadBalancerAttributesInput{ - LoadBalancerArn: awsv2.String(arn), - Attributes: attrs, - }) - if err != nil { - return nil, fmt.Errorf("alb: modify %q: %w", name, err) - } - } - - return d.Read(ctx, name) -} - -func (d *ALBDriver) Delete(ctx context.Context, name string) error { - current, err := d.Read(ctx, name) - if err != nil { - return err - } - arn, _ := current.Properties["arn"].(string) - if arn == "" { - return fmt.Errorf("alb: delete %q: missing ARN", name) - } - - _, err = d.client.DeleteLoadBalancer(ctx, &elbv2.DeleteLoadBalancerInput{ - LoadBalancerArn: awsv2.String(arn), - }) - if err != nil { - return fmt.Errorf("alb: delete %q: %w", name, err) - } - return nil -} - -func (d *ALBDriver) HealthCheck(ctx context.Context, name string) (*platform.HealthStatus, error) { - out, err := d.Read(ctx, name) - if err != nil { - return &platform.HealthStatus{ - Status: "unhealthy", - Message: err.Error(), - CheckedAt: time.Now(), - }, nil - } - status := "healthy" - if out.Status != platform.ResourceStatusActive { - status = "degraded" - } - return &platform.HealthStatus{ - Status: status, - Message: string(out.Status), - CheckedAt: time.Now(), - }, nil -} - -func (d *ALBDriver) Scale(_ context.Context, _ string, _ map[string]any) (*platform.ResourceOutput, error) { - return nil, &platform.NotScalableError{ResourceType: "aws.alb"} -} - -func (d *ALBDriver) Diff(ctx context.Context, name string, desired map[string]any) ([]platform.DiffEntry, error) { - current, err := d.Read(ctx, name) - if err != nil { - return nil, err - } - return diffProperties(current.Properties, desired), nil -} - -func albToOutput(lb *elbtypes.LoadBalancer) *platform.ResourceOutput { - if lb == nil { - return nil - } - - status := platform.ResourceStatusActive - if lb.State != nil { - switch lb.State.Code { - case elbtypes.LoadBalancerStateEnumProvisioning: - status = platform.ResourceStatusCreating - case elbtypes.LoadBalancerStateEnumFailed: - status = platform.ResourceStatusFailed - } - } - - props := map[string]any{ - "scheme": string(lb.Scheme), - "type": string(lb.Type), - } - if lb.LoadBalancerArn != nil { - props["arn"] = *lb.LoadBalancerArn - } - if lb.DNSName != nil { - props["dns_name"] = *lb.DNSName - } - if lb.VpcId != nil { - props["vpc_id"] = *lb.VpcId - } - - endpoint := "" - if lb.DNSName != nil { - endpoint = *lb.DNSName - } - - name := "" - if lb.LoadBalancerName != nil { - name = *lb.LoadBalancerName - } - - return &platform.ResourceOutput{ - Name: name, - Type: "load_balancer", - ProviderType: "aws.alb", - Endpoint: endpoint, - Properties: props, - Status: status, - LastSynced: time.Now(), - } -} - -var _ platform.ResourceDriver = (*ALBDriver)(nil) diff --git a/platform/providers/aws/drivers/alb_test.go b/platform/providers/aws/drivers/alb_test.go deleted file mode 100644 index f991f45d..00000000 --- a/platform/providers/aws/drivers/alb_test.go +++ /dev/null @@ -1,200 +0,0 @@ -//go:build aws - -package drivers - -import ( - "context" - "testing" - - awsv2 "github.com/aws/aws-sdk-go-v2/aws" - elbv2 "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2" - elbtypes "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2/types" - - "github.com/GoCodeAlone/workflow/platform" -) - -type mockELBv2Client struct { - createFunc func(ctx context.Context, params *elbv2.CreateLoadBalancerInput, optFns ...func(*elbv2.Options)) (*elbv2.CreateLoadBalancerOutput, error) - describeFunc func(ctx context.Context, params *elbv2.DescribeLoadBalancersInput, optFns ...func(*elbv2.Options)) (*elbv2.DescribeLoadBalancersOutput, error) - deleteFunc func(ctx context.Context, params *elbv2.DeleteLoadBalancerInput, optFns ...func(*elbv2.Options)) (*elbv2.DeleteLoadBalancerOutput, error) - modifyFunc func(ctx context.Context, params *elbv2.ModifyLoadBalancerAttributesInput, optFns ...func(*elbv2.Options)) (*elbv2.ModifyLoadBalancerAttributesOutput, error) -} - -func (m *mockELBv2Client) CreateLoadBalancer(ctx context.Context, params *elbv2.CreateLoadBalancerInput, optFns ...func(*elbv2.Options)) (*elbv2.CreateLoadBalancerOutput, error) { - if m.createFunc != nil { - return m.createFunc(ctx, params, optFns...) - } - return &elbv2.CreateLoadBalancerOutput{ - LoadBalancers: []elbtypes.LoadBalancer{ - { - LoadBalancerName: params.Name, - LoadBalancerArn: awsv2.String("arn:aws:elasticloadbalancing:us-east-1:123456789:loadbalancer/app/test/123"), - DNSName: awsv2.String("test-123.us-east-1.elb.amazonaws.com"), - Scheme: params.Scheme, - Type: elbtypes.LoadBalancerTypeEnumApplication, - State: &elbtypes.LoadBalancerState{ - Code: elbtypes.LoadBalancerStateEnumActive, - }, - }, - }, - }, nil -} - -func (m *mockELBv2Client) DescribeLoadBalancers(ctx context.Context, params *elbv2.DescribeLoadBalancersInput, optFns ...func(*elbv2.Options)) (*elbv2.DescribeLoadBalancersOutput, error) { - if m.describeFunc != nil { - return m.describeFunc(ctx, params, optFns...) - } - name := "test-alb" - if len(params.Names) > 0 { - name = params.Names[0] - } - return &elbv2.DescribeLoadBalancersOutput{ - LoadBalancers: []elbtypes.LoadBalancer{ - { - LoadBalancerName: awsv2.String(name), - LoadBalancerArn: awsv2.String("arn:aws:elasticloadbalancing:us-east-1:123456789:loadbalancer/app/test/123"), - DNSName: awsv2.String("test-123.us-east-1.elb.amazonaws.com"), - Scheme: elbtypes.LoadBalancerSchemeEnumInternetFacing, - Type: elbtypes.LoadBalancerTypeEnumApplication, - VpcId: awsv2.String("vpc-12345"), - State: &elbtypes.LoadBalancerState{ - Code: elbtypes.LoadBalancerStateEnumActive, - }, - }, - }, - }, nil -} - -func (m *mockELBv2Client) DeleteLoadBalancer(ctx context.Context, params *elbv2.DeleteLoadBalancerInput, optFns ...func(*elbv2.Options)) (*elbv2.DeleteLoadBalancerOutput, error) { - if m.deleteFunc != nil { - return m.deleteFunc(ctx, params, optFns...) - } - return &elbv2.DeleteLoadBalancerOutput{}, nil -} - -func (m *mockELBv2Client) ModifyLoadBalancerAttributes(ctx context.Context, params *elbv2.ModifyLoadBalancerAttributesInput, optFns ...func(*elbv2.Options)) (*elbv2.ModifyLoadBalancerAttributesOutput, error) { - if m.modifyFunc != nil { - return m.modifyFunc(ctx, params, optFns...) - } - return &elbv2.ModifyLoadBalancerAttributesOutput{}, nil -} - -func TestALBDriver_ResourceType(t *testing.T) { - d := NewALBDriverWithClient(&mockELBv2Client{}) - if d.ResourceType() != "aws.alb" { - t.Errorf("ResourceType() = %q, want aws.alb", d.ResourceType()) - } -} - -func TestALBDriver_Create(t *testing.T) { - d := NewALBDriverWithClient(&mockELBv2Client{}) - ctx := context.Background() - - out, err := d.Create(ctx, "test-alb", map[string]any{ - "scheme": "internet-facing", - }) - if err != nil { - t.Fatalf("Create() error: %v", err) - } - if out.ProviderType != "aws.alb" { - t.Errorf("ProviderType = %q, want aws.alb", out.ProviderType) - } - if out.Endpoint != "test-123.us-east-1.elb.amazonaws.com" { - t.Errorf("Endpoint = %q", out.Endpoint) - } - if out.Status != platform.ResourceStatusActive { - t.Errorf("Status = %q, want active", out.Status) - } -} - -func TestALBDriver_CreateInternal(t *testing.T) { - d := NewALBDriverWithClient(&mockELBv2Client{}) - ctx := context.Background() - - out, err := d.Create(ctx, "internal-alb", map[string]any{ - "scheme": "internal", - }) - if err != nil { - t.Fatalf("Create() error: %v", err) - } - if out == nil { - t.Fatal("Create() returned nil") - } -} - -func TestALBDriver_Read(t *testing.T) { - d := NewALBDriverWithClient(&mockELBv2Client{}) - ctx := context.Background() - - out, err := d.Read(ctx, "test-alb") - if err != nil { - t.Fatalf("Read() error: %v", err) - } - if out.Properties["vpc_id"] != "vpc-12345" { - t.Errorf("vpc_id = %v, want vpc-12345", out.Properties["vpc_id"]) - } -} - -func TestALBDriver_ReadNotFound(t *testing.T) { - d := NewALBDriverWithClient(&mockELBv2Client{ - describeFunc: func(ctx context.Context, params *elbv2.DescribeLoadBalancersInput, optFns ...func(*elbv2.Options)) (*elbv2.DescribeLoadBalancersOutput, error) { - return &elbv2.DescribeLoadBalancersOutput{LoadBalancers: []elbtypes.LoadBalancer{}}, nil - }, - }) - ctx := context.Background() - - _, err := d.Read(ctx, "nonexistent") - if err == nil { - t.Fatal("expected error for nonexistent ALB") - } -} - -func TestALBDriver_Delete(t *testing.T) { - d := NewALBDriverWithClient(&mockELBv2Client{}) - ctx := context.Background() - - if err := d.Delete(ctx, "test-alb"); err != nil { - t.Fatalf("Delete() error: %v", err) - } -} - -func TestALBDriver_Scale(t *testing.T) { - d := NewALBDriverWithClient(&mockELBv2Client{}) - ctx := context.Background() - - _, err := d.Scale(ctx, "test-alb", nil) - if err == nil { - t.Fatal("expected NotScalableError") - } - if _, ok := err.(*platform.NotScalableError); !ok { - t.Errorf("expected NotScalableError, got %T", err) - } -} - -func TestALBDriver_HealthCheck(t *testing.T) { - d := NewALBDriverWithClient(&mockELBv2Client{}) - ctx := context.Background() - - health, err := d.HealthCheck(ctx, "test-alb") - if err != nil { - t.Fatalf("HealthCheck() error: %v", err) - } - if health.Status != "healthy" { - t.Errorf("health = %q, want healthy", health.Status) - } -} - -func TestALBDriver_Diff(t *testing.T) { - d := NewALBDriverWithClient(&mockELBv2Client{}) - ctx := context.Background() - - diffs, err := d.Diff(ctx, "test-alb", map[string]any{ - "scheme": "internal", - }) - if err != nil { - t.Fatalf("Diff() error: %v", err) - } - if len(diffs) == 0 { - t.Error("expected diffs for scheme change") - } -} diff --git a/platform/providers/aws/drivers/eks_cluster.go b/platform/providers/aws/drivers/eks_cluster.go deleted file mode 100644 index ed86fb05..00000000 --- a/platform/providers/aws/drivers/eks_cluster.go +++ /dev/null @@ -1,190 +0,0 @@ -//go:build aws - -package drivers - -import ( - "context" - "fmt" - "time" - - "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/service/eks" - ekstypes "github.com/aws/aws-sdk-go-v2/service/eks/types" - - "github.com/GoCodeAlone/workflow/platform" -) - -// EKSClusterClient defines the EKS operations for cluster management. -type EKSClusterClient interface { - CreateCluster(ctx context.Context, params *eks.CreateClusterInput, optFns ...func(*eks.Options)) (*eks.CreateClusterOutput, error) - DescribeCluster(ctx context.Context, params *eks.DescribeClusterInput, optFns ...func(*eks.Options)) (*eks.DescribeClusterOutput, error) - UpdateClusterVersion(ctx context.Context, params *eks.UpdateClusterVersionInput, optFns ...func(*eks.Options)) (*eks.UpdateClusterVersionOutput, error) - DeleteCluster(ctx context.Context, params *eks.DeleteClusterInput, optFns ...func(*eks.Options)) (*eks.DeleteClusterOutput, error) -} - -// EKSClusterDriver manages EKS cluster resources. -type EKSClusterDriver struct { - client EKSClusterClient -} - -// NewEKSClusterDriver creates a new EKS cluster driver. -func NewEKSClusterDriver(cfg aws.Config) *EKSClusterDriver { - return &EKSClusterDriver{ - client: eks.NewFromConfig(cfg), - } -} - -// NewEKSClusterDriverWithClient creates an EKS cluster driver with a custom client (for testing). -func NewEKSClusterDriverWithClient(client EKSClusterClient) *EKSClusterDriver { - return &EKSClusterDriver{client: client} -} - -func (d *EKSClusterDriver) ResourceType() string { return "aws.eks_cluster" } - -func (d *EKSClusterDriver) Create(ctx context.Context, name string, properties map[string]any) (*platform.ResourceOutput, error) { - version, _ := properties["version"].(string) - if version == "" { - version = "1.29" - } - - roleARN, _ := properties["role_arn"].(string) - subnetIDs := stringSliceProp(properties, "subnet_ids") - securityGroupIDs := stringSliceProp(properties, "security_group_ids") - - input := &eks.CreateClusterInput{ - Name: aws.String(name), - Version: aws.String(version), - ResourcesVpcConfig: &ekstypes.VpcConfigRequest{ - SubnetIds: subnetIDs, - SecurityGroupIds: securityGroupIDs, - }, - } - if roleARN != "" { - input.RoleArn = aws.String(roleARN) - } - - out, err := d.client.CreateCluster(ctx, input) - if err != nil { - return nil, fmt.Errorf("eks: create cluster %q: %w", name, err) - } - - return clusterToOutput(out.Cluster), nil -} - -func (d *EKSClusterDriver) Read(ctx context.Context, name string) (*platform.ResourceOutput, error) { - out, err := d.client.DescribeCluster(ctx, &eks.DescribeClusterInput{ - Name: aws.String(name), - }) - if err != nil { - return nil, fmt.Errorf("eks: describe cluster %q: %w", name, err) - } - return clusterToOutput(out.Cluster), nil -} - -func (d *EKSClusterDriver) Update(ctx context.Context, name string, _, desired map[string]any) (*platform.ResourceOutput, error) { - version, _ := desired["version"].(string) - if version != "" { - _, err := d.client.UpdateClusterVersion(ctx, &eks.UpdateClusterVersionInput{ - Name: aws.String(name), - Version: aws.String(version), - }) - if err != nil { - return nil, fmt.Errorf("eks: update cluster version %q: %w", name, err) - } - } - return d.Read(ctx, name) -} - -func (d *EKSClusterDriver) Delete(ctx context.Context, name string) error { - _, err := d.client.DeleteCluster(ctx, &eks.DeleteClusterInput{ - Name: aws.String(name), - }) - if err != nil { - return fmt.Errorf("eks: delete cluster %q: %w", name, err) - } - return nil -} - -func (d *EKSClusterDriver) HealthCheck(ctx context.Context, name string) (*platform.HealthStatus, error) { - out, err := d.client.DescribeCluster(ctx, &eks.DescribeClusterInput{ - Name: aws.String(name), - }) - if err != nil { - return &platform.HealthStatus{ - Status: "unhealthy", - Message: err.Error(), - CheckedAt: time.Now(), - }, nil - } - - status := "healthy" - if out.Cluster.Status != ekstypes.ClusterStatusActive { - status = "degraded" - } - return &platform.HealthStatus{ - Status: status, - Message: string(out.Cluster.Status), - CheckedAt: time.Now(), - }, nil -} - -func (d *EKSClusterDriver) Scale(_ context.Context, _ string, _ map[string]any) (*platform.ResourceOutput, error) { - return nil, &platform.NotScalableError{ResourceType: "aws.eks_cluster"} -} - -func (d *EKSClusterDriver) Diff(ctx context.Context, name string, desired map[string]any) ([]platform.DiffEntry, error) { - current, err := d.Read(ctx, name) - if err != nil { - return nil, err - } - return diffProperties(current.Properties, desired), nil -} - -func clusterToOutput(cluster *ekstypes.Cluster) *platform.ResourceOutput { - if cluster == nil { - return nil - } - status := platform.ResourceStatusActive - switch cluster.Status { - case ekstypes.ClusterStatusCreating: - status = platform.ResourceStatusCreating - case ekstypes.ClusterStatusDeleting: - status = platform.ResourceStatusDeleting - case ekstypes.ClusterStatusFailed: - status = platform.ResourceStatusFailed - case ekstypes.ClusterStatusUpdating: - status = platform.ResourceStatusUpdating - } - - endpoint := "" - if cluster.Endpoint != nil { - endpoint = *cluster.Endpoint - } - - props := map[string]any{ - "status": string(cluster.Status), - } - if cluster.Version != nil { - props["version"] = *cluster.Version - } - if cluster.Arn != nil { - props["arn"] = *cluster.Arn - } - - name := "" - if cluster.Name != nil { - name = *cluster.Name - } - - return &platform.ResourceOutput{ - Name: name, - Type: "kubernetes_cluster", - ProviderType: "aws.eks_cluster", - Endpoint: endpoint, - Properties: props, - Status: status, - LastSynced: time.Now(), - } -} - -var _ platform.ResourceDriver = (*EKSClusterDriver)(nil) diff --git a/platform/providers/aws/drivers/eks_cluster_test.go b/platform/providers/aws/drivers/eks_cluster_test.go deleted file mode 100644 index 042f8393..00000000 --- a/platform/providers/aws/drivers/eks_cluster_test.go +++ /dev/null @@ -1,183 +0,0 @@ -//go:build aws - -package drivers - -import ( - "context" - "testing" - - "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/service/eks" - ekstypes "github.com/aws/aws-sdk-go-v2/service/eks/types" - - "github.com/GoCodeAlone/workflow/platform" -) - -type mockEKSClusterClient struct { - createFunc func(ctx context.Context, params *eks.CreateClusterInput, optFns ...func(*eks.Options)) (*eks.CreateClusterOutput, error) - describeFunc func(ctx context.Context, params *eks.DescribeClusterInput, optFns ...func(*eks.Options)) (*eks.DescribeClusterOutput, error) - updateFunc func(ctx context.Context, params *eks.UpdateClusterVersionInput, optFns ...func(*eks.Options)) (*eks.UpdateClusterVersionOutput, error) - deleteFunc func(ctx context.Context, params *eks.DeleteClusterInput, optFns ...func(*eks.Options)) (*eks.DeleteClusterOutput, error) -} - -func (m *mockEKSClusterClient) CreateCluster(ctx context.Context, params *eks.CreateClusterInput, optFns ...func(*eks.Options)) (*eks.CreateClusterOutput, error) { - if m.createFunc != nil { - return m.createFunc(ctx, params, optFns...) - } - return &eks.CreateClusterOutput{ - Cluster: &ekstypes.Cluster{ - Name: params.Name, - Version: params.Version, - Status: ekstypes.ClusterStatusCreating, - Endpoint: aws.String("https://eks.example.com"), - Arn: aws.String("arn:aws:eks:us-east-1:123456789:cluster/test"), - }, - }, nil -} - -func (m *mockEKSClusterClient) DescribeCluster(ctx context.Context, params *eks.DescribeClusterInput, optFns ...func(*eks.Options)) (*eks.DescribeClusterOutput, error) { - if m.describeFunc != nil { - return m.describeFunc(ctx, params, optFns...) - } - return &eks.DescribeClusterOutput{ - Cluster: &ekstypes.Cluster{ - Name: params.Name, - Version: aws.String("1.29"), - Status: ekstypes.ClusterStatusActive, - Endpoint: aws.String("https://eks.example.com"), - Arn: aws.String("arn:aws:eks:us-east-1:123456789:cluster/test"), - }, - }, nil -} - -func (m *mockEKSClusterClient) UpdateClusterVersion(ctx context.Context, params *eks.UpdateClusterVersionInput, optFns ...func(*eks.Options)) (*eks.UpdateClusterVersionOutput, error) { - if m.updateFunc != nil { - return m.updateFunc(ctx, params, optFns...) - } - return &eks.UpdateClusterVersionOutput{}, nil -} - -func (m *mockEKSClusterClient) DeleteCluster(ctx context.Context, params *eks.DeleteClusterInput, optFns ...func(*eks.Options)) (*eks.DeleteClusterOutput, error) { - if m.deleteFunc != nil { - return m.deleteFunc(ctx, params, optFns...) - } - return &eks.DeleteClusterOutput{}, nil -} - -func TestEKSClusterDriver_ResourceType(t *testing.T) { - d := NewEKSClusterDriverWithClient(&mockEKSClusterClient{}) - if d.ResourceType() != "aws.eks_cluster" { - t.Errorf("ResourceType() = %q, want %q", d.ResourceType(), "aws.eks_cluster") - } -} - -func TestEKSClusterDriver_Create(t *testing.T) { - mock := &mockEKSClusterClient{} - d := NewEKSClusterDriverWithClient(mock) - ctx := context.Background() - - out, err := d.Create(ctx, "test-cluster", map[string]any{ - "version": "1.28", - }) - if err != nil { - t.Fatalf("Create() error: %v", err) - } - if out.Name != "test-cluster" { - t.Errorf("Name = %q, want test-cluster", out.Name) - } - if out.ProviderType != "aws.eks_cluster" { - t.Errorf("ProviderType = %q, want aws.eks_cluster", out.ProviderType) - } - if out.Endpoint != "https://eks.example.com" { - t.Errorf("Endpoint = %q, want https://eks.example.com", out.Endpoint) - } - if out.Status != platform.ResourceStatusCreating { - t.Errorf("Status = %q, want creating", out.Status) - } -} - -func TestEKSClusterDriver_Read(t *testing.T) { - mock := &mockEKSClusterClient{} - d := NewEKSClusterDriverWithClient(mock) - ctx := context.Background() - - out, err := d.Read(ctx, "test-cluster") - if err != nil { - t.Fatalf("Read() error: %v", err) - } - if out.Status != platform.ResourceStatusActive { - t.Errorf("Status = %q, want active", out.Status) - } - if out.Properties["version"] != "1.29" { - t.Errorf("version = %v, want 1.29", out.Properties["version"]) - } -} - -func TestEKSClusterDriver_Update(t *testing.T) { - mock := &mockEKSClusterClient{} - d := NewEKSClusterDriverWithClient(mock) - ctx := context.Background() - - out, err := d.Update(ctx, "test-cluster", nil, map[string]any{ - "version": "1.29", - }) - if err != nil { - t.Fatalf("Update() error: %v", err) - } - if out == nil { - t.Fatal("Update() returned nil") - } -} - -func TestEKSClusterDriver_Delete(t *testing.T) { - mock := &mockEKSClusterClient{} - d := NewEKSClusterDriverWithClient(mock) - ctx := context.Background() - - if err := d.Delete(ctx, "test-cluster"); err != nil { - t.Fatalf("Delete() error: %v", err) - } -} - -func TestEKSClusterDriver_HealthCheck(t *testing.T) { - mock := &mockEKSClusterClient{} - d := NewEKSClusterDriverWithClient(mock) - ctx := context.Background() - - health, err := d.HealthCheck(ctx, "test-cluster") - if err != nil { - t.Fatalf("HealthCheck() error: %v", err) - } - if health.Status != "healthy" { - t.Errorf("health status = %q, want healthy", health.Status) - } -} - -func TestEKSClusterDriver_Scale(t *testing.T) { - d := NewEKSClusterDriverWithClient(&mockEKSClusterClient{}) - ctx := context.Background() - - _, err := d.Scale(ctx, "test-cluster", nil) - if err == nil { - t.Fatal("expected NotScalableError") - } - if _, ok := err.(*platform.NotScalableError); !ok { - t.Errorf("expected NotScalableError, got %T", err) - } -} - -func TestEKSClusterDriver_Diff(t *testing.T) { - mock := &mockEKSClusterClient{} - d := NewEKSClusterDriverWithClient(mock) - ctx := context.Background() - - diffs, err := d.Diff(ctx, "test-cluster", map[string]any{ - "version": "1.30", - }) - if err != nil { - t.Fatalf("Diff() error: %v", err) - } - if len(diffs) == 0 { - t.Error("expected diffs for version change") - } -} diff --git a/platform/providers/aws/drivers/eks_nodegroup.go b/platform/providers/aws/drivers/eks_nodegroup.go deleted file mode 100644 index 7783044b..00000000 --- a/platform/providers/aws/drivers/eks_nodegroup.go +++ /dev/null @@ -1,235 +0,0 @@ -//go:build aws - -package drivers - -import ( - "context" - "fmt" - "time" - - "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/service/eks" - ekstypes "github.com/aws/aws-sdk-go-v2/service/eks/types" - - "github.com/GoCodeAlone/workflow/platform" -) - -// EKSNodeGroupClient defines the EKS operations for node group management. -type EKSNodeGroupClient interface { - CreateNodegroup(ctx context.Context, params *eks.CreateNodegroupInput, optFns ...func(*eks.Options)) (*eks.CreateNodegroupOutput, error) - DescribeNodegroup(ctx context.Context, params *eks.DescribeNodegroupInput, optFns ...func(*eks.Options)) (*eks.DescribeNodegroupOutput, error) - UpdateNodegroupConfig(ctx context.Context, params *eks.UpdateNodegroupConfigInput, optFns ...func(*eks.Options)) (*eks.UpdateNodegroupConfigOutput, error) - DeleteNodegroup(ctx context.Context, params *eks.DeleteNodegroupInput, optFns ...func(*eks.Options)) (*eks.DeleteNodegroupOutput, error) -} - -// EKSNodeGroupDriver manages EKS node group resources. -type EKSNodeGroupDriver struct { - client EKSNodeGroupClient -} - -// NewEKSNodeGroupDriver creates a new EKS node group driver. -func NewEKSNodeGroupDriver(cfg aws.Config) *EKSNodeGroupDriver { - return &EKSNodeGroupDriver{ - client: eks.NewFromConfig(cfg), - } -} - -// NewEKSNodeGroupDriverWithClient creates an EKS node group driver with a custom client. -func NewEKSNodeGroupDriverWithClient(client EKSNodeGroupClient) *EKSNodeGroupDriver { - return &EKSNodeGroupDriver{client: client} -} - -func (d *EKSNodeGroupDriver) ResourceType() string { return "aws.eks_nodegroup" } - -func (d *EKSNodeGroupDriver) Create(ctx context.Context, name string, properties map[string]any) (*platform.ResourceOutput, error) { - clusterName, _ := properties["cluster_name"].(string) - nodeCount := intPropDrivers(properties, "node_count", 2) - instanceType, _ := properties["instance_type"].(string) - if instanceType == "" { - instanceType = "t3.medium" - } - nodeRole, _ := properties["node_role_arn"].(string) - subnetIDs := stringSliceProp(properties, "subnet_ids") - - input := &eks.CreateNodegroupInput{ - ClusterName: aws.String(clusterName), - NodegroupName: aws.String(name), - ScalingConfig: &ekstypes.NodegroupScalingConfig{ - DesiredSize: aws.Int32(int32(nodeCount)), - MinSize: aws.Int32(1), - MaxSize: aws.Int32(int32(nodeCount * 2)), - }, - InstanceTypes: []string{instanceType}, - Subnets: subnetIDs, - } - if nodeRole != "" { - input.NodeRole = aws.String(nodeRole) - } - - out, err := d.client.CreateNodegroup(ctx, input) - if err != nil { - return nil, fmt.Errorf("eks: create nodegroup %q: %w", name, err) - } - return nodeGroupToOutput(out.Nodegroup), nil -} - -func (d *EKSNodeGroupDriver) Read(ctx context.Context, name string) (*platform.ResourceOutput, error) { - // Name format: we need the cluster name. Try parsing from properties or use name. - // In practice the cluster name would be stored in state. For the driver we receive - // only the name, so we store cluster_name:nodegroup_name format. - clusterName, ngName := splitNodeGroupName(name) - - out, err := d.client.DescribeNodegroup(ctx, &eks.DescribeNodegroupInput{ - ClusterName: aws.String(clusterName), - NodegroupName: aws.String(ngName), - }) - if err != nil { - return nil, fmt.Errorf("eks: describe nodegroup %q: %w", name, err) - } - return nodeGroupToOutput(out.Nodegroup), nil -} - -func (d *EKSNodeGroupDriver) Update(ctx context.Context, name string, _, desired map[string]any) (*platform.ResourceOutput, error) { - clusterName, ngName := splitNodeGroupName(name) - nodeCount := intPropDrivers(desired, "node_count", 0) - - if nodeCount > 0 { - _, err := d.client.UpdateNodegroupConfig(ctx, &eks.UpdateNodegroupConfigInput{ - ClusterName: aws.String(clusterName), - NodegroupName: aws.String(ngName), - ScalingConfig: &ekstypes.NodegroupScalingConfig{ - DesiredSize: aws.Int32(int32(nodeCount)), - MinSize: aws.Int32(1), - MaxSize: aws.Int32(int32(nodeCount * 2)), - }, - }) - if err != nil { - return nil, fmt.Errorf("eks: update nodegroup %q: %w", name, err) - } - } - return d.Read(ctx, name) -} - -func (d *EKSNodeGroupDriver) Delete(ctx context.Context, name string) error { - clusterName, ngName := splitNodeGroupName(name) - _, err := d.client.DeleteNodegroup(ctx, &eks.DeleteNodegroupInput{ - ClusterName: aws.String(clusterName), - NodegroupName: aws.String(ngName), - }) - if err != nil { - return fmt.Errorf("eks: delete nodegroup %q: %w", name, err) - } - return nil -} - -func (d *EKSNodeGroupDriver) HealthCheck(ctx context.Context, name string) (*platform.HealthStatus, error) { - clusterName, ngName := splitNodeGroupName(name) - out, err := d.client.DescribeNodegroup(ctx, &eks.DescribeNodegroupInput{ - ClusterName: aws.String(clusterName), - NodegroupName: aws.String(ngName), - }) - if err != nil { - return &platform.HealthStatus{ - Status: "unhealthy", - Message: err.Error(), - CheckedAt: time.Now(), - }, nil - } - - status := "healthy" - if out.Nodegroup.Status != ekstypes.NodegroupStatusActive { - status = "degraded" - } - return &platform.HealthStatus{ - Status: status, - Message: string(out.Nodegroup.Status), - CheckedAt: time.Now(), - }, nil -} - -func (d *EKSNodeGroupDriver) Scale(ctx context.Context, name string, scaleParams map[string]any) (*platform.ResourceOutput, error) { - clusterName, ngName := splitNodeGroupName(name) - nodeCount := intPropDrivers(scaleParams, "node_count", 0) - if nodeCount <= 0 { - return nil, fmt.Errorf("eks: scale nodegroup: node_count must be positive") - } - - _, err := d.client.UpdateNodegroupConfig(ctx, &eks.UpdateNodegroupConfigInput{ - ClusterName: aws.String(clusterName), - NodegroupName: aws.String(ngName), - ScalingConfig: &ekstypes.NodegroupScalingConfig{ - DesiredSize: aws.Int32(int32(nodeCount)), - }, - }) - if err != nil { - return nil, fmt.Errorf("eks: scale nodegroup %q: %w", name, err) - } - return d.Read(ctx, name) -} - -func (d *EKSNodeGroupDriver) Diff(ctx context.Context, name string, desired map[string]any) ([]platform.DiffEntry, error) { - current, err := d.Read(ctx, name) - if err != nil { - return nil, err - } - return diffProperties(current.Properties, desired), nil -} - -func nodeGroupToOutput(ng *ekstypes.Nodegroup) *platform.ResourceOutput { - if ng == nil { - return nil - } - status := platform.ResourceStatusActive - switch ng.Status { - case ekstypes.NodegroupStatusCreating: - status = platform.ResourceStatusCreating - case ekstypes.NodegroupStatusDeleting: - status = platform.ResourceStatusDeleting - case ekstypes.NodegroupStatusDegraded: - status = platform.ResourceStatusDegraded - case ekstypes.NodegroupStatusUpdating: - status = platform.ResourceStatusUpdating - } - - props := map[string]any{ - "status": string(ng.Status), - } - if ng.ScalingConfig != nil { - if ng.ScalingConfig.DesiredSize != nil { - props["node_count"] = int(*ng.ScalingConfig.DesiredSize) - } - } - if len(ng.InstanceTypes) > 0 { - props["instance_type"] = ng.InstanceTypes[0] - } - if ng.NodegroupArn != nil { - props["arn"] = *ng.NodegroupArn - } - - name := "" - if ng.NodegroupName != nil { - name = *ng.NodegroupName - } - - return &platform.ResourceOutput{ - Name: name, - Type: "kubernetes_cluster", - ProviderType: "aws.eks_nodegroup", - Properties: props, - Status: status, - LastSynced: time.Now(), - } -} - -// splitNodeGroupName splits "cluster:nodegroup" format. Falls back to using -// name as both cluster and nodegroup if no separator found. -func splitNodeGroupName(name string) (string, string) { - for i, c := range name { - if c == ':' { - return name[:i], name[i+1:] - } - } - return name, name -} - -var _ platform.ResourceDriver = (*EKSNodeGroupDriver)(nil) diff --git a/platform/providers/aws/drivers/eks_nodegroup_test.go b/platform/providers/aws/drivers/eks_nodegroup_test.go deleted file mode 100644 index 388325a5..00000000 --- a/platform/providers/aws/drivers/eks_nodegroup_test.go +++ /dev/null @@ -1,168 +0,0 @@ -//go:build aws - -package drivers - -import ( - "context" - "testing" - - "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/service/eks" - ekstypes "github.com/aws/aws-sdk-go-v2/service/eks/types" - - "github.com/GoCodeAlone/workflow/platform" -) - -type mockEKSNodeGroupClient struct { - createFunc func(ctx context.Context, params *eks.CreateNodegroupInput, optFns ...func(*eks.Options)) (*eks.CreateNodegroupOutput, error) - describeFunc func(ctx context.Context, params *eks.DescribeNodegroupInput, optFns ...func(*eks.Options)) (*eks.DescribeNodegroupOutput, error) - updateFunc func(ctx context.Context, params *eks.UpdateNodegroupConfigInput, optFns ...func(*eks.Options)) (*eks.UpdateNodegroupConfigOutput, error) - deleteFunc func(ctx context.Context, params *eks.DeleteNodegroupInput, optFns ...func(*eks.Options)) (*eks.DeleteNodegroupOutput, error) -} - -func (m *mockEKSNodeGroupClient) CreateNodegroup(ctx context.Context, params *eks.CreateNodegroupInput, optFns ...func(*eks.Options)) (*eks.CreateNodegroupOutput, error) { - if m.createFunc != nil { - return m.createFunc(ctx, params, optFns...) - } - return &eks.CreateNodegroupOutput{ - Nodegroup: &ekstypes.Nodegroup{ - NodegroupName: params.NodegroupName, - ClusterName: params.ClusterName, - Status: ekstypes.NodegroupStatusCreating, - ScalingConfig: params.ScalingConfig, - InstanceTypes: params.InstanceTypes, - NodegroupArn: aws.String("arn:aws:eks:us-east-1:123456789:nodegroup/test"), - }, - }, nil -} - -func (m *mockEKSNodeGroupClient) DescribeNodegroup(ctx context.Context, params *eks.DescribeNodegroupInput, optFns ...func(*eks.Options)) (*eks.DescribeNodegroupOutput, error) { - if m.describeFunc != nil { - return m.describeFunc(ctx, params, optFns...) - } - return &eks.DescribeNodegroupOutput{ - Nodegroup: &ekstypes.Nodegroup{ - NodegroupName: params.NodegroupName, - ClusterName: params.ClusterName, - Status: ekstypes.NodegroupStatusActive, - ScalingConfig: &ekstypes.NodegroupScalingConfig{ - DesiredSize: aws.Int32(2), - MinSize: aws.Int32(1), - MaxSize: aws.Int32(4), - }, - InstanceTypes: []string{"t3.medium"}, - NodegroupArn: aws.String("arn:aws:eks:us-east-1:123456789:nodegroup/test"), - }, - }, nil -} - -func (m *mockEKSNodeGroupClient) UpdateNodegroupConfig(ctx context.Context, params *eks.UpdateNodegroupConfigInput, optFns ...func(*eks.Options)) (*eks.UpdateNodegroupConfigOutput, error) { - if m.updateFunc != nil { - return m.updateFunc(ctx, params, optFns...) - } - return &eks.UpdateNodegroupConfigOutput{}, nil -} - -func (m *mockEKSNodeGroupClient) DeleteNodegroup(ctx context.Context, params *eks.DeleteNodegroupInput, optFns ...func(*eks.Options)) (*eks.DeleteNodegroupOutput, error) { - if m.deleteFunc != nil { - return m.deleteFunc(ctx, params, optFns...) - } - return &eks.DeleteNodegroupOutput{}, nil -} - -func TestEKSNodeGroupDriver_ResourceType(t *testing.T) { - d := NewEKSNodeGroupDriverWithClient(&mockEKSNodeGroupClient{}) - if d.ResourceType() != "aws.eks_nodegroup" { - t.Errorf("ResourceType() = %q, want aws.eks_nodegroup", d.ResourceType()) - } -} - -func TestEKSNodeGroupDriver_Create(t *testing.T) { - mock := &mockEKSNodeGroupClient{} - d := NewEKSNodeGroupDriverWithClient(mock) - ctx := context.Background() - - out, err := d.Create(ctx, "test-nodes", map[string]any{ - "cluster_name": "test-cluster", - "node_count": 3, - "instance_type": "m5.large", - }) - if err != nil { - t.Fatalf("Create() error: %v", err) - } - if out.Name != "test-nodes" { - t.Errorf("Name = %q, want test-nodes", out.Name) - } - if out.Status != platform.ResourceStatusCreating { - t.Errorf("Status = %q, want creating", out.Status) - } -} - -func TestEKSNodeGroupDriver_Read(t *testing.T) { - mock := &mockEKSNodeGroupClient{} - d := NewEKSNodeGroupDriverWithClient(mock) - ctx := context.Background() - - out, err := d.Read(ctx, "test-cluster:test-nodes") - if err != nil { - t.Fatalf("Read() error: %v", err) - } - if out.Status != platform.ResourceStatusActive { - t.Errorf("Status = %q, want active", out.Status) - } - if out.Properties["node_count"] != 2 { - t.Errorf("node_count = %v, want 2", out.Properties["node_count"]) - } -} - -func TestEKSNodeGroupDriver_Scale(t *testing.T) { - mock := &mockEKSNodeGroupClient{} - d := NewEKSNodeGroupDriverWithClient(mock) - ctx := context.Background() - - out, err := d.Scale(ctx, "test-cluster:test-nodes", map[string]any{ - "node_count": 5, - }) - if err != nil { - t.Fatalf("Scale() error: %v", err) - } - if out == nil { - t.Fatal("Scale() returned nil") - } -} - -func TestEKSNodeGroupDriver_ScaleInvalid(t *testing.T) { - d := NewEKSNodeGroupDriverWithClient(&mockEKSNodeGroupClient{}) - ctx := context.Background() - - _, err := d.Scale(ctx, "test-cluster:test-nodes", map[string]any{}) - if err == nil { - t.Fatal("expected error for missing node_count") - } -} - -func TestEKSNodeGroupDriver_Delete(t *testing.T) { - d := NewEKSNodeGroupDriverWithClient(&mockEKSNodeGroupClient{}) - ctx := context.Background() - - if err := d.Delete(ctx, "test-cluster:test-nodes"); err != nil { - t.Fatalf("Delete() error: %v", err) - } -} - -func TestSplitNodeGroupName(t *testing.T) { - tests := []struct { - name string - cluster string - ng string - }{ - {"cluster:nodegroup", "cluster", "nodegroup"}, - {"single", "single", "single"}, - } - for _, tt := range tests { - c, n := splitNodeGroupName(tt.name) - if c != tt.cluster || n != tt.ng { - t.Errorf("splitNodeGroupName(%q) = (%q, %q), want (%q, %q)", tt.name, c, n, tt.cluster, tt.ng) - } - } -} diff --git a/platform/providers/aws/drivers/helpers.go b/platform/providers/aws/drivers/helpers.go deleted file mode 100644 index 91138d44..00000000 --- a/platform/providers/aws/drivers/helpers.go +++ /dev/null @@ -1,81 +0,0 @@ -//go:build aws - -package drivers - -import ( - "fmt" - - "github.com/GoCodeAlone/workflow/platform" -) - -// diffProperties compares current and desired property maps, returning entries -// for any fields that differ. -func diffProperties(current map[string]any, desired map[string]any) []platform.DiffEntry { - var diffs []platform.DiffEntry - for k, desiredVal := range desired { - currentVal, exists := current[k] - if !exists || fmt.Sprintf("%v", currentVal) != fmt.Sprintf("%v", desiredVal) { - diffs = append(diffs, platform.DiffEntry{ - Path: k, - OldValue: currentVal, - NewValue: desiredVal, - }) - } - } - return diffs -} - -// stringSliceProp extracts a string slice from a properties map. -func stringSliceProp(props map[string]any, key string) []string { - v, ok := props[key] - if !ok { - return nil - } - switch s := v.(type) { - case []string: - return s - case []any: - var result []string - for _, item := range s { - if str, ok := item.(string); ok { - result = append(result, str) - } - } - return result - default: - return nil - } -} - -// intPropDrivers extracts an int property with a default value. -func intPropDrivers(props map[string]any, key string, def int) int { - v, ok := props[key] - if !ok { - return def - } - switch n := v.(type) { - case int: - return n - case int32: - return int(n) - case int64: - return int(n) - case float64: - return int(n) - default: - return def - } -} - -// boolPropDrivers extracts a bool property with a default value. -func boolPropDrivers(props map[string]any, key string, def bool) bool { - v, ok := props[key] - if !ok { - return def - } - b, ok := v.(bool) - if !ok { - return def - } - return b -} diff --git a/platform/providers/aws/drivers/iam.go b/platform/providers/aws/drivers/iam.go deleted file mode 100644 index 9f3035b2..00000000 --- a/platform/providers/aws/drivers/iam.go +++ /dev/null @@ -1,268 +0,0 @@ -//go:build aws - -package drivers - -import ( - "context" - "encoding/json" - "fmt" - "time" - - awsv2 "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/service/iam" - - "github.com/GoCodeAlone/workflow/platform" -) - -// IAMClient defines the IAM operations used by the driver. -type IAMClient interface { - CreateRole(ctx context.Context, params *iam.CreateRoleInput, optFns ...func(*iam.Options)) (*iam.CreateRoleOutput, error) - GetRole(ctx context.Context, params *iam.GetRoleInput, optFns ...func(*iam.Options)) (*iam.GetRoleOutput, error) - UpdateAssumeRolePolicy(ctx context.Context, params *iam.UpdateAssumeRolePolicyInput, optFns ...func(*iam.Options)) (*iam.UpdateAssumeRolePolicyOutput, error) - DeleteRole(ctx context.Context, params *iam.DeleteRoleInput, optFns ...func(*iam.Options)) (*iam.DeleteRoleOutput, error) - AttachRolePolicy(ctx context.Context, params *iam.AttachRolePolicyInput, optFns ...func(*iam.Options)) (*iam.AttachRolePolicyOutput, error) - DetachRolePolicy(ctx context.Context, params *iam.DetachRolePolicyInput, optFns ...func(*iam.Options)) (*iam.DetachRolePolicyOutput, error) - ListAttachedRolePolicies(ctx context.Context, params *iam.ListAttachedRolePoliciesInput, optFns ...func(*iam.Options)) (*iam.ListAttachedRolePoliciesOutput, error) -} - -// IAMDriver manages IAM roles and policies. -type IAMDriver struct { - client IAMClient -} - -// NewIAMDriver creates a new IAM driver. -func NewIAMDriver(cfg awsv2.Config) *IAMDriver { - return &IAMDriver{ - client: iam.NewFromConfig(cfg), - } -} - -// NewIAMDriverWithClient creates an IAM driver with a custom client. -func NewIAMDriverWithClient(client IAMClient) *IAMDriver { - return &IAMDriver{client: client} -} - -func (d *IAMDriver) ResourceType() string { return "aws.iam_role" } - -func (d *IAMDriver) Create(ctx context.Context, name string, properties map[string]any) (*platform.ResourceOutput, error) { - assumeRolePolicy, _ := properties["assume_role_policy"].(string) - if assumeRolePolicy == "" { - assumeRolePolicy = defaultAssumeRolePolicy() - } - description, _ := properties["description"].(string) - path, _ := properties["path"].(string) - if path == "" { - path = "/" - } - - out, err := d.client.CreateRole(ctx, &iam.CreateRoleInput{ - RoleName: awsv2.String(name), - AssumeRolePolicyDocument: awsv2.String(assumeRolePolicy), - Description: awsv2.String(description), - Path: awsv2.String(path), - }) - if err != nil { - return nil, fmt.Errorf("iam: create role %q: %w", name, err) - } - - // Attach policies if specified - policyARNs := stringSliceProp(properties, "policy_arns") - for _, arn := range policyARNs { - _, err := d.client.AttachRolePolicy(ctx, &iam.AttachRolePolicyInput{ - RoleName: awsv2.String(name), - PolicyArn: awsv2.String(arn), - }) - if err != nil { - return nil, fmt.Errorf("iam: attach policy to role %q: %w", name, err) - } - } - - props := map[string]any{} - if out.Role != nil { - if out.Role.Arn != nil { - props["arn"] = *out.Role.Arn - } - if out.Role.RoleId != nil { - props["role_id"] = *out.Role.RoleId - } - } - props["policy_arns"] = policyARNs - - return &platform.ResourceOutput{ - Name: name, - Type: "iam_role", - ProviderType: "aws.iam_role", - Properties: props, - Status: platform.ResourceStatusActive, - LastSynced: time.Now(), - }, nil -} - -func (d *IAMDriver) Read(ctx context.Context, name string) (*platform.ResourceOutput, error) { - out, err := d.client.GetRole(ctx, &iam.GetRoleInput{ - RoleName: awsv2.String(name), - }) - if err != nil { - return nil, fmt.Errorf("iam: get role %q: %w", name, err) - } - - props := map[string]any{} - if out.Role != nil { - if out.Role.Arn != nil { - props["arn"] = *out.Role.Arn - } - if out.Role.RoleId != nil { - props["role_id"] = *out.Role.RoleId - } - if out.Role.Description != nil { - props["description"] = *out.Role.Description - } - } - - // List attached policies - polOut, err := d.client.ListAttachedRolePolicies(ctx, &iam.ListAttachedRolePoliciesInput{ - RoleName: awsv2.String(name), - }) - if err == nil && polOut != nil { - var arns []string - for _, p := range polOut.AttachedPolicies { - if p.PolicyArn != nil { - arns = append(arns, *p.PolicyArn) - } - } - props["policy_arns"] = arns - } - - return &platform.ResourceOutput{ - Name: name, - Type: "iam_role", - ProviderType: "aws.iam_role", - Properties: props, - Status: platform.ResourceStatusActive, - LastSynced: time.Now(), - }, nil -} - -func (d *IAMDriver) Update(ctx context.Context, name string, current, desired map[string]any) (*platform.ResourceOutput, error) { - // Update assume role policy if changed - if policy, ok := desired["assume_role_policy"].(string); ok && policy != "" { - _, err := d.client.UpdateAssumeRolePolicy(ctx, &iam.UpdateAssumeRolePolicyInput{ - RoleName: awsv2.String(name), - PolicyDocument: awsv2.String(policy), - }) - if err != nil { - return nil, fmt.Errorf("iam: update assume role policy %q: %w", name, err) - } - } - - // Reconcile attached policies - desiredPolicies := stringSliceProp(desired, "policy_arns") - currentPolicies := stringSliceProp(current, "policy_arns") - - toAttach := diffSlice(desiredPolicies, currentPolicies) - toDetach := diffSlice(currentPolicies, desiredPolicies) - - for _, arn := range toAttach { - _, err := d.client.AttachRolePolicy(ctx, &iam.AttachRolePolicyInput{ - RoleName: awsv2.String(name), - PolicyArn: awsv2.String(arn), - }) - if err != nil { - return nil, fmt.Errorf("iam: attach policy %q to %q: %w", arn, name, err) - } - } - for _, arn := range toDetach { - _, err := d.client.DetachRolePolicy(ctx, &iam.DetachRolePolicyInput{ - RoleName: awsv2.String(name), - PolicyArn: awsv2.String(arn), - }) - if err != nil { - return nil, fmt.Errorf("iam: detach policy %q from %q: %w", arn, name, err) - } - } - - return d.Read(ctx, name) -} - -func (d *IAMDriver) Delete(ctx context.Context, name string) error { - // Detach all policies first - polOut, err := d.client.ListAttachedRolePolicies(ctx, &iam.ListAttachedRolePoliciesInput{ - RoleName: awsv2.String(name), - }) - if err == nil && polOut != nil { - for _, p := range polOut.AttachedPolicies { - _, _ = d.client.DetachRolePolicy(ctx, &iam.DetachRolePolicyInput{ - RoleName: awsv2.String(name), - PolicyArn: p.PolicyArn, - }) - } - } - - _, err = d.client.DeleteRole(ctx, &iam.DeleteRoleInput{ - RoleName: awsv2.String(name), - }) - if err != nil { - return fmt.Errorf("iam: delete role %q: %w", name, err) - } - return nil -} - -func (d *IAMDriver) HealthCheck(ctx context.Context, name string) (*platform.HealthStatus, error) { - _, err := d.Read(ctx, name) - if err != nil { - return &platform.HealthStatus{ - Status: "unhealthy", - Message: err.Error(), - CheckedAt: time.Now(), - }, nil - } - return &platform.HealthStatus{ - Status: "healthy", - Message: "role exists", - CheckedAt: time.Now(), - }, nil -} - -func (d *IAMDriver) Scale(_ context.Context, _ string, _ map[string]any) (*platform.ResourceOutput, error) { - return nil, &platform.NotScalableError{ResourceType: "aws.iam_role"} -} - -func (d *IAMDriver) Diff(ctx context.Context, name string, desired map[string]any) ([]platform.DiffEntry, error) { - current, err := d.Read(ctx, name) - if err != nil { - return nil, err - } - return diffProperties(current.Properties, desired), nil -} - -func defaultAssumeRolePolicy() string { - policy := map[string]any{ - "Version": "2012-10-17", - "Statement": []map[string]any{ - { - "Effect": "Allow", - "Principal": map[string]string{"Service": "eks.amazonaws.com"}, - "Action": "sts:AssumeRole", - }, - }, - } - data, _ := json.Marshal(policy) - return string(data) -} - -// diffSlice returns elements in a that are not in b. -func diffSlice(a, b []string) []string { - bSet := make(map[string]struct{}, len(b)) - for _, v := range b { - bSet[v] = struct{}{} - } - var diff []string - for _, v := range a { - if _, ok := bSet[v]; !ok { - diff = append(diff, v) - } - } - return diff -} - -var _ platform.ResourceDriver = (*IAMDriver)(nil) diff --git a/platform/providers/aws/drivers/iam_test.go b/platform/providers/aws/drivers/iam_test.go deleted file mode 100644 index 845dcf9e..00000000 --- a/platform/providers/aws/drivers/iam_test.go +++ /dev/null @@ -1,200 +0,0 @@ -//go:build aws - -package drivers - -import ( - "context" - "testing" - - awsv2 "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/service/iam" - iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types" - - "github.com/GoCodeAlone/workflow/platform" -) - -type mockIAMClient struct { - createFunc func(ctx context.Context, params *iam.CreateRoleInput, optFns ...func(*iam.Options)) (*iam.CreateRoleOutput, error) - getFunc func(ctx context.Context, params *iam.GetRoleInput, optFns ...func(*iam.Options)) (*iam.GetRoleOutput, error) - updatePolicyFunc func(ctx context.Context, params *iam.UpdateAssumeRolePolicyInput, optFns ...func(*iam.Options)) (*iam.UpdateAssumeRolePolicyOutput, error) - deleteFunc func(ctx context.Context, params *iam.DeleteRoleInput, optFns ...func(*iam.Options)) (*iam.DeleteRoleOutput, error) - attachFunc func(ctx context.Context, params *iam.AttachRolePolicyInput, optFns ...func(*iam.Options)) (*iam.AttachRolePolicyOutput, error) - detachFunc func(ctx context.Context, params *iam.DetachRolePolicyInput, optFns ...func(*iam.Options)) (*iam.DetachRolePolicyOutput, error) - listAttachedFunc func(ctx context.Context, params *iam.ListAttachedRolePoliciesInput, optFns ...func(*iam.Options)) (*iam.ListAttachedRolePoliciesOutput, error) -} - -func (m *mockIAMClient) CreateRole(ctx context.Context, params *iam.CreateRoleInput, optFns ...func(*iam.Options)) (*iam.CreateRoleOutput, error) { - if m.createFunc != nil { - return m.createFunc(ctx, params, optFns...) - } - return &iam.CreateRoleOutput{ - Role: &iamtypes.Role{ - RoleName: params.RoleName, - Arn: awsv2.String("arn:aws:iam::123456789:role/" + *params.RoleName), - RoleId: awsv2.String("AROA123456789"), - }, - }, nil -} - -func (m *mockIAMClient) GetRole(ctx context.Context, params *iam.GetRoleInput, optFns ...func(*iam.Options)) (*iam.GetRoleOutput, error) { - if m.getFunc != nil { - return m.getFunc(ctx, params, optFns...) - } - return &iam.GetRoleOutput{ - Role: &iamtypes.Role{ - RoleName: params.RoleName, - Arn: awsv2.String("arn:aws:iam::123456789:role/" + *params.RoleName), - RoleId: awsv2.String("AROA123456789"), - Description: awsv2.String("test role"), - }, - }, nil -} - -func (m *mockIAMClient) UpdateAssumeRolePolicy(ctx context.Context, params *iam.UpdateAssumeRolePolicyInput, optFns ...func(*iam.Options)) (*iam.UpdateAssumeRolePolicyOutput, error) { - if m.updatePolicyFunc != nil { - return m.updatePolicyFunc(ctx, params, optFns...) - } - return &iam.UpdateAssumeRolePolicyOutput{}, nil -} - -func (m *mockIAMClient) DeleteRole(ctx context.Context, params *iam.DeleteRoleInput, optFns ...func(*iam.Options)) (*iam.DeleteRoleOutput, error) { - if m.deleteFunc != nil { - return m.deleteFunc(ctx, params, optFns...) - } - return &iam.DeleteRoleOutput{}, nil -} - -func (m *mockIAMClient) AttachRolePolicy(ctx context.Context, params *iam.AttachRolePolicyInput, optFns ...func(*iam.Options)) (*iam.AttachRolePolicyOutput, error) { - if m.attachFunc != nil { - return m.attachFunc(ctx, params, optFns...) - } - return &iam.AttachRolePolicyOutput{}, nil -} - -func (m *mockIAMClient) DetachRolePolicy(ctx context.Context, params *iam.DetachRolePolicyInput, optFns ...func(*iam.Options)) (*iam.DetachRolePolicyOutput, error) { - if m.detachFunc != nil { - return m.detachFunc(ctx, params, optFns...) - } - return &iam.DetachRolePolicyOutput{}, nil -} - -func (m *mockIAMClient) ListAttachedRolePolicies(ctx context.Context, params *iam.ListAttachedRolePoliciesInput, optFns ...func(*iam.Options)) (*iam.ListAttachedRolePoliciesOutput, error) { - if m.listAttachedFunc != nil { - return m.listAttachedFunc(ctx, params, optFns...) - } - return &iam.ListAttachedRolePoliciesOutput{ - AttachedPolicies: []iamtypes.AttachedPolicy{ - {PolicyArn: awsv2.String("arn:aws:iam::aws:policy/AmazonEKSClusterPolicy")}, - }, - }, nil -} - -func TestIAMDriver_ResourceType(t *testing.T) { - d := NewIAMDriverWithClient(&mockIAMClient{}) - if d.ResourceType() != "aws.iam_role" { - t.Errorf("ResourceType() = %q, want aws.iam_role", d.ResourceType()) - } -} - -func TestIAMDriver_Create(t *testing.T) { - d := NewIAMDriverWithClient(&mockIAMClient{}) - ctx := context.Background() - - out, err := d.Create(ctx, "eks-role", map[string]any{ - "policy_arns": []string{"arn:aws:iam::aws:policy/AmazonEKSClusterPolicy"}, - }) - if err != nil { - t.Fatalf("Create() error: %v", err) - } - if out.Name != "eks-role" { - t.Errorf("Name = %q, want eks-role", out.Name) - } - if out.Properties["arn"] != "arn:aws:iam::123456789:role/eks-role" { - t.Errorf("arn = %v", out.Properties["arn"]) - } -} - -func TestIAMDriver_Read(t *testing.T) { - d := NewIAMDriverWithClient(&mockIAMClient{}) - ctx := context.Background() - - out, err := d.Read(ctx, "eks-role") - if err != nil { - t.Fatalf("Read() error: %v", err) - } - if out.Properties["description"] != "test role" { - t.Errorf("description = %v, want test role", out.Properties["description"]) - } -} - -func TestIAMDriver_Update(t *testing.T) { - attached := []string{} - detached := []string{} - d := NewIAMDriverWithClient(&mockIAMClient{ - attachFunc: func(ctx context.Context, params *iam.AttachRolePolicyInput, optFns ...func(*iam.Options)) (*iam.AttachRolePolicyOutput, error) { - attached = append(attached, *params.PolicyArn) - return &iam.AttachRolePolicyOutput{}, nil - }, - detachFunc: func(ctx context.Context, params *iam.DetachRolePolicyInput, optFns ...func(*iam.Options)) (*iam.DetachRolePolicyOutput, error) { - detached = append(detached, *params.PolicyArn) - return &iam.DetachRolePolicyOutput{}, nil - }, - }) - ctx := context.Background() - - _, err := d.Update(ctx, "eks-role", - map[string]any{"policy_arns": []string{"arn:old"}}, - map[string]any{"policy_arns": []string{"arn:new"}}, - ) - if err != nil { - t.Fatalf("Update() error: %v", err) - } - if len(attached) != 1 || attached[0] != "arn:new" { - t.Errorf("attached = %v, want [arn:new]", attached) - } - if len(detached) != 1 || detached[0] != "arn:old" { - t.Errorf("detached = %v, want [arn:old]", detached) - } -} - -func TestIAMDriver_Delete(t *testing.T) { - d := NewIAMDriverWithClient(&mockIAMClient{}) - ctx := context.Background() - - if err := d.Delete(ctx, "eks-role"); err != nil { - t.Fatalf("Delete() error: %v", err) - } -} - -func TestIAMDriver_Scale(t *testing.T) { - d := NewIAMDriverWithClient(&mockIAMClient{}) - ctx := context.Background() - - _, err := d.Scale(ctx, "eks-role", nil) - if err == nil { - t.Fatal("expected NotScalableError") - } - if _, ok := err.(*platform.NotScalableError); !ok { - t.Errorf("expected NotScalableError, got %T", err) - } -} - -func TestIAMDriver_HealthCheck(t *testing.T) { - d := NewIAMDriverWithClient(&mockIAMClient{}) - ctx := context.Background() - - health, err := d.HealthCheck(ctx, "eks-role") - if err != nil { - t.Fatalf("HealthCheck() error: %v", err) - } - if health.Status != "healthy" { - t.Errorf("health = %q, want healthy", health.Status) - } -} - -func TestDiffSlice(t *testing.T) { - result := diffSlice([]string{"a", "b", "c"}, []string{"b", "d"}) - if len(result) != 2 { - t.Fatalf("diffSlice length = %d, want 2", len(result)) - } -} diff --git a/platform/providers/aws/drivers/rds.go b/platform/providers/aws/drivers/rds.go deleted file mode 100644 index 4abfb9aa..00000000 --- a/platform/providers/aws/drivers/rds.go +++ /dev/null @@ -1,244 +0,0 @@ -//go:build aws - -package drivers - -import ( - "context" - "fmt" - "time" - - awsv2 "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/service/rds" - rdstypes "github.com/aws/aws-sdk-go-v2/service/rds/types" - - "github.com/GoCodeAlone/workflow/platform" -) - -// RDSClient defines the RDS operations used by the driver. -type RDSClient interface { - CreateDBInstance(ctx context.Context, params *rds.CreateDBInstanceInput, optFns ...func(*rds.Options)) (*rds.CreateDBInstanceOutput, error) - DescribeDBInstances(ctx context.Context, params *rds.DescribeDBInstancesInput, optFns ...func(*rds.Options)) (*rds.DescribeDBInstancesOutput, error) - ModifyDBInstance(ctx context.Context, params *rds.ModifyDBInstanceInput, optFns ...func(*rds.Options)) (*rds.ModifyDBInstanceOutput, error) - DeleteDBInstance(ctx context.Context, params *rds.DeleteDBInstanceInput, optFns ...func(*rds.Options)) (*rds.DeleteDBInstanceOutput, error) -} - -// RDSDriver manages RDS database instances. -type RDSDriver struct { - client RDSClient -} - -// NewRDSDriver creates a new RDS driver. -func NewRDSDriver(cfg awsv2.Config) *RDSDriver { - return &RDSDriver{ - client: rds.NewFromConfig(cfg), - } -} - -// NewRDSDriverWithClient creates an RDS driver with a custom client. -func NewRDSDriverWithClient(client RDSClient) *RDSDriver { - return &RDSDriver{client: client} -} - -func (d *RDSDriver) ResourceType() string { return "aws.rds" } - -func (d *RDSDriver) Create(ctx context.Context, name string, properties map[string]any) (*platform.ResourceOutput, error) { - engine, _ := properties["engine"].(string) - engineVersion, _ := properties["engine_version"].(string) - instanceClass, _ := properties["instance_class"].(string) - if instanceClass == "" { - instanceClass = "db.t3.micro" - } - allocatedStorage := int32(intPropDrivers(properties, "allocated_storage", 20)) - multiAZ := boolPropDrivers(properties, "multi_az", false) - masterUser, _ := properties["master_username"].(string) - if masterUser == "" { - masterUser = "admin" - } - masterPass, _ := properties["master_password"].(string) - if masterPass == "" { - return nil, fmt.Errorf("rds: create %q: master_password is required", name) - } - - input := &rds.CreateDBInstanceInput{ - DBInstanceIdentifier: awsv2.String(name), - Engine: awsv2.String(engine), - DBInstanceClass: awsv2.String(instanceClass), - AllocatedStorage: awsv2.Int32(allocatedStorage), - MultiAZ: awsv2.Bool(multiAZ), - MasterUsername: awsv2.String(masterUser), - MasterUserPassword: awsv2.String(masterPass), - } - if engineVersion != "" { - input.EngineVersion = awsv2.String(engineVersion) - } - - out, err := d.client.CreateDBInstance(ctx, input) - if err != nil { - return nil, fmt.Errorf("rds: create %q: %w", name, err) - } - - return dbInstanceToOutput(out.DBInstance), nil -} - -func (d *RDSDriver) Read(ctx context.Context, name string) (*platform.ResourceOutput, error) { - out, err := d.client.DescribeDBInstances(ctx, &rds.DescribeDBInstancesInput{ - DBInstanceIdentifier: awsv2.String(name), - }) - if err != nil { - return nil, fmt.Errorf("rds: describe %q: %w", name, err) - } - if len(out.DBInstances) == 0 { - return nil, &platform.ResourceNotFoundError{Name: name, Provider: "aws"} - } - return dbInstanceToOutput(&out.DBInstances[0]), nil -} - -func (d *RDSDriver) Update(ctx context.Context, name string, _, desired map[string]any) (*platform.ResourceOutput, error) { - input := &rds.ModifyDBInstanceInput{ - DBInstanceIdentifier: awsv2.String(name), - ApplyImmediately: awsv2.Bool(true), - } - - if ic, ok := desired["instance_class"].(string); ok && ic != "" { - input.DBInstanceClass = awsv2.String(ic) - } - if storage := intPropDrivers(desired, "allocated_storage", 0); storage > 0 { - input.AllocatedStorage = awsv2.Int32(int32(storage)) - } - if multiAZ, ok := desired["multi_az"].(bool); ok { - input.MultiAZ = awsv2.Bool(multiAZ) - } - - out, err := d.client.ModifyDBInstance(ctx, input) - if err != nil { - return nil, fmt.Errorf("rds: modify %q: %w", name, err) - } - return dbInstanceToOutput(out.DBInstance), nil -} - -func (d *RDSDriver) Delete(ctx context.Context, name string) error { - _, err := d.client.DeleteDBInstance(ctx, &rds.DeleteDBInstanceInput{ - DBInstanceIdentifier: awsv2.String(name), - SkipFinalSnapshot: awsv2.Bool(true), - }) - if err != nil { - return fmt.Errorf("rds: delete %q: %w", name, err) - } - return nil -} - -func (d *RDSDriver) HealthCheck(ctx context.Context, name string) (*platform.HealthStatus, error) { - out, err := d.Read(ctx, name) - if err != nil { - return &platform.HealthStatus{ - Status: "unhealthy", - Message: err.Error(), - CheckedAt: time.Now(), - }, nil - } - status := "healthy" - if out.Status != platform.ResourceStatusActive { - status = "degraded" - } - return &platform.HealthStatus{ - Status: status, - Message: string(out.Status), - CheckedAt: time.Now(), - }, nil -} - -func (d *RDSDriver) Scale(ctx context.Context, name string, scaleParams map[string]any) (*platform.ResourceOutput, error) { - // RDS scaling = changing instance class - ic, _ := scaleParams["instance_class"].(string) - if ic == "" { - return nil, fmt.Errorf("rds: scale requires 'instance_class' parameter") - } - return d.Update(ctx, name, nil, scaleParams) -} - -func (d *RDSDriver) Diff(ctx context.Context, name string, desired map[string]any) ([]platform.DiffEntry, error) { - current, err := d.Read(ctx, name) - if err != nil { - return nil, err - } - return diffProperties(current.Properties, desired), nil -} - -func dbInstanceToOutput(db *rdstypes.DBInstance) *platform.ResourceOutput { - if db == nil { - return nil - } - - status := platform.ResourceStatusActive - dbStatus := "" - if db.DBInstanceStatus != nil { - dbStatus = *db.DBInstanceStatus - } - switch dbStatus { - case "creating", "backing-up": - status = platform.ResourceStatusCreating - case "deleting": - status = platform.ResourceStatusDeleting - case "modifying": - status = platform.ResourceStatusUpdating - case "failed", "incompatible-parameters", "storage-full": - status = platform.ResourceStatusFailed - } - - props := map[string]any{ - "db_status": dbStatus, - } - if db.Engine != nil { - props["engine"] = *db.Engine - } - if db.EngineVersion != nil { - props["engine_version"] = *db.EngineVersion - } - if db.DBInstanceClass != nil { - props["instance_class"] = *db.DBInstanceClass - } - if db.AllocatedStorage != nil { - props["allocated_storage"] = int(*db.AllocatedStorage) - } - if db.MultiAZ != nil { - props["multi_az"] = *db.MultiAZ - } - if db.DBInstanceArn != nil { - props["arn"] = *db.DBInstanceArn - } - - endpoint := "" - connStr := "" - if db.Endpoint != nil { - if db.Endpoint.Address != nil { - endpoint = *db.Endpoint.Address - port := int32(0) - if db.Endpoint.Port != nil { - port = *db.Endpoint.Port - } - eng := "" - if db.Engine != nil { - eng = *db.Engine - } - connStr = fmt.Sprintf("%s://%s:%d", eng, endpoint, port) - } - } - - name := "" - if db.DBInstanceIdentifier != nil { - name = *db.DBInstanceIdentifier - } - - return &platform.ResourceOutput{ - Name: name, - Type: "database", - ProviderType: "aws.rds", - Endpoint: endpoint, - ConnectionStr: connStr, - Properties: props, - Status: status, - LastSynced: time.Now(), - } -} - -var _ platform.ResourceDriver = (*RDSDriver)(nil) diff --git a/platform/providers/aws/drivers/rds_test.go b/platform/providers/aws/drivers/rds_test.go deleted file mode 100644 index 640ee5ed..00000000 --- a/platform/providers/aws/drivers/rds_test.go +++ /dev/null @@ -1,217 +0,0 @@ -//go:build aws - -package drivers - -import ( - "context" - "testing" - - awsv2 "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/service/rds" - rdstypes "github.com/aws/aws-sdk-go-v2/service/rds/types" - - "github.com/GoCodeAlone/workflow/platform" -) - -type mockRDSClient struct { - createFunc func(ctx context.Context, params *rds.CreateDBInstanceInput, optFns ...func(*rds.Options)) (*rds.CreateDBInstanceOutput, error) - describeFunc func(ctx context.Context, params *rds.DescribeDBInstancesInput, optFns ...func(*rds.Options)) (*rds.DescribeDBInstancesOutput, error) - modifyFunc func(ctx context.Context, params *rds.ModifyDBInstanceInput, optFns ...func(*rds.Options)) (*rds.ModifyDBInstanceOutput, error) - deleteFunc func(ctx context.Context, params *rds.DeleteDBInstanceInput, optFns ...func(*rds.Options)) (*rds.DeleteDBInstanceOutput, error) -} - -func (m *mockRDSClient) CreateDBInstance(ctx context.Context, params *rds.CreateDBInstanceInput, optFns ...func(*rds.Options)) (*rds.CreateDBInstanceOutput, error) { - if m.createFunc != nil { - return m.createFunc(ctx, params, optFns...) - } - return &rds.CreateDBInstanceOutput{ - DBInstance: &rdstypes.DBInstance{ - DBInstanceIdentifier: params.DBInstanceIdentifier, - Engine: params.Engine, - DBInstanceClass: params.DBInstanceClass, - AllocatedStorage: params.AllocatedStorage, - MultiAZ: params.MultiAZ, - DBInstanceStatus: awsv2.String("creating"), - DBInstanceArn: awsv2.String("arn:aws:rds:us-east-1:123456789:db/test"), - }, - }, nil -} - -func (m *mockRDSClient) DescribeDBInstances(ctx context.Context, params *rds.DescribeDBInstancesInput, optFns ...func(*rds.Options)) (*rds.DescribeDBInstancesOutput, error) { - if m.describeFunc != nil { - return m.describeFunc(ctx, params, optFns...) - } - return &rds.DescribeDBInstancesOutput{ - DBInstances: []rdstypes.DBInstance{ - { - DBInstanceIdentifier: params.DBInstanceIdentifier, - Engine: awsv2.String("postgres"), - EngineVersion: awsv2.String("15.4"), - DBInstanceClass: awsv2.String("db.t3.micro"), - AllocatedStorage: awsv2.Int32(20), - MultiAZ: awsv2.Bool(false), - DBInstanceStatus: awsv2.String("available"), - DBInstanceArn: awsv2.String("arn:aws:rds:us-east-1:123456789:db/test"), - Endpoint: &rdstypes.Endpoint{ - Address: awsv2.String("test-db.cxx.us-east-1.rds.amazonaws.com"), - Port: awsv2.Int32(5432), - }, - }, - }, - }, nil -} - -func (m *mockRDSClient) ModifyDBInstance(ctx context.Context, params *rds.ModifyDBInstanceInput, optFns ...func(*rds.Options)) (*rds.ModifyDBInstanceOutput, error) { - if m.modifyFunc != nil { - return m.modifyFunc(ctx, params, optFns...) - } - return &rds.ModifyDBInstanceOutput{ - DBInstance: &rdstypes.DBInstance{ - DBInstanceIdentifier: params.DBInstanceIdentifier, - DBInstanceStatus: awsv2.String("modifying"), - }, - }, nil -} - -func (m *mockRDSClient) DeleteDBInstance(ctx context.Context, params *rds.DeleteDBInstanceInput, optFns ...func(*rds.Options)) (*rds.DeleteDBInstanceOutput, error) { - if m.deleteFunc != nil { - return m.deleteFunc(ctx, params, optFns...) - } - return &rds.DeleteDBInstanceOutput{}, nil -} - -func TestRDSDriver_ResourceType(t *testing.T) { - d := NewRDSDriverWithClient(&mockRDSClient{}) - if d.ResourceType() != "aws.rds" { - t.Errorf("ResourceType() = %q, want aws.rds", d.ResourceType()) - } -} - -func TestRDSDriver_Create(t *testing.T) { - d := NewRDSDriverWithClient(&mockRDSClient{}) - ctx := context.Background() - - out, err := d.Create(ctx, "test-db", map[string]any{ - "engine": "postgres", - "instance_class": "db.r5.large", - "allocated_storage": 50, - "multi_az": true, - "master_password": "s3cureP@ss!", - }) - if err != nil { - t.Fatalf("Create() error: %v", err) - } - if out.Name != "test-db" { - t.Errorf("Name = %q, want test-db", out.Name) - } - if out.ProviderType != "aws.rds" { - t.Errorf("ProviderType = %q, want aws.rds", out.ProviderType) - } - if out.Status != platform.ResourceStatusCreating { - t.Errorf("Status = %q, want creating", out.Status) - } -} - -func TestRDSDriver_CreateMissingPassword(t *testing.T) { - d := NewRDSDriverWithClient(&mockRDSClient{}) - ctx := context.Background() - - _, err := d.Create(ctx, "test-db", map[string]any{ - "engine": "postgres", - }) - if err == nil { - t.Fatal("expected error for missing master_password") - } -} - -func TestRDSDriver_Read(t *testing.T) { - d := NewRDSDriverWithClient(&mockRDSClient{}) - ctx := context.Background() - - out, err := d.Read(ctx, "test-db") - if err != nil { - t.Fatalf("Read() error: %v", err) - } - if out.Status != platform.ResourceStatusActive { - t.Errorf("Status = %q, want active", out.Status) - } - if out.Endpoint != "test-db.cxx.us-east-1.rds.amazonaws.com" { - t.Errorf("Endpoint = %q, want test-db.cxx...", out.Endpoint) - } - if out.ConnectionStr != "postgres://test-db.cxx.us-east-1.rds.amazonaws.com:5432" { - t.Errorf("ConnectionStr = %q", out.ConnectionStr) - } - if out.Properties["engine"] != "postgres" { - t.Errorf("engine = %v, want postgres", out.Properties["engine"]) - } -} - -func TestRDSDriver_ReadNotFound(t *testing.T) { - d := NewRDSDriverWithClient(&mockRDSClient{ - describeFunc: func(ctx context.Context, params *rds.DescribeDBInstancesInput, optFns ...func(*rds.Options)) (*rds.DescribeDBInstancesOutput, error) { - return &rds.DescribeDBInstancesOutput{DBInstances: []rdstypes.DBInstance{}}, nil - }, - }) - ctx := context.Background() - - _, err := d.Read(ctx, "nonexistent") - if err == nil { - t.Fatal("expected error for nonexistent DB") - } -} - -func TestRDSDriver_Update(t *testing.T) { - d := NewRDSDriverWithClient(&mockRDSClient{}) - ctx := context.Background() - - _, err := d.Update(ctx, "test-db", nil, map[string]any{ - "instance_class": "db.r5.xlarge", - }) - if err != nil { - t.Fatalf("Update() error: %v", err) - } -} - -func TestRDSDriver_Delete(t *testing.T) { - d := NewRDSDriverWithClient(&mockRDSClient{}) - ctx := context.Background() - - if err := d.Delete(ctx, "test-db"); err != nil { - t.Fatalf("Delete() error: %v", err) - } -} - -func TestRDSDriver_Scale(t *testing.T) { - d := NewRDSDriverWithClient(&mockRDSClient{}) - ctx := context.Background() - - _, err := d.Scale(ctx, "test-db", map[string]any{ - "instance_class": "db.r5.xlarge", - }) - if err != nil { - t.Fatalf("Scale() error: %v", err) - } -} - -func TestRDSDriver_ScaleMissingClass(t *testing.T) { - d := NewRDSDriverWithClient(&mockRDSClient{}) - ctx := context.Background() - - _, err := d.Scale(ctx, "test-db", map[string]any{}) - if err == nil { - t.Fatal("expected error for missing instance_class") - } -} - -func TestRDSDriver_HealthCheck(t *testing.T) { - d := NewRDSDriverWithClient(&mockRDSClient{}) - ctx := context.Background() - - health, err := d.HealthCheck(ctx, "test-db") - if err != nil { - t.Fatalf("HealthCheck() error: %v", err) - } - if health.Status != "healthy" { - t.Errorf("health = %q, want healthy", health.Status) - } -} diff --git a/platform/providers/aws/drivers/sqs.go b/platform/providers/aws/drivers/sqs.go deleted file mode 100644 index c63ab805..00000000 --- a/platform/providers/aws/drivers/sqs.go +++ /dev/null @@ -1,202 +0,0 @@ -//go:build aws - -package drivers - -import ( - "context" - "fmt" - "time" - - awsv2 "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/service/sqs" - sqstypes "github.com/aws/aws-sdk-go-v2/service/sqs/types" - - "github.com/GoCodeAlone/workflow/platform" -) - -// SQSClient defines the SQS operations used by the driver. -type SQSClient interface { - CreateQueue(ctx context.Context, params *sqs.CreateQueueInput, optFns ...func(*sqs.Options)) (*sqs.CreateQueueOutput, error) - GetQueueUrl(ctx context.Context, params *sqs.GetQueueUrlInput, optFns ...func(*sqs.Options)) (*sqs.GetQueueUrlOutput, error) - GetQueueAttributes(ctx context.Context, params *sqs.GetQueueAttributesInput, optFns ...func(*sqs.Options)) (*sqs.GetQueueAttributesOutput, error) - SetQueueAttributes(ctx context.Context, params *sqs.SetQueueAttributesInput, optFns ...func(*sqs.Options)) (*sqs.SetQueueAttributesOutput, error) - DeleteQueue(ctx context.Context, params *sqs.DeleteQueueInput, optFns ...func(*sqs.Options)) (*sqs.DeleteQueueOutput, error) -} - -// SQSDriver manages SQS queue resources. -type SQSDriver struct { - client SQSClient -} - -// NewSQSDriver creates a new SQS driver. -func NewSQSDriver(cfg awsv2.Config) *SQSDriver { - return &SQSDriver{ - client: sqs.NewFromConfig(cfg), - } -} - -// NewSQSDriverWithClient creates an SQS driver with a custom client. -func NewSQSDriverWithClient(client SQSClient) *SQSDriver { - return &SQSDriver{client: client} -} - -func (d *SQSDriver) ResourceType() string { return "aws.sqs" } - -func (d *SQSDriver) Create(ctx context.Context, name string, properties map[string]any) (*platform.ResourceOutput, error) { - attrs := map[string]string{} - if fifo := boolPropDrivers(properties, "fifo", false); fifo { - attrs["FifoQueue"] = "true" - // FIFO queues require .fifo suffix - if len(name) < 5 || name[len(name)-5:] != ".fifo" { - name += ".fifo" - } - } - if vt := intPropDrivers(properties, "visibility_timeout", 30); vt > 0 { - attrs["VisibilityTimeout"] = fmt.Sprintf("%d", vt) - } - if rp := intPropDrivers(properties, "retention_period", 345600); rp > 0 { - attrs["MessageRetentionPeriod"] = fmt.Sprintf("%d", rp) - } - - out, err := d.client.CreateQueue(ctx, &sqs.CreateQueueInput{ - QueueName: awsv2.String(name), - Attributes: attrs, - }) - if err != nil { - return nil, fmt.Errorf("sqs: create queue %q: %w", name, err) - } - - queueURL := "" - if out.QueueUrl != nil { - queueURL = *out.QueueUrl - } - - return &platform.ResourceOutput{ - Name: name, - Type: "message_queue", - ProviderType: "aws.sqs", - Endpoint: queueURL, - Properties: map[string]any{ - "queue_url": queueURL, - "attributes": attrs, - }, - Status: platform.ResourceStatusActive, - LastSynced: time.Now(), - }, nil -} - -func (d *SQSDriver) Read(ctx context.Context, name string) (*platform.ResourceOutput, error) { - urlOut, err := d.client.GetQueueUrl(ctx, &sqs.GetQueueUrlInput{ - QueueName: awsv2.String(name), - }) - if err != nil { - return nil, fmt.Errorf("sqs: get queue url %q: %w", name, err) - } - - queueURL := "" - if urlOut.QueueUrl != nil { - queueURL = *urlOut.QueueUrl - } - - attrOut, err := d.client.GetQueueAttributes(ctx, &sqs.GetQueueAttributesInput{ - QueueUrl: awsv2.String(queueURL), - AttributeNames: []sqstypes.QueueAttributeName{sqstypes.QueueAttributeNameAll}, - }) - if err != nil { - return nil, fmt.Errorf("sqs: get queue attributes %q: %w", name, err) - } - - props := map[string]any{ - "queue_url": queueURL, - } - for k, v := range attrOut.Attributes { - props[k] = v - } - - return &platform.ResourceOutput{ - Name: name, - Type: "message_queue", - ProviderType: "aws.sqs", - Endpoint: queueURL, - Properties: props, - Status: platform.ResourceStatusActive, - LastSynced: time.Now(), - }, nil -} - -func (d *SQSDriver) Update(ctx context.Context, name string, current, desired map[string]any) (*platform.ResourceOutput, error) { - queueURL, _ := current["queue_url"].(string) - if queueURL == "" { - out, err := d.Read(ctx, name) - if err != nil { - return nil, err - } - queueURL, _ = out.Properties["queue_url"].(string) - } - - attrs := map[string]string{} - if vt := intPropDrivers(desired, "visibility_timeout", 0); vt > 0 { - attrs["VisibilityTimeout"] = fmt.Sprintf("%d", vt) - } - if rp := intPropDrivers(desired, "retention_period", 0); rp > 0 { - attrs["MessageRetentionPeriod"] = fmt.Sprintf("%d", rp) - } - - if len(attrs) > 0 { - _, err := d.client.SetQueueAttributes(ctx, &sqs.SetQueueAttributesInput{ - QueueUrl: awsv2.String(queueURL), - Attributes: attrs, - }) - if err != nil { - return nil, fmt.Errorf("sqs: update queue %q: %w", name, err) - } - } - return d.Read(ctx, name) -} - -func (d *SQSDriver) Delete(ctx context.Context, name string) error { - urlOut, err := d.client.GetQueueUrl(ctx, &sqs.GetQueueUrlInput{ - QueueName: awsv2.String(name), - }) - if err != nil { - return fmt.Errorf("sqs: get queue url for delete %q: %w", name, err) - } - - _, err = d.client.DeleteQueue(ctx, &sqs.DeleteQueueInput{ - QueueUrl: urlOut.QueueUrl, - }) - if err != nil { - return fmt.Errorf("sqs: delete queue %q: %w", name, err) - } - return nil -} - -func (d *SQSDriver) HealthCheck(ctx context.Context, name string) (*platform.HealthStatus, error) { - _, err := d.Read(ctx, name) - if err != nil { - return &platform.HealthStatus{ - Status: "unhealthy", - Message: err.Error(), - CheckedAt: time.Now(), - }, nil - } - return &platform.HealthStatus{ - Status: "healthy", - Message: "queue accessible", - CheckedAt: time.Now(), - }, nil -} - -func (d *SQSDriver) Scale(_ context.Context, _ string, _ map[string]any) (*platform.ResourceOutput, error) { - return nil, &platform.NotScalableError{ResourceType: "aws.sqs"} -} - -func (d *SQSDriver) Diff(ctx context.Context, name string, desired map[string]any) ([]platform.DiffEntry, error) { - current, err := d.Read(ctx, name) - if err != nil { - return nil, err - } - return diffProperties(current.Properties, desired), nil -} - -var _ platform.ResourceDriver = (*SQSDriver)(nil) diff --git a/platform/providers/aws/drivers/sqs_test.go b/platform/providers/aws/drivers/sqs_test.go deleted file mode 100644 index 1686d1b3..00000000 --- a/platform/providers/aws/drivers/sqs_test.go +++ /dev/null @@ -1,178 +0,0 @@ -//go:build aws - -package drivers - -import ( - "context" - "testing" - - awsv2 "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/service/sqs" - - "github.com/GoCodeAlone/workflow/platform" -) - -type mockSQSClient struct { - createFunc func(ctx context.Context, params *sqs.CreateQueueInput, optFns ...func(*sqs.Options)) (*sqs.CreateQueueOutput, error) - getURLFunc func(ctx context.Context, params *sqs.GetQueueUrlInput, optFns ...func(*sqs.Options)) (*sqs.GetQueueUrlOutput, error) - getAttrsFunc func(ctx context.Context, params *sqs.GetQueueAttributesInput, optFns ...func(*sqs.Options)) (*sqs.GetQueueAttributesOutput, error) - setAttrsFunc func(ctx context.Context, params *sqs.SetQueueAttributesInput, optFns ...func(*sqs.Options)) (*sqs.SetQueueAttributesOutput, error) - deleteFunc func(ctx context.Context, params *sqs.DeleteQueueInput, optFns ...func(*sqs.Options)) (*sqs.DeleteQueueOutput, error) -} - -func (m *mockSQSClient) CreateQueue(ctx context.Context, params *sqs.CreateQueueInput, optFns ...func(*sqs.Options)) (*sqs.CreateQueueOutput, error) { - if m.createFunc != nil { - return m.createFunc(ctx, params, optFns...) - } - return &sqs.CreateQueueOutput{ - QueueUrl: awsv2.String("https://sqs.us-east-1.amazonaws.com/123456789/test-queue"), - }, nil -} - -func (m *mockSQSClient) GetQueueUrl(ctx context.Context, params *sqs.GetQueueUrlInput, optFns ...func(*sqs.Options)) (*sqs.GetQueueUrlOutput, error) { - if m.getURLFunc != nil { - return m.getURLFunc(ctx, params, optFns...) - } - return &sqs.GetQueueUrlOutput{ - QueueUrl: awsv2.String("https://sqs.us-east-1.amazonaws.com/123456789/test-queue"), - }, nil -} - -func (m *mockSQSClient) GetQueueAttributes(ctx context.Context, params *sqs.GetQueueAttributesInput, optFns ...func(*sqs.Options)) (*sqs.GetQueueAttributesOutput, error) { - if m.getAttrsFunc != nil { - return m.getAttrsFunc(ctx, params, optFns...) - } - return &sqs.GetQueueAttributesOutput{ - Attributes: map[string]string{ - "VisibilityTimeout": "30", - "MessageRetentionPeriod": "345600", - "QueueArn": "arn:aws:sqs:us-east-1:123456789:test-queue", - }, - }, nil -} - -func (m *mockSQSClient) SetQueueAttributes(ctx context.Context, params *sqs.SetQueueAttributesInput, optFns ...func(*sqs.Options)) (*sqs.SetQueueAttributesOutput, error) { - if m.setAttrsFunc != nil { - return m.setAttrsFunc(ctx, params, optFns...) - } - return &sqs.SetQueueAttributesOutput{}, nil -} - -func (m *mockSQSClient) DeleteQueue(ctx context.Context, params *sqs.DeleteQueueInput, optFns ...func(*sqs.Options)) (*sqs.DeleteQueueOutput, error) { - if m.deleteFunc != nil { - return m.deleteFunc(ctx, params, optFns...) - } - return &sqs.DeleteQueueOutput{}, nil -} - -func TestSQSDriver_ResourceType(t *testing.T) { - d := NewSQSDriverWithClient(&mockSQSClient{}) - if d.ResourceType() != "aws.sqs" { - t.Errorf("ResourceType() = %q, want aws.sqs", d.ResourceType()) - } -} - -func TestSQSDriver_Create(t *testing.T) { - d := NewSQSDriverWithClient(&mockSQSClient{}) - ctx := context.Background() - - out, err := d.Create(ctx, "test-queue", map[string]any{ - "visibility_timeout": 60, - "retention_period": 86400, - }) - if err != nil { - t.Fatalf("Create() error: %v", err) - } - if out.Name != "test-queue" { - t.Errorf("Name = %q, want test-queue", out.Name) - } - if out.ProviderType != "aws.sqs" { - t.Errorf("ProviderType = %q, want aws.sqs", out.ProviderType) - } - if out.Endpoint == "" { - t.Error("expected non-empty endpoint (queue URL)") - } -} - -func TestSQSDriver_CreateFIFO(t *testing.T) { - d := NewSQSDriverWithClient(&mockSQSClient{}) - ctx := context.Background() - - out, err := d.Create(ctx, "test-queue", map[string]any{ - "fifo": true, - }) - if err != nil { - t.Fatalf("Create() error: %v", err) - } - // FIFO queues get .fifo suffix - if out.Name != "test-queue.fifo" { - t.Errorf("Name = %q, want test-queue.fifo", out.Name) - } -} - -func TestSQSDriver_Read(t *testing.T) { - d := NewSQSDriverWithClient(&mockSQSClient{}) - ctx := context.Background() - - out, err := d.Read(ctx, "test-queue") - if err != nil { - t.Fatalf("Read() error: %v", err) - } - if out.Endpoint == "" { - t.Error("expected queue URL endpoint") - } - if out.Properties["QueueArn"] != "arn:aws:sqs:us-east-1:123456789:test-queue" { - t.Errorf("QueueArn = %v", out.Properties["QueueArn"]) - } -} - -func TestSQSDriver_Update(t *testing.T) { - d := NewSQSDriverWithClient(&mockSQSClient{}) - ctx := context.Background() - - out, err := d.Update(ctx, "test-queue", - map[string]any{"queue_url": "https://sqs.us-east-1.amazonaws.com/123456789/test-queue"}, - map[string]any{"visibility_timeout": 120}, - ) - if err != nil { - t.Fatalf("Update() error: %v", err) - } - if out == nil { - t.Fatal("Update() returned nil") - } -} - -func TestSQSDriver_Delete(t *testing.T) { - d := NewSQSDriverWithClient(&mockSQSClient{}) - ctx := context.Background() - - if err := d.Delete(ctx, "test-queue"); err != nil { - t.Fatalf("Delete() error: %v", err) - } -} - -func TestSQSDriver_Scale(t *testing.T) { - d := NewSQSDriverWithClient(&mockSQSClient{}) - ctx := context.Background() - - _, err := d.Scale(ctx, "test-queue", nil) - if err == nil { - t.Fatal("expected NotScalableError") - } - if _, ok := err.(*platform.NotScalableError); !ok { - t.Errorf("expected NotScalableError, got %T", err) - } -} - -func TestSQSDriver_HealthCheck(t *testing.T) { - d := NewSQSDriverWithClient(&mockSQSClient{}) - ctx := context.Background() - - health, err := d.HealthCheck(ctx, "test-queue") - if err != nil { - t.Fatalf("HealthCheck() error: %v", err) - } - if health.Status != "healthy" { - t.Errorf("health = %q, want healthy", health.Status) - } -} diff --git a/platform/providers/aws/drivers/vpc.go b/platform/providers/aws/drivers/vpc.go deleted file mode 100644 index 09ca412a..00000000 --- a/platform/providers/aws/drivers/vpc.go +++ /dev/null @@ -1,190 +0,0 @@ -//go:build aws - -package drivers - -import ( - "context" - "fmt" - "time" - - awsv2 "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/service/ec2" - ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types" - - "github.com/GoCodeAlone/workflow/platform" -) - -// EC2VPCClient defines the EC2 operations for VPC management. -type EC2VPCClient interface { - CreateVpc(ctx context.Context, params *ec2.CreateVpcInput, optFns ...func(*ec2.Options)) (*ec2.CreateVpcOutput, error) - DescribeVpcs(ctx context.Context, params *ec2.DescribeVpcsInput, optFns ...func(*ec2.Options)) (*ec2.DescribeVpcsOutput, error) - DeleteVpc(ctx context.Context, params *ec2.DeleteVpcInput, optFns ...func(*ec2.Options)) (*ec2.DeleteVpcOutput, error) - CreateTags(ctx context.Context, params *ec2.CreateTagsInput, optFns ...func(*ec2.Options)) (*ec2.CreateTagsOutput, error) -} - -// VPCDriver manages AWS VPC resources. -type VPCDriver struct { - client EC2VPCClient -} - -// NewVPCDriver creates a new VPC driver. -func NewVPCDriver(cfg awsv2.Config) *VPCDriver { - return &VPCDriver{ - client: ec2.NewFromConfig(cfg), - } -} - -// NewVPCDriverWithClient creates a VPC driver with a custom client. -func NewVPCDriverWithClient(client EC2VPCClient) *VPCDriver { - return &VPCDriver{client: client} -} - -func (d *VPCDriver) ResourceType() string { return "aws.vpc" } - -func (d *VPCDriver) Create(ctx context.Context, name string, properties map[string]any) (*platform.ResourceOutput, error) { - cidr, _ := properties["cidr"].(string) - if cidr == "" { - cidr = "10.0.0.0/16" - } - - out, err := d.client.CreateVpc(ctx, &ec2.CreateVpcInput{ - CidrBlock: awsv2.String(cidr), - TagSpecifications: []ec2types.TagSpecification{ - { - ResourceType: ec2types.ResourceTypeVpc, - Tags: []ec2types.Tag{ - {Key: awsv2.String("Name"), Value: awsv2.String(name)}, - }, - }, - }, - }) - if err != nil { - return nil, fmt.Errorf("vpc: create %q: %w", name, err) - } - - return vpcToOutput(name, out.Vpc), nil -} - -func (d *VPCDriver) Read(ctx context.Context, name string) (*platform.ResourceOutput, error) { - out, err := d.client.DescribeVpcs(ctx, &ec2.DescribeVpcsInput{ - Filters: []ec2types.Filter{ - { - Name: awsv2.String("tag:Name"), - Values: []string{name}, - }, - }, - }) - if err != nil { - return nil, fmt.Errorf("vpc: describe %q: %w", name, err) - } - if len(out.Vpcs) == 0 { - return nil, &platform.ResourceNotFoundError{Name: name, Provider: "aws"} - } - return vpcToOutput(name, &out.Vpcs[0]), nil -} - -func (d *VPCDriver) Update(ctx context.Context, name string, _, desired map[string]any) (*platform.ResourceOutput, error) { - // VPC CIDR cannot be changed after creation. Tags can be updated. - current, err := d.Read(ctx, name) - if err != nil { - return nil, err - } - - vpcID, _ := current.Properties["vpc_id"].(string) - if vpcID != "" { - _, err = d.client.CreateTags(ctx, &ec2.CreateTagsInput{ - Resources: []string{vpcID}, - Tags: []ec2types.Tag{ - {Key: awsv2.String("Name"), Value: awsv2.String(name)}, - }, - }) - if err != nil { - return nil, fmt.Errorf("vpc: update tags %q: %w", name, err) - } - } - - return d.Read(ctx, name) -} - -func (d *VPCDriver) Delete(ctx context.Context, name string) error { - current, err := d.Read(ctx, name) - if err != nil { - return err - } - - vpcID, _ := current.Properties["vpc_id"].(string) - if vpcID == "" { - return fmt.Errorf("vpc: cannot delete %q: no vpc_id in state", name) - } - - _, err = d.client.DeleteVpc(ctx, &ec2.DeleteVpcInput{ - VpcId: awsv2.String(vpcID), - }) - if err != nil { - return fmt.Errorf("vpc: delete %q: %w", name, err) - } - return nil -} - -func (d *VPCDriver) HealthCheck(ctx context.Context, name string) (*platform.HealthStatus, error) { - out, err := d.Read(ctx, name) - if err != nil { - return &platform.HealthStatus{ - Status: "unhealthy", - Message: err.Error(), - CheckedAt: time.Now(), - }, nil - } - status := "healthy" - if out.Status != platform.ResourceStatusActive { - status = "degraded" - } - return &platform.HealthStatus{ - Status: status, - Message: string(out.Status), - CheckedAt: time.Now(), - }, nil -} - -func (d *VPCDriver) Scale(_ context.Context, _ string, _ map[string]any) (*platform.ResourceOutput, error) { - return nil, &platform.NotScalableError{ResourceType: "aws.vpc"} -} - -func (d *VPCDriver) Diff(ctx context.Context, name string, desired map[string]any) ([]platform.DiffEntry, error) { - current, err := d.Read(ctx, name) - if err != nil { - return nil, err - } - return diffProperties(current.Properties, desired), nil -} - -func vpcToOutput(name string, vpc *ec2types.Vpc) *platform.ResourceOutput { - if vpc == nil { - return nil - } - status := platform.ResourceStatusActive - if vpc.State == ec2types.VpcStatePending { - status = platform.ResourceStatusCreating - } - - props := map[string]any{ - "state": string(vpc.State), - } - if vpc.VpcId != nil { - props["vpc_id"] = *vpc.VpcId - } - if vpc.CidrBlock != nil { - props["cidr"] = *vpc.CidrBlock - } - - return &platform.ResourceOutput{ - Name: name, - Type: "network", - ProviderType: "aws.vpc", - Properties: props, - Status: status, - LastSynced: time.Now(), - } -} - -var _ platform.ResourceDriver = (*VPCDriver)(nil) diff --git a/platform/providers/aws/drivers/vpc_test.go b/platform/providers/aws/drivers/vpc_test.go deleted file mode 100644 index 1110eddc..00000000 --- a/platform/providers/aws/drivers/vpc_test.go +++ /dev/null @@ -1,168 +0,0 @@ -//go:build aws - -package drivers - -import ( - "context" - "testing" - - awsv2 "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/service/ec2" - ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types" - - "github.com/GoCodeAlone/workflow/platform" -) - -type mockEC2VPCClient struct { - createFunc func(ctx context.Context, params *ec2.CreateVpcInput, optFns ...func(*ec2.Options)) (*ec2.CreateVpcOutput, error) - describeFunc func(ctx context.Context, params *ec2.DescribeVpcsInput, optFns ...func(*ec2.Options)) (*ec2.DescribeVpcsOutput, error) - deleteFunc func(ctx context.Context, params *ec2.DeleteVpcInput, optFns ...func(*ec2.Options)) (*ec2.DeleteVpcOutput, error) - tagFunc func(ctx context.Context, params *ec2.CreateTagsInput, optFns ...func(*ec2.Options)) (*ec2.CreateTagsOutput, error) -} - -func (m *mockEC2VPCClient) CreateVpc(ctx context.Context, params *ec2.CreateVpcInput, optFns ...func(*ec2.Options)) (*ec2.CreateVpcOutput, error) { - if m.createFunc != nil { - return m.createFunc(ctx, params, optFns...) - } - return &ec2.CreateVpcOutput{ - Vpc: &ec2types.Vpc{ - VpcId: awsv2.String("vpc-12345"), - CidrBlock: params.CidrBlock, - State: ec2types.VpcStateAvailable, - }, - }, nil -} - -func (m *mockEC2VPCClient) DescribeVpcs(ctx context.Context, params *ec2.DescribeVpcsInput, optFns ...func(*ec2.Options)) (*ec2.DescribeVpcsOutput, error) { - if m.describeFunc != nil { - return m.describeFunc(ctx, params, optFns...) - } - return &ec2.DescribeVpcsOutput{ - Vpcs: []ec2types.Vpc{ - { - VpcId: awsv2.String("vpc-12345"), - CidrBlock: awsv2.String("10.0.0.0/16"), - State: ec2types.VpcStateAvailable, - }, - }, - }, nil -} - -func (m *mockEC2VPCClient) DeleteVpc(ctx context.Context, params *ec2.DeleteVpcInput, optFns ...func(*ec2.Options)) (*ec2.DeleteVpcOutput, error) { - if m.deleteFunc != nil { - return m.deleteFunc(ctx, params, optFns...) - } - return &ec2.DeleteVpcOutput{}, nil -} - -func (m *mockEC2VPCClient) CreateTags(ctx context.Context, params *ec2.CreateTagsInput, optFns ...func(*ec2.Options)) (*ec2.CreateTagsOutput, error) { - if m.tagFunc != nil { - return m.tagFunc(ctx, params, optFns...) - } - return &ec2.CreateTagsOutput{}, nil -} - -func TestVPCDriver_ResourceType(t *testing.T) { - d := NewVPCDriverWithClient(&mockEC2VPCClient{}) - if d.ResourceType() != "aws.vpc" { - t.Errorf("ResourceType() = %q, want aws.vpc", d.ResourceType()) - } -} - -func TestVPCDriver_Create(t *testing.T) { - d := NewVPCDriverWithClient(&mockEC2VPCClient{}) - ctx := context.Background() - - out, err := d.Create(ctx, "test-vpc", map[string]any{ - "cidr": "10.0.0.0/16", - }) - if err != nil { - t.Fatalf("Create() error: %v", err) - } - if out.Name != "test-vpc" { - t.Errorf("Name = %q, want test-vpc", out.Name) - } - if out.ProviderType != "aws.vpc" { - t.Errorf("ProviderType = %q, want aws.vpc", out.ProviderType) - } - if out.Properties["vpc_id"] != "vpc-12345" { - t.Errorf("vpc_id = %v, want vpc-12345", out.Properties["vpc_id"]) - } -} - -func TestVPCDriver_Read(t *testing.T) { - d := NewVPCDriverWithClient(&mockEC2VPCClient{}) - ctx := context.Background() - - out, err := d.Read(ctx, "test-vpc") - if err != nil { - t.Fatalf("Read() error: %v", err) - } - if out.Properties["cidr"] != "10.0.0.0/16" { - t.Errorf("cidr = %v, want 10.0.0.0/16", out.Properties["cidr"]) - } -} - -func TestVPCDriver_ReadNotFound(t *testing.T) { - d := NewVPCDriverWithClient(&mockEC2VPCClient{ - describeFunc: func(ctx context.Context, params *ec2.DescribeVpcsInput, optFns ...func(*ec2.Options)) (*ec2.DescribeVpcsOutput, error) { - return &ec2.DescribeVpcsOutput{Vpcs: []ec2types.Vpc{}}, nil - }, - }) - ctx := context.Background() - - _, err := d.Read(ctx, "nonexistent") - if err == nil { - t.Fatal("expected error for nonexistent VPC") - } - if _, ok := err.(*platform.ResourceNotFoundError); !ok { - t.Errorf("expected ResourceNotFoundError, got %T", err) - } -} - -func TestVPCDriver_Delete(t *testing.T) { - d := NewVPCDriverWithClient(&mockEC2VPCClient{}) - ctx := context.Background() - - if err := d.Delete(ctx, "test-vpc"); err != nil { - t.Fatalf("Delete() error: %v", err) - } -} - -func TestVPCDriver_Scale(t *testing.T) { - d := NewVPCDriverWithClient(&mockEC2VPCClient{}) - ctx := context.Background() - - _, err := d.Scale(ctx, "test-vpc", nil) - if err == nil { - t.Fatal("expected NotScalableError") - } -} - -func TestVPCDriver_HealthCheck(t *testing.T) { - d := NewVPCDriverWithClient(&mockEC2VPCClient{}) - ctx := context.Background() - - health, err := d.HealthCheck(ctx, "test-vpc") - if err != nil { - t.Fatalf("HealthCheck() error: %v", err) - } - if health.Status != "healthy" { - t.Errorf("health = %q, want healthy", health.Status) - } -} - -func TestVPCDriver_Diff(t *testing.T) { - d := NewVPCDriverWithClient(&mockEC2VPCClient{}) - ctx := context.Background() - - diffs, err := d.Diff(ctx, "test-vpc", map[string]any{ - "cidr": "10.1.0.0/16", - }) - if err != nil { - t.Fatalf("Diff() error: %v", err) - } - if len(diffs) == 0 { - t.Error("expected diffs for different CIDR") - } -} diff --git a/platform/providers/aws/provider.go b/platform/providers/aws/provider.go deleted file mode 100644 index 483d3c1b..00000000 --- a/platform/providers/aws/provider.go +++ /dev/null @@ -1,228 +0,0 @@ -//go:build aws - -package aws - -import ( - "context" - "fmt" - "sync" - - awscfg "github.com/aws/aws-sdk-go-v2/config" - "github.com/aws/aws-sdk-go-v2/credentials" - - "github.com/GoCodeAlone/workflow/platform" -) - -const ( - ProviderName = "aws" - ProviderVersion = "0.1.0" -) - -// AWSProvider implements platform.Provider for Amazon Web Services. -type AWSProvider struct { - mu sync.RWMutex - initialized bool - region string - capabilityMapper *AWSCapabilityMapper - credBroker *AWSCredentialBroker - stateStore *AWSS3StateStore - drivers map[string]platform.ResourceDriver -} - -// NewProvider creates a new AWS provider instance. -func NewProvider() platform.Provider { - return &AWSProvider{ - drivers: make(map[string]platform.ResourceDriver), - } -} - -func (p *AWSProvider) Name() string { return ProviderName } -func (p *AWSProvider) Version() string { return ProviderVersion } - -// Initialize configures the AWS SDK client from the provided config map. -// Expected config keys: "region", "access_key_id", "secret_access_key", "role_arn", -// "state_bucket", "state_table". -func (p *AWSProvider) Initialize(ctx context.Context, config map[string]any) error { - p.mu.Lock() - defer p.mu.Unlock() - - region, _ := config["region"].(string) - if region == "" { - region = "us-east-1" - } - 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, ""), - )) - } - - cfg, err := awscfg.LoadDefaultConfig(ctx, opts...) - if err != nil { - return fmt.Errorf("aws: load config: %w", err) - } - - // Initialize capability mapper - p.capabilityMapper = NewAWSCapabilityMapper() - - // Initialize credential broker - roleARN, _ := config["role_arn"].(string) - p.credBroker = NewAWSCredentialBroker(cfg, roleARN) - - // Initialize state store - bucket, _ := config["state_bucket"].(string) - table, _ := config["state_table"].(string) - p.stateStore = NewAWSS3StateStore(cfg, bucket, table) - - // Register resource drivers - p.registerDrivers(cfg) - - p.initialized = true - return nil -} - -func (p *AWSProvider) registerDrivers(cfg awsSDKConfig) { - driverList := []platform.ResourceDriver{ - NewEKSClusterDriver(cfg), - NewEKSNodeGroupDriver(cfg), - NewVPCDriver(cfg), - NewRDSDriver(cfg), - NewSQSDriver(cfg), - NewIAMDriver(cfg), - NewALBDriver(cfg), - } - for _, d := range driverList { - p.drivers[d.ResourceType()] = d - } -} - -func (p *AWSProvider) Capabilities() []platform.CapabilityType { - return []platform.CapabilityType{ - { - Name: "kubernetes_cluster", - Description: "EKS Kubernetes cluster with managed node groups", - Tier: platform.TierInfrastructure, - Fidelity: platform.FidelityFull, - Properties: []platform.PropertySchema{ - {Name: "version", Type: "string", Description: "Kubernetes version"}, - {Name: "node_count", Type: "int", Required: true, Description: "Number of worker nodes"}, - {Name: "instance_type", Type: "string", Description: "EC2 instance type for nodes", DefaultValue: "t3.medium"}, - }, - }, - { - Name: "network", - Description: "VPC with subnets and routing", - Tier: platform.TierInfrastructure, - Fidelity: platform.FidelityFull, - Properties: []platform.PropertySchema{ - {Name: "cidr", Type: "string", Required: true, Description: "VPC CIDR block"}, - {Name: "availability_zones", Type: "list", Description: "AZs for subnets"}, - {Name: "enable_nat", Type: "bool", Description: "Enable NAT gateway", DefaultValue: true}, - }, - }, - { - Name: "database", - Description: "RDS managed database instance", - Tier: platform.TierSharedPrimitive, - Fidelity: platform.FidelityFull, - Properties: []platform.PropertySchema{ - {Name: "engine", Type: "string", Required: true, Description: "Database engine (postgres, mysql)"}, - {Name: "engine_version", Type: "string", Description: "Engine version"}, - {Name: "instance_class", Type: "string", Description: "RDS instance class", DefaultValue: "db.t3.micro"}, - {Name: "allocated_storage", Type: "int", Description: "Storage in GB", DefaultValue: 20}, - {Name: "multi_az", Type: "bool", Description: "Multi-AZ deployment", DefaultValue: false}, - }, - }, - { - Name: "message_queue", - Description: "SQS message queue", - Tier: platform.TierSharedPrimitive, - Fidelity: platform.FidelityFull, - Properties: []platform.PropertySchema{ - {Name: "fifo", Type: "bool", Description: "FIFO queue", DefaultValue: false}, - {Name: "visibility_timeout", Type: "int", Description: "Visibility timeout in seconds", DefaultValue: 30}, - {Name: "retention_period", Type: "int", Description: "Message retention in seconds", DefaultValue: 345600}, - }, - }, - { - Name: "container_runtime", - Description: "EKS-based container deployment", - Tier: platform.TierApplication, - Fidelity: platform.FidelityFull, - Properties: []platform.PropertySchema{ - {Name: "image", Type: "string", Required: true, Description: "Container image"}, - {Name: "replicas", Type: "int", Description: "Number of replicas", DefaultValue: 1}, - {Name: "memory", Type: "string", Description: "Memory limit"}, - {Name: "cpu", Type: "string", Description: "CPU limit"}, - }, - }, - { - Name: "load_balancer", - Description: "Application Load Balancer", - Tier: platform.TierInfrastructure, - Fidelity: platform.FidelityFull, - Properties: []platform.PropertySchema{ - {Name: "scheme", Type: "string", Description: "internal or internet-facing", DefaultValue: "internet-facing"}, - {Name: "listeners", Type: "list", Description: "Listener configurations"}, - }, - }, - } -} - -func (p *AWSProvider) MapCapability(ctx context.Context, decl platform.CapabilityDeclaration, pctx *platform.PlatformContext) ([]platform.ResourcePlan, error) { - p.mu.RLock() - defer p.mu.RUnlock() - if !p.initialized { - return nil, platform.ErrProviderNotInitialized - } - if !p.capabilityMapper.CanMap(decl.Type) { - return nil, &platform.CapabilityUnsupportedError{Capability: decl.Type, Provider: ProviderName} - } - return p.capabilityMapper.Map(decl, pctx) -} - -func (p *AWSProvider) ResourceDriver(resourceType string) (platform.ResourceDriver, error) { - p.mu.RLock() - defer p.mu.RUnlock() - d, ok := p.drivers[resourceType] - if !ok { - return nil, &platform.ResourceDriverNotFoundError{ResourceType: resourceType, Provider: ProviderName} - } - return d, nil -} - -func (p *AWSProvider) CredentialBroker() platform.CredentialBroker { - p.mu.RLock() - defer p.mu.RUnlock() - return p.credBroker -} - -func (p *AWSProvider) StateStore() platform.StateStore { - p.mu.RLock() - defer p.mu.RUnlock() - return p.stateStore -} - -func (p *AWSProvider) Healthy(ctx context.Context) error { - p.mu.RLock() - defer p.mu.RUnlock() - if !p.initialized { - return platform.ErrProviderNotInitialized - } - // STS GetCallerIdentity would be ideal, but for now just check init state - return nil -} - -func (p *AWSProvider) Close() error { - p.mu.Lock() - defer p.mu.Unlock() - p.initialized = false - return nil -} diff --git a/platform/providers/aws/provider_test.go b/platform/providers/aws/provider_test.go deleted file mode 100644 index 4c16fb90..00000000 --- a/platform/providers/aws/provider_test.go +++ /dev/null @@ -1,420 +0,0 @@ -//go:build aws - -package aws - -import ( - "context" - "testing" - - "github.com/GoCodeAlone/workflow/platform" -) - -func TestNewProvider(t *testing.T) { - p := NewProvider() - if p == nil { - t.Fatal("NewProvider returned nil") - } - if p.Name() != "aws" { - t.Errorf("Name() = %q, want %q", p.Name(), "aws") - } - if p.Version() != "0.1.0" { - t.Errorf("Version() = %q, want %q", p.Version(), "0.1.0") - } -} - -func TestProvider_NotInitialized(t *testing.T) { - p := NewProvider() - ctx := context.Background() - - err := p.Healthy(ctx) - if err != platform.ErrProviderNotInitialized { - t.Errorf("Healthy before init: got %v, want ErrProviderNotInitialized", err) - } - - _, err = p.MapCapability(ctx, platform.CapabilityDeclaration{Type: "network"}, nil) - if err != platform.ErrProviderNotInitialized { - t.Errorf("MapCapability before init: got %v, want ErrProviderNotInitialized", err) - } -} - -func TestProvider_Capabilities(t *testing.T) { - p := NewProvider() - caps := p.Capabilities() - - expected := map[string]bool{ - "kubernetes_cluster": false, - "network": false, - "database": false, - "message_queue": false, - "container_runtime": false, - "load_balancer": false, - } - - for _, c := range caps { - if _, ok := expected[c.Name]; ok { - expected[c.Name] = true - } - } - - for name, found := range expected { - if !found { - t.Errorf("missing capability: %s", name) - } - } -} - -func TestProvider_ResourceDriverNotFound(t *testing.T) { - ap := &AWSProvider{ - initialized: true, - drivers: make(map[string]platform.ResourceDriver), - } - - _, err := ap.ResourceDriver("aws.nonexistent") - if err == nil { - t.Fatal("expected error for non-existent driver") - } - if _, ok := err.(*platform.ResourceDriverNotFoundError); !ok { - t.Errorf("expected ResourceDriverNotFoundError, got %T", err) - } -} - -func TestProvider_Close(t *testing.T) { - ap := &AWSProvider{initialized: true} - if err := ap.Close(); err != nil { - t.Fatalf("Close() error: %v", err) - } - if ap.initialized { - t.Error("expected initialized=false after Close") - } -} - -func TestCapabilityMapper_CanMap(t *testing.T) { - m := NewAWSCapabilityMapper() - - supported := []string{"kubernetes_cluster", "network", "database", "message_queue", "container_runtime", "load_balancer"} - for _, cap := range supported { - if !m.CanMap(cap) { - t.Errorf("CanMap(%q) = false, want true", cap) - } - } - - if m.CanMap("unsupported_type") { - t.Error("CanMap(unsupported_type) = true, want false") - } -} - -func TestCapabilityMapper_MapKubernetesCluster(t *testing.T) { - m := NewAWSCapabilityMapper() - decl := platform.CapabilityDeclaration{ - Name: "my-cluster", - Type: "kubernetes_cluster", - Properties: map[string]any{ - "version": "1.28", - "node_count": 3, - "instance_type": "m5.large", - }, - } - - plans, err := m.Map(decl, nil) - if err != nil { - t.Fatalf("Map() error: %v", err) - } - if len(plans) != 2 { - t.Fatalf("expected 2 resource plans, got %d", len(plans)) - } - - // First should be EKS cluster - if plans[0].ResourceType != "aws.eks_cluster" { - t.Errorf("plan[0].ResourceType = %q, want %q", plans[0].ResourceType, "aws.eks_cluster") - } - if plans[0].Properties["version"] != "1.28" { - t.Errorf("plan[0] version = %v, want 1.28", plans[0].Properties["version"]) - } - - // Second should be node group - if plans[1].ResourceType != "aws.eks_nodegroup" { - t.Errorf("plan[1].ResourceType = %q, want %q", plans[1].ResourceType, "aws.eks_nodegroup") - } - if plans[1].Properties["node_count"] != 3 { - t.Errorf("plan[1] node_count = %v, want 3", plans[1].Properties["node_count"]) - } - if plans[1].Properties["instance_type"] != "m5.large" { - t.Errorf("plan[1] instance_type = %v, want m5.large", plans[1].Properties["instance_type"]) - } - - // Node group depends on cluster - if len(plans[1].DependsOn) == 0 || plans[1].DependsOn[0] != "my-cluster-eks" { - t.Errorf("plan[1].DependsOn = %v, want [my-cluster-eks]", plans[1].DependsOn) - } -} - -func TestCapabilityMapper_MapNetwork(t *testing.T) { - m := NewAWSCapabilityMapper() - decl := platform.CapabilityDeclaration{ - Name: "main-vpc", - Type: "network", - Properties: map[string]any{ - "cidr": "10.0.0.0/16", - }, - } - - plans, err := m.Map(decl, nil) - if err != nil { - t.Fatalf("Map() error: %v", err) - } - if len(plans) != 1 { - t.Fatalf("expected 1 resource plan, got %d", len(plans)) - } - if plans[0].ResourceType != "aws.vpc" { - t.Errorf("ResourceType = %q, want aws.vpc", plans[0].ResourceType) - } - if plans[0].Properties["cidr"] != "10.0.0.0/16" { - t.Errorf("cidr = %v, want 10.0.0.0/16", plans[0].Properties["cidr"]) - } -} - -func TestCapabilityMapper_MapNetworkMissingCIDR(t *testing.T) { - m := NewAWSCapabilityMapper() - decl := platform.CapabilityDeclaration{ - Name: "main-vpc", - Type: "network", - Properties: map[string]any{}, - } - - _, err := m.Map(decl, nil) - if err == nil { - t.Fatal("expected error for missing CIDR") - } -} - -func TestCapabilityMapper_MapDatabase(t *testing.T) { - m := NewAWSCapabilityMapper() - decl := platform.CapabilityDeclaration{ - Name: "app-db", - Type: "database", - Properties: map[string]any{ - "engine": "postgres", - "engine_version": "15.4", - "instance_class": "db.r5.large", - "allocated_storage": 100, - "multi_az": true, - }, - } - - plans, err := m.Map(decl, nil) - if err != nil { - t.Fatalf("Map() error: %v", err) - } - if len(plans) != 1 { - t.Fatalf("expected 1 resource plan, got %d", len(plans)) - } - if plans[0].ResourceType != "aws.rds" { - t.Errorf("ResourceType = %q, want aws.rds", plans[0].ResourceType) - } - if plans[0].Properties["engine"] != "postgres" { - t.Errorf("engine = %v, want postgres", plans[0].Properties["engine"]) - } - if plans[0].Properties["multi_az"] != true { - t.Errorf("multi_az = %v, want true", plans[0].Properties["multi_az"]) - } -} - -func TestCapabilityMapper_MapMessageQueue(t *testing.T) { - m := NewAWSCapabilityMapper() - decl := platform.CapabilityDeclaration{ - Name: "order-queue", - Type: "message_queue", - Properties: map[string]any{ - "fifo": true, - "visibility_timeout": 60, - }, - } - - plans, err := m.Map(decl, nil) - if err != nil { - t.Fatalf("Map() error: %v", err) - } - if len(plans) != 1 { - t.Fatalf("expected 1 plan, got %d", len(plans)) - } - if plans[0].ResourceType != "aws.sqs" { - t.Errorf("ResourceType = %q, want aws.sqs", plans[0].ResourceType) - } - if plans[0].Properties["fifo"] != true { - t.Errorf("fifo = %v, want true", plans[0].Properties["fifo"]) - } -} - -func TestCapabilityMapper_MapContainerRuntime(t *testing.T) { - m := NewAWSCapabilityMapper() - decl := platform.CapabilityDeclaration{ - Name: "api-service", - Type: "container_runtime", - Properties: map[string]any{ - "image": "myapp:latest", - "replicas": 3, - "memory": "512Mi", - }, - } - - plans, err := m.Map(decl, nil) - if err != nil { - t.Fatalf("Map() error: %v", err) - } - if len(plans) != 1 { - t.Fatalf("expected 1 plan, got %d", len(plans)) - } - if plans[0].Properties["image"] != "myapp:latest" { - t.Errorf("image = %v, want myapp:latest", plans[0].Properties["image"]) - } -} - -func TestCapabilityMapper_MapContainerRuntimeMissingImage(t *testing.T) { - m := NewAWSCapabilityMapper() - decl := platform.CapabilityDeclaration{ - Name: "api-service", - Type: "container_runtime", - Properties: map[string]any{}, - } - - _, err := m.Map(decl, nil) - if err == nil { - t.Fatal("expected error for missing image") - } -} - -func TestCapabilityMapper_MapLoadBalancer(t *testing.T) { - m := NewAWSCapabilityMapper() - decl := platform.CapabilityDeclaration{ - Name: "api-lb", - Type: "load_balancer", - Properties: map[string]any{ - "scheme": "internal", - }, - } - - plans, err := m.Map(decl, nil) - if err != nil { - t.Fatalf("Map() error: %v", err) - } - if len(plans) != 1 { - t.Fatalf("expected 1 plan, got %d", len(plans)) - } - if plans[0].ResourceType != "aws.alb" { - t.Errorf("ResourceType = %q, want aws.alb", plans[0].ResourceType) - } - if plans[0].Properties["scheme"] != "internal" { - t.Errorf("scheme = %v, want internal", plans[0].Properties["scheme"]) - } -} - -func TestCapabilityMapper_UnsupportedType(t *testing.T) { - m := NewAWSCapabilityMapper() - decl := platform.CapabilityDeclaration{ - Name: "x", - Type: "magic_service", - } - - _, err := m.Map(decl, nil) - if err == nil { - t.Fatal("expected error for unsupported type") - } - if _, ok := err.(*platform.CapabilityUnsupportedError); !ok { - t.Errorf("expected CapabilityUnsupportedError, got %T", err) - } -} - -func TestCapabilityMapper_ValidateConstraints(t *testing.T) { - m := NewAWSCapabilityMapper() - decl := platform.CapabilityDeclaration{ - Name: "test", - Type: "database", - Properties: map[string]any{ - "allocated_storage": 200, - "replicas": 5, - }, - } - constraints := []platform.Constraint{ - {Field: "allocated_storage", Operator: "<=", Value: 100, Source: "tier1"}, - {Field: "replicas", Operator: "<=", Value: 10, Source: "tier1"}, - } - - violations := m.ValidateConstraints(decl, constraints) - if len(violations) != 1 { - t.Fatalf("expected 1 violation, got %d", len(violations)) - } - if violations[0].Constraint.Field != "allocated_storage" { - t.Errorf("violation field = %q, want allocated_storage", violations[0].Constraint.Field) - } -} - -func TestCheckConstraint(t *testing.T) { - tests := []struct { - op string - actual any - limit any - want bool - }{ - {"<=", 5, 10, true}, - {"<=", 10, 10, true}, - {"<=", 11, 10, false}, - {">=", 10, 5, true}, - {">=", 5, 5, true}, - {">=", 4, 5, false}, - {"==", 5, 5, true}, - {"==", 5, 6, false}, - {"==", "foo", "foo", true}, - {"==", "foo", "bar", false}, - } - for _, tt := range tests { - got := checkConstraint(tt.op, tt.actual, tt.limit) - if got != tt.want { - t.Errorf("checkConstraint(%q, %v, %v) = %v, want %v", tt.op, tt.actual, tt.limit, got, tt.want) - } - } -} - -func TestHelperFunctions(t *testing.T) { - props := map[string]any{ - "count": 3, - "enabled": true, - "name": "test", - } - - if v := intProp(props, "count", 0); v != 3 { - t.Errorf("intProp(count) = %d, want 3", v) - } - if v := intProp(props, "missing", 42); v != 42 { - t.Errorf("intProp(missing) = %d, want 42", v) - } - if v := boolProp(props, "enabled", false); v != true { - t.Errorf("boolProp(enabled) = %v, want true", v) - } - if v := boolProp(props, "missing", true); v != true { - t.Errorf("boolProp(missing) = %v, want true", v) - } -} - -func TestSplitContextPath(t *testing.T) { - tests := []struct { - input string - want []string - }{ - {"acme/prod/api", []string{"acme", "prod", "api"}}, - {"acme/prod", []string{"acme", "prod"}}, - {"single", []string{"single"}}, - {"", nil}, - } - for _, tt := range tests { - got := splitContextPath(tt.input) - if len(got) != len(tt.want) { - t.Errorf("splitContextPath(%q) = %v, want %v", tt.input, got, tt.want) - continue - } - for i := range got { - if got[i] != tt.want[i] { - t.Errorf("splitContextPath(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.want[i]) - } - } - } -} diff --git a/platform/providers/aws/state_store.go b/platform/providers/aws/state_store.go deleted file mode 100644 index 6ef955be..00000000 --- a/platform/providers/aws/state_store.go +++ /dev/null @@ -1,399 +0,0 @@ -//go:build aws - -package aws - -import ( - "context" - "encoding/json" - "fmt" - "sync" - "time" - - "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/service/dynamodb" - dbtypes "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" - "github.com/aws/aws-sdk-go-v2/service/s3" - "github.com/google/uuid" - - "github.com/GoCodeAlone/workflow/platform" -) - -// S3Client defines the S3 operations used by the state store. -type S3Client interface { - PutObject(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error) - GetObject(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.Options)) (*s3.GetObjectOutput, error) - DeleteObject(ctx context.Context, params *s3.DeleteObjectInput, optFns ...func(*s3.Options)) (*s3.DeleteObjectOutput, error) -} - -// DynamoDBClient defines the DynamoDB operations used by the state store. -type DynamoDBClient interface { - PutItem(ctx context.Context, params *dynamodb.PutItemInput, optFns ...func(*dynamodb.Options)) (*dynamodb.PutItemOutput, error) - GetItem(ctx context.Context, params *dynamodb.GetItemInput, optFns ...func(*dynamodb.Options)) (*dynamodb.GetItemOutput, error) - DeleteItem(ctx context.Context, params *dynamodb.DeleteItemInput, optFns ...func(*dynamodb.Options)) (*dynamodb.DeleteItemOutput, error) - Query(ctx context.Context, params *dynamodb.QueryInput, optFns ...func(*dynamodb.Options)) (*dynamodb.QueryOutput, error) -} - -// AWSS3StateStore implements platform.StateStore using S3 for state and DynamoDB for locking. -type AWSS3StateStore struct { - s3Client S3Client - dbClient DynamoDBClient - bucket string - table string - lockTable string -} - -// NewAWSS3StateStore creates a state store backed by S3 and DynamoDB. -func NewAWSS3StateStore(cfg awsSDKConfig, bucket, table string) *AWSS3StateStore { - if bucket == "" { - bucket = "workflow-platform-state" - } - if table == "" { - table = "workflow-platform-state" - } - return &AWSS3StateStore{ - s3Client: s3.NewFromConfig(cfg), - dbClient: dynamodb.NewFromConfig(cfg), - bucket: bucket, - table: table, - lockTable: table + "-locks", - } -} - -func (s *AWSS3StateStore) s3Key(contextPath, resourceName string) string { - return fmt.Sprintf("state/%s/%s.json", contextPath, resourceName) -} - -func (s *AWSS3StateStore) SaveResource(ctx context.Context, contextPath string, output *platform.ResourceOutput) error { - data, err := json.Marshal(output) - if err != nil { - return fmt.Errorf("aws state: marshal resource: %w", err) - } - - key := s.s3Key(contextPath, output.Name) - _, err = s.s3Client.PutObject(ctx, &s3.PutObjectInput{ - Bucket: aws.String(s.bucket), - Key: aws.String(key), - }) - if err != nil { - return fmt.Errorf("aws state: put s3 object: %w", err) - } - - // Also index in DynamoDB for listing - _, err = s.dbClient.PutItem(ctx, &dynamodb.PutItemInput{ - TableName: aws.String(s.table), - Item: map[string]dbtypes.AttributeValue{ - "pk": &dbtypes.AttributeValueMemberS{Value: contextPath}, - "sk": &dbtypes.AttributeValueMemberS{Value: output.Name}, - "resourceType": &dbtypes.AttributeValueMemberS{Value: output.ProviderType}, - "status": &dbtypes.AttributeValueMemberS{Value: string(output.Status)}, - "data": &dbtypes.AttributeValueMemberS{Value: string(data)}, - }, - }) - if err != nil { - return fmt.Errorf("aws state: put dynamodb item: %w", err) - } - - return nil -} - -func (s *AWSS3StateStore) GetResource(ctx context.Context, contextPath, resourceName string) (*platform.ResourceOutput, error) { - result, err := s.dbClient.GetItem(ctx, &dynamodb.GetItemInput{ - TableName: aws.String(s.table), - Key: map[string]dbtypes.AttributeValue{ - "pk": &dbtypes.AttributeValueMemberS{Value: contextPath}, - "sk": &dbtypes.AttributeValueMemberS{Value: resourceName}, - }, - }) - if err != nil { - return nil, fmt.Errorf("aws state: get dynamodb item: %w", err) - } - if result.Item == nil { - return nil, &platform.ResourceNotFoundError{Name: resourceName, Provider: ProviderName} - } - - dataAttr, ok := result.Item["data"].(*dbtypes.AttributeValueMemberS) - if !ok { - return nil, fmt.Errorf("aws state: invalid data attribute type") - } - - var output platform.ResourceOutput - if err := json.Unmarshal([]byte(dataAttr.Value), &output); err != nil { - return nil, fmt.Errorf("aws state: unmarshal resource: %w", err) - } - return &output, nil -} - -func (s *AWSS3StateStore) ListResources(ctx context.Context, contextPath string) ([]*platform.ResourceOutput, error) { - result, err := s.dbClient.Query(ctx, &dynamodb.QueryInput{ - TableName: aws.String(s.table), - KeyConditionExpression: aws.String("pk = :pk"), - ExpressionAttributeValues: map[string]dbtypes.AttributeValue{ - ":pk": &dbtypes.AttributeValueMemberS{Value: contextPath}, - }, - }) - if err != nil { - return nil, fmt.Errorf("aws state: query dynamodb: %w", err) - } - - var resources []*platform.ResourceOutput - for _, item := range result.Items { - dataAttr, ok := item["data"].(*dbtypes.AttributeValueMemberS) - if !ok { - continue - } - var output platform.ResourceOutput - if err := json.Unmarshal([]byte(dataAttr.Value), &output); err != nil { - continue - } - resources = append(resources, &output) - } - return resources, nil -} - -func (s *AWSS3StateStore) DeleteResource(ctx context.Context, contextPath, resourceName string) error { - // Delete from S3 - key := s.s3Key(contextPath, resourceName) - _, err := s.s3Client.DeleteObject(ctx, &s3.DeleteObjectInput{ - Bucket: aws.String(s.bucket), - Key: aws.String(key), - }) - if err != nil { - return fmt.Errorf("aws state: delete s3 object: %w", err) - } - - // Delete from DynamoDB - _, err = s.dbClient.DeleteItem(ctx, &dynamodb.DeleteItemInput{ - TableName: aws.String(s.table), - Key: map[string]dbtypes.AttributeValue{ - "pk": &dbtypes.AttributeValueMemberS{Value: contextPath}, - "sk": &dbtypes.AttributeValueMemberS{Value: resourceName}, - }, - }) - if err != nil { - return fmt.Errorf("aws state: delete dynamodb item: %w", err) - } - return nil -} - -func (s *AWSS3StateStore) SavePlan(ctx context.Context, plan *platform.Plan) error { - data, err := json.Marshal(plan) - if err != nil { - return fmt.Errorf("aws state: marshal plan: %w", err) - } - - _, err = s.dbClient.PutItem(ctx, &dynamodb.PutItemInput{ - TableName: aws.String(s.table), - Item: map[string]dbtypes.AttributeValue{ - "pk": &dbtypes.AttributeValueMemberS{Value: "plan:" + plan.Context}, - "sk": &dbtypes.AttributeValueMemberS{Value: plan.ID}, - "status": &dbtypes.AttributeValueMemberS{Value: plan.Status}, - "createdAt": &dbtypes.AttributeValueMemberS{Value: plan.CreatedAt.Format(time.RFC3339)}, - "data": &dbtypes.AttributeValueMemberS{Value: string(data)}, - }, - }) - if err != nil { - return fmt.Errorf("aws state: put plan: %w", err) - } - return nil -} - -func (s *AWSS3StateStore) GetPlan(ctx context.Context, planID string) (*platform.Plan, error) { - // Scan for plan by ID across contexts (simplified; production would use a GSI) - result, err := s.dbClient.Query(ctx, &dynamodb.QueryInput{ - TableName: aws.String(s.table), - KeyConditionExpression: aws.String("sk = :sk"), - ExpressionAttributeValues: map[string]dbtypes.AttributeValue{ - ":sk": &dbtypes.AttributeValueMemberS{Value: planID}, - }, - IndexName: aws.String("sk-index"), - }) - if err != nil { - return nil, fmt.Errorf("aws state: query plan: %w", err) - } - if len(result.Items) == 0 { - return nil, fmt.Errorf("plan %q not found", planID) - } - - dataAttr, ok := result.Items[0]["data"].(*dbtypes.AttributeValueMemberS) - if !ok { - return nil, fmt.Errorf("aws state: invalid plan data attribute") - } - - var plan platform.Plan - if err := json.Unmarshal([]byte(dataAttr.Value), &plan); err != nil { - return nil, fmt.Errorf("aws state: unmarshal plan: %w", err) - } - return &plan, nil -} - -func (s *AWSS3StateStore) ListPlans(ctx context.Context, contextPath string, limit int) ([]*platform.Plan, error) { - input := &dynamodb.QueryInput{ - TableName: aws.String(s.table), - KeyConditionExpression: aws.String("pk = :pk"), - ExpressionAttributeValues: map[string]dbtypes.AttributeValue{ - ":pk": &dbtypes.AttributeValueMemberS{Value: "plan:" + contextPath}, - }, - ScanIndexForward: aws.Bool(false), // newest first - } - if limit > 0 { - input.Limit = aws.Int32(int32(limit)) - } - - result, err := s.dbClient.Query(ctx, input) - if err != nil { - return nil, fmt.Errorf("aws state: list plans: %w", err) - } - - var plans []*platform.Plan - for _, item := range result.Items { - dataAttr, ok := item["data"].(*dbtypes.AttributeValueMemberS) - if !ok { - continue - } - var plan platform.Plan - if err := json.Unmarshal([]byte(dataAttr.Value), &plan); err != nil { - continue - } - plans = append(plans, &plan) - } - return plans, nil -} - -func (s *AWSS3StateStore) Lock(ctx context.Context, contextPath string, ttl time.Duration) (platform.LockHandle, error) { - lockID := uuid.New().String() - expiresAt := time.Now().Add(ttl) - - _, err := s.dbClient.PutItem(ctx, &dynamodb.PutItemInput{ - TableName: aws.String(s.lockTable), - Item: map[string]dbtypes.AttributeValue{ - "pk": &dbtypes.AttributeValueMemberS{Value: contextPath}, - "lockId": &dbtypes.AttributeValueMemberS{Value: lockID}, - "expiresAt": &dbtypes.AttributeValueMemberS{Value: expiresAt.Format(time.RFC3339)}, - }, - ConditionExpression: aws.String("attribute_not_exists(pk)"), - }) - if err != nil { - return nil, &platform.LockConflictError{ContextPath: contextPath} - } - - return &dynamoDBLock{ - client: s.dbClient, - table: s.lockTable, - contextPath: contextPath, - lockID: lockID, - }, nil -} - -func (s *AWSS3StateStore) Dependencies(ctx context.Context, contextPath, resourceName string) ([]platform.DependencyRef, error) { - result, err := s.dbClient.Query(ctx, &dynamodb.QueryInput{ - TableName: aws.String(s.table), - KeyConditionExpression: aws.String("pk = :pk AND begins_with(sk, :prefix)"), - ExpressionAttributeValues: map[string]dbtypes.AttributeValue{ - ":pk": &dbtypes.AttributeValueMemberS{Value: "dep:" + contextPath}, - ":prefix": &dbtypes.AttributeValueMemberS{Value: resourceName + ":"}, - }, - }) - if err != nil { - return nil, fmt.Errorf("aws state: query dependencies: %w", err) - } - - var deps []platform.DependencyRef - for _, item := range result.Items { - dataAttr, ok := item["data"].(*dbtypes.AttributeValueMemberS) - if !ok { - continue - } - var dep platform.DependencyRef - if err := json.Unmarshal([]byte(dataAttr.Value), &dep); err != nil { - continue - } - deps = append(deps, dep) - } - return deps, nil -} - -func (s *AWSS3StateStore) AddDependency(ctx context.Context, dep platform.DependencyRef) error { - data, err := json.Marshal(dep) - if err != nil { - return fmt.Errorf("aws state: marshal dependency: %w", err) - } - - sk := dep.SourceResource + ":" + dep.TargetContext + "/" + dep.TargetResource - _, err = s.dbClient.PutItem(ctx, &dynamodb.PutItemInput{ - TableName: aws.String(s.table), - Item: map[string]dbtypes.AttributeValue{ - "pk": &dbtypes.AttributeValueMemberS{Value: "dep:" + dep.SourceContext}, - "sk": &dbtypes.AttributeValueMemberS{Value: sk}, - "data": &dbtypes.AttributeValueMemberS{Value: string(data)}, - }, - }) - if err != nil { - return fmt.Errorf("aws state: put dependency: %w", err) - } - return nil -} - -// Verify interface compliance. -var _ platform.StateStore = (*AWSS3StateStore)(nil) - -// dynamoDBLock implements platform.LockHandle using DynamoDB conditional writes. -type dynamoDBLock struct { - client DynamoDBClient - table string - contextPath string - lockID string - mu sync.Mutex - released bool -} - -func (l *dynamoDBLock) Unlock(ctx context.Context) error { - l.mu.Lock() - defer l.mu.Unlock() - if l.released { - return nil - } - - _, err := l.client.DeleteItem(ctx, &dynamodb.DeleteItemInput{ - TableName: aws.String(l.table), - Key: map[string]dbtypes.AttributeValue{ - "pk": &dbtypes.AttributeValueMemberS{Value: l.contextPath}, - }, - ConditionExpression: aws.String("lockId = :lid"), - ExpressionAttributeValues: map[string]dbtypes.AttributeValue{ - ":lid": &dbtypes.AttributeValueMemberS{Value: l.lockID}, - }, - }) - if err != nil { - return fmt.Errorf("aws state: unlock: %w", err) - } - l.released = true - return nil -} - -func (l *dynamoDBLock) Refresh(ctx context.Context, ttl time.Duration) error { - l.mu.Lock() - defer l.mu.Unlock() - if l.released { - return fmt.Errorf("lock already released") - } - - newExpiry := time.Now().Add(ttl) - _, err := l.client.PutItem(ctx, &dynamodb.PutItemInput{ - TableName: aws.String(l.table), - Item: map[string]dbtypes.AttributeValue{ - "pk": &dbtypes.AttributeValueMemberS{Value: l.contextPath}, - "lockId": &dbtypes.AttributeValueMemberS{Value: l.lockID}, - "expiresAt": &dbtypes.AttributeValueMemberS{Value: newExpiry.Format(time.RFC3339)}, - }, - ConditionExpression: aws.String("lockId = :lid"), - ExpressionAttributeValues: map[string]dbtypes.AttributeValue{ - ":lid": &dbtypes.AttributeValueMemberS{Value: l.lockID}, - }, - }) - if err != nil { - return fmt.Errorf("aws state: refresh lock: %w", err) - } - return nil -} - -var _ platform.LockHandle = (*dynamoDBLock)(nil) diff --git a/platform/providers/aws/state_store_test.go b/platform/providers/aws/state_store_test.go deleted file mode 100644 index cfdfbed0..00000000 --- a/platform/providers/aws/state_store_test.go +++ /dev/null @@ -1,546 +0,0 @@ -//go:build aws - -package aws - -import ( - "context" - "encoding/json" - "testing" - "time" - - "github.com/aws/aws-sdk-go-v2/service/dynamodb" - dbtypes "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" - "github.com/aws/aws-sdk-go-v2/service/s3" - - "github.com/GoCodeAlone/workflow/platform" -) - -type mockS3Client struct { - putFunc func(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error) - getFunc func(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.Options)) (*s3.GetObjectOutput, error) - deleteFunc func(ctx context.Context, params *s3.DeleteObjectInput, optFns ...func(*s3.Options)) (*s3.DeleteObjectOutput, error) -} - -func (m *mockS3Client) PutObject(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error) { - if m.putFunc != nil { - return m.putFunc(ctx, params, optFns...) - } - return &s3.PutObjectOutput{}, nil -} - -func (m *mockS3Client) GetObject(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.Options)) (*s3.GetObjectOutput, error) { - if m.getFunc != nil { - return m.getFunc(ctx, params, optFns...) - } - return &s3.GetObjectOutput{}, nil -} - -func (m *mockS3Client) DeleteObject(ctx context.Context, params *s3.DeleteObjectInput, optFns ...func(*s3.Options)) (*s3.DeleteObjectOutput, error) { - if m.deleteFunc != nil { - return m.deleteFunc(ctx, params, optFns...) - } - return &s3.DeleteObjectOutput{}, nil -} - -type mockDynamoDBClient struct { - putItemFunc func(ctx context.Context, params *dynamodb.PutItemInput, optFns ...func(*dynamodb.Options)) (*dynamodb.PutItemOutput, error) - getItemFunc func(ctx context.Context, params *dynamodb.GetItemInput, optFns ...func(*dynamodb.Options)) (*dynamodb.GetItemOutput, error) - deleteItemFunc func(ctx context.Context, params *dynamodb.DeleteItemInput, optFns ...func(*dynamodb.Options)) (*dynamodb.DeleteItemOutput, error) - queryFunc func(ctx context.Context, params *dynamodb.QueryInput, optFns ...func(*dynamodb.Options)) (*dynamodb.QueryOutput, error) -} - -func (m *mockDynamoDBClient) PutItem(ctx context.Context, params *dynamodb.PutItemInput, optFns ...func(*dynamodb.Options)) (*dynamodb.PutItemOutput, error) { - if m.putItemFunc != nil { - return m.putItemFunc(ctx, params, optFns...) - } - return &dynamodb.PutItemOutput{}, nil -} - -func (m *mockDynamoDBClient) GetItem(ctx context.Context, params *dynamodb.GetItemInput, optFns ...func(*dynamodb.Options)) (*dynamodb.GetItemOutput, error) { - if m.getItemFunc != nil { - return m.getItemFunc(ctx, params, optFns...) - } - return &dynamodb.GetItemOutput{}, nil -} - -func (m *mockDynamoDBClient) DeleteItem(ctx context.Context, params *dynamodb.DeleteItemInput, optFns ...func(*dynamodb.Options)) (*dynamodb.DeleteItemOutput, error) { - if m.deleteItemFunc != nil { - return m.deleteItemFunc(ctx, params, optFns...) - } - return &dynamodb.DeleteItemOutput{}, nil -} - -func (m *mockDynamoDBClient) Query(ctx context.Context, params *dynamodb.QueryInput, optFns ...func(*dynamodb.Options)) (*dynamodb.QueryOutput, error) { - if m.queryFunc != nil { - return m.queryFunc(ctx, params, optFns...) - } - return &dynamodb.QueryOutput{}, nil -} - -func newTestStateStore(s3c S3Client, dbc DynamoDBClient) *AWSS3StateStore { - return &AWSS3StateStore{ - s3Client: s3c, - dbClient: dbc, - bucket: "test-bucket", - table: "test-table", - lockTable: "test-table-locks", - } -} - -func TestStateStore_SaveResource(t *testing.T) { - s3Called := false - dbCalled := false - - store := newTestStateStore( - &mockS3Client{ - putFunc: func(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error) { - s3Called = true - if *params.Bucket != "test-bucket" { - t.Errorf("bucket = %q, want test-bucket", *params.Bucket) - } - return &s3.PutObjectOutput{}, nil - }, - }, - &mockDynamoDBClient{ - putItemFunc: func(ctx context.Context, params *dynamodb.PutItemInput, optFns ...func(*dynamodb.Options)) (*dynamodb.PutItemOutput, error) { - dbCalled = true - if *params.TableName != "test-table" { - t.Errorf("table = %q, want test-table", *params.TableName) - } - return &dynamodb.PutItemOutput{}, nil - }, - }, - ) - - ctx := context.Background() - output := &platform.ResourceOutput{ - Name: "test-vpc", - Type: "network", - ProviderType: "aws.vpc", - Status: platform.ResourceStatusActive, - } - - err := store.SaveResource(ctx, "acme/prod", output) - if err != nil { - t.Fatalf("SaveResource() error: %v", err) - } - if !s3Called { - t.Error("S3 PutObject not called") - } - if !dbCalled { - t.Error("DynamoDB PutItem not called") - } -} - -func TestStateStore_GetResource(t *testing.T) { - output := &platform.ResourceOutput{ - Name: "test-vpc", - Type: "network", - ProviderType: "aws.vpc", - Status: platform.ResourceStatusActive, - Properties: map[string]any{"cidr": "10.0.0.0/16"}, - } - data, _ := json.Marshal(output) - - store := newTestStateStore( - &mockS3Client{}, - &mockDynamoDBClient{ - getItemFunc: func(ctx context.Context, params *dynamodb.GetItemInput, optFns ...func(*dynamodb.Options)) (*dynamodb.GetItemOutput, error) { - return &dynamodb.GetItemOutput{ - Item: map[string]dbtypes.AttributeValue{ - "pk": &dbtypes.AttributeValueMemberS{Value: "acme/prod"}, - "sk": &dbtypes.AttributeValueMemberS{Value: "test-vpc"}, - "data": &dbtypes.AttributeValueMemberS{Value: string(data)}, - }, - }, nil - }, - }, - ) - - ctx := context.Background() - result, err := store.GetResource(ctx, "acme/prod", "test-vpc") - if err != nil { - t.Fatalf("GetResource() error: %v", err) - } - if result.Name != "test-vpc" { - t.Errorf("Name = %q, want test-vpc", result.Name) - } - if result.Status != platform.ResourceStatusActive { - t.Errorf("Status = %q, want active", result.Status) - } -} - -func TestStateStore_GetResourceNotFound(t *testing.T) { - store := newTestStateStore( - &mockS3Client{}, - &mockDynamoDBClient{ - getItemFunc: func(ctx context.Context, params *dynamodb.GetItemInput, optFns ...func(*dynamodb.Options)) (*dynamodb.GetItemOutput, error) { - return &dynamodb.GetItemOutput{Item: nil}, nil - }, - }, - ) - - ctx := context.Background() - _, err := store.GetResource(ctx, "acme/prod", "nonexistent") - if err == nil { - t.Fatal("expected error for nonexistent resource") - } - if _, ok := err.(*platform.ResourceNotFoundError); !ok { - t.Errorf("expected ResourceNotFoundError, got %T", err) - } -} - -func TestStateStore_ListResources(t *testing.T) { - outputs := []*platform.ResourceOutput{ - {Name: "vpc-1", Type: "network", Status: platform.ResourceStatusActive}, - {Name: "db-1", Type: "database", Status: platform.ResourceStatusActive}, - } - var items []map[string]dbtypes.AttributeValue - for _, o := range outputs { - data, _ := json.Marshal(o) - items = append(items, map[string]dbtypes.AttributeValue{ - "data": &dbtypes.AttributeValueMemberS{Value: string(data)}, - }) - } - - store := newTestStateStore( - &mockS3Client{}, - &mockDynamoDBClient{ - queryFunc: func(ctx context.Context, params *dynamodb.QueryInput, optFns ...func(*dynamodb.Options)) (*dynamodb.QueryOutput, error) { - return &dynamodb.QueryOutput{Items: items}, nil - }, - }, - ) - - ctx := context.Background() - results, err := store.ListResources(ctx, "acme/prod") - if err != nil { - t.Fatalf("ListResources() error: %v", err) - } - if len(results) != 2 { - t.Fatalf("expected 2 resources, got %d", len(results)) - } - if results[0].Name != "vpc-1" { - t.Errorf("results[0].Name = %q, want vpc-1", results[0].Name) - } -} - -func TestStateStore_DeleteResource(t *testing.T) { - s3Deleted := false - dbDeleted := false - - store := newTestStateStore( - &mockS3Client{ - deleteFunc: func(ctx context.Context, params *s3.DeleteObjectInput, optFns ...func(*s3.Options)) (*s3.DeleteObjectOutput, error) { - s3Deleted = true - return &s3.DeleteObjectOutput{}, nil - }, - }, - &mockDynamoDBClient{ - deleteItemFunc: func(ctx context.Context, params *dynamodb.DeleteItemInput, optFns ...func(*dynamodb.Options)) (*dynamodb.DeleteItemOutput, error) { - dbDeleted = true - return &dynamodb.DeleteItemOutput{}, nil - }, - }, - ) - - ctx := context.Background() - err := store.DeleteResource(ctx, "acme/prod", "test-vpc") - if err != nil { - t.Fatalf("DeleteResource() error: %v", err) - } - if !s3Deleted { - t.Error("S3 DeleteObject not called") - } - if !dbDeleted { - t.Error("DynamoDB DeleteItem not called") - } -} - -func TestStateStore_SavePlan(t *testing.T) { - store := newTestStateStore(&mockS3Client{}, &mockDynamoDBClient{}) - ctx := context.Background() - - plan := &platform.Plan{ - ID: "plan-123", - Context: "acme/prod", - Status: "pending", - CreatedAt: time.Now(), - Actions: []platform.PlanAction{ - {Action: "create", ResourceName: "vpc-1", ResourceType: "aws.vpc"}, - }, - } - - err := store.SavePlan(ctx, plan) - if err != nil { - t.Fatalf("SavePlan() error: %v", err) - } -} - -func TestStateStore_GetPlan(t *testing.T) { - plan := &platform.Plan{ - ID: "plan-123", - Context: "acme/prod", - Status: "pending", - } - data, _ := json.Marshal(plan) - - store := newTestStateStore( - &mockS3Client{}, - &mockDynamoDBClient{ - queryFunc: func(ctx context.Context, params *dynamodb.QueryInput, optFns ...func(*dynamodb.Options)) (*dynamodb.QueryOutput, error) { - return &dynamodb.QueryOutput{ - Items: []map[string]dbtypes.AttributeValue{ - {"data": &dbtypes.AttributeValueMemberS{Value: string(data)}}, - }, - }, nil - }, - }, - ) - - ctx := context.Background() - result, err := store.GetPlan(ctx, "plan-123") - if err != nil { - t.Fatalf("GetPlan() error: %v", err) - } - if result.ID != "plan-123" { - t.Errorf("ID = %q, want plan-123", result.ID) - } -} - -func TestStateStore_GetPlanNotFound(t *testing.T) { - store := newTestStateStore( - &mockS3Client{}, - &mockDynamoDBClient{ - queryFunc: func(ctx context.Context, params *dynamodb.QueryInput, optFns ...func(*dynamodb.Options)) (*dynamodb.QueryOutput, error) { - return &dynamodb.QueryOutput{Items: nil}, nil - }, - }, - ) - - ctx := context.Background() - _, err := store.GetPlan(ctx, "nonexistent") - if err == nil { - t.Fatal("expected error for nonexistent plan") - } -} - -func TestStateStore_ListPlans(t *testing.T) { - plans := []*platform.Plan{ - {ID: "plan-1", Context: "acme/prod", Status: "applied"}, - {ID: "plan-2", Context: "acme/prod", Status: "pending"}, - } - var items []map[string]dbtypes.AttributeValue - for _, p := range plans { - data, _ := json.Marshal(p) - items = append(items, map[string]dbtypes.AttributeValue{ - "data": &dbtypes.AttributeValueMemberS{Value: string(data)}, - }) - } - - store := newTestStateStore( - &mockS3Client{}, - &mockDynamoDBClient{ - queryFunc: func(ctx context.Context, params *dynamodb.QueryInput, optFns ...func(*dynamodb.Options)) (*dynamodb.QueryOutput, error) { - return &dynamodb.QueryOutput{Items: items}, nil - }, - }, - ) - - ctx := context.Background() - results, err := store.ListPlans(ctx, "acme/prod", 10) - if err != nil { - t.Fatalf("ListPlans() error: %v", err) - } - if len(results) != 2 { - t.Fatalf("expected 2 plans, got %d", len(results)) - } -} - -func TestStateStore_Lock(t *testing.T) { - store := newTestStateStore(&mockS3Client{}, &mockDynamoDBClient{}) - ctx := context.Background() - - handle, err := store.Lock(ctx, "acme/prod", 5*time.Minute) - if err != nil { - t.Fatalf("Lock() error: %v", err) - } - if handle == nil { - t.Fatal("Lock() returned nil handle") - } - - // Unlock - err = handle.Unlock(ctx) - if err != nil { - t.Fatalf("Unlock() error: %v", err) - } - - // Double unlock should be idempotent - err = handle.Unlock(ctx) - if err != nil { - t.Fatalf("double Unlock() error: %v", err) - } -} - -func TestStateStore_LockRefresh(t *testing.T) { - store := newTestStateStore(&mockS3Client{}, &mockDynamoDBClient{}) - ctx := context.Background() - - handle, err := store.Lock(ctx, "acme/prod", 5*time.Minute) - if err != nil { - t.Fatalf("Lock() error: %v", err) - } - - err = handle.Refresh(ctx, 10*time.Minute) - if err != nil { - t.Fatalf("Refresh() error: %v", err) - } -} - -func TestStateStore_LockRefreshAfterRelease(t *testing.T) { - store := newTestStateStore(&mockS3Client{}, &mockDynamoDBClient{}) - ctx := context.Background() - - handle, err := store.Lock(ctx, "acme/prod", 5*time.Minute) - if err != nil { - t.Fatalf("Lock() error: %v", err) - } - - _ = handle.Unlock(ctx) - err = handle.Refresh(ctx, 10*time.Minute) - if err == nil { - t.Error("expected error refreshing released lock") - } -} - -func TestStateStore_Dependencies(t *testing.T) { - dep := platform.DependencyRef{ - SourceContext: "acme/prod", - SourceResource: "vpc-1", - TargetContext: "acme/prod", - TargetResource: "eks-1", - Type: "hard", - } - data, _ := json.Marshal(dep) - - store := newTestStateStore( - &mockS3Client{}, - &mockDynamoDBClient{ - queryFunc: func(ctx context.Context, params *dynamodb.QueryInput, optFns ...func(*dynamodb.Options)) (*dynamodb.QueryOutput, error) { - return &dynamodb.QueryOutput{ - Items: []map[string]dbtypes.AttributeValue{ - {"data": &dbtypes.AttributeValueMemberS{Value: string(data)}}, - }, - }, nil - }, - }, - ) - - ctx := context.Background() - deps, err := store.Dependencies(ctx, "acme/prod", "vpc-1") - if err != nil { - t.Fatalf("Dependencies() error: %v", err) - } - if len(deps) != 1 { - t.Fatalf("expected 1 dependency, got %d", len(deps)) - } - if deps[0].TargetResource != "eks-1" { - t.Errorf("TargetResource = %q, want eks-1", deps[0].TargetResource) - } -} - -func TestStateStore_AddDependency(t *testing.T) { - store := newTestStateStore(&mockS3Client{}, &mockDynamoDBClient{}) - ctx := context.Background() - - dep := platform.DependencyRef{ - SourceContext: "acme/prod", - SourceResource: "vpc-1", - TargetContext: "acme/prod", - TargetResource: "eks-1", - Type: "hard", - } - - err := store.AddDependency(ctx, dep) - if err != nil { - t.Fatalf("AddDependency() error: %v", err) - } -} - -func TestStateStore_S3Key(t *testing.T) { - store := newTestStateStore(&mockS3Client{}, &mockDynamoDBClient{}) - key := store.s3Key("acme/prod", "vpc-1") - if key != "state/acme/prod/vpc-1.json" { - t.Errorf("s3Key = %q, want state/acme/prod/vpc-1.json", key) - } -} - -func TestProvider_InitializedMapCapability(t *testing.T) { - ap := &AWSProvider{ - initialized: true, - capabilityMapper: NewAWSCapabilityMapper(), - drivers: make(map[string]platform.ResourceDriver), - } - ctx := context.Background() - - plans, err := ap.MapCapability(ctx, platform.CapabilityDeclaration{ - Name: "test-vpc", - Type: "network", - Properties: map[string]any{ - "cidr": "10.0.0.0/16", - }, - }, nil) - if err != nil { - t.Fatalf("MapCapability() error: %v", err) - } - if len(plans) != 1 { - t.Fatalf("expected 1 plan, got %d", len(plans)) - } -} - -func TestProvider_InitializedMapCapabilityUnsupported(t *testing.T) { - ap := &AWSProvider{ - initialized: true, - capabilityMapper: NewAWSCapabilityMapper(), - drivers: make(map[string]platform.ResourceDriver), - } - ctx := context.Background() - - _, err := ap.MapCapability(ctx, platform.CapabilityDeclaration{ - Name: "test", - Type: "magic_service", - }, nil) - if err == nil { - t.Fatal("expected error for unsupported capability") - } -} - -func TestProvider_CredentialBrokerAndStateStore(t *testing.T) { - broker := &AWSCredentialBroker{} - stateStore := &AWSS3StateStore{} - ap := &AWSProvider{ - initialized: true, - credBroker: broker, - stateStore: stateStore, - drivers: make(map[string]platform.ResourceDriver), - } - - if ap.CredentialBroker() != broker { - t.Error("CredentialBroker() returned wrong instance") - } - if ap.StateStore() != stateStore { - t.Error("StateStore() returned wrong instance") - } -} - -func TestProvider_Healthy(t *testing.T) { - ap := &AWSProvider{ - initialized: true, - drivers: make(map[string]platform.ResourceDriver), - } - ctx := context.Background() - - if err := ap.Healthy(ctx); err != nil { - t.Errorf("Healthy() = %v, want nil", err) - } -}