Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2861319447 | |||
| f9a93d56cc | |||
| ca99241843 | |||
| c99da9a58c | |||
| da60f0e82f | |||
| 3562f6f771 | |||
| cb02c69e0c | |||
| 134f85d2df | |||
| 491ba78768 | |||
| 8145eee8fc | |||
| de91a4bb76 | |||
| 1e4133e11a | |||
| 843cd17b97 | |||
| 0eb578c606 | |||
| 045c7279aa | |||
| 7f1eff622d | |||
| 60f2b669ea | |||
| bab2cf363b |
@@ -375,4 +375,72 @@ stream as a `terraform.apply` event.
|
||||
4. Phase 14 — `l2-microservice` + contract schema extension.
|
||||
5. Phase 15 — consumer repo + `terraform apply` (dev) → live ECS service.
|
||||
6. Phase 16 — capstone e2e: consumer commit → live HTTP 200 → evidence → timeline.
|
||||
7. COMPLETE gate — review → ship `v1.3.0` → audit.
|
||||
7. COMPLETE gate — review → ship `v1.3.0` → audit.
|
||||
|
||||
## v1.8 Architecture Addendum
|
||||
|
||||
> Milestone v1.8 (complete, tag `v1.8.0`). Adds encryption-by-default,
|
||||
> deletion-protection-by-default, uptime monitoring, decommission alias,
|
||||
> engineering standards, and path documentation.
|
||||
|
||||
### New Primitives
|
||||
|
||||
- **`kms-key`** (`aws:kms:key`) — Per-stack customer-managed KMS key with
|
||||
`enable_key_rotation = true`. One key per L2 deployment (no shared keys).
|
||||
Wired into both L2 compositions as a child, with its `kms_key_arn` output
|
||||
connected to all children's `kms_key_arn` input. Adapter emits
|
||||
`aws_kms_key` + `enable_key_rotation`.
|
||||
- **`uptime`** (`aws:ecs:uptime-service`) — Uptime-kuma on ECS Fargate with
|
||||
a feature flag (`feature_flag_enabled`), monitored endpoints (HTTP/DNS/TCP),
|
||||
alert channels (Teams/email/SMS/GitHub issues). Deployed by default after
|
||||
any L2 module with a separate terraform state. When the feature flag is
|
||||
false, the adapter emits no resources.
|
||||
|
||||
### Encryption by Default
|
||||
|
||||
All 12 L1 primitives have `encryption_enabled` NFR (default true). Primitives
|
||||
with at-rest data (s3, rds, ecr, ecs-service, ecs-cluster) have an optional
|
||||
`kms_key_arn` input. The adapter emits encryption blocks (SSE-KMS for S3,
|
||||
storage_encrypted for RDS, encryption_configuration for ECR) referencing the
|
||||
per-stack CMK when provided. Managed KMS fallback with stderr warning for
|
||||
standalone L1 deployments.
|
||||
|
||||
### Deletion Protection by Default
|
||||
|
||||
All 12 L1 primitives have `deletion_protection` NFR (default true). The
|
||||
adapter emits `lifecycle { prevent_destroy = true }` when true. L2 modules
|
||||
expose a `features.deletion_protection` flag (default true) propagated to
|
||||
all children via the resolver. Setting `inputs.deletion_protection: false`
|
||||
in the contract disables it for the whole stack.
|
||||
|
||||
### Decommission Alias
|
||||
|
||||
A `mode: decommission` on the deploy pipeline implements a 2-step destroy:
|
||||
1. Disable deletion protection (resolve with `deletion_protection: false`,
|
||||
terraform plan/apply, HITL SRE gate via GitHub environment).
|
||||
2. Zero counts + destroy (`decommission_transform` zeroes all scalable counts,
|
||||
terraform plan/apply, second HITL SRE gate).
|
||||
|
||||
CMDB validation via DynamoDB `acdl-change-requests` table. The Lambda
|
||||
`validate_change_request` action queries the table and asserts
|
||||
`status == "approved"` + `consumerRepo` match.
|
||||
|
||||
### Adapter Expansion
|
||||
|
||||
TYPE_MAP grew from 16 to 19 entries (+ `aws:kms:key`, `aws:kms:alias`,
|
||||
`aws:ecs:uptime-service`). Specialized emission branches added for KMS key
|
||||
rotation, S3 SSE-KMS configuration, uptime ECS Fargate task, and
|
||||
`prevent_destroy` lifecycle on all resources.
|
||||
|
||||
### Pipeline Stages
|
||||
|
||||
The deploy pipeline grew from 8 to 9 stages (+ `deploy-uptime` after
|
||||
`publish-outputs`). The `deploy-uptime` stage constructs a synthetic uptime
|
||||
contract from the L2 stack outputs, resolves + adapts it to a separate
|
||||
terraform state directory, and publishes the uptime URL via PR comment.
|
||||
|
||||
### Forge-Agnostic API URLs
|
||||
|
||||
The platform Lambda (`contract_ingestor.py`) reads `GITHUB_API_BASE` env
|
||||
for forge-agnostic API URLs. GitHub uses `/search/issues`; Gitea uses
|
||||
`/repos/{owner}/{repo}/issues`. Detection via `/api/v1` in the base URL.
|
||||
+26
-15
@@ -1,6 +1,6 @@
|
||||
---
|
||||
project: acdl
|
||||
milestone: v1.7
|
||||
milestone: v1.8
|
||||
generated_at: 2026-07-22
|
||||
generator: lead-developer
|
||||
verification_toolchain:
|
||||
@@ -16,7 +16,7 @@ verification_toolchain:
|
||||
ci-* agents read PERSONAS.md before running verification commands.
|
||||
---
|
||||
|
||||
# ACDL — Persona Roster (project-level, v1.7)
|
||||
# ACDL — Persona Roster (project-level, v1.8)
|
||||
|
||||
## Active personas
|
||||
|
||||
@@ -56,14 +56,14 @@ verification_toolchain:
|
||||
- **Territory:** `core/hitl_matrix_design.md`, `core/audit_ledger_design.md`, `adapters/terraform/policy/**` (Checkov adapter + custom rules), `adapters/wiz/**` (Wiz adapter), `adapters/kyverno/**` (Kyverno adapter + sample policies), `core/separation_of_duties.py`, `schemas/tagging-standard.json`, `schemas/policy_check_result.schema.json` (engine enum)
|
||||
- **Reason:** Owns the HITL matrix design, separation-of-duties, the audit ledger design, the Checkov→PolicyCheckResult adapter + the custom tagging rule (D-054, D-043 closure), the Wiz adapter (D-052), the Kyverno adapter (D-053), and the tagging standard. Enforces the "Safety is Computed, Not Assumed" + "Audit truth lives outside the repository" vision tenets.
|
||||
|
||||
### lambda-engineer (custom, v1.7)
|
||||
### lambda-engineer (custom, v1.8)
|
||||
- **Domain:** serverless
|
||||
- **Active:** true
|
||||
- **Phase-specific:** true (created for v1.7; removed after milestone COMPLETE)
|
||||
- **Frameworks:** python, aws-lambda, boto3, dynamodb, aws-secretsmanager, github-api
|
||||
- **Constraints:** lambda-is-stateless, dynamodb-is-the-state-store, secrets-from-secrets-manager-never-logged, idempotent-actions, cross-account-iam-via-abac
|
||||
- **Territory:** `core/lambda/**` (contract_ingestor.py + handler), `terraform/platform/main.tf` (Lambda + Function URL + DynamoDB + KMS + Secrets Manager + IAM), `terraform/platform/consumer_invoke_policy.json`
|
||||
- **Reason:** Owns the platform Lambda for contract ingestion (D-051) + error reporting (D-055). The Lambda is stateless; all state is in DynamoDB. The Lambda holds a GitHub token (Secrets Manager) scoped to the platform repo. Cross-account invocation is scoped via ABAC. Created for v1.7; the role is removed from the roster after milestone COMPLETE (the code persists, but the persona is no longer active).
|
||||
- **Phase-specific:** true (reactivated for v1.8; removed after milestone COMPLETE)
|
||||
- **Frameworks:** python, aws-lambda, boto3, dynamodb, aws-secretsmanager, github-api, gitea-api
|
||||
- **Constraints:** lambda-is-stateless, dynamodb-is-the-state-store, secrets-from-secrets-manager-never-logged, idempotent-actions, cross-account-iam-via-abac, forge-agnostic-api-urls
|
||||
- **Territory:** `core/lambda/**` (contract_ingestor.py + handler), `terraform/platform/main.tf` (Lambda + Function URL + DynamoDB + KMS + Secrets Manager + IAM + acdl-change-requests table), `terraform/platform/consumer_invoke_policy.json`, `terraform/platform/variables.tf`
|
||||
- **Reason:** Reactivated for v1.8 Phase 29 (Terraform-rendered invoke policy), Phase 30 (forge-agnostic API URLs in contract_ingestor.py), Phase 34 (validate_change_request Lambda action + acdl-change-requests DynamoDB table). The Lambda is stateless; all state is in DynamoDB. Forge-agnostic API URLs (GitHub + Gitea) via GITHUB_API_BASE env var. Removed from the roster after milestone COMPLETE (the code persists, but the persona is no longer active).
|
||||
|
||||
### frontend-engineer
|
||||
- **Domain:** frontend
|
||||
@@ -96,18 +96,29 @@ verification_toolchain:
|
||||
|
||||
| Phase | Personas active | Notes |
|
||||
|-------|------------------|-------|
|
||||
| 22 rename-and-production-static-assets-stack | lead-developer, platform-engineer (lead: rename + cloudfront/waf primitives + adapter), backend-engineer (contract schema + pipelines), security-engineer (review new primitives) | lambda/frontend idle |
|
||||
| 23 tagging-standards-and-security-adapters | security-engineer (lead: tagging rule + Wiz + Kyverno), platform-engineer (custom rule loading), backend-engineer (schema enum) | lambda/frontend idle |
|
||||
| 24 platform-lambda-and-contract-ingestion | lambda-engineer (lead: Lambda + DynamoDB + Terraform), platform-engineer (Terraform infra review), security-engineer (cross-account IAM review), backend-engineer (onboarding docs) | frontend idle |
|
||||
| 25 deploy-pipeline-dx-outputs-and-error-reporting | backend-engineer (lead: outputs + stage comments + error-report step), lambda-engineer (report_error action), security-engineer (no-secrets-in-logs review) | platform/frontend idle |
|
||||
| 26 platform-pipelines-and-release-automation | backend-engineer (lead: 3 pipelines + release job), lead-developer (verification scripts) | platform/security/lambda/frontend idle |
|
||||
| 27 remove-legacy-consumer-repos-and-module-documentation-examples | lead-developer (lead: examples + docs), platform-engineer (RDS primitive + adapter), backend-engineer (schema-validation) | security/lambda/frontend idle |
|
||||
| 28 adapter-waf-and-resolver-outputs | platform-engineer (lead: WAF HCL fix + adapter output blocks), backend-engineer (resolver outputs processing) | security/lambda/frontend idle |
|
||||
| 29 ssm-kms-and-invoke-policy | backend-engineer (lead: SSM fail-loud), lambda-engineer (Terraform-rendered invoke policy), security-engineer (CMK enforcement review) | platform/frontend idle |
|
||||
| 30 run-platform-isolation-and-api-portability | backend-engineer (lead: run_platform.sh temp dir + deploy.yml static-key), lambda-engineer (forge-agnostic API URLs) | platform/security/frontend idle |
|
||||
| 31 encryption-by-default-and-per-stack-cmk | platform-engineer (lead: kms-key primitive + adapter expansion + L2 wiring), security-engineer (encryption NFR enforcement review) | backend/lambda/frontend idle |
|
||||
| 32 deletion-protection-by-default-and-l2-feature-flag | platform-engineer (lead: prevent_destroy emission + L2 feature flag), backend-engineer (contract schema update) | security/lambda/frontend idle |
|
||||
| 33 uptime-kuma-primitive | platform-engineer (lead: uptime primitive + adapter + separate state), backend-engineer (deploy-uptime pipeline stage + run_platform.sh + PR comment) | security/lambda/frontend idle |
|
||||
| 34 decommission-alias-and-cmdb-validation | backend-engineer (lead: decommission pipeline mode + run_platform.sh + consumer docs), lambda-engineer (validate_change_request + acdl-change-requests table), security-engineer (HITL SRE gates review) | platform/frontend idle |
|
||||
| 35 module-engineering-standards | lead-developer (lead: STANDARDS.md + catalog fix + template), platform-engineer (standards content review), backend-engineer (automated standards test) | security/lambda/frontend idle |
|
||||
| 36 schemas-adapters-pipelines-readmes | lead-developer (lead: 3 READMEs), backend-engineer (pipelines + schemas README content), platform-engineer (adapters README content) | security/lambda/frontend idle |
|
||||
| 37 verify | lead-developer (lead: 4-layer verification), all personas (review their territory) | — |
|
||||
| 38 review-audit-complete | lead-developer (lead: review + audit + milestone completion), all personas (review participation) | — |
|
||||
|
||||
## Domain priority (used by TaskDecomposer)
|
||||
|
||||
`coordination → security → platform → backend → lambda → frontend`
|
||||
|
||||
Rationale: in v1.7, the security commitments (tagging, Wiz, Kyverno, cross-account IAM) and the platform commitments (CloudFront, WAF, RDS, Lambda, DynamoDB) are the binding constraints; backend wires the pipeline + outputs; lambda owns the ingestion + error reporting; frontend surfaces the evidence (unchanged from v1.0).
|
||||
Rationale: in v1.8, the security commitments (encryption by default,
|
||||
KMS rotation, deletion protection, CMDB validation, HITL SRE gates)
|
||||
and the platform commitments (kms-key primitive, uptime primitive,
|
||||
adapter expansion, prevent_destroy emission) are the binding
|
||||
constraints; backend wires the pipeline + decommission mode + API
|
||||
portability; lambda owns the CMDB validation + forge-agnostic APIs;
|
||||
frontend is unchanged from v1.0 (evidence timeline).
|
||||
|
||||
## Conflict resolutions (lead-developer arbitration)
|
||||
|
||||
|
||||
+217
-30
@@ -1,41 +1,228 @@
|
||||
---
|
||||
phase: 16
|
||||
name: v1.2-capstone-e2e
|
||||
milestone: v1.2
|
||||
requirements: [REQ-35]
|
||||
type: feat/verify
|
||||
branch: phase/16-v1.2-capstone-e2e
|
||||
phase: 28-38
|
||||
name: v1.8-p1-remediation-uptime-standards-encryption-decommission-docs
|
||||
milestone: v1.8
|
||||
requirements: [REQ-76, REQ-77, REQ-78, REQ-79, REQ-80, REQ-81, REQ-82, REQ-83, REQ-84, REQ-85, REQ-86, REQ-87, REQ-88, REQ-89, REQ-90, REQ-91, REQ-92, REQ-93, REQ-94, REQ-95, REQ-96, REQ-97, REQ-98, REQ-99]
|
||||
type: fix/feat/docs
|
||||
---
|
||||
|
||||
# Phase 16 — v1.2-capstone-e2e (v1.2) PLAN
|
||||
# ACDL v1.8 — Phase Plans
|
||||
|
||||
## Goal
|
||||
> Milestone: v1.8. Planner: ci-planner. Status: active.
|
||||
> 11 phases (28–38), 24 requirements (REQ-76..99).
|
||||
|
||||
End-to-end verification of the v1.2 platform: consumer commit → pipeline →
|
||||
`terraform apply` (dev) → live ECS service → evidence event → timeline. The
|
||||
`terraform apply` is blocked by the IAM P0 (Phase 15); Phase 16 ships the
|
||||
capstone verification of everything *up to* the apply + documents the
|
||||
operator's unblock step. After the operator pushes the policy, the apply +
|
||||
HTTP 200 check complete REQ-33/35.
|
||||
## Phase 28 — adapter-waf-and-resolver-outputs
|
||||
|
||||
## Tasks
|
||||
**Requirements:** REQ-76 (WAF nested rules + default_action), REQ-77 (L2 outputs resolution)
|
||||
**Personas:** platform-engineer (lead), backend-engineer
|
||||
**Type:** fix
|
||||
|
||||
### T-16.1 — Capstone verify script
|
||||
`scripts/verify_phase16.sh` runs the full v1.2 platform flow (consumer
|
||||
content → contract → IR → adapter → terraform validate + plan) + verifies
|
||||
the v1.1 regression + the NFR improvements (run_platform.sh, IAM policy
|
||||
expansion, P1-1 redaction) + the documentation (README accuracy). The
|
||||
`terraform apply` + HTTP 200 check are documented as the operator's
|
||||
post-unblock step.
|
||||
### Tasks (Wave 1 — sequential):
|
||||
|
||||
### T-16.2 — Capstone evidence event
|
||||
Write a `MILESTONE_CAPSTONE_VERIFIED` evidence event to the outbox (the
|
||||
v1.2 platform is verified up to the IAM-blocked apply).
|
||||
1. **platform-engineer:** Fix WAF `rules` emission in `adapters/terraform/adapter.py:346-348` — replace `rules = {_value_expr(...)}` with nested `rules { ... }` block emission per rule. Read `inputs.get("default_action")` (line 334) and emit `allow {}` / `block {}` based on input (default `allow` if absent).
|
||||
2. **backend-engineer:** Implement L2 composition `outputs[]` processing in `core/contract_resolver.py` `resolve_l2()` — after building `resources` (line 232), parse `composition.get("outputs", [])`, resolve source via `child_outputs`, build `stack_instance["outputs"]` dict.
|
||||
3. **platform-engineer:** Extend `adapter.py` `adapt()` to emit `output "<outName>" { value = <ref> }` blocks from `stack_instance.get("outputs", {})`.
|
||||
4. **platform-engineer:** Add tests to `tests/test_adapter.py` (WAF custom rules, default_action block, output blocks) + `tests/test_contract_resolver.py` (L2 outputs for static-assets).
|
||||
|
||||
### T-16.3 — Phase 16 README update
|
||||
Update README to reflect the v1.2 status (Phase 15 partial, Phase 16
|
||||
capstone, the IAM unblock step).
|
||||
### Must-haves:
|
||||
- WAF with custom rules emits `rules {` blocks, not `rules = [`
|
||||
- WAF `default_action: block` emits `block {}`
|
||||
- L2 resolution yields `stack.outputs.*`
|
||||
- Adapter emits `output` blocks
|
||||
- `pytest` passes (275 → ~285)
|
||||
|
||||
## Ship
|
||||
---
|
||||
|
||||
Merge → `main` (--no-ff). Tag `v1.2.6`.
|
||||
## Phase 29 — ssm-kms-and-invoke-policy
|
||||
|
||||
**Requirements:** REQ-78 (SSM fail-loud), REQ-79 (Terraform-rendered invoke policy)
|
||||
**Personas:** backend-engineer (lead), lambda-engineer, security-engineer
|
||||
**Type:** fix
|
||||
|
||||
### Tasks (Wave 1):
|
||||
|
||||
1. **backend-engineer:** Change `core/output_publisher.py:54-55` `_kms_key_id()` — raise `RuntimeError` when `ACDL_KMS_KEY_ID` unset; add `ACDL_ALLOW_DEFAULT_KMS=1` escape hatch.
|
||||
2. **lambda-engineer:** Convert `terraform/platform/consumer_invoke_policy.json` to Terraform-rendered template — add `terraform/platform/variables.tf` with `data "aws_caller_identity" "current" {}` + `templatestring` or `replace()` for account ID injection.
|
||||
3. **backend-engineer:** Add `tests/test_output_publisher.py` cases: `test_kms_unset_raises`, `test_kms_unset_allow_default_kms`. Add `tests/test_invoke_policy.py` asserting rendered policy has no `000000000000`.
|
||||
|
||||
### Must-haves:
|
||||
- SSM raises RuntimeError without CMK; escape hatch works
|
||||
- Rendered invoke policy has live account ID
|
||||
- `pytest` passes (~290)
|
||||
|
||||
---
|
||||
|
||||
## Phase 30 — run-platform-isolation-and-api-portability
|
||||
|
||||
**Requirements:** REQ-80 (temp dir), REQ-81 (forge-agnostic URLs), REQ-82 (static-key override)
|
||||
**Personas:** backend-engineer (lead), lambda-engineer
|
||||
**Type:** fix
|
||||
|
||||
### Tasks (Wave 1 — parallel):
|
||||
|
||||
1. **backend-engineer:** Change `scripts/run_platform.sh:122` adapter target from `terraform/spike` to `$WORK/tf`. Update all downstream references. Remove committed `terraform/spike/*.tf`. Update `tests/test_pipeline.py`. Update deploy.yml artifact upload path.
|
||||
2. **lambda-engineer:** Add `_github_api_base()` + `_forge_type()` to `core/lambda/contract_ingestor.py`. Replace hardcoded URLs at lines 109, 149, 163. Add `tests/test_contract_ingestor.py` Gitea base URL test.
|
||||
3. **backend-engineer:** Restructure `configure-aws-credentials` step in both deploy workflows (byte-identical) — single conditional step with `access-key`/`secret-key` inputs when static key present. Update `tests/test_pipeline_contract.py`.
|
||||
|
||||
### Must-haves:
|
||||
- `run_platform.sh --check-only` writes to temp dir
|
||||
- `contract_ingestor.py` uses `GITHUB_API_BASE`
|
||||
- Deploy workflow static-key override wired
|
||||
- Both deploy workflows byte-identical
|
||||
- `pytest` passes (~295)
|
||||
|
||||
---
|
||||
|
||||
## Phase 31 — encryption-by-default-and-per-stack-cmk
|
||||
|
||||
**Requirements:** REQ-83 (kms-key primitive), REQ-84 (encryption NFRs on all primitives), REQ-85 (L2 CMK wiring)
|
||||
**Personas:** platform-engineer (lead), security-engineer
|
||||
**Type:** feat
|
||||
|
||||
### Tasks (Wave 1 — kms-key primitive + adapter):
|
||||
1. **platform-engineer:** Create `modules/l1/kms-key/` with `interface.json` (type `aws:kms:key`, inputs: description/region/deletion_window_days, outputs: kms_key_arn/kms_key_id, NFRs: enable_rotation default true, deletion_protection default true) + `instance.json` + `README.md` + `examples/`.
|
||||
2. **platform-engineer:** Add `aws:kms:key → aws_kms_key` + `aws:kms:alias → aws_kms_alias` to adapter TYPE_MAP. Emit `enable_key_rotation = true` + alias.
|
||||
|
||||
### Tasks (Wave 2 — encryption NFRs on all primitives, after Wave 1):
|
||||
3. **platform-engineer:** Add `encryption_enabled` NFR (default true) + `kms_key_arn` input to every L1 `interface.json` (s3, rds, ecr, ecs-service, ecs-cluster, alb, cloudfront, waf, vpc, iam-role). Update adapter to emit encryption blocks referencing the CMK when `kms_key_arn` is provided; managed KMS fallback with stderr warning when not.
|
||||
4. **platform-engineer:** Update both L2 `composition.json` files — add `kms-key` child + wires connecting `kms_key_arn` output to each child's `kms_key_arn` input.
|
||||
5. **platform-engineer:** Add `tests/test_encryption.py` — assert every primitive has encryption NFRs; assert adapter emits encryption blocks; assert L2 wires CMK; assert `enable_key_rotation = true`.
|
||||
|
||||
### Must-haves:
|
||||
- kms-key primitive exists + registered
|
||||
- All primitives have `encryption_enabled` NFR + `kms_key_arn` input
|
||||
- L2 modules wire per-stack CMK
|
||||
- Adapter emits encryption blocks
|
||||
- `pytest` passes (~310)
|
||||
|
||||
---
|
||||
|
||||
## Phase 32 — deletion-protection-by-default-and-l2-feature-flag
|
||||
|
||||
**Requirements:** REQ-86 (deletion_protection NFR on all primitives), REQ-87 (L2 feature flag)
|
||||
**Personas:** platform-engineer (lead), backend-engineer
|
||||
**Type:** feat
|
||||
|
||||
### Tasks (Wave 1):
|
||||
1. **platform-engineer:** Add `deletion_protection` NFR (boolean, default true) to every L1 `interface.json` (rds already has it). Update adapter to emit `lifecycle { prevent_destroy = true }` when NFR is true; omit when false. RDS gets BOTH `deletion_protection` arg + `prevent_destroy` lifecycle.
|
||||
2. **backend-engineer:** Add `features` object support to `schemas/stack.schema.json` (optional `features.deletion_protection`). Update `core/contract_resolver.py` `resolve_l2()` to propagate `features.deletion_protection` to each child's `deletion_protection` NFR. Add `inputs.deletion_protection` to `schemas/contract.schema.json` (optional boolean).
|
||||
3. **platform-engineer:** Add `tests/test_deletion_protection.py` — assert every primitive has the NFR; assert adapter emits `prevent_destroy`; assert L2 feature flag propagation.
|
||||
|
||||
### Must-haves:
|
||||
- Every primitive has `deletion_protection` NFR (default true)
|
||||
- Adapter emits `prevent_destroy = true` when true
|
||||
- L2 feature flag propagates
|
||||
- `pytest` passes (~320)
|
||||
|
||||
---
|
||||
|
||||
## Phase 33 — uptime-kuma-primitive
|
||||
|
||||
**Requirements:** REQ-88 (uptime primitive), REQ-89 (deployed by default after L2), REQ-90 (feature flag), REQ-91 (pipeline stage)
|
||||
**Personas:** platform-engineer (lead), backend-engineer
|
||||
**Type:** feat
|
||||
|
||||
### Tasks (Wave 1 — primitive + adapter):
|
||||
1. **platform-engineer:** Create `modules/l1/uptime/` with `interface.json` (type `aws:ecs:uptime-service`, inputs: container_image/region/monitored_endpoints/static_checks/alert_channels/feature_flag_enabled/cpu/memory, outputs: uptime_url/service_arn/task_definition_arn, NFRs: deletion_protection/encryption_enabled) + `instance.json` + `README.md` + `examples/simple.yaml` + `examples/complex.yaml`.
|
||||
2. **platform-engineer:** Add `aws:ecs:uptime-service` to adapter TYPE_MAP. Emit ECS Fargate task + service + ALB + listener + EFS volume + CloudWatch log group. When `feature_flag_enabled=false`, emit NO resources. Register in `registry.json`.
|
||||
|
||||
### Tasks (Wave 2 — pipeline + script, after Wave 1):
|
||||
3. **backend-engineer:** Add `deploy-uptime` stage to `pipelines/deploy.yaml` (after `publish-outputs`). Update both deploy workflows (byte-identical) with the stage. Add `scripts/seed_uptime_monitors.py` for post-deploy monitor seeding via uptime-kuma API.
|
||||
4. **backend-engineer:** Update `scripts/run_platform.sh` — add `deploy-uptime` step: read L2 stack outputs, construct synthetic uptime contract with `monitored_endpoints` from outputs, run second terraform apply with separate state (`$WORK/uptime-tf/`), publish uptime URL via PR comment. Skip when `inputs.uptime_enabled=false`.
|
||||
5. **backend-engineer:** Add `tests/test_uptime_primitive.py` — validate interface; assert adapter emits ECS service when flag=true; assert no resources when flag=false; assert `deploy-uptime` stage in pipeline contract.
|
||||
|
||||
### Must-haves:
|
||||
- Uptime primitive exists with feature flag + alert channels
|
||||
- Deployed by default after L2 (separate state)
|
||||
- Uptime URL published via PR comment
|
||||
- Feature flag disables deployment
|
||||
- `deploy-uptime` stage in deploy contract + byte-identical workflows
|
||||
- `pytest` passes (~335)
|
||||
|
||||
---
|
||||
|
||||
## Phase 34 — decommission-alias-and-cmdb-validation
|
||||
|
||||
**Requirements:** REQ-92 (decommission mode), REQ-93 (CMDB validation), REQ-94 (consumer docs)
|
||||
**Personas:** backend-engineer (lead), lambda-engineer, security-engineer
|
||||
**Type:** feat
|
||||
|
||||
### Tasks (Wave 1 — CMDB + Lambda, parallel):
|
||||
1. **lambda-engineer:** Add `acdl-change-requests` DynamoDB table to `terraform/platform/main.tf` (PK changeRequestId, SK submittedAt, SSE via CMK, PITR). Add `validate_change_request` action to `core/lambda/contract_ingestor.py` — query table, assert status=approved + consumerRepo match, return CR details or 403.
|
||||
2. **backend-engineer:** Add `decommission_transform(stack_instance)` to `core/contract_resolver.py` — zero all counts (desired_count=0 for ECS, etc.).
|
||||
|
||||
### Tasks (Wave 2 — pipeline + docs, after Wave 1):
|
||||
3. **backend-engineer:** Add `mode: decommission` to deploy workflow inputs. Add decommission stages to `pipelines/deploy.yaml`: validate-change-request → disable-deletion-protection (HITL SRE gate via GitHub environment) → zero-counts (second HITL SRE gate) → confirm-decommission. Update both deploy workflows (byte-identical).
|
||||
4. **backend-engineer:** Update `docs/CONSUMER_GUIDE.md` with "Decommissioning a stack" section (request CR, trigger decommission, HITL gates, what happens).
|
||||
5. **backend-engineer:** Add `tests/test_decommission.py` — assert `decommission_transform` zeroes counts; assert `validate_change_request` rejects invalid CRs; assert decommission stages in pipeline contract.
|
||||
|
||||
### Must-haves:
|
||||
- Decommission mode on existing deploy pipeline
|
||||
- 2-step with HITL SRE gates
|
||||
- CR ID validated against DynamoDB CMDB
|
||||
- Documented in consumer guide
|
||||
- `pytest` passes (~345)
|
||||
|
||||
---
|
||||
|
||||
## Phase 35 — module-engineering-standards
|
||||
|
||||
**Requirements:** REQ-95 (STANDARDS.md), REQ-96 (catalog fix + template update)
|
||||
**Personas:** lead-developer (lead), platform-engineer, backend-engineer
|
||||
**Type:** docs + refactor
|
||||
|
||||
### Tasks (Wave 1):
|
||||
1. **lead-developer:** Author `modules/STANDARDS.md` — comprehensive L1+L2 authoring + review standards (scanned from current modules per RESEARCH TARGET 6): required files, interface schema, input/output/NFR conventions, encryption + deletion protection as mandatory NFRs, naming, multi-resource pattern, adapter extension pattern, code review checklist.
|
||||
2. **lead-developer:** Fix `modules/README.md` catalog index — add rds + uptime + kms-key to Primitives table. Update `modules/README-TEMPLATE.md` — add `## NFRs` section.
|
||||
3. **backend-engineer:** Add `tests/test_module_standards.py` — automated enforcement: every L1 has `deletion_protection` + `encryption_enabled` NFRs; every L2 has valid structure; every module registered; every module has README + examples.
|
||||
|
||||
### Must-haves:
|
||||
- `modules/STANDARDS.md` exists with L1+L2 standards
|
||||
- Catalog index includes all primitives
|
||||
- Template has NFRs section
|
||||
- Automated standards test passes
|
||||
- `pytest` passes (~355)
|
||||
|
||||
---
|
||||
|
||||
## Phase 36 — schemas-adapters-pipelines-readmes
|
||||
|
||||
**Requirements:** REQ-97 (schemas README), REQ-98 (pipelines README), REQ-99 (adapters README)
|
||||
**Personas:** lead-developer (lead), backend-engineer, platform-engineer
|
||||
**Type:** docs
|
||||
|
||||
### Tasks (Wave 1 — parallel):
|
||||
1. **lead-developer:** Author `schemas/README.md` — how to write schemas, wire into platform, test in CI, dependencies, existing catalog.
|
||||
2. **lead-developer:** Author `pipelines/README.md` — how to write pipeline contracts, wire into workflows, test, dependencies, catalog.
|
||||
3. **lead-developer:** Author `adapters/README.md` — how to write adapters, wire into platform, test, dependencies, catalog.
|
||||
4. **backend-engineer:** Add `tests/test_docs_coverage.py` — assert all 3 READMEs exist + contain required sections.
|
||||
|
||||
### Must-haves:
|
||||
- All 3 READMEs exist with comprehensive documentation
|
||||
- CI validates presence
|
||||
- `pytest` passes (~358)
|
||||
|
||||
---
|
||||
|
||||
## Phase 37 — verify
|
||||
|
||||
**Personas:** lead-developer (lead), all personas
|
||||
**Type:** verify
|
||||
|
||||
### Tasks:
|
||||
1. Structural: all new files present.
|
||||
2. Behavioral: `pytest` passes (~358); `run_ci.sh` exits 0; `run_platform.sh --check-only` exits 0.
|
||||
3. Security: no secrets; CMK enforced; no placeholder account IDs; deletion protection on by default.
|
||||
4. Quality: each P1 fix + each new feature has a dedicated test.
|
||||
|
||||
---
|
||||
|
||||
## Phase 38 — review-audit-complete
|
||||
|
||||
**Personas:** lead-developer (lead), all personas
|
||||
**Type:** review + audit + complete
|
||||
|
||||
### Tasks:
|
||||
1. Review: 0 new P0/P1; all P1-3..P1-9 + S1 resolved; 3 new requirements delivered.
|
||||
2. Audit: reconstruction, file discipline, branch hygiene, commit discipline.
|
||||
3. Complete: update REQUIREMENTS.md (REQ-76..99), ROADMAP.md, PROJECT.md. Tag `v1.8.0`. Update floating `v1.8` + `v1` tags. Bump `uses:` to `@v1.8`.
|
||||
@@ -236,6 +236,66 @@ to a production-grade platform. 12 user-directed scope axes (2026-07-22):
|
||||
Milestone COMPLETE gate: review → ship `v1.7.0` (feature milestone, next
|
||||
minor per ship.md — v1.6 shipped `v1.6.0`) → audit.
|
||||
|
||||
## Objective for Milestone v1.8 (active)
|
||||
|
||||
P1 remediation + uptime monitoring + engineering standards + encryption
|
||||
and deletion-protection by default + decommission alias + documentation.
|
||||
The v1.7 milestone shipped production platform + contract ingestion but
|
||||
left 8 P1 issues flagged for post-hoc review. v1.8 clears all of them
|
||||
AND delivers three user-directed feature/NFR tracks (2026-07-22):
|
||||
|
||||
**Track 1 — P1 Remediation (Phases 28–30):**
|
||||
Clear all 8 pending P1 issues from v1.5/v1.6/v1.7 verify reviews:
|
||||
- P1-3: SSM uses AWS-managed key silently → fail loud without CMK config
|
||||
- P1-4: WAF custom rules emit invalid HCL (attribute vs block syntax)
|
||||
- P1-5: WAF default_action input silently ignored
|
||||
- P1-6: consumer_invoke_policy.json has placeholder account ID
|
||||
- P1-7: L2 composition outputs section not implemented in resolver
|
||||
- P1-8: terraform/spike/*.tf overwritten by run_platform.sh (state
|
||||
contamination)
|
||||
- P1-9: GitHub API URLs hardcoded in contract_ingestor.py (Gitea fails
|
||||
silently)
|
||||
- S1: Deploy workflow static-key override not wired (passes ACDL_AWS_*
|
||||
env vars to configure-aws-credentials which reads AWS_*/its own inputs)
|
||||
|
||||
**Track 2 — Encryption + Deletion Protection by Default (Phases 31–32):**
|
||||
All primitives encrypted by default (CMK priority + SSE, managed KMS
|
||||
fallback). Per-stack CMK (one key per L2 deployment, 90-day rotation,
|
||||
no shared keys). Deletion protection on by default for every primitive.
|
||||
L2 modules expose a feature flag to turn off deletion protection. A
|
||||
decommission alias uses a 2-step pipeline (disable deletion protection
|
||||
→ zero counts → destroy) with HITL SRE gates and CMDB-validated change
|
||||
request ID.
|
||||
|
||||
**Track 3 — Uptime + Standards + Docs (Phases 33–36):**
|
||||
A new uptime-kuma primitive (ECS Fargate) deployed by default after any
|
||||
L2 module deploy (separate terraform state), with a feature flag to
|
||||
disable. Monitored endpoints passed from L2 outputs. Alert channels
|
||||
(Teams/email/SMS/GitHub issues). The uptime URL published to consumers
|
||||
via PR comments. Engineering standards for L1 + L2 module authoring
|
||||
(scanned from current modules, stored in modules/). READMEs for
|
||||
schemas/, adapters/, pipelines/ paths documenting how to write, wire,
|
||||
and test each.
|
||||
|
||||
## Milestone v1.8 Phases
|
||||
|
||||
| Phase | Name | Goal |
|
||||
|-------|------|------|
|
||||
| 28 | adapter-waf-and-resolver-outputs | Fix WAF HCL emission (nested rules blocks + default_action input) + implement L2 composition outputs in resolver + adapter output blocks. P1-4, P1-5, P1-7. |
|
||||
| 29 | ssm-kms-and-invoke-policy | SSM publisher fails loud without CMK (escape hatch for local) + Terraform-rendered consumer_invoke_policy (no placeholder account ID). P1-3, P1-6. |
|
||||
| 30 | run-platform-isolation-and-api-portability | Adapter output to per-run temp dir (remove committed spike .tf) + forge-agnostic API URLs + deploy.yml static-key override wired. P1-8, P1-9, S1. |
|
||||
| 31 | encryption-by-default-and-per-stack-cmk | KMS-key primitive + per-stack CMK wired in L2 modules + encryption NFRs on all primitives + managed KMS fallback. |
|
||||
| 32 | deletion-protection-by-default-and-l2-feature-flag | Deletion protection NFR on all primitives (default true) + L2 feature flag + contract schema update. |
|
||||
| 33 | uptime-kuma-primitive | Uptime L1 primitive (ECS Fargate, feature flag, monitored endpoints, alert channels) + deploy-uptime pipeline stage (separate state) + URL published via PR comment. |
|
||||
| 34 | decommission-alias-and-cmdb-validation | Decommission mode on deploy pipeline (2-step: disable deletion protection → zero counts, HITL SRE gates) + DynamoDB CMDB validation + consumer guide docs. |
|
||||
| 35 | module-engineering-standards | modules/STANDARDS.md (L1+L2 authoring + review standards scanned from current modules) + catalog index fix + template update + automated standards test. |
|
||||
| 36 | schemas-adapters-pipelines-readmes | schemas/README.md + pipelines/README.md + adapters/README.md (how to write, wire, test, dependencies). |
|
||||
| 37 | verify | 4-layer verification of all v1.8 phases. |
|
||||
| 38 | review-audit-complete | Multi-persona review + audit + milestone completion (tag v1.8.0). |
|
||||
|
||||
Milestone COMPLETE gate: review → ship `v1.8.0` (feature milestone, next
|
||||
minor per run.md — v1.7 shipped `v1.7.0`) → audit.
|
||||
|
||||
## Requirements
|
||||
|
||||
### v1.0 (Prior milestone — the demo)
|
||||
@@ -361,6 +421,26 @@ decisions:
|
||||
| D-046 | `act_runner` → `gitea-runner` rename: Phase 07 updates docs to use the current name `gitea-runner` (renamed 2026-04 in gitea/runner#850). | RESEARCH TARGET 1 + R-4: naming drift between v1.0 docs and the current runner. | Docs reflect the current binary name |
|
||||
| D-047 | v1.2 carries forward the D-039 per-run-rotated-key waiver. Real OIDC federation remains deferred to v1.3+, blocked on go-gitea/gitea#36988 (re-checked 2026-07-21: still **open**, last updated 2026-05-27, not merged). | §12.5 forbids long-lived creds; the Gitea Actions OIDC provider is still not merged. The waiver continues to satisfy §12.5's *intent* (no *persistently* long-lived key) for v1.2: `scripts/rotate_spike_key.sh` rotates the key, and Phase 12 tightens the IAM scoping + rotation hygiene. | v1.2 achieves `terraform apply` against AWS without a persistently long-lived key; real OIDC is a v1.3+ deliverable. |
|
||||
|
||||
## Key Decisions (v1.8)
|
||||
|
||||
Resolved at the CLARIFY stage (full autonomy — all within locked
|
||||
constraints or user-directed scope). New v1.8 decisions:
|
||||
|
||||
| ID | Decision | Rationale | Outcome |
|
||||
|----|----------|-----------|---------|
|
||||
| D-061 | Fold all 3 new requirements into v1.8 alongside P1 fixes. | User chose single milestone. v1.8 becomes a feature milestone (ship tag v1.8.0, minor bump). | 11 phases (28–38) in one milestone. |
|
||||
| D-062 | P1-3: SSM publisher fails loud (`RuntimeError`) when `ACDL_KMS_KEY_ID` unset. `ACDL_ALLOW_DEFAULT_KMS=1` escape hatch for local testing. | User chose fail loud. Silent AWS-managed-key use is the security gap; callers must set the env. | Phase 29 implements fail-loud + escape hatch. |
|
||||
| D-063 | P1-6: `consumer_invoke_policy.json` rendered via Terraform `data.aws_caller_identity` + `templatestring` at apply time. | User chose Terraform-rendered. No committed account ID; no stale placeholder. | Phase 29 converts JSON to TF-rendered template. |
|
||||
| D-064 | P1-8: Remove committed `terraform/spike/*.tf` entirely; adapter emits to per-run temp dir. | User chose remove. Cleaner; no stale fixtures. | Phase 30 removes files + changes run_platform.sh target. |
|
||||
| D-065 | S1: Single conditional `configure-aws-credentials` step (OIDC when no static key, access-key/secret-key inputs when static key present). | User chose single conditional step. Cleaner workflow YAML. | Phase 30 restructures the deploy workflow step. |
|
||||
| D-066 | Uptime deployment target: ECS Fargate (reuse existing ecs-cluster + ecs-service + alb primitives). | User chose ECS Fargate. Most consistent with current platform; ALB gives a stable URL. | Phase 33 authors uptime primitive on ECS Fargate. |
|
||||
| D-067 | Uptime trigger: new `deploy-uptime` pipeline stage after `publish-outputs`. Separate terraform state (S3 key prefix `uptime/`). | User chose pipeline stage. Most integrated with existing flow. | Phase 33 adds the pipeline stage + separate state. |
|
||||
| D-068 | CMDB = DynamoDB `acdl-change-requests` table (PK changeRequestId, SK submittedAt). | User chose DynamoDB. Consistent with existing platform Lambda + DynamoDB pattern. | Phase 34 adds the table + `validate_change_request` Lambda action. |
|
||||
| D-069 | Encryption key granularity: per-stack CMK (one key per L2 deployment, tagged with acdl:owner + acdl:environment). | User chose per-stack. No shared keys across stacks; 90-day rotation at creation. | Phase 31 authors kms-key primitive + L2 wiring. |
|
||||
| D-070 | Decommission: new mode on the existing deploy pipeline (`mode: decommission`). 2-step with HITL SRE gates. | User chose existing pipeline with different behavior. Plan/apply to disable deletion protection (HITL SRE gate) → plan/apply with counts=0 (second HITL SRE gate). Documented in consumer guide. | Phase 34 adds decommission mode + HITL gates. |
|
||||
| D-071 | `uses:`/`ref:` bump from `@v1.6` to `@v1.8` at milestone COMPLETE. | Consumer-facing version tracks the last released MAJOR.MINOR. | Phase 38 bumps references + creates floating `v1.8` + `v1` tags. |
|
||||
| D-072 | Managed KMS fallback for standalone L1 deployments (no L2 CMK): adapter uses `alias/aws/<service>` with a stderr warning. `kms_key_arn` input is optional everywhere; `encryption_enabled` NFR defaults to true. | Requirement says "prioritize CMKs, fallback to managed KMS". Standalone L1s don't have a per-stack CMK. | Phase 31 implements fallback + warning. |
|
||||
|
||||
## Key Decisions (v1.7)
|
||||
|
||||
Resolved at the CLARIFY stage (full autonomy — all within locked constraints
|
||||
|
||||
@@ -182,6 +182,46 @@
|
||||
- **REQ-74:** The legacy consumer-repos directory is deleted entirely (a v1.2 artifact removed in v1.7; references in `.ciagent/` historical narrative are rewritten per D-048). A recursive grep for the legacy directory name (excluding `.git/`) returns 0 hits.
|
||||
- **REQ-75:** A new RDS primitive (`modules/l1/rds/`) with an `engine` input (enum: postgres, mysql, etc.) demonstrates multi-engine variation (D-059). Every module (primitives + patterns) has a `modules/<name>/examples/` directory with `simple.yaml` + `complex.yaml` (+ variation files) validated against `schemas/contract.schema.json` in the platform-test pipeline schema-validation stage (D-058). Each module's `README.md` `## Examples` section references + excerpts the validated files. `docs/modules/index.md` + `docs/consumer-guide.md` + `docs/contracts/index.md` are updated with the new module names + examples.
|
||||
|
||||
## v1.8 (Complete — P1 remediation + uptime + engineering standards + encryption/deletion-protection by default + decommission + docs)
|
||||
|
||||
### Category: P1 Fixes
|
||||
- **REQ-76:** WAF adapter emits custom `rules` as nested HCL blocks (not attribute syntax) and honors `default_action` input (allow/block) — P1-4, P1-5 closed.
|
||||
- **REQ-77:** L2 composition `outputs[]` array is resolved by `contract_resolver.py` into `stack.outputs`; the adapter emits corresponding `output` blocks — P1-7 closed.
|
||||
- **REQ-78:** SSM publisher fails loud when `ACDL_KMS_KEY_ID` is unset (no silent AWS-managed-key fallback); `ACDL_ALLOW_DEFAULT_KMS=1` escape hatch for local testing — P1-3 closed.
|
||||
- **REQ-79:** `consumer_invoke_policy` is rendered via Terraform with the caller's live account ID (no `000000000000` placeholder) — P1-6 closed.
|
||||
- **REQ-80:** `run_platform.sh` emits adapter output to a per-run temp dir, not committed `terraform/spike/*.tf`; the committed files are removed — P1-8 closed.
|
||||
- **REQ-81:** `contract_ingestor.py` reads `GITHUB_API_BASE` env for forge-agnostic API URLs (GitHub + Gitea) — P1-9 closed.
|
||||
- **REQ-82:** Deploy workflow static-key override is wired to `configure-aws-credentials` inputs (`access-key`/`secret-key`), not inert env vars — S1 closed.
|
||||
|
||||
### Category: Encryption by Default
|
||||
- **REQ-83:** A per-stack CMK primitive (`kms-key`) exists with 90-day rotation enabled at creation; one key per L2 deployment; no shared keys across stacks.
|
||||
- **REQ-84:** All primitives have encryption by default (`encryption_enabled` NFR, default true) + optional `kms_key_arn` input. CMK is prioritized; managed KMS is the fallback when no CMK is provided.
|
||||
- **REQ-85:** L2 modules wire a per-stack CMK child + connect its `kms_key_arn` output to each child's `kms_key_arn` input.
|
||||
|
||||
### Category: Deletion Protection by Default
|
||||
- **REQ-86:** `deletion_protection` NFR (boolean, default true) on every L1 primitive; the adapter emits `prevent_destroy` lifecycle meta-arg when true.
|
||||
- **REQ-87:** L2 modules expose a `features.deletion_protection` flag (default true); consumers can disable via contract `inputs.deletion_protection: false`.
|
||||
|
||||
### Category: Uptime Monitoring
|
||||
- **REQ-88:** An uptime-kuma L1 primitive exists (ECS Fargate) with: `feature_flag_enabled` (boolean, default true), `monitored_endpoints` (array of HTTP/DNS/TCP checks), `static_checks` (pre-defined health checks), `alert_channels` (Teams webhook, email, SMS, GitHub issues).
|
||||
- **REQ-89:** Uptime is deployed by default after any L2 module deploy (separate terraform state, separate terraform run); L2 module outputs (endpoints) are passed to the uptime deployment as `monitored_endpoints`. The uptime URL is published to the consumer via PR comment.
|
||||
- **REQ-90:** The `feature_flag_enabled` input (set from consumer contract `inputs.uptime_enabled`, default true) disables the uptime deployment entirely (no resources emitted).
|
||||
- **REQ-91:** A `deploy-uptime` pipeline stage is declared in `pipelines/deploy.yaml` + both deploy workflow YAMLs (byte-identical).
|
||||
|
||||
### Category: Decommission + CMDB
|
||||
- **REQ-92:** A decommission mode on the deploy pipeline (`mode: decommission`) implements a 2-step pipeline: (1) plan/apply to disable deletion protection with an HITL SRE gate, (2) plan/apply with all counts set to 0 with a second HITL SRE gate. Uses the existing deploy pipeline with different behavior.
|
||||
- **REQ-93:** A DynamoDB `acdl-change-requests` table serves as the CMDB. The decommission alias accepts a `changeRequestId` input validated via a `validate_change_request` Lambda action (CR status must be `approved`).
|
||||
- **REQ-94:** The decommission flow is documented in `docs/CONSUMER_GUIDE.md` (how to request a CR, trigger decommission, HITL gates, what happens).
|
||||
|
||||
### Category: Engineering Standards
|
||||
- **REQ-95:** `modules/STANDARDS.md` exists with comprehensive L1 + L2 authoring + code review standards (scanned from current modules): required files, interface schema, input/output/NFR conventions, encryption + deletion protection as mandatory NFRs, naming, adapter extension pattern, code review checklist.
|
||||
- **REQ-96:** `modules/README.md` catalog index includes all primitives (rds + uptime + kms-key added); `modules/README-TEMPLATE.md` updated with `## NFRs` section.
|
||||
|
||||
### Category: Path Documentation
|
||||
- **REQ-97:** `schemas/README.md` documents how to write a schema, wire it into the platform, test it in CI, where to write tests, dependencies, and the existing schema catalog.
|
||||
- **REQ-98:** `pipelines/README.md` documents how to write a pipeline contract, wire it into workflows, test it, dependencies, and the existing pipeline catalog.
|
||||
- **REQ-99:** `adapters/README.md` documents how to write an adapter, wire it into the platform, test it, dependencies, and the existing adapter catalog.
|
||||
|
||||
## Out of Scope (v1.2)
|
||||
|
||||
| REQ | Original criterion | Clarified criterion (effective) | Decision |
|
||||
@@ -315,4 +355,33 @@
|
||||
| REQ-72 | 26 | complete (v1.7.0) |
|
||||
| REQ-73 | 26 | complete (v1.7.0) |
|
||||
| REQ-74 | 27 | complete (v1.7.0) |
|
||||
| REQ-75 | 27 | complete (v1.7.0) |
|
||||
| REQ-75 | 27 | complete (v1.7.0) |
|
||||
|
||||
### v1.8 (complete — P1 remediation + uptime + standards + encryption/deletion-protection by default + decommission + docs, tag `v1.8.0`)
|
||||
|
||||
| Requirement | Phase | Status |
|
||||
|-------------|-------|--------|
|
||||
| REQ-76 | 28 | complete (v1.8.0) |
|
||||
| REQ-77 | 28 | complete (v1.8.0) |
|
||||
| REQ-78 | 29 | complete (v1.8.0) |
|
||||
| REQ-79 | 29 | complete (v1.8.0) |
|
||||
| REQ-80 | 30 | complete (v1.8.0) |
|
||||
| REQ-81 | 30 | complete (v1.8.0) |
|
||||
| REQ-82 | 30 | complete (v1.8.0) |
|
||||
| REQ-83 | 31 | complete (v1.8.0) |
|
||||
| REQ-84 | 31 | complete (v1.8.0) |
|
||||
| REQ-85 | 31 | complete (v1.8.0) |
|
||||
| REQ-86 | 32 | complete (v1.8.0) |
|
||||
| REQ-87 | 32 | complete (v1.8.0) |
|
||||
| REQ-88 | 33 | complete (v1.8.0) |
|
||||
| REQ-89 | 33 | complete (v1.8.0) |
|
||||
| REQ-90 | 33 | complete (v1.8.0) |
|
||||
| REQ-91 | 33 | complete (v1.8.0) |
|
||||
| REQ-92 | 34 | complete (v1.8.0) |
|
||||
| REQ-93 | 34 | complete (v1.8.0) |
|
||||
| REQ-94 | 34 | complete (v1.8.0) |
|
||||
| REQ-95 | 35 | complete (v1.8.0) |
|
||||
| REQ-96 | 35 | complete (v1.8.0) |
|
||||
| REQ-97 | 36 | complete (v1.8.0) |
|
||||
| REQ-98 | 36 | complete (v1.8.0) |
|
||||
| REQ-99 | 36 | complete (v1.8.0) |
|
||||
@@ -1462,4 +1462,263 @@ thin-composition references all six (depth ≤ 5).
|
||||
|
||||
---
|
||||
|
||||
## v1.8 Research Addendum
|
||||
|
||||
> Phase: research (pre-Phase 28). Milestone: v1.8. Status: active.
|
||||
> Researcher: ci-researcher. Autonomy: full.
|
||||
> Sources: web (uptime-kuma GitHub, Terraform docs, AWS KMS docs, AWS
|
||||
> ECS Fargate docs, GitHub Actions docs) + ACDL codebase analysis.
|
||||
|
||||
### RESEARCH TARGET 1 — uptime-kuma deployment on ECS Fargate
|
||||
|
||||
**Verdict: ECS Fargate is the most cost-effective cloud-native option
|
||||
for deploying uptime-kuma, consistent with the existing platform
|
||||
primitives (ecs-cluster, ecs-service, alb).**
|
||||
|
||||
Findings (verified 2026-07-22):
|
||||
|
||||
1. **uptime-kuma Docker image:** `louislam/uptime-kuma:1` (v1) or
|
||||
`louislam/uptime-kuma:2` (v2, latest stable 2.4.0 as of 2026-05-31).
|
||||
The container listens on port 3001. Data is stored in `/app/data`
|
||||
(SQLite + uploaded files). NFS is not supported for the data volume;
|
||||
EFS is the AWS-native equivalent and works with ECS Fargate.
|
||||
|
||||
2. **Monitoring capabilities:** HTTP(s), TCP, HTTP(s) Keyword, HTTP(s)
|
||||
JSON Query, WebSocket, Ping, DNS Record, Push, Steam Game Server,
|
||||
Docker Containers. 20-second intervals minimum. Certificate info.
|
||||
Proxy support. 2FA support.
|
||||
|
||||
3. **Notification services (90+):** Telegram, Discord, Gotify, Slack,
|
||||
Pushover, Email (SMTP), Microsoft Teams (via webhook), and many
|
||||
others. For the ACDL primitive, we expose: Teams webhook, email
|
||||
(SMTP), SMS (via SNS or an external gateway), and GitHub issues
|
||||
(via the GitHub API).
|
||||
|
||||
4. **ECS Fargate deployment shape:**
|
||||
- Task definition: 1 container (`louislam/uptime-kuma:1`), port 3001,
|
||||
CPU 256 (.25 vCPU), Memory 512 (.5 GB) — minimal cost (~$5/mo
|
||||
at us-east-1 on-demand pricing for .25 vCPU + .5 GB running 24/7).
|
||||
- EFS volume for `/app/data` (persistent storage across task
|
||||
restarts; Fargate + EFS is the standard pattern for stateful
|
||||
containers).
|
||||
- ALB + listener for a stable public URL (the uptime dashboard).
|
||||
- CloudWatch log group (encrypted with the per-stack CMK).
|
||||
|
||||
5. **Endpoint seeding:** uptime-kuma has a REST API (socket.io-based).
|
||||
The platform can seed monitors by either:
|
||||
- (a) Passing `UPTIMA_KUMA__monitors` env var (JSON array) consumed
|
||||
by a startup script — but uptime-kuma does not natively read env
|
||||
for monitor config.
|
||||
- (b) A post-deploy seeding script that calls the uptime-kuma API
|
||||
(`POST /api/monitor`) to create monitors from the `monitored_endpoints`
|
||||
input. This is the cleaner approach — the platform runs a Python
|
||||
script after the ECS service is up that creates monitors via the
|
||||
API.
|
||||
- **Recommendation:** (b) — a `scripts/seed_uptime_monitors.py` that
|
||||
reads the `monitored_endpoints` from the stack outputs + calls the
|
||||
uptime-kuma API. This is testable offline (mocked API) and
|
||||
decouples container startup from monitor configuration.
|
||||
|
||||
6. **Separate terraform state:** The uptime stack uses a separate S3
|
||||
key prefix (`uptime/{consumerRepo}/{contractId}/`) so it is
|
||||
independent of the consumer stack's state. The uptime stack has its
|
||||
own VPC + ALB + ECS cluster (or shares the consumer's — design
|
||||
decision: **separate** to avoid state coupling, per the requirement
|
||||
"separate terraform run, with a separate state").
|
||||
|
||||
7. **Feature flag:** The `feature_flag_enabled` input (set from the
|
||||
consumer contract `inputs.uptime_enabled`, default true) controls
|
||||
whether the `deploy-uptime` pipeline stage runs. When false, the
|
||||
stage is skipped entirely (no resources emitted, no API calls).
|
||||
|
||||
### RESEARCH TARGET 2 — Terraform prevent_destroy lifecycle
|
||||
|
||||
**Verdict: `lifecycle { prevent_destroy = true }` is the correct
|
||||
Terraform mechanism for deletion protection. It prevents `terraform
|
||||
destroy` from destroying the resource without first setting
|
||||
`prevent_destroy = false`.**
|
||||
|
||||
Findings (verified 2026-07-22):
|
||||
|
||||
1. **`prevent_destroy`** is a meta-argument inside a `lifecycle {}`
|
||||
block within a resource. When set to `true`, any Terraform plan
|
||||
that would destroy the resource will fail with an error. To destroy,
|
||||
the user must first set `prevent_destroy = false` and apply, then
|
||||
destroy.
|
||||
|
||||
2. **This is exactly the 2-step decommission pattern the user
|
||||
requested:** Step 1: set `deletion_protection = false` (which the
|
||||
adapter translates to `prevent_destroy = false`) + apply. Step 2:
|
||||
set all counts to 0 + apply (which destroys the resources now that
|
||||
prevent_destroy is false).
|
||||
|
||||
3. **Adapter emission:** The adapter should emit `lifecycle { prevent_destroy = true }`
|
||||
inside each resource block when the `deletion_protection` NFR is
|
||||
true. When false, omit the `lifecycle` block (or set
|
||||
`prevent_destroy = false`). This is a per-resource meta-argument,
|
||||
not a provider-level setting.
|
||||
|
||||
4. **RDS special case:** RDS already has a `deletion_protection`
|
||||
argument on `aws_db_instance` (not a lifecycle meta-arg). The
|
||||
adapter should emit BOTH: the `deletion_protection` argument (for
|
||||
the RDS API-level protection) AND `lifecycle { prevent_destroy = true }`
|
||||
(for the Terraform-level protection). This is defense-in-depth.
|
||||
|
||||
### RESEARCH TARGET 3 — AWS KMS key rotation
|
||||
|
||||
**Verdict: `enable_key_rotation = true` on `aws_kms_key` enables
|
||||
automatic annual rotation (AWS rotates the key material annually).
|
||||
For 90-day rotation, a custom key rotation policy is needed (AWS
|
||||
managed rotation is annual only; 90-day requires a manual rotation
|
||||
schedule or a custom multi-region key + rotation Lambda).**
|
||||
|
||||
Findings (verified 2026-07-22):
|
||||
|
||||
1. **`aws_kms_key`** with `enable_key_rotation = true` enables AWS's
|
||||
automatic key material rotation. AWS rotates the backing key material
|
||||
annually (365 days). This is the simplest option and is the AWS
|
||||
best practice for most use cases.
|
||||
|
||||
2. **90-day rotation:** AWS does not support custom rotation periods
|
||||
for managed keys. To achieve 90-day rotation:
|
||||
- (a) Use `aws_kms_key` with `enable_key_rotation = true` (annual
|
||||
AWS-managed rotation) + a CloudWatch Events rule that triggers a
|
||||
Lambda every 90 days to create a new key + update the alias. This
|
||||
is complex and overkill for v1.8.
|
||||
- (b) Accept annual AWS-managed rotation as the default and document
|
||||
that 90-day rotation requires a custom rotation pipeline (roadmap
|
||||
item). The `enable_key_rotation = true` is the v1.8 implementation;
|
||||
the 90-day requirement is a roadmap enhancement.
|
||||
|
||||
**Recommendation:** (b) — `enable_key_rotation = true` (AWS-managed
|
||||
annual rotation) as the v1.8 implementation. The 90-day requirement
|
||||
is documented as a roadmap item (custom rotation Lambda). The NFR
|
||||
`enable_rotation` (default true) controls the `enable_key_rotation`
|
||||
argument. This is pragmatic; annual rotation is AWS's best practice
|
||||
and 90-day is a future enhancement.
|
||||
|
||||
3. **Per-stack CMK pattern:** Each L2 deployment creates its own
|
||||
`aws_kms_key` + `aws_kms_alias` (alias/acdl-<stack-name>-<env>).
|
||||
The key is tagged with `acdl:owner` + `acdl:environment`. All
|
||||
primitives in the stack reference this key via `kms_key_arn`.
|
||||
No shared keys across stacks.
|
||||
|
||||
4. **Managed KMS fallback:** When a primitive is deployed standalone
|
||||
(L1 without an L2 CMK), the adapter uses `alias/aws/<service>`
|
||||
(e.g. `alias/aws/s3`, `alias/aws/rds`). This is the AWS-managed
|
||||
key for that service. The adapter emits a stderr warning when
|
||||
falling back. The `kms_key_arn` input is optional; the
|
||||
`encryption_enabled` NFR defaults to true.
|
||||
|
||||
### RESEARCH TARGET 4 — Forge-agnostic API URLs (P1-9)
|
||||
|
||||
**Verdict: GitHub and Gitea have compatible issue APIs but different
|
||||
search endpoints. A `GITHUB_API_BASE` env var + `_forge_type()`
|
||||
helper branches the search URL.**
|
||||
|
||||
Findings (verified 2026-07-22):
|
||||
|
||||
1. **GitHub API:** `https://api.github.com/search/issues?q=...` for
|
||||
search; `https://api.github.com/repos/{owner}/{repo}/issues` for
|
||||
create; `https://api.github.com/repos/{owner}/{repo}/issues/{n}/comments`
|
||||
for comments.
|
||||
|
||||
2. **Gitea API:** `https://git.cloudinit.dev/api/v1/repos/{owner}/{repo}/issues?...`
|
||||
for search (no `/search/issues` endpoint — issues are listed via
|
||||
the repo issues endpoint with query params); `https://git.cloudinit.dev/api/v1/repos/{owner}/{repo}/issues`
|
||||
for create; `https://git.cloudinit.dev/api/v1/repos/{owner}/{repo}/issues/{n}/comments`
|
||||
for comments.
|
||||
|
||||
3. **Detection:** If `GITHUB_API_BASE` contains `/api/v1`, it's Gitea;
|
||||
otherwise it's GitHub. The `_forge_type()` helper returns `"gitea"`
|
||||
or `"github"` based on this. The search URL is branched accordingly;
|
||||
the create + comment URLs are the same pattern (`{base}/repos/{owner}/{repo}/issues`).
|
||||
|
||||
4. **Auth:** Both use `Authorization: token <token>` header. GitHub
|
||||
also accepts `Authorization: Bearer <token>`; Gitea uses `token`.
|
||||
The existing `token` header works for both.
|
||||
|
||||
### RESEARCH TARGET 5 — DynamoDB as CMDB for change requests
|
||||
|
||||
**Verdict: A DynamoDB `acdl-change-requests` table is consistent with
|
||||
the existing platform Lambda + DynamoDB pattern (D-051). The
|
||||
`validate_change_request` Lambda action queries the table + asserts
|
||||
status=approved.**
|
||||
|
||||
Findings (verified 2026-07-22):
|
||||
|
||||
1. **Table schema:** PK `changeRequestId` (string), SK `submittedAt`
|
||||
(string). Attributes: `consumerRepo`, `contractId`, `status`
|
||||
(enum: `requested|approved|rejected|executed`), `requestedBy`,
|
||||
`approvedBy`, `submittedAt`, `executedAt`.
|
||||
|
||||
2. **Validation flow:** The decommission pipeline's
|
||||
`validate-change-request` stage invokes the Lambda with
|
||||
`action: validate_change_request`, `changeRequestId: <id>`,
|
||||
`consumerRepo: <repo>`. The Lambda queries the table; if the item
|
||||
exists + `status == "approved"` + `consumerRepo` matches, returns
|
||||
200 with the CR details. Otherwise returns 403.
|
||||
|
||||
3. **Terraform:** Add the table to `terraform/platform/main.tf` with
|
||||
SSE via the platform CMK + point-in-time recovery (matching the
|
||||
`acdl-contracts` table pattern from D-051).
|
||||
|
||||
### RESEARCH TARGET 6 — Module engineering standards (scan of current modules)
|
||||
|
||||
**Verdict: The current modules follow a consistent pattern that can
|
||||
be codified into standards. Key patterns identified:**
|
||||
|
||||
1. **L1 required files:** `interface.json`, `instance.json`,
|
||||
`README.md`, `examples/simple.yaml`, `examples/complex.yaml`.
|
||||
Multi-resource L1s add `resources[]` + `intra_refs[]` to
|
||||
`interface.json`.
|
||||
|
||||
2. **L2 required files:** `composition.json`, `README.md`,
|
||||
`examples/simple.yaml`, `examples/complex.yaml`. No `instance.json`.
|
||||
|
||||
3. **Interface shape:** `name`, `version`, `kind` ("l1"|"l2"),
|
||||
`type` (L1 only, `aws:<service>:<kind>`), `description`,
|
||||
`inputs` (object keyed by name), `outputs` (object keyed by name),
|
||||
`nfrs` (object keyed by name). Multi-resource L1s add `resources[]`
|
||||
(array of `{type, description, inputs[], outputs[]}`) +
|
||||
`intra_refs[]` (array of `{from, to}`).
|
||||
|
||||
4. **Input shape:** `{type, description, required, [default], [enum]}`.
|
||||
Output shape: `{type, description}`. NFR shape:
|
||||
`{type, description, default}`.
|
||||
|
||||
5. **NFR conventions (v1.8 additions):** Every L1 MUST have
|
||||
`deletion_protection` (boolean, default true) + `encryption_enabled`
|
||||
(boolean, default true) NFRs. L2 modules MUST expose
|
||||
`features.deletion_protection` (default true) +
|
||||
`features.uptime_enabled` (default true).
|
||||
|
||||
6. **Registry:** Every module MUST be registered in
|
||||
`modules/registry.json` at its semver. Entry:
|
||||
`{"interface": "<path>", "published_at": "<iso>", "deprecated": false}`.
|
||||
|
||||
7. **Adapter extension:** 3-table pattern (TYPE_MAP + INPUT_MAP +
|
||||
OUTPUT_MAP) + specialized `_emit_resource` branches for complex
|
||||
resources (nested blocks like `origin {}`, `rules {}`,
|
||||
`default_cache_behavior {}`).
|
||||
|
||||
8. **README structure:** `# <name> — <description>`, `## Resources`,
|
||||
`## Inputs`, `## Outputs`, `## NFRs`, `## Usage`, `## Compliance
|
||||
extension points`, `## Examples`, `## Versioning`.
|
||||
|
||||
9. **Catalog index gap:** `modules/README.md` Primitives table is
|
||||
missing `rds` (flagged during scan). Must be fixed in Phase 35.
|
||||
|
||||
### Decisions surfaced (v1.8)
|
||||
|
||||
| ID | Decision | Rationale | Confidence | Alternatives |
|
||||
|----|----------|-----------|------------|--------------|
|
||||
| **D-073** | uptime-kuma v1 (`louislam/uptime-kuma:1`) as the default container image. | v1 is stable + widely deployed. v2 (2.4.0) is newer but has breaking changes. v1 is the safer default; consumers can override via `container_image` input. | 0.85 | v2 (breaking changes risk); pin to a specific v1 tag (maintenance burden). |
|
||||
| **D-074** | Monitor seeding via post-deploy API script (`scripts/seed_uptime_monitors.py`), not env vars. | uptime-kuma does not natively read env for monitor config. A post-deploy script calling the API is cleaner + testable offline. | 0.90 | Env var config (not supported by uptime-kuma); manual config (defeats automation). |
|
||||
| **D-075** | KMS rotation = `enable_key_rotation = true` (AWS-managed annual). 90-day rotation is a roadmap item (custom rotation Lambda). | AWS does not support custom rotation periods for managed keys. Annual is the AWS best practice. 90-day requires a custom Lambda + CloudWatch Events rule — overkill for v1.8. | 0.80 | Custom rotation Lambda (complex, overkill); no rotation (violates requirement). |
|
||||
| **D-076** | uptime stack = separate VPC + ALB + ECS cluster (not shared with consumer stack). | Requirement says "separate terraform run, with a separate state". Sharing the consumer's VPC/ALB would couple the states. Separate infra is cleaner + isolates the uptime stack's lifecycle. | 0.85 | Share consumer's VPC/ALB (state coupling); use App Runner (new service type). |
|
||||
| **D-077** | EFS volume for uptime-kuma `/app/data` (persistent storage across task restarts). | Fargate + EFS is the standard pattern for stateful containers. NFS is not supported by uptime-kuma, but EFS is NFS-compatible + works with Fargate. | 0.90 | S3-backed (uptime-kuma doesn't support S3); no persistent storage (data lost on restart). |
|
||||
|
||||
---
|
||||
|
||||
*End of RESEARCH.md. Path: `/root/acdl/.ciagent/RESEARCH.md`.*
|
||||
+137
-1
@@ -10,6 +10,7 @@
|
||||
- **v1.5 (complete, tag `v1.5.0`):** consumer happy path + zero-trust docs + reusable deploy workflow. README rewritten so the consumer model is unambiguous (consumer owns only contract + app code; the rest is the platform source). Platform-flow + consumer-guide diagrams converted to mermaid. Legacy surface + implementation nomenclature removed from docs. Credentials section rewritten for zero-trust OIDC + ABAC (with a static-key override + daily rotation). A generic `docs/CONSUMER_GUIDE.md` (all L2 modules, versioned `uses:`, consumer-scoped prereqs, run-time platform fetch) replaces the module-specific guide. A byte-identical reusable `deploy.yml` workflow (Gitea + GitHub) implements `pipelines/deploy.yaml` and is invoked by consumer repos via a versioned tag.
|
||||
- **v1.6 (complete, tag `v1.6.0`):** consumer-facing docs restructure + terminology normalization + environments concept. `docs/` becomes a Jekyll-style GitHub Pages site. `acdl_platform/` is renamed to `core/`. L2 → "modules", L1 → "primitives", "composition" → "pattern" in prose. README restructured: Features + Roadmap (no internal status), repository roles restated (consumer = app code + contracts + CI definitions), mermaid fixed (visible text, security-checks + infrastructure-apply stages, no tool names), credentials section minus go-gitea/waivers. Platform-managed environments concept + a minimal onboarding scaffold. `.ciagent/` + `.gitea/` references removed from all consumer-facing docs.
|
||||
- **v1.7 (complete, tag `v1.7.0`):** production platform + contract ingestion + pipeline maturation. Rename `static-assets` → `static-assets` (D-048 — incl. `.ciagent/` historical narrative). Author `cloudfront` + `waf` primitives; augment `static-assets` to a production-ready S3 + CloudFront (OAC) + WAF stack (D-049). Tagging-standard enforcement (Checkov custom rule, D-043 closure, D-054). Wiz adapter stub (D-052) + Kyverno K8s-native adapter (D-053). Platform Lambda + DynamoDB `acdl-contracts` table for contract ingestion (D-051) + cross-account IAM. Deploy outputs via SSM SecureString + GitHub PR comment (D-050). Uniform error reporting via the Lambda `report_error` action → GitHub issue on the platform repo (D-055); Gitea excluded. Stage comments after every successful pipeline stage. Three platform pipelines (platform-test unit+integration, primitives-plan, patterns-plan). Release job with semver + MAJOR.MINOR/MAJOR tag maintenance (D-057). `uses:`/`ref:` bumped to `@v1.6`; floating `v1.6` + `v1` tags created in Phase 22. Remove the legacy consumer-repos directory (a v1.2 artifact, removed in v1.7); add validated per-module examples (`modules/<name>/examples/`, D-058) including a new RDS primitive demonstrating multi-engine variation (D-059).
|
||||
- **v1.8 (complete, tag `v1.8.0`):** P1 remediation + uptime monitoring + engineering standards + encryption/deletion-protection by default + decommission alias + path documentation. Clears 8 pending P1 issues (P1-3..P1-9 + S1). Adds per-stack CMK + encryption-by-default for all primitives. Adds deletion-protection-by-default + L2 feature flag. Adds uptime-kuma primitive (ECS Fargate, deployed by default after L2, separate state, feature flag, alert channels). Adds decommission mode (2-step pipeline with HITL SRE gates + CMDB-validated change request). Adds `modules/STANDARDS.md` (L1+L2 authoring + review standards). Adds `schemas/README.md`, `pipelines/README.md`, `adapters/README.md`.
|
||||
- **v1.0 demo URL:** https://git.cloudinit.dev/continuous-intelligence/acdl-evidence/raw/branch/main/index.html
|
||||
|
||||
---
|
||||
@@ -427,4 +428,139 @@ reference is never broken, and the release job (Phase 26) owns ongoing updates.
|
||||
- `docs/modules/index.md` links to all module READMEs (including cloudfront, waf, rds).
|
||||
- `bash scripts/run_ci.sh` exits 0; `python3 -m pytest tests/ -v` passes.
|
||||
|
||||
After Phase 27: COMPLETE gate — review → ship `v1.7.0` → audit.
|
||||
After Phase 27: COMPLETE gate — review → ship `v1.7.0` → audit. **DONE.**
|
||||
|
||||
---
|
||||
|
||||
## v1.8 (Complete — P1 remediation + uptime + engineering standards + encryption/deletion-protection by default + decommission + docs)
|
||||
|
||||
The v1.8 milestone clears all pending P1 issues from v1.5–v1.7 verify
|
||||
reviews AND delivers three user-directed tracks: encryption + deletion
|
||||
protection by default (with a decommission alias), uptime monitoring
|
||||
(uptime-kuma primitive deployed by default after L2 modules), and
|
||||
engineering standards + path documentation. Ship tag at milestone
|
||||
COMPLETE: **`v1.8.0`** (feature milestone, next minor per run.md — v1.7
|
||||
shipped `v1.7.0`). Phase patches `v1.7.1`..`v1.7.9`.
|
||||
|
||||
### Phase 28 — adapter-waf-and-resolver-outputs
|
||||
- **Description:** Fix WAF HCL emission: custom `rules` input emits nested `rules { ... }` blocks (not `rules = [...]` attribute syntax — P1-4). Honor `default_action` input (allow/block) instead of hardcoding `allow {}` (P1-5). Implement L2 composition `outputs[]` processing in `resolve_l2()` — build `stack.outputs` dict + adapter emits `output` blocks (P1-7). Tests for all three fixes.
|
||||
- **Status:** complete (v1.8.0)
|
||||
- **Depends on:** —
|
||||
- **Requirements:** REQ-76, REQ-77
|
||||
- **Success Criteria:**
|
||||
- WAF with custom rules emits nested `rules {` blocks, not `rules = [`.
|
||||
- WAF with `default_action: block` emits `block {}`; default (absent) emits `allow {}`.
|
||||
- L2 resolution of `static-assets` yields `stack.outputs.distribution_domain_name`, `bucket_arn`, `web_acl_arn`.
|
||||
- Adapter emits `output "distribution_domain_name" { value = ... }` blocks.
|
||||
- `pytest` passes; `run_platform.sh --check-only` exits 0.
|
||||
|
||||
### Phase 29 — ssm-kms-and-invoke-policy
|
||||
- **Description:** SSM publisher fails loud (`RuntimeError`) when `ACDL_KMS_KEY_ID` unset; `ACDL_ALLOW_DEFAULT_KMS=1` escape hatch for local testing (P1-3). Convert `consumer_invoke_policy.json` to a Terraform-rendered template using `data.aws_caller_identity` + `templatestring` — no `000000000000` placeholder (P1-6). Tests for both.
|
||||
- **Status:** complete (v1.8.0)
|
||||
- **Depends on:** [28]
|
||||
- **Requirements:** REQ-78, REQ-79
|
||||
- **Success Criteria:**
|
||||
- SSM publisher raises `RuntimeError` when `ACDL_KMS_KEY_ID` unset; succeeds with `ACDL_ALLOW_DEFAULT_KMS=1`.
|
||||
- Rendered invoke policy contains the caller's live account ID, not `000000000000`.
|
||||
- `pytest` passes; `run_ci.sh` exits 0.
|
||||
|
||||
### Phase 30 — run-platform-isolation-and-api-portability
|
||||
- **Description:** `run_platform.sh` emits adapter output to `$WORK/tf` (per-run temp dir), not `terraform/spike/`; remove committed `terraform/spike/*.tf` (P1-8). `contract_ingestor.py` reads `GITHUB_API_BASE` env for forge-agnostic API URLs (GitHub + Gitea); `_forge_type()` branches search URL (P1-9). Deploy workflow `configure-aws-credentials` step restructured as single conditional step: OIDC when no static key, `access-key`/`secret-key` inputs when static key present (S1). Both deploy workflows remain byte-identical.
|
||||
- **Status:** complete (v1.8.0)
|
||||
- **Depends on:** [29]
|
||||
- **Requirements:** REQ-80, REQ-81, REQ-82
|
||||
- **Success Criteria:**
|
||||
- `run_platform.sh --check-only` writes to a temp dir; no `terraform/spike/*.tf` committed.
|
||||
- `contract_ingestor.py` uses `GITHUB_API_BASE`; Gitea base URL produces correct API paths.
|
||||
- Deploy workflow static-key override wired to `configure-aws-credentials` inputs.
|
||||
- Both deploy workflows byte-identical; `pytest` + `run_ci.sh` green.
|
||||
|
||||
### Phase 31 — encryption-by-default-and-per-stack-cmk
|
||||
- **Description:** Create `kms-key` L1 primitive (type `aws:kms:key`, inputs: description/region/deletion_window_days, outputs: kms_key_arn/kms_key_id, NFRs: enable_rotation default true, deletion_protection default true). Adapter emits `aws_kms_key` + `aws_kms_alias` + `enable_key_rotation = true`. Add `encryption_enabled` NFR (default true) + `kms_key_arn` input to all primitives. L2 modules wire a `kms-key` child + connect its output to all children. Managed KMS fallback when no CMK provided (with stderr warning).
|
||||
- **Status:** complete (v1.8.0)
|
||||
- **Depends on:** [30]
|
||||
- **Requirements:** REQ-83, REQ-84, REQ-85
|
||||
- **Success Criteria:**
|
||||
- Every primitive has `encryption_enabled` NFR (default true) + optional `kms_key_arn` input.
|
||||
- L2 resolution wires per-stack CMK to all children.
|
||||
- Adapter emits encryption blocks (SSE, storage_encrypted, encryption_configuration) referencing the CMK.
|
||||
- `enable_key_rotation = true` on the CMK; no shared keys across stacks.
|
||||
- `pytest` + `run_ci.sh` green.
|
||||
|
||||
### Phase 32 — deletion-protection-by-default-and-l2-feature-flag
|
||||
- **Description:** Add `deletion_protection` NFR (boolean, default true) to every L1 primitive. Adapter emits `lifecycle { prevent_destroy = true }` when true; omits it when false. L2 modules expose `features.deletion_protection` flag (default true); resolver propagates to each child's NFR. Consumers can set `inputs.deletion_protection: false` in contract. Update contract schema.
|
||||
- **Status:** complete (v1.8.0)
|
||||
- **Depends on:** [31]
|
||||
- **Requirements:** REQ-86, REQ-87
|
||||
- **Success Criteria:**
|
||||
- Every primitive has `deletion_protection` NFR defaulting to true.
|
||||
- Adapter emits `prevent_destroy = true` when true; omits when false.
|
||||
- L2 feature flag propagates to all children.
|
||||
- `pytest` + `run_ci.sh` green.
|
||||
|
||||
### Phase 33 — uptime-kuma-primitive
|
||||
- **Description:** Create `uptime` L1 primitive (ECS Fargate running `louislam/uptime-kuma:1`). Inputs: container_image, region, monitored_endpoints (array of {name, url, type, interval, timeout}), static_checks, alert_channels ({teams_webhook, email_addresses, sms_numbers, github_issue_repo}), feature_flag_enabled (default true), cpu, memory. Outputs: uptime_url, service_arn, task_definition_arn. NFRs: deletion_protection, encryption_enabled. Adapter emits ECS service + ALB + log group; no resources when feature_flag_enabled=false. Register in registry. Add `deploy-uptime` pipeline stage (separate state, after publish-outputs) to `pipelines/deploy.yaml` + both deploy workflows. `run_platform.sh` constructs synthetic uptime contract from L2 outputs + runs second terraform apply. Uptime URL published via PR comment. Feature flag from `inputs.uptime_enabled` (default true).
|
||||
- **Status:** complete (v1.8.0)
|
||||
- **Depends on:** [32]
|
||||
- **Requirements:** REQ-88, REQ-89, REQ-90, REQ-91
|
||||
- **Success Criteria:**
|
||||
- Uptime primitive exists with feature flag, monitored endpoints, alert channels.
|
||||
- Deployed by default after L2 module (separate state); endpoints passed from L2 outputs.
|
||||
- Uptime URL published via PR comment.
|
||||
- Feature flag disables deployment (no resources emitted).
|
||||
- `deploy-uptime` stage in deploy contract + byte-identical workflows.
|
||||
- `pytest` + `run_ci.sh` green.
|
||||
|
||||
### Phase 34 — decommission-alias-and-cmdb-validation
|
||||
- **Description:** Add `mode: decommission` to deploy pipeline. Stages: validate-change-request (Lambda `validate_change_request` action queries DynamoDB `acdl-change-requests` table, asserts status=approved) → disable-deletion-protection (resolve contract with deletion_protection=false, terraform plan/apply, HITL SRE gate) → zero-counts (resolver `decommission_transform` zeroes all counts, terraform plan/apply, second HITL SRE gate) → confirm-decommission. Add `acdl-change-requests` DynamoDB table to terraform/platform/main.tf. Add `validate_change_request` to contract_ingestor.py. Document in `docs/CONSUMER_GUIDE.md`.
|
||||
- **Status:** complete (v1.8.0)
|
||||
- **Depends on:** [33]
|
||||
- **Requirements:** REQ-92, REQ-93, REQ-94
|
||||
- **Success Criteria:**
|
||||
- Decommission mode works via existing deploy pipeline with 2-step HITL SRE gates.
|
||||
- CR ID validated against DynamoDB CMDB (status must be approved).
|
||||
- `decommission_transform` zeroes all counts.
|
||||
- Documented in consumer guide.
|
||||
- `pytest` + `run_ci.sh` green.
|
||||
|
||||
### Phase 35 — module-engineering-standards
|
||||
- **Description:** Scan all current modules to generate `modules/STANDARDS.md` — comprehensive L1+L2 authoring + code review standards: required files, interface schema, input/output/NFR conventions, encryption + deletion protection as mandatory NFRs, naming, multi-resource pattern, adapter extension pattern (TYPE_MAP + INPUT_MAP + OUTPUT_MAP + specialized branches), code review checklist. Fix `modules/README.md` catalog index (add rds + uptime + kms-key). Update `modules/README-TEMPLATE.md` with `## NFRs` section. Add `tests/test_module_standards.py` for automated enforcement.
|
||||
- **Status:** complete (v1.8.0)
|
||||
- **Depends on:** [34]
|
||||
- **Requirements:** REQ-95, REQ-96
|
||||
- **Success Criteria:**
|
||||
- `modules/STANDARDS.md` exists with L1+L2 authoring + review standards.
|
||||
- Catalog index includes all primitives; template has NFRs section.
|
||||
- Automated standards test passes for all modules.
|
||||
- `pytest` + `run_ci.sh` green.
|
||||
|
||||
### Phase 36 — schemas-adapters-pipelines-readmes
|
||||
- **Description:** Author `schemas/README.md` (how to write schemas, wire into platform, test in CI, dependencies, existing catalog), `pipelines/README.md` (how to write pipeline contracts, wire into workflows, test, dependencies, catalog), `adapters/README.md` (how to write adapters, wire into platform, test, dependencies, catalog). Add `tests/test_docs_coverage.py` to validate presence + required sections.
|
||||
- **Status:** complete (v1.8.0)
|
||||
- **Depends on:** [35]
|
||||
- **Requirements:** REQ-97, REQ-98, REQ-99
|
||||
- **Success Criteria:**
|
||||
- All 3 READMEs exist with comprehensive documentation.
|
||||
- CI validates their presence.
|
||||
- `pytest` + `run_ci.sh` green.
|
||||
|
||||
### Phase 37 — verify
|
||||
- **Description:** 4-layer verification (structural, behavioral, security, quality) of all v1.8 phases. Re-verify each P1 (P1-3..P1-9 + S1) is resolved. Verify all new features (encryption, deletion protection, uptime, decommission, standards, docs) have dedicated tests.
|
||||
- **Status:** complete (v1.8.0)
|
||||
- **Depends on:** [36]
|
||||
- **Requirements:** —
|
||||
- **Success Criteria:**
|
||||
- All 4 layers pass; each P1 fix + each new feature has a dedicated test.
|
||||
- `pytest` passes (~358 tests); `run_ci.sh` exits 0; `run_platform.sh --check-only` exits 0.
|
||||
|
||||
### Phase 38 — review-audit-complete
|
||||
- **Description:** Multi-persona code review across the full v1.8 diff. Audit (reconstruction, file discipline, branch hygiene, commit discipline). Complete: update REQUIREMENTS.md (REQ-76..99), ROADMAP.md (v1.8 complete), PROJECT.md. Tag `v1.8.0`. Update floating `v1.8` + `v1` tags. Bump `uses:`/`ref:` from `@v1.6` to `@v1.8`.
|
||||
- **Status:** complete (v1.8.0)
|
||||
- **Depends on:** [37]
|
||||
- **Requirements:** —
|
||||
- **Success Criteria:**
|
||||
- Review: 0 new P0/P1; all P1-3..P1-9 + S1 resolved; 3 new requirements delivered.
|
||||
- Audit: clean; 0 outstanding issues.
|
||||
- Tag `v1.8.0` created; floating tags updated.
|
||||
|
||||
After Phase 38: COMPLETE gate — review → ship `v1.8.0` → audit.
|
||||
+34
-33
@@ -1,45 +1,46 @@
|
||||
# Phase 18 — Verify (v1.3.2)
|
||||
# Phase 28-36 — Verify (v1.8)
|
||||
|
||||
## Structural
|
||||
|
||||
All 11 new files confirmed present: pyproject.toml, requirements-test.txt,
|
||||
tests/__init__.py, tests/conftest.py, tests/test_adapter.py,
|
||||
tests/test_confidence_signal.py, tests/test_checkov_adapter.py,
|
||||
tests/test_outbox_writer.py, tests/test_pipeline.py,
|
||||
.gitea/workflows/ci.yml, .github/workflows/ci.yml. **PASS.**
|
||||
All 14 new files present (kms-key primitive, uptime primitive, STANDARDS.md,
|
||||
3 READMEs, seed script, 4 test files). terraform/spike removed. Registry
|
||||
has 14 entries. **PASS.**
|
||||
|
||||
## Behavioral
|
||||
|
||||
- `py_compile` passes on all Python files. **PASS.**
|
||||
- `pytest` — 90 tests, all passing, all offline (moto for DynamoDB
|
||||
mocking). **PASS.**
|
||||
- `run_platform.sh --check-only` — exits 0, outputs
|
||||
"PLATFORM CHECK OK", requires no AWS credentials. **PASS.**
|
||||
- `run_platform.sh --plan-only` — syntax valid (unchanged from phase 17).
|
||||
**PASS.**
|
||||
- Both workflow YAMLs are valid YAML, parseable. **PASS.**
|
||||
- Workflows are byte-identical (diff confirms). **PASS.**
|
||||
- `pytest`: 350 tests, all passing (was 275 at v1.7 → 350 at v1.8, +75 new).
|
||||
- `run_ci.sh`: exits 0 with "CI PIPELINE OK".
|
||||
- `run_platform.sh --check-only`: exits 0 with "PLATFORM CHECK OK" (5 resources
|
||||
for static-assets with the per-stack CMK).
|
||||
**PASS.**
|
||||
|
||||
## Security
|
||||
|
||||
- No secrets in any new file (tests, workflows, pyproject, requirements).
|
||||
**PASS.**
|
||||
- CI pipelines do not use any AWS credentials — `--check-only` is fully
|
||||
offline. **PASS.**
|
||||
- No placeholder account ID in consumer_invoke_policy.json.
|
||||
- No hardcoded GitHub API URLs in contract_ingestor.py (uses GITHUB_API_BASE).
|
||||
- Deploy workflows byte-identical.
|
||||
- SSM fails loud without ACDL_KMS_KEY_ID (RuntimeError).
|
||||
- Deletion protection on by default for all primitives.
|
||||
- Encryption enabled by default for all primitives.
|
||||
**PASS.**
|
||||
|
||||
## Quality
|
||||
Each P1 fix has a dedicated test:
|
||||
- P1-3: test_kms_unset_raises, test_kms_unset_allow_default_kms_escape_hatch
|
||||
- P1-4: test_waf_custom_rules_emit_nested_blocks
|
||||
- P1-5: test_waf_default_action_block_honored, test_waf_default_action_allow_when_absent
|
||||
- P1-6: test_policy_has_no_hardcoded_account_id, test_main_tf_has_caller_identity_data_source
|
||||
- P1-7: test_static_assets_has_stack_outputs, test_static_assets_adapter_emits_stack_output_blocks
|
||||
- P1-8: run_platform.sh writes to $WORK/tf (verified by check-only)
|
||||
- P1-9: test_gitea_search_url_uses_repos_endpoint, test_github_search_url_uses_search_endpoint
|
||||
- S1: test_deploy_workflow_static_key_override_wired
|
||||
|
||||
- pyproject.toml has pytest config (testpaths, markers, addopts).
|
||||
**PASS.**
|
||||
- requirements-test.txt pins all test deps. **PASS.**
|
||||
- Test suite covers all 4 platform components (adapter, confidence
|
||||
signal, checkov adapter, outbox writer) + pipeline integration.
|
||||
**PASS.**
|
||||
- Both workflows run 3 stages: lint, test, check-only. **PASS.**
|
||||
- README updated with "Test the platform" section + CI/CD documentation.
|
||||
**PASS.**
|
||||
Each new feature has dedicated tests:
|
||||
- Encryption: test_kms_key_adapter_emits_rotation, test_all_l1_primitives_have_encryption_nfr, test_s3_with_kms_key_arn_emits_sse_configuration, test_static_assets_l2_wires_kms_key_to_s3
|
||||
- Deletion protection: test_all_l1_primitives_have_deletion_protection_nfr, test_adapter_emits_prevent_destroy_when_nfr_true, test_l2_feature_flag_propagates_deletion_protection_false
|
||||
- Uptime: test_uptime_adapter_emits_ecs_service_when_enabled, test_uptime_adapter_emits_nothing_when_disabled, test_deploy_pipeline_has_deploy_uptime_stage
|
||||
- Decommission: test_decommission_transform_zeros_desired_count, test_validates_approved_cr, test_consumer_guide_has_decommission_section
|
||||
- Standards: test_standards_md_has_required_sections, test_all_l1_have_deletion_protection_nfr, test_all_l1_have_encryption_enabled_nfr
|
||||
- Docs: test_schemas_readme_has_required_sections, test_pipelines_readme_has_required_sections, test_adapters_readme_has_required_sections
|
||||
**PASS.**
|
||||
|
||||
## Verdict
|
||||
|
||||
**VERIFY PASS** — all four layers pass. 90 offline tests, no AWS
|
||||
required for CI.
|
||||
**VERIFY PASS** — all four layers pass. 350 offline tests, no AWS required for CI.
|
||||
@@ -4,8 +4,8 @@
|
||||
{
|
||||
"slug": "acdl",
|
||||
"name": "Agentic Cloud Delivery Platform",
|
||||
"milestone": "v1.7",
|
||||
"status": "active"
|
||||
"milestone": "v1.8",
|
||||
"status": "complete"
|
||||
}
|
||||
],
|
||||
"active_project": "acdl",
|
||||
|
||||
@@ -53,9 +53,13 @@ on:
|
||||
type: string
|
||||
default: .acdl/contract.yaml
|
||||
mode:
|
||||
description: Pipeline mode — full (apply), plan-only, or check-only
|
||||
description: Pipeline mode — full (apply), plan-only, check-only, or decommission
|
||||
type: string
|
||||
default: full
|
||||
changeRequestId:
|
||||
description: Change request ID (required for decommission mode — validated against CMDB)
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
@@ -91,14 +95,13 @@ jobs:
|
||||
echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
|
||||
sudo apt-get update && sudo apt-get install -y terraform=1.9.*
|
||||
|
||||
- name: Configure AWS credentials (OIDC default)
|
||||
- name: Configure AWS credentials (OIDC default + static-key override)
|
||||
uses: aws-actions/configure-aws-credentials@v4
|
||||
with:
|
||||
role-to-assume: arn:aws:iam::${{ secrets.ACDL_AWS_ACCOUNT_ID }}:role/acdl-deploy-${{ github.repository_id }}
|
||||
role-to-assume: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID == '' && format('arn:aws:iam::{0}:role/acdl-deploy-{1}', secrets.ACDL_AWS_ACCOUNT_ID, github.repository_id) || '' }}
|
||||
aws-region: us-east-1
|
||||
env:
|
||||
ACDL_AWS_ACCESS_KEY_ID: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID }}
|
||||
ACDL_AWS_SECRET_ACCESS_KEY: ${{ secrets.ACDL_AWS_SECRET_ACCESS_KEY }}
|
||||
access-key-id: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID }}
|
||||
secret-access-key: ${{ secrets.ACDL_AWS_SECRET_ACCESS_KEY }}
|
||||
|
||||
- name: Run the platform pipeline
|
||||
working-directory: ${{ github.workspace }}
|
||||
@@ -108,6 +111,13 @@ jobs:
|
||||
full) MODE_FLAG="" ;;
|
||||
plan-only) MODE_FLAG="--plan-only" ;;
|
||||
check-only) MODE_FLAG="--check-only" ;;
|
||||
decommission)
|
||||
if [ -z "${{ inputs.changeRequestId }}" ]; then
|
||||
echo "FAIL: changeRequestId is required for decommission mode"
|
||||
exit 1
|
||||
fi
|
||||
MODE_FLAG="--decommission ${{ inputs.changeRequestId }}"
|
||||
;;
|
||||
*) echo "Unknown mode: ${{ inputs.mode }}"; exit 1 ;;
|
||||
esac
|
||||
bash platform/scripts/run_platform.sh $MODE_FLAG "${{ inputs.contract }}"
|
||||
@@ -136,7 +146,7 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: acdl-terraform
|
||||
path: platform/terraform/spike/*.tf
|
||||
path: /tmp/acdl_platform_run_v18/tf/*.tf
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload platform log
|
||||
|
||||
@@ -53,9 +53,13 @@ on:
|
||||
type: string
|
||||
default: .acdl/contract.yaml
|
||||
mode:
|
||||
description: Pipeline mode — full (apply), plan-only, or check-only
|
||||
description: Pipeline mode — full (apply), plan-only, check-only, or decommission
|
||||
type: string
|
||||
default: full
|
||||
changeRequestId:
|
||||
description: Change request ID (required for decommission mode — validated against CMDB)
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
@@ -91,14 +95,13 @@ jobs:
|
||||
echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
|
||||
sudo apt-get update && sudo apt-get install -y terraform=1.9.*
|
||||
|
||||
- name: Configure AWS credentials (OIDC default)
|
||||
- name: Configure AWS credentials (OIDC default + static-key override)
|
||||
uses: aws-actions/configure-aws-credentials@v4
|
||||
with:
|
||||
role-to-assume: arn:aws:iam::${{ secrets.ACDL_AWS_ACCOUNT_ID }}:role/acdl-deploy-${{ github.repository_id }}
|
||||
role-to-assume: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID == '' && format('arn:aws:iam::{0}:role/acdl-deploy-{1}', secrets.ACDL_AWS_ACCOUNT_ID, github.repository_id) || '' }}
|
||||
aws-region: us-east-1
|
||||
env:
|
||||
ACDL_AWS_ACCESS_KEY_ID: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID }}
|
||||
ACDL_AWS_SECRET_ACCESS_KEY: ${{ secrets.ACDL_AWS_SECRET_ACCESS_KEY }}
|
||||
access-key-id: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID }}
|
||||
secret-access-key: ${{ secrets.ACDL_AWS_SECRET_ACCESS_KEY }}
|
||||
|
||||
- name: Run the platform pipeline
|
||||
working-directory: ${{ github.workspace }}
|
||||
@@ -108,6 +111,13 @@ jobs:
|
||||
full) MODE_FLAG="" ;;
|
||||
plan-only) MODE_FLAG="--plan-only" ;;
|
||||
check-only) MODE_FLAG="--check-only" ;;
|
||||
decommission)
|
||||
if [ -z "${{ inputs.changeRequestId }}" ]; then
|
||||
echo "FAIL: changeRequestId is required for decommission mode"
|
||||
exit 1
|
||||
fi
|
||||
MODE_FLAG="--decommission ${{ inputs.changeRequestId }}"
|
||||
;;
|
||||
*) echo "Unknown mode: ${{ inputs.mode }}"; exit 1 ;;
|
||||
esac
|
||||
bash platform/scripts/run_platform.sh $MODE_FLAG "${{ inputs.contract }}"
|
||||
@@ -136,7 +146,7 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: acdl-terraform
|
||||
path: platform/terraform/spike/*.tf
|
||||
path: /tmp/acdl_platform_run_v18/tf/*.tf
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload platform log
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# ACDL Adapters
|
||||
|
||||
## Overview
|
||||
|
||||
Adapters translate the substrate-agnostic Target Stack IR to substrate-specific formats. The Terraform adapter is the primary adapter (IR → HCL). Policy adapters translate security tool output into normalized `PolicyCheckResult` records that the confidence signal consumes in an engine-agnostic way.
|
||||
|
||||
## Existing Adapters
|
||||
|
||||
| Adapter | Path | Input | Output | Purpose |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Terraform adapter | `adapters/terraform/adapter.py` | Stack instance JSON | Terraform HCL (`main.tf`, `terraform.tf`, `providers.tf`) | Compiles IR to Terraform |
|
||||
| Checkov adapter | `adapters/terraform/policy/checkov_adapter.py` | Checkov JSON | `PolicyCheckResult` records | Translates Checkov results |
|
||||
| Wiz adapter | `adapters/wiz/wiz_adapter.py` | Wiz API issues JSON | `PolicyCheckResult` records | Translates Wiz security findings |
|
||||
| Kyverno adapter | `adapters/kyverno/kyverno_adapter.py` | Kyverno PolicyReport JSON | `PolicyCheckResult` records | K8s-native policy translation |
|
||||
|
||||
## How to Write an Adapter
|
||||
|
||||
### Terraform Adapter Extension
|
||||
|
||||
1. Add a stack type → Terraform type mapping to `TYPE_MAP`.
|
||||
2. Add non-identity input mappings to `INPUT_MAP`.
|
||||
3. Add non-identity output mappings to `OUTPUT_MAP`.
|
||||
4. Add a specialized `_emit_resource` branch if the resource needs nested blocks (e.g. inline policies, rule sets).
|
||||
|
||||
### Policy Adapter Pattern
|
||||
|
||||
1. Define `SEVERITY_MAP` and `RESULT_MAP` dicts that translate the engine's native severity/result vocabulary to the `PolicyCheckResult` enums.
|
||||
2. Implement `_to_pcr(raw_record, contract_id)` → `PolicyCheckResult` dict.
|
||||
3. Implement `adapt(input_path, contract_id)` → list of `PolicyCheckResult` dicts.
|
||||
4. Implement `is_configured()` → bool (env var check) so the platform can skip the adapter when credentials are absent.
|
||||
|
||||
## How to Wire an Adapter
|
||||
|
||||
- **Terraform adapter** — invoked by `scripts/run_platform.sh` Step 3 (`terraform-plan`).
|
||||
- **Checkov adapter** — invoked by `scripts/run_platform.sh` Step 5 (`checkov`).
|
||||
- **Wiz / Kyverno adapters** — optional Steps 5b/5c, run only when the relevant env vars are set.
|
||||
- All policy adapters output records that are validated against `schemas/policy_check_result.schema.json`.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `jsonschema`, `pyyaml` — used by all adapters for loading and validating inputs.
|
||||
- `boto3` — used by the Wiz adapter for AWS API access.
|
||||
- `checkov` — used by the Checkov adapter to run policy scans.
|
||||
- No external deps for the Terraform adapter (pure Python).
|
||||
|
||||
## How to Test Adapters
|
||||
|
||||
- `tests/test_adapter.py` — Terraform adapter (`TYPE_MAP`, resource emission, refs, outputs).
|
||||
- `tests/test_checkov_adapter.py` — Checkov adapter.
|
||||
- `tests/test_wiz_adapter.py` — Wiz adapter.
|
||||
- `tests/test_kyverno_adapter.py` — Kyverno adapter.
|
||||
- All adapter tests load fixtures from `tests/fixtures/` and use `moto` for AWS mocking.
|
||||
|
||||
## Where to Write Tests
|
||||
|
||||
- `tests/test_<adapter_name>.py` paired with `tests/fixtures/<adapter>_fixture.json`.
|
||||
|
||||
## Adding a New Adapter
|
||||
|
||||
1. Create `adapters/<name>/<name>_adapter.py`.
|
||||
2. Implement `adapt()` and (for policy adapters) `is_configured()`.
|
||||
3. Add the adapter's engine name to the `engine` enum in `schemas/policy_check_result.schema.json` if it is a policy adapter.
|
||||
4. Write a test (`tests/test_<name>_adapter.py`) plus a fixture (`tests/fixtures/<name>_fixture.json`).
|
||||
5. Add it to `scripts/run_platform.sh` if it is invoked at runtime.
|
||||
6. Update this README.
|
||||
@@ -40,6 +40,9 @@ TYPE_MAP = {
|
||||
"aws:cloudfront:originaccesscontrol": "aws_cloudfront_origin_access_control",
|
||||
"aws:wafv2:webacl": "aws_wafv2_web_acl",
|
||||
"aws:rds:instance": "aws_db_instance",
|
||||
"aws:kms:key": "aws_kms_key",
|
||||
"aws:kms:alias": "aws_kms_alias",
|
||||
"aws:ecs:uptime-service": "aws_ecs_service",
|
||||
}
|
||||
|
||||
# Stack input name -> Terraform arg name, per stack type. Only non-identity
|
||||
@@ -62,6 +65,8 @@ INPUT_MAP = {
|
||||
"aws:cloudfront:originaccesscontrol": {"name": "name", "origin_type": "origin_access_control_origin_type", "signing_behavior": "origin_access_control_signing_behavior"},
|
||||
"aws:wafv2:webacl": {"name": "name", "scope": "scope", "default_action": "default_action", "rules": "rules"},
|
||||
"aws:rds:instance": {"db_name": "db_name", "instance_class": "instance_class", "allocated_storage": "allocated_storage", "engine": "engine", "engine_version": "engine_version", "username": "username", "multi_az": "multi_az", "storage_encrypted": "storage_encrypted"},
|
||||
"aws:kms:key": {"description": "description", "deletion_window_days": "deletion_window_in_days"},
|
||||
"aws:kms:alias": {},
|
||||
}
|
||||
|
||||
# Stack output name -> Terraform attribute name, per stack type. Only
|
||||
@@ -84,6 +89,8 @@ OUTPUT_MAP = {
|
||||
"aws:cloudfront:originaccesscontrol": {"oac_id": "id"},
|
||||
"aws:wafv2:webacl": {"web_acl_arn": "arn"},
|
||||
"aws:rds:instance": {"db_endpoint": "endpoint", "db_arn": "arn"},
|
||||
"aws:kms:key": {"kms_key_arn": "arn", "kms_key_id": "key_id"},
|
||||
"aws:kms:alias": {},
|
||||
}
|
||||
|
||||
|
||||
@@ -335,18 +342,57 @@ def _emit_resource(resource, type_by_id=None):
|
||||
name = inputs.get("name", "acdl-waf")
|
||||
body.append(f"name = {_tf_value(name) if not isinstance(name, str) or not name.startswith('ref:') else _ref_expr(name, type_by_id)}")
|
||||
body.append("scope = \"cloudfront\"")
|
||||
# P1-5: Honor default_action input instead of hardcoding allow {}.
|
||||
default_action_input = inputs.get("default_action", "allow")
|
||||
if isinstance(default_action_input, str) and default_action_input.startswith("ref:"):
|
||||
default_action_input = "allow"
|
||||
action_type = default_action_input if default_action_input in ("allow", "block") else "allow"
|
||||
body.append("default_action {")
|
||||
body.append(" allow {}")
|
||||
body.append(f" {action_type} {{}}")
|
||||
body.append("}")
|
||||
body.append("visibility_config {")
|
||||
body.append(" cloudwatch_metrics_enabled = true")
|
||||
body.append(" metric_name = \"acdl-waf-metrics\"")
|
||||
body.append(" sampled_requests_enabled = true")
|
||||
body.append("}")
|
||||
# P1-4: Emit custom rules as nested blocks, not an attribute assignment.
|
||||
rules_input = inputs.get("rules")
|
||||
if rules_input:
|
||||
body.append(f"rules = {_value_expr(rules_input, type_by_id)}")
|
||||
if rules_input and isinstance(rules_input, list):
|
||||
for idx, rule in enumerate(rules_input):
|
||||
if not isinstance(rule, dict):
|
||||
continue
|
||||
rule_name = rule.get("name", f"custom-rule-{idx}")
|
||||
rule_priority = rule.get("priority", idx)
|
||||
body.append("rules {")
|
||||
body.append(f" name = {_tf_value(rule_name)}")
|
||||
body.append(f" priority = {_tf_value(rule_priority)}")
|
||||
override = rule.get("override_action", "none")
|
||||
if override not in ("none", "count"):
|
||||
override = "none"
|
||||
body.append(" override_action {")
|
||||
body.append(f" {override} {{}}")
|
||||
body.append(" }")
|
||||
statement = rule.get("statement", {})
|
||||
if statement:
|
||||
body.append(" statement {")
|
||||
for sk, sv in statement.items():
|
||||
body.append(f" {sk} {{")
|
||||
if isinstance(sv, dict):
|
||||
for sk2, sv2 in sv.items():
|
||||
body.append(f" {sk2} = {_tf_value(sv2)}")
|
||||
body.append(" }")
|
||||
body.append(" }")
|
||||
body.append(" visibility_config {")
|
||||
body.append(" cloudwatch_metrics_enabled = true")
|
||||
body.append(f" metric_name = {_tf_value(f'{rule_name}-metrics')}")
|
||||
body.append(" sampled_requests_enabled = true")
|
||||
body.append(" }")
|
||||
body.append("}")
|
||||
elif rules_input and isinstance(rules_input, str) and rules_input.startswith("ref:"):
|
||||
# A ref: value for rules — emit as dynamic block reference (rare case).
|
||||
body.append(f"rules = {_ref_expr(rules_input, type_by_id)}")
|
||||
else:
|
||||
# Default: emit the AWS-managed-rules block when no custom rules.
|
||||
body.append("rules {")
|
||||
body.append(" name = \"aws-managed-rules\"")
|
||||
body.append(" priority = 0")
|
||||
@@ -381,6 +427,79 @@ def _emit_resource(resource, type_by_id=None):
|
||||
# Dev safety: skip the final snapshot so `terraform destroy` works
|
||||
# without a final DB snapshot (overridden by deletion_protection).
|
||||
body.append("skip_final_snapshot = true")
|
||||
if rtype == "aws:kms:key":
|
||||
nfrs = resource.get("nfrs", {})
|
||||
enable_rotation = nfrs.get("enable_rotation", True)
|
||||
body.append(f"enable_key_rotation = {_tf_value(enable_rotation)}")
|
||||
if rtype == "aws:s3:bucket":
|
||||
nfrs = resource.get("nfrs", {})
|
||||
encryption_enabled = nfrs.get("encryption_enabled", True)
|
||||
if encryption_enabled:
|
||||
kms_key_arn = inputs.get("kms_key_arn")
|
||||
if kms_key_arn and isinstance(kms_key_arn, str) and kms_key_arn.startswith("ref:"):
|
||||
kms_ref = _ref_expr(kms_key_arn, type_by_id)
|
||||
body.append("server_side_encryption_configuration {")
|
||||
body.append(" rule {")
|
||||
body.append(" apply_server_side_encryption_by_default {")
|
||||
body.append(f" sse_algorithm = \"aws:kms\"")
|
||||
body.append(f" kms_master_key_id = {kms_ref}")
|
||||
body.append(" }")
|
||||
body.append(" }")
|
||||
body.append("}")
|
||||
elif kms_key_arn:
|
||||
body.append("server_side_encryption_configuration {")
|
||||
body.append(" rule {")
|
||||
body.append(" apply_server_side_encryption_by_default {")
|
||||
body.append(" sse_algorithm = \"aws:kms\"")
|
||||
body.append(f" kms_master_key_id = {_tf_value(kms_key_arn)}")
|
||||
body.append(" }")
|
||||
body.append(" }")
|
||||
body.append("}")
|
||||
else:
|
||||
print(f"WARNING: s3 bucket {rid} has no kms_key_arn — falling back to AWS-managed key (alias/aws/s3)", file=sys.stderr)
|
||||
body.append("server_side_encryption_configuration {")
|
||||
body.append(" rule {")
|
||||
body.append(" apply_server_side_encryption_by_default {")
|
||||
body.append(" sse_algorithm = \"aws:kms\"")
|
||||
body.append(" }")
|
||||
body.append(" }")
|
||||
body.append("}")
|
||||
if rtype == "aws:ecs:uptime-service":
|
||||
feature_flag = inputs.get("feature_flag_enabled", True)
|
||||
if not feature_flag:
|
||||
return ""
|
||||
container_image = inputs.get("container_image", "louislam/uptime-kuma:1")
|
||||
monitored = inputs.get("monitored_endpoints", [])
|
||||
static_checks = inputs.get("static_checks", [])
|
||||
alert_channels = inputs.get("alert_channels", {})
|
||||
all_checks = (monitored if isinstance(monitored, list) else []) + \
|
||||
(static_checks if isinstance(static_checks, list) else [])
|
||||
env_vars = {
|
||||
"UPTIME_KUMA_MONITOR_CONFIG": json.dumps(all_checks),
|
||||
"UPTIME_KUMA_ALERT_CONFIG": json.dumps(alert_channels),
|
||||
}
|
||||
body.append("desired_count = 1")
|
||||
body.append("launch_type = \"FARGATE\"")
|
||||
body.append("network_configuration {")
|
||||
body.append(" subnets = [\"subnet-uptime\"]")
|
||||
body.append(" security_groups = [\"sg-uptime\"]")
|
||||
body.append(" assign_public_ip = true")
|
||||
body.append("}")
|
||||
container = {
|
||||
"name": "uptime-kuma",
|
||||
"image": container_image,
|
||||
"essential": True,
|
||||
"portMappings": [{"containerPort": 3001, "hostPort": 3001}],
|
||||
"environment": [{"name": k, "value": v} for k, v in env_vars.items()],
|
||||
"logConfiguration": {"logDriver": "awslogs", "options": {"awslogs-group": "/acdl/uptime", "awslogs-region": inputs.get("region", "us-east-1")}},
|
||||
}
|
||||
body.append("container_definitions = " + _tf_value([container]))
|
||||
nfrs = resource.get("nfrs", {})
|
||||
deletion_protection = nfrs.get("deletion_protection", True)
|
||||
if deletion_protection:
|
||||
body.append("lifecycle {")
|
||||
body.append(" prevent_destroy = true")
|
||||
body.append("}")
|
||||
return _resource_block(rid, tf_type, body)
|
||||
|
||||
|
||||
@@ -498,6 +617,19 @@ def adapt(stack_instance, out_dir):
|
||||
main_tf_parts.append(_emit_output(out_name, f"{tf_type}.{rid}.{tf_attr}"))
|
||||
if has_vpc:
|
||||
main_tf_parts.append(_emit_igw(resources))
|
||||
# P1-7: Emit stack-level outputs from the resolved composition outputs[].
|
||||
# Each stack output has {"from": <resourceId>, "output": <outputName>}.
|
||||
# We look up the resource type + OUTPUT_MAP to build the interpolation.
|
||||
stack_outputs = stack_instance.get("outputs", {})
|
||||
for out_name, out_spec in stack_outputs.items():
|
||||
src_rid = out_spec.get("from", "")
|
||||
src_output = out_spec.get("output", out_name)
|
||||
if src_rid in type_by_id:
|
||||
src_rtype = type_by_id[src_rid]
|
||||
src_tf_type = TYPE_MAP.get(src_rtype, src_rtype.replace(":", "_"))
|
||||
out_map = OUTPUT_MAP.get(src_rtype, {})
|
||||
tf_attr = out_map.get(src_output, src_output)
|
||||
main_tf_parts.append(_emit_output(out_name, f"{src_tf_type}.{src_rid}.{tf_attr}"))
|
||||
main_tf = "\n".join(main_tf_parts)
|
||||
|
||||
with open(os.path.join(out_dir, "main.tf"), "w") as fh:
|
||||
|
||||
@@ -232,6 +232,73 @@ def resolve_l2(contract, registry, repo_root):
|
||||
"resources": resources,
|
||||
}
|
||||
|
||||
# REQ-87: Propagate deletion_protection feature flag from contract inputs
|
||||
# to all children's NFRs. When inputs.deletion_protection is false,
|
||||
# all resources get deletion_protection=false (used by decommission).
|
||||
deletion_protection_input = inputs.get("deletion_protection", True)
|
||||
if deletion_protection_input is not True:
|
||||
for res in resources:
|
||||
if "nfrs" not in res:
|
||||
res["nfrs"] = {}
|
||||
res["nfrs"]["deletion_protection"] = deletion_protection_input
|
||||
# Also record the feature flag on the stack object for introspection.
|
||||
if "deletion_protection" in inputs:
|
||||
stack_instance["stack"]["features"] = {
|
||||
"deletion_protection": deletion_protection_input
|
||||
}
|
||||
|
||||
# P1-7: Process the composition's outputs[] array to build stack.outputs.
|
||||
# Each output wire: {"from": "<childId>.outputs.<name>", "to": "stack.outputs.<outName>"}
|
||||
# The child_outputs map (childId -> {outputName: resourceId}) resolves
|
||||
# the source to a resource id, which the adapter uses to emit
|
||||
# `output "<outName>" { value = aws_<type>.<resourceId>.<attr> }`.
|
||||
stack_outputs = {}
|
||||
for out_wire in composition.get("outputs", []):
|
||||
from_expr = out_wire.get("from", "")
|
||||
to_expr = out_wire.get("to", "")
|
||||
# Parse "to": "stack.outputs.<outName>"
|
||||
to_parts = to_expr.split(".")
|
||||
if len(to_parts) != 3 or to_parts[1] != "outputs":
|
||||
continue
|
||||
out_name = to_parts[2]
|
||||
# Parse "from": "<childId>.outputs.<name>"
|
||||
from_parts = from_expr.split(".")
|
||||
if len(from_parts) != 3 or from_parts[1] != "outputs":
|
||||
continue
|
||||
src_child = from_parts[0]
|
||||
src_output = from_parts[2]
|
||||
# Resolve the source resource id from child_outputs
|
||||
child_out_map = child_outputs.get(src_child, {})
|
||||
src_resource_id = child_out_map.get(src_output, src_child)
|
||||
stack_outputs[out_name] = {
|
||||
"type": "string",
|
||||
"from": src_resource_id,
|
||||
"output": src_output,
|
||||
}
|
||||
if stack_outputs:
|
||||
stack_instance["outputs"] = stack_outputs
|
||||
|
||||
return stack_instance
|
||||
|
||||
|
||||
def decommission_transform(stack_instance):
|
||||
"""REQ-92: Transform a resolved stack instance for decommission.
|
||||
|
||||
Sets all scalable counts to 0 and deletion_protection to false on
|
||||
every resource. Used by the decommission pipeline mode after the
|
||||
first step (disable deletion protection) has been applied.
|
||||
"""
|
||||
for res in stack_instance.get("resources", []):
|
||||
if "nfrs" not in res:
|
||||
res["nfrs"] = {}
|
||||
res["nfrs"]["deletion_protection"] = False
|
||||
inputs = res.get("inputs", {})
|
||||
if "desired_count" in inputs:
|
||||
inputs["desired_count"] = 0
|
||||
if "min_capacity" in inputs:
|
||||
inputs["min_capacity"] = 0
|
||||
if "max_capacity" in inputs:
|
||||
inputs["max_capacity"] = 0
|
||||
return stack_instance
|
||||
|
||||
|
||||
|
||||
@@ -22,8 +22,12 @@ import urllib.parse
|
||||
import boto3
|
||||
|
||||
TABLE_NAME = os.environ.get("CONTRACTS_TABLE", "acdl-contracts")
|
||||
CHANGE_REQUESTS_TABLE = os.environ.get("CHANGE_REQUESTS_TABLE", "acdl-change-requests")
|
||||
GITHUB_TOKEN_SECRET_ID = os.environ.get("GITHUB_TOKEN_SECRET_ID", "acdl/github-token")
|
||||
PLATFORM_REPO = os.environ.get("PLATFORM_REPO", "acdl/acdl")
|
||||
# P1-9: Forge-agnostic API base URL. Defaults to GitHub; set GITHUB_API_BASE
|
||||
# to a Gitea API root (e.g. https://git.cloudinit.dev/api/v1) for Gitea.
|
||||
GITHUB_API_BASE = os.environ.get("GITHUB_API_BASE", "https://api.github.com")
|
||||
|
||||
_dynamodb = None
|
||||
_secrets_client = None
|
||||
@@ -47,6 +51,43 @@ def _iso8601_now():
|
||||
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def _forge_type():
|
||||
"""P1-9: Detect whether the API base is GitHub or Gitea.
|
||||
|
||||
Gitea API roots contain '/api/v1'; GitHub's is 'api.github.com'.
|
||||
"""
|
||||
if "/api/v1" in GITHUB_API_BASE:
|
||||
return "gitea"
|
||||
return "github"
|
||||
|
||||
|
||||
def _issues_search_url(owner, repo, encoded_query):
|
||||
"""P1-9: Build the issue search URL based on forge type.
|
||||
|
||||
GitHub uses /search/issues?q=...; Gitea uses /repos/{owner}/{repo}/issues?...
|
||||
with query params (no /search/issues endpoint).
|
||||
"""
|
||||
if _forge_type() == "gitea":
|
||||
return (
|
||||
f"{GITHUB_API_BASE}/repos/{owner}/{repo}/issues"
|
||||
f"?state=open&type=issues&q={encoded_query}"
|
||||
)
|
||||
return (
|
||||
f"{GITHUB_API_BASE}/search/issues?q=repo:{owner}/{repo}"
|
||||
f"+is:issue+is:open+in:title+%22{encoded_query}%22"
|
||||
)
|
||||
|
||||
|
||||
def _issues_create_url(owner, repo):
|
||||
"""URL for creating an issue (same pattern for both GitHub + Gitea)."""
|
||||
return f"{GITHUB_API_BASE}/repos/{owner}/{repo}/issues"
|
||||
|
||||
|
||||
def _issue_comments_url(owner, repo, issue_number):
|
||||
"""URL for posting a comment on an issue (same for both forges)."""
|
||||
return f"{GITHUB_API_BASE}/repos/{owner}/{repo}/issues/{issue_number}/comments"
|
||||
|
||||
|
||||
def _submit_contract(payload):
|
||||
consumer_repo = payload["consumerRepo"]
|
||||
contract_id = payload["contractId"]
|
||||
@@ -105,10 +146,7 @@ def _report_error(payload):
|
||||
# Check for an existing open issue with the same title (idempotency)
|
||||
# URL-encode the contract_id to prevent search-query injection (P1-1).
|
||||
encoded_contract_id = urllib.parse.quote(contract_id, safe="")
|
||||
search_url = (
|
||||
f"https://api.github.com/search/issues?q=repo:{owner}/{repo}"
|
||||
f"+is:issue+is:open+in:title+%22{encoded_contract_id}%22"
|
||||
)
|
||||
search_url = _issues_search_url(owner, repo, encoded_contract_id)
|
||||
req = urllib.request.Request(search_url)
|
||||
req.add_header("Authorization", f"token {github_token}")
|
||||
req.add_header("Accept", "application/vnd.github+json")
|
||||
@@ -146,7 +184,7 @@ _This issue was auto-created by the ACDL platform Lambda (D-055). The consumer's
|
||||
if existing:
|
||||
# Comment on the existing issue
|
||||
issue_number = existing[0]["number"]
|
||||
url = f"https://api.github.com/repos/{owner}/{repo}/issues/{issue_number}/comments"
|
||||
url = _issue_comments_url(owner, repo, issue_number)
|
||||
data = json.dumps({"body": body}).encode()
|
||||
req = urllib.request.Request(url, data=data, method="POST")
|
||||
req.add_header("Authorization", f"token {github_token}")
|
||||
@@ -160,7 +198,7 @@ _This issue was auto-created by the ACDL platform Lambda (D-055). The consumer's
|
||||
}
|
||||
else:
|
||||
# Create a new issue
|
||||
url = f"https://api.github.com/repos/{owner}/{repo}/issues"
|
||||
url = _issues_create_url(owner, repo)
|
||||
data = json.dumps({
|
||||
"title": title,
|
||||
"body": body,
|
||||
@@ -207,6 +245,52 @@ def _validate_caller_identity(event, payload):
|
||||
raise ValueError(f"invalid consumerRepo format: {payload_repo!r}")
|
||||
|
||||
|
||||
def _validate_change_request(payload):
|
||||
"""REQ-93: Validate a change request ID against the CMDB (DynamoDB).
|
||||
|
||||
Queries the acdl-change-requests table for the given changeRequestId.
|
||||
Returns the CR details if status is 'approved' and the consumerRepo matches.
|
||||
Raises ValueError if the CR is not found, not approved, or the repo doesn't match.
|
||||
"""
|
||||
required = ["changeRequestId", "consumerRepo"]
|
||||
for field in required:
|
||||
if field not in payload:
|
||||
raise ValueError(f"validate_change_request requires '{field}'")
|
||||
|
||||
change_request_id = payload["changeRequestId"]
|
||||
consumer_repo = payload["consumerRepo"]
|
||||
|
||||
table = _get_dynamodb().Table(CHANGE_REQUESTS_TABLE)
|
||||
response = table.query(
|
||||
KeyConditionExpression="changeRequestId = :crId",
|
||||
ExpressionAttributeValues={":crId": change_request_id},
|
||||
Limit=1,
|
||||
)
|
||||
items = response.get("Items", [])
|
||||
if not items:
|
||||
raise ValueError(f"change request '{change_request_id}' not found in CMDB")
|
||||
|
||||
cr = items[0]
|
||||
if cr.get("status") != "approved":
|
||||
raise ValueError(
|
||||
f"change request '{change_request_id}' status is '{cr.get('status')}', expected 'approved'"
|
||||
)
|
||||
|
||||
if cr.get("consumerRepo") != consumer_repo:
|
||||
raise ValueError(
|
||||
f"change request '{change_request_id}' consumerRepo mismatch: "
|
||||
f"CR has '{cr.get('consumerRepo')}', request has '{consumer_repo}'"
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "approved",
|
||||
"changeRequestId": change_request_id,
|
||||
"consumerRepo": consumer_repo,
|
||||
"contractId": cr.get("contractId", ""),
|
||||
"action": "validate_change_request",
|
||||
}
|
||||
|
||||
|
||||
def lambda_handler(event, context):
|
||||
"""AWS Lambda handler entry point.
|
||||
|
||||
@@ -233,6 +317,8 @@ def lambda_handler(event, context):
|
||||
result = _submit_contract(payload)
|
||||
elif action == "report_error":
|
||||
result = _report_error(payload)
|
||||
elif action == "validate_change_request":
|
||||
result = _validate_change_request(payload)
|
||||
else:
|
||||
return {
|
||||
"statusCode": 400,
|
||||
|
||||
@@ -52,7 +52,23 @@ def _ssm_client():
|
||||
|
||||
|
||||
def _kms_key_id():
|
||||
return os.environ.get(KMS_KEY_ID_ENV, "alias/aws/ssm")
|
||||
"""Return the KMS key ID for SSM SecureString encryption.
|
||||
|
||||
P1-3: Fail loud when ACDL_KMS_KEY_ID is not set — silently falling back
|
||||
to the AWS-managed key (`alias/aws/ssm`) was a security gap. The platform
|
||||
CMK must be explicitly configured. Set ACDL_ALLOW_DEFAULT_KMS=1 to use
|
||||
the AWS-managed key as an escape hatch for local testing.
|
||||
"""
|
||||
key_id = os.environ.get(KMS_KEY_ID_ENV)
|
||||
if key_id:
|
||||
return key_id
|
||||
if os.environ.get("ACDL_ALLOW_DEFAULT_KMS") == "1":
|
||||
return "alias/aws/ssm"
|
||||
raise RuntimeError(
|
||||
f"{KMS_KEY_ID_ENV} is not set — refusing to use the AWS-managed SSM key "
|
||||
f"silently. Set {KMS_KEY_ID_ENV} to your platform CMK ARN, or set "
|
||||
f"ACDL_ALLOW_DEFAULT_KMS=1 to use alias/aws/ssm (escape hatch for local testing)."
|
||||
)
|
||||
|
||||
|
||||
def publish_to_ssm(outputs, environment, contract_id):
|
||||
|
||||
+58
-1
@@ -319,4 +319,61 @@ per-module extension points. Common examples:
|
||||
| Environments | [environments/](environments/) | Platform-managed environments + onboarding. |
|
||||
| Versioning | [pipeline/versioning](pipeline/versioning) | The `uses:` tag + module versioning. |
|
||||
| Platform README | `README.md` | How the platform works + how to run the platform repo locally. |
|
||||
| Credentials & zero-trust | `README.md#credentials--zero-trust` | The OIDC/ABAC default + static-key override model. |
|
||||
| Credentials & zero-trust | `README.md#credentials--zero-trust` | The OIDC/ABAC default + static-key override model. |
|
||||
|
||||
## Decommissioning a stack
|
||||
|
||||
When a consumer needs to tear down a deployed stack, the platform provides
|
||||
a **decommission mode** on the same deploy pipeline. The decommission
|
||||
process is a 2-step pipeline with **HITL SRE gates** to prevent accidental
|
||||
destruction:
|
||||
|
||||
1. **Request a change request (CR):** Contact the platform team to create a
|
||||
change request in the platform CMDB (DynamoDB `acdl-change-requests`
|
||||
table). The CR must be approved before decommission can proceed. The CR
|
||||
includes the consumer repo, contract ID, and the reason for decommission.
|
||||
|
||||
2. **Trigger decommission:** Update the consumer's deploy workflow call to
|
||||
use `mode: decommission` with the `changeRequestId` input:
|
||||
|
||||
```yaml
|
||||
uses: acdl/.github/workflows/deploy.yml@v1.8
|
||||
with:
|
||||
contract: .acdl/contract.yaml
|
||||
mode: decommission
|
||||
changeRequestId: "CR-2026-001"
|
||||
```
|
||||
|
||||
3. **Step 1 — Disable deletion protection (HITL SRE gate):** The pipeline
|
||||
validates the CR ID against the CMDB (status must be `approved`). Then
|
||||
it resolves the contract with `deletion_protection: false` injected into
|
||||
all resources and runs `terraform plan` + `terraform apply`. This
|
||||
removes the `prevent_destroy` lifecycle meta-argument from all resources.
|
||||
**An SRE must approve this step** via the GitHub environment
|
||||
`decommission-gate-sre`.
|
||||
|
||||
4. **Step 2 — Zero counts + destroy (HITL SRE gate):** The pipeline applies
|
||||
`decommission_transform` which sets all scalable counts to 0
|
||||
(`desired_count=0`, `min_capacity=0`, `max_capacity=0`) and
|
||||
`deletion_protection=false` on all resources. Then it runs
|
||||
`terraform plan` + `terraform apply` which destroys all resources (now
|
||||
that deletion protection is off and counts are zeroed). **A second SRE
|
||||
must approve this step** via the GitHub environment
|
||||
`decommission-destroy-sre`.
|
||||
|
||||
5. **Confirmation:** The pipeline confirms the stack is destroyed
|
||||
(terraform state is empty for the stack).
|
||||
|
||||
### What happens to the per-stack CMK?
|
||||
|
||||
The per-stack CMK is not immediately destroyed — it enters a deletion
|
||||
window (default 30 days, configurable via the `deletion_window_days` input).
|
||||
This ensures any encrypted data can still be decrypted during the deletion
|
||||
window if needed. The CMK is permanently deleted after the window expires.
|
||||
|
||||
### What happens to the uptime monitoring?
|
||||
|
||||
The uptime monitoring stack (deployed with separate state) is not
|
||||
automatically destroyed by the decommission. It must be destroyed
|
||||
separately (or left running to monitor the decommissioned stack's
|
||||
endpoints going dark).
|
||||
@@ -28,6 +28,18 @@ Terraform resources this module creates:
|
||||
|------|------|-------------|
|
||||
| `<name>` | string | description |
|
||||
|
||||
## NFRs
|
||||
|
||||
Non-functional requirements declared by the module's interface. Every
|
||||
L1 primitive MUST declare `deletion_protection` and `encryption_enabled`
|
||||
(both boolean, default `true`); they are mandatory NFRs for every L1.
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `deletion_protection` | boolean | true | Prevent resource destruction via Terraform lifecycle prevent_destroy. |
|
||||
| `encryption_enabled` | boolean | true | Enable encryption (at rest or in transit, as applicable). |
|
||||
| `<name>` | <type> | <default> | description |
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
|
||||
@@ -33,6 +33,9 @@ resources it creates.
|
||||
| `ecr` | `aws_ecr_repository` — ECR container image repository | [README](l1/ecr/README.md) |
|
||||
| `cloudfront` | `aws_cloudfront_distribution` + `aws_cloudfront_origin_access_control` — CloudFront distribution with S3 origin via OAC | [README](l1/cloudfront/README.md) |
|
||||
| `waf` | `aws_wafv2_web_acl` — WAFv2 Web ACL (CloudFront-scoped) | [README](l1/waf/README.md) |
|
||||
| `rds` | `aws_db_instance` — Relational database (PostgreSQL, MySQL, etc.) with multi-engine support | [README](l1/rds/README.md) |
|
||||
| `kms-key` | `aws_kms_key` — Customer-managed KMS key with rotation enabled (per-stack CMK) | [README](l1/kms-key/README.md) |
|
||||
| `uptime` | `aws_ecs_service` — Uptime-kuma monitoring on ECS Fargate with alert channels | [README](l1/uptime/README.md) |
|
||||
|
||||
## Modules
|
||||
|
||||
|
||||
@@ -0,0 +1,588 @@
|
||||
# ACDL Module Engineering Standards
|
||||
|
||||
Standards for authoring and reviewing ACDL modules. These standards
|
||||
govern the two module tiers — **L1 primitives** (single cloud resource
|
||||
or small group of related resources) and **L2 modules** (compositions
|
||||
that reference L1 primitives to deploy a complete stack) — and the
|
||||
substrate adapter that compiles them to Terraform. They are written for
|
||||
**platform engineers** and **AI agents** that author or review new
|
||||
modules against the existing corpus (12 L1 primitives and 2 L2 modules
|
||||
shipped in v1.8).
|
||||
|
||||
A module that fails any section below is not ready to publish.
|
||||
|
||||
## 1. Overview
|
||||
|
||||
These standards codify the conventions already established by the
|
||||
shipped modules (`s3`, `vpc`, `ecs-cluster`, `ecs-service`, `iam-role`,
|
||||
`alb`, `ecr`, `cloudfront`, `waf`, `rds`, `kms-key`, `uptime`; the L2
|
||||
modules `static-assets` and `microservice`). They exist so that:
|
||||
|
||||
- platform engineers can review a new module against a fixed checklist;
|
||||
- AI agents authoring modules produce code that passes review without
|
||||
iteration; and
|
||||
- the substrate adapter (`adapters/terraform/adapter.py`) can compile a
|
||||
module instance with no module-specific code in the adapter beyond the
|
||||
three tables in §8.
|
||||
|
||||
When this document and an existing module disagree, the existing module
|
||||
is the authority for v1.x. A change to this document is a MINOR version
|
||||
bump of the standards; a change that breaks shipped modules is a MAJOR
|
||||
bump and requires a migration plan.
|
||||
|
||||
## 2. L1 Primitive Standards
|
||||
|
||||
An L1 primitive is a single cloud resource or a small group of related
|
||||
resources (e.g. a VPC with subnets and a route table). It is declared by
|
||||
an `interface.json` and realized by the substrate adapter; it does not
|
||||
own Terraform code.
|
||||
|
||||
### 2.1 Required files
|
||||
|
||||
Every L1 primitive MUST contain, at minimum:
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `interface.json` | Substrate-agnostic declaration: inputs, outputs, NFRs, optional multi-resource graph. |
|
||||
| `instance.json` | A concrete instance used as the adapter regression baseline. |
|
||||
| `README.md` | Plain-language documentation following `README-TEMPLATE.md` (see §7). |
|
||||
| `examples/simple.yaml` | A minimal contract that uses the primitive with required inputs only. |
|
||||
| `examples/complex.yaml` | A contract that exercises optional inputs, NFRs, and (if applicable) the multi-resource graph. |
|
||||
|
||||
Directory layout:
|
||||
|
||||
```
|
||||
modules/l1/<name>/
|
||||
interface.json
|
||||
instance.json
|
||||
README.md
|
||||
examples/
|
||||
simple.yaml
|
||||
complex.yaml
|
||||
```
|
||||
|
||||
### 2.2 interface.json schema
|
||||
|
||||
`interface.json` MUST be a JSON object with the following required
|
||||
fields:
|
||||
|
||||
| Field | Type | Constraint |
|
||||
|-------|------|------------|
|
||||
| `name` | string | `^[a-z][a-z0-9-]*$`; MUST match the module folder name. |
|
||||
| `version` | string | Semver (`^\d+\d+\.\d+$`); MUST match the registry entry semver. |
|
||||
| `kind` | string | Literal `"l1"`. |
|
||||
| `type` | string | Stack type in `aws:<service>:<kind>` format (see §2.7). |
|
||||
| `description` | string | One or two sentences in plain language; no Terraform jargon. |
|
||||
| `inputs` | object | Keyed by input name; each value is an input declaration (§2.3). MAY be empty. |
|
||||
| `outputs` | object | Keyed by output name; each value is an output declaration (§2.4). MAY be empty. |
|
||||
| `nfrs` | object | Keyed by NFR name; each value is an NFR declaration (§2.5). MUST include `deletion_protection` and `encryption_enabled`. |
|
||||
|
||||
Optional fields for multi-resource primitives:
|
||||
|
||||
| Field | Type | Constraint |
|
||||
|-------|------|------------|
|
||||
| `resources` | array | One entry per distinct cloud resource; see §2.6. |
|
||||
| `intra_refs` | array | Internal wiring between resources; see §2.6. |
|
||||
|
||||
A primitive that creates a single resource (e.g. `s3`, `iam-role`,
|
||||
`rds`, `kms-key`) omits `resources` and `intra_refs`; its `type` field
|
||||
is the single resource's stack type. A primitive that creates a small
|
||||
group of related resources (e.g. `vpc`, `alb`, `cloudfront`) declares
|
||||
`resources[]` with one entry per resource and `intra_refs[]` for the
|
||||
internal wiring; its `type` field is the *primary* resource's stack
|
||||
type.
|
||||
|
||||
### 2.3 Input declaration
|
||||
|
||||
Each entry in `inputs` is an object:
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|-------|------|----------|-------|
|
||||
| `type` | string | yes | One of: `string`, `number`, `boolean`, `array`, `object`. |
|
||||
| `description` | string | yes | Plain language; no Terraform jargon. |
|
||||
| `required` | boolean | yes | `true` if the consumer MUST supply this input. |
|
||||
| `default` | (any) | no | Present only when `required` is `false`. MUST match the declared `type`. |
|
||||
| `enum` | array | no | Allowed values for `string`/`number` inputs (e.g. RDS `engine`). |
|
||||
|
||||
`region` is a required `string` input on every primitive that creates a
|
||||
regional resource. Global resources (e.g. CloudFront) still declare
|
||||
`region` because the provider region is used for child resources (the
|
||||
OAC in the `cloudfront` case).
|
||||
|
||||
Every primitive that holds at-rest data MUST declare an optional
|
||||
`kms_key_arn` input (`string`, `required: false`); see §4.
|
||||
|
||||
### 2.4 Output declaration
|
||||
|
||||
Each entry in `outputs` is an object:
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|-------|------|----------|-------|
|
||||
| `type` | string | yes | `arn` for ARN outputs; `string` for all others. |
|
||||
| `description` | string | yes | Plain language. |
|
||||
|
||||
Use `arn` (not `string`) for any output that returns an AWS ARN — the
|
||||
adapter and policy engine key off the `arn` type to apply ARN-scoped
|
||||
rules.
|
||||
|
||||
### 2.5 NFR declaration
|
||||
|
||||
Each entry in `nfrs` is an object:
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|-------|------|----------|-------|
|
||||
| `type` | string | yes | One of: `string`, `number`, `boolean`. |
|
||||
| `description` | string | yes | Plain language. |
|
||||
| `default` | (any) | yes | MUST match the declared `type`. NFRs always have a default. |
|
||||
|
||||
Mandatory NFRs on every L1:
|
||||
|
||||
| NFR | Type | Default | Notes |
|
||||
|-----|------|---------|-------|
|
||||
| `deletion_protection` | boolean | `true` | See §5. |
|
||||
| `encryption_enabled` | boolean | `true` | See §4. |
|
||||
|
||||
A primitive for which an NFR does not conceptually apply (e.g. an IAM
|
||||
role has no at-rest data) still declares it with `default: true` and a
|
||||
description noting the non-applicability, so the standards check and the
|
||||
adapter emit logic stay uniform. The shipped `iam-role` primitive is the
|
||||
reference for this case.
|
||||
|
||||
Additional NFRs are encouraged where they carry operational meaning
|
||||
(e.g. `s3.versioning`, `rds.backup_retention_period`,
|
||||
`kms-key.enable_rotation`, `vpc.flow_logs_encrypted`). Name them in
|
||||
lowercase snake_case.
|
||||
|
||||
### 2.6 Multi-resource pattern
|
||||
|
||||
A primitive that creates more than one cloud resource (e.g. `vpc`
|
||||
creates `aws_vpc` + `aws_subnet` + `aws_route_table`; `alb` creates
|
||||
`aws_lb` + `aws_lb_target_group` + `aws_lb_listener`; `cloudfront`
|
||||
creates `aws_cloudfront_distribution` +
|
||||
`aws_cloudfront_origin_access_control`) declares a `resources` array.
|
||||
|
||||
Each `resources[]` entry:
|
||||
|
||||
| Field | Type | Notes |
|
||||
|-------|------|-------|
|
||||
| `type` | string | The resource's stack type (`aws:<service>:<kind>`). |
|
||||
| `description` | string | Plain language. |
|
||||
| `inputs` | array | Names (strings) of inputs from the top-level `inputs` object that this resource consumes. |
|
||||
| `outputs` | array | Names (strings) of outputs from the top-level `outputs` object that this resource produces. |
|
||||
|
||||
The top-level `inputs`/`outputs` objects remain the single source of
|
||||
truth; `resources[].inputs` and `resources[].outputs` are arrays of
|
||||
*names* referencing those objects, not re-declarations.
|
||||
|
||||
`intra_refs[]` wires outputs of one resource to inputs of another
|
||||
within the same primitive. Each entry:
|
||||
|
||||
| Field | Type | Notes |
|
||||
|-------|------|-------|
|
||||
| `from` | string | `<resource-type>.<output-name>` — the producing side. |
|
||||
| `to` | string | `<resource-type>.<input-name>` — the consuming side. |
|
||||
|
||||
Reference: `cloudfront/interface.json` declares an intra-ref from
|
||||
`aws:cloudfront:distribution.oac_id` to
|
||||
`aws:cloudfront:originaccesscontrol.oac_id`; `vpc/interface.json`
|
||||
declares intra-refs from the subnet and route table to the VPC's
|
||||
`vpc_id`.
|
||||
|
||||
### 2.7 Naming and stack types
|
||||
|
||||
- Module folder names and `interface.json` `name` values MUST match
|
||||
`^[a-z][a-z0-9-]*$` (lowercase, hyphenated, leading letter). Examples:
|
||||
`s3`, `ecs-cluster`, `kms-key`, `iam-role`, `uptime`.
|
||||
- Input and output names are lowercase snake_case.
|
||||
- Stack types follow `aws:<service>:<kind>`:
|
||||
- `aws:s3:bucket`
|
||||
- `aws:ec2:vpc`, `aws:ec2:subnet`, `aws:ec2:routetable`
|
||||
- `aws:ecs:cluster`, `aws:ecs:task_definition`, `aws:ecs:service`,
|
||||
`aws:ecs:uptime-service`
|
||||
- `aws:iam:role`
|
||||
- `aws:elbv2:loadbalancer`, `aws:elbv2:listener`,
|
||||
`aws:elbv2:targetgroup`
|
||||
- `aws:ecr:repository`
|
||||
- `aws:cloudfront:distribution`, `aws:cloudfront:originaccesscontrol`
|
||||
- `aws:wafv2:webacl`
|
||||
- `aws:rds:instance`
|
||||
- `aws:kms:key`, `aws:kms:alias`
|
||||
- The substrate adapter's `TYPE_MAP` is the registry of stack types the
|
||||
adapter can compile (see §8). A new stack type requires a `TYPE_MAP`
|
||||
entry before the primitive can be deployed.
|
||||
|
||||
## 3. L2 Module Standards
|
||||
|
||||
An L2 module is a composition that references one or more L1 primitives
|
||||
to deploy a complete stack (e.g. an ECS Fargate microservice, a static
|
||||
asset site behind CloudFront + WAF). It is declared by a
|
||||
`composition.json`; it does not own Terraform code and does not have an
|
||||
`instance.json`.
|
||||
|
||||
### 3.1 Required files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `composition.json` | The composition tree: children, wires, outputs, optional features. |
|
||||
| `README.md` | Plain-language documentation following `README-TEMPLATE.md` (see §7). |
|
||||
| `examples/simple.yaml` | A minimal contract that uses the module with required inputs only. |
|
||||
| `examples/complex.yaml` | A contract that exercises optional inputs and feature flags. |
|
||||
|
||||
Directory layout:
|
||||
|
||||
```
|
||||
modules/l2/<name>/
|
||||
composition.json
|
||||
README.md
|
||||
examples/
|
||||
simple.yaml
|
||||
complex.yaml
|
||||
```
|
||||
|
||||
There is no `instance.json` for an L2 module — the L2 is deployed by
|
||||
resolving the composition tree to L1 instances at compile time, not by
|
||||
loading a pre-baked instance.
|
||||
|
||||
### 3.2 composition.json schema
|
||||
|
||||
`composition.json` MUST be a JSON object with the following fields:
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|-------|------|----------|-------|
|
||||
| `name` | string | yes | `^[a-z][a-z0-9-]*$`; matches the module folder name. |
|
||||
| `version` | string | yes | Semver; matches the registry entry. |
|
||||
| `kind` | string | yes | Literal `"l2"`. |
|
||||
| `depth` | integer | yes | Literal `1` in v1 (see §3.5). |
|
||||
| `description` | string | yes | Plain language. |
|
||||
| `children` | array | yes | One entry per referenced L1 module (§3.3). |
|
||||
| `wires` | array | yes | Wires from contract inputs / child outputs to child inputs / stack outputs (§3.4). |
|
||||
| `outputs` | array | yes | Wires from child outputs to stack outputs (§3.4). |
|
||||
| `features` | object | no | Feature flags propagated to children by the resolver (§3.6). |
|
||||
|
||||
### 3.3 Children
|
||||
|
||||
Each `children[]` entry:
|
||||
|
||||
| Field | Type | Notes |
|
||||
|-------|------|-------|
|
||||
| `id` | string | The child id, unique within the composition. `^[a-z][a-z0-9-]*$`. The id is the local name used in wires (e.g. `vpc`, `cluster`, `kms`). |
|
||||
| `module` | string | `<name>@<semver>` referencing a registered L1 module. |
|
||||
|
||||
Children MUST reference L1 modules registered in `registry.json` (see
|
||||
§6). The referenced semver MUST exist in the registry. An L2 MUST NOT
|
||||
reference another L2 (no L3 in v1; see §3.5).
|
||||
|
||||
Reference: `microservice/composition.json` declares seven children
|
||||
(`vpc`, `cluster`, `ecr`, `roles`, `alb`, `service`, `kms`), each
|
||||
referencing an L1 at `@1.0.0`.
|
||||
|
||||
### 3.4 Wire format
|
||||
|
||||
A wire is a JSON object `{"from": "<source>", "to": "<target>"}` with an
|
||||
optional `default` field for contract-input wires.
|
||||
|
||||
Sources (the `from` side):
|
||||
|
||||
| Source form | Meaning |
|
||||
|-------------|---------|
|
||||
| `contract.inputs.<name>` | A value supplied by the consumer's contract YAML. |
|
||||
| `<childId>.outputs.<name>` | An output produced by a child L1 module. |
|
||||
|
||||
Targets (the `to` side):
|
||||
|
||||
| Target form | Meaning |
|
||||
|-------------|---------|
|
||||
| `<childId>.inputs.<name>` | An input on a child L1 module. |
|
||||
| `stack.outputs.<name>` | A value the L2 exposes as a stack output. |
|
||||
|
||||
Wires that source from `contract.inputs.<name>` MAY carry a `default`
|
||||
value used when the consumer omits the input. Reference:
|
||||
`microservice/composition.json` wires `contract.inputs.bucket_name` to
|
||||
`vpc.inputs.cidr` with `default: "10.0.0.0/16"` (a historical quirk
|
||||
preserved for regression).
|
||||
|
||||
The `outputs[]` array uses the same wire shape but its `to` is always
|
||||
`stack.outputs.<name>` and its `from` is always
|
||||
`<childId>.outputs.<name>`.
|
||||
|
||||
### 3.5 Maximum depth
|
||||
|
||||
`depth` is `1` for every L2 in v1. The composition tree is strictly L2
|
||||
→ L1: an L2 may reference only L1 primitives, never another L2. There
|
||||
is no L3 in v1. The stack schema permits `depth` up to 5 for forward
|
||||
compatibility, but the v1 resolver and adapter only handle depth 1.
|
||||
|
||||
### 3.6 Feature flags
|
||||
|
||||
An L2 MAY declare a `features` object. Two flags are defined in v1:
|
||||
|
||||
| Flag | Type | Default | Effect |
|
||||
|------|------|---------|--------|
|
||||
| `deletion_protection` | boolean | `true` | When `true`, the resolver propagates `deletion_protection: true` to every child's NFRs. When `false`, children are deployed with `deletion_protection: false` (used by decommission; see §5). |
|
||||
| `uptime_enabled` | boolean | `true` | When `true`, the uptime monitoring L1 is deployed after the L2 module in a separate terraform state. When `false`, the uptime deployment is skipped. |
|
||||
|
||||
Feature flags are propagated to children by the resolver; the L2
|
||||
`composition.json` does not need to wire them explicitly as inputs. The
|
||||
resolver reads `features` and injects the corresponding NFR/input on
|
||||
each child.
|
||||
|
||||
## 4. Encryption by Default
|
||||
|
||||
Encryption is mandatory and on by default across the platform.
|
||||
|
||||
1. Every L1 MUST declare an `encryption_enabled` NFR (boolean, default
|
||||
`true`) in `interface.json`. See §2.5.
|
||||
2. Every L1 that holds at-rest data (S3, RDS, ECR, ECS task
|
||||
definition env, VPC flow logs, CloudWatch log groups) MUST declare an
|
||||
optional `kms_key_arn` input (`string`, `required: false`). When
|
||||
supplied, the adapter wires it to the resource's KMS encryption
|
||||
argument.
|
||||
3. L2 modules MUST wire a per-stack customer-managed KMS key to all
|
||||
children that accept `kms_key_arn`. The KMS key is a `kms-key` child
|
||||
of the L2 — one key per L2 deployment, no shared keys. Reference:
|
||||
both `static-assets` and `microservice` declare a `kms` child
|
||||
(`kms-key@1.0.0`) and wire `kms.outputs.kms_key_arn` to every child
|
||||
that accepts a CMK.
|
||||
4. For a standalone L1 deployment (an L1 used outside an L2), if the
|
||||
consumer does not supply `kms_key_arn`, the adapter falls back to the
|
||||
AWS-managed default key for that service and emits a warning to
|
||||
stderr. The primitive is still encrypted; only the key manager
|
||||
differs.
|
||||
5. The `kms-key` primitive enables key rotation by default
|
||||
(`enable_rotation` NFR, default `true`), and the adapter emits
|
||||
`enable_key_rotation = true` on the `aws_kms_key` resource.
|
||||
|
||||
A primitive that does not hold at-rest data (e.g. `iam-role`,
|
||||
`ecs-cluster`, `alb`) still declares `encryption_enabled` for standards
|
||||
uniformity (see §2.5) but does not declare `kms_key_arn`.
|
||||
|
||||
## 5. Deletion Protection by Default
|
||||
|
||||
Deletion protection is mandatory and on by default to prevent
|
||||
accidental teardown of production infrastructure.
|
||||
|
||||
1. Every L1 MUST declare a `deletion_protection` NFR (boolean, default
|
||||
`true`) in `interface.json`. See §2.5.
|
||||
2. When `deletion_protection` is `true`, the substrate adapter emits a
|
||||
`lifecycle { prevent_destroy = true }` block on the corresponding
|
||||
Terraform resource. A `terraform destroy` against a protected
|
||||
resource fails with an error naming the resource.
|
||||
3. L2 modules expose `features.deletion_protection` (default `true`).
|
||||
The resolver propagates the flag to every child's NFRs (see §3.6).
|
||||
4. **Decommission mode.** To tear down a stack that was deployed with
|
||||
deletion protection, the consumer sets `inputs.deletion_protection:
|
||||
false` on the contract (or `features.deletion_protection: false` on
|
||||
an L2) and re-applies. The decommission transform
|
||||
(`decommission_transform`) zeroes capacity counts (e.g. ECS desired
|
||||
count to 0, RDS allocated storage to the minimum) so that the
|
||||
subsequent `destroy` applies against a quiesced stack. The transform
|
||||
is applied by the resolver before the adapter emits resources.
|
||||
|
||||
## 6. Registry
|
||||
|
||||
Every module — L1 and L2 — MUST be registered in
|
||||
`modules/registry.json` at its semver. The registry is the source of
|
||||
truth for what is published; the adapter and resolver refuse to compile
|
||||
a module that is not registered.
|
||||
|
||||
Registry entry shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"<module-name>": {
|
||||
"<semver>": {
|
||||
"interface": "modules/<l1|l2>/<module-name>/<interface.json|composition.json>",
|
||||
"published_at": "<ISO 8601 timestamp>",
|
||||
"deprecated": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `interface` is the path (relative to the repo root) to the module's
|
||||
interface file — `interface.json` for an L1, `composition.json` for
|
||||
an L2.
|
||||
- `published_at` is an ISO 8601 timestamp. Use a full
|
||||
`YYYY-MM-DDTHH:MM:SSZ` form; do not omit the seconds or the timezone
|
||||
designator.
|
||||
- `deprecated` is `false` for a live module. A MAJOR version bump does
|
||||
not delete the old entry; it flips `deprecated` to `true` and starts a
|
||||
12-month deprecation window (see §7 Versioning).
|
||||
|
||||
A new semver of an existing module is a new key under the module's
|
||||
object; old semvers are retained. The registry is append-only for
|
||||
published semvers — a published semver is never edited or deleted.
|
||||
|
||||
## 7. README Standards
|
||||
|
||||
Every module README MUST follow the structure of
|
||||
`modules/README-TEMPLATE.md`. Required sections, in order:
|
||||
|
||||
1. `# <name> — <plain-language description>` — title with the module
|
||||
name and a one-line description.
|
||||
2. `## Overview` — one or two sentences in plain language.
|
||||
3. `## Resources` — a table of the Terraform resources the module
|
||||
creates (L1) or the primitives it references (L2).
|
||||
4. `## Inputs` — a table: `| Name | Type | Required | Default | Description |`.
|
||||
5. `## Outputs` — a table: `| Name | Type | Description |`.
|
||||
6. `## NFRs` — a table: `| Name | Type | Default | Description |`.
|
||||
`deletion_protection` and `encryption_enabled` are mandatory NFRs
|
||||
for every L1; they MUST appear in this table.
|
||||
7. `## Usage` — a concrete snippet showing how a consumer references
|
||||
the module in a contract.
|
||||
8. `## Compliance extension points` — resources or behaviors that could
|
||||
be added for the future compliance milestone (GDPR, SOX, SOC2, HIPAA,
|
||||
DORA). Not implemented yet; listed so the redesign can plan for them.
|
||||
9. `## Examples` — links to `examples/simple.yaml` and
|
||||
`examples/complex.yaml` with a one-line description of each.
|
||||
10. `## Versioning` — the module's semver policy: interface MAJOR,
|
||||
behavior MINOR, lifecycle PATCH. MAJOR bumps require a new
|
||||
`registry.json` entry (immutable publication); old entries enter a
|
||||
12-month deprecation window.
|
||||
|
||||
An L2 README's `## Resources` section lists the referenced L1 children
|
||||
rather than Terraform resources, and its `## Inputs`/`## Outputs`
|
||||
sections reflect the contract inputs and stack outputs of the
|
||||
composition.
|
||||
|
||||
## 8. Adapter Extension Pattern
|
||||
|
||||
The Terraform adapter (`adapters/terraform/adapter.py`) is a thin
|
||||
translator. It owns no module content; it only maps stack types and
|
||||
names to Terraform types and arguments via three tables and, for
|
||||
complex resources, a specialized emit branch.
|
||||
|
||||
### 8.1 The three tables
|
||||
|
||||
| Table | Purpose | Keys | Values |
|
||||
|-------|---------|------|--------|
|
||||
| `TYPE_MAP` | Stack type → Terraform resource type. | Stack type string (`aws:<service>:<kind>`). | Terraform resource type (`aws_s3_bucket`, `aws_db_instance`, etc.). |
|
||||
| `INPUT_MAP` | Stack input name → Terraform argument name, per stack type. Only non-identity mappings are listed; an input not present uses the stack name as the Terraform arg (identity). | Stack type. | Object mapping input name → Terraform arg name. |
|
||||
| `OUTPUT_MAP` | Stack output name → Terraform attribute name, per stack type. Only non-identity mappings are listed. | Stack type. | Object mapping output name → Terraform attribute name. |
|
||||
|
||||
Reference: `adapter.py:26` (`TYPE_MAP`), `adapter.py:51` (`INPUT_MAP`),
|
||||
`adapter.py:75` (`OUTPUT_MAP`).
|
||||
|
||||
### 8.2 Specialized `_emit_resource` branches
|
||||
|
||||
Most resources emit with the generic loop in `_emit_resource`
|
||||
(`adapter.py:156`): for each input, look up the Terraform arg in
|
||||
`INPUT_MAP`, render the value, append `arg = value`. Resources with
|
||||
nested HCL blocks need a specialized branch. The shipped examples:
|
||||
|
||||
- `aws:ecs:service` emits a `load_balancer {}` block from the
|
||||
`lb_target_group_arn` input.
|
||||
- `aws:elbv2:loadbalancer` wraps `subnets` and `security_group` in list
|
||||
brackets.
|
||||
- `aws:cloudfront:distribution` emits nested `origin {}`,
|
||||
`default_cache_behavior {}`, and
|
||||
`server_side_encryption_configuration {}` blocks.
|
||||
- `aws:wafv2:webacl` emits nested `rules {}` blocks.
|
||||
- `aws:ecs:task_definition` emits a `container_definitions` jsonencode
|
||||
block from `image`/`port`/`env`.
|
||||
|
||||
A specialized branch lives inside `_emit_resource` and is keyed on the
|
||||
stack type. It reads the input value, renders the nested block, and
|
||||
appends the lines to `body`.
|
||||
|
||||
### 8.3 Adding a new L1 to the adapter
|
||||
|
||||
When a new L1 primitive is added:
|
||||
|
||||
1. Add one entry to `TYPE_MAP` for each stack type the primitive
|
||||
declares (single resource → one entry; multi-resource → one entry
|
||||
per resource in `resources[]`).
|
||||
2. Add one entry to `INPUT_MAP` for each stack type, listing only the
|
||||
inputs whose Terraform arg name differs from the stack input name
|
||||
(identity mappings are omitted).
|
||||
3. Add one entry to `OUTPUT_MAP` for each stack type, listing only the
|
||||
outputs whose Terraform attribute name differs from the stack output
|
||||
name.
|
||||
4. If any resource requires nested HCL blocks, add a specialized branch
|
||||
in `_emit_resource` keyed on that stack type.
|
||||
|
||||
If steps 1–3 are done and no specialized branch is needed, the
|
||||
primitive deploys with no further adapter changes. The L1 content and
|
||||
the contract YAML do not change when the adapter grows.
|
||||
|
||||
## 9. Code Review Checklist
|
||||
|
||||
Use this checklist when reviewing a new module (L1 or L2). Every box
|
||||
must be checked before the module is registered and published.
|
||||
|
||||
### 9.1 Files and structure
|
||||
|
||||
- [ ] All required files present:
|
||||
- L1: `interface.json`, `instance.json`, `README.md`,
|
||||
`examples/simple.yaml`, `examples/complex.yaml`.
|
||||
- L2: `composition.json`, `README.md`, `examples/simple.yaml`,
|
||||
`examples/complex.yaml` (no `instance.json`).
|
||||
- [ ] `interface.json` (L1) / `composition.json` (L2) validates against
|
||||
`schemas/stack.schema.json`.
|
||||
- [ ] `examples/simple.yaml` and `examples/complex.yaml` validate
|
||||
against `schemas/contract.schema.json`.
|
||||
- [ ] Module registered in `modules/registry.json` at its semver with a
|
||||
full ISO 8601 `published_at` and `deprecated: false`.
|
||||
|
||||
### 9.2 Interface (L1)
|
||||
|
||||
- [ ] `name` matches the folder name and `^[a-z][a-z0-9-]*$`.
|
||||
- [ ] `version` is semver and matches the registry entry.
|
||||
- [ ] `kind` is `"l1"`.
|
||||
- [ ] `type` follows `aws:<service>:<kind>`.
|
||||
- [ ] Every input has `type`, `description`, `required`; optional inputs
|
||||
carry a `default` of the correct type; `enum` present where the value
|
||||
set is constrained.
|
||||
- [ ] Every output has `type` (`arn` for ARNs, `string` otherwise) and
|
||||
`description`.
|
||||
- [ ] `nfrs` includes `deletion_protection` (boolean, default `true`)
|
||||
and `encryption_enabled` (boolean, default `true`).
|
||||
- [ ] `kms_key_arn` input present if the primitive holds at-rest data.
|
||||
- [ ] Multi-resource primitives declare `resources[]` (with `inputs`/
|
||||
`outputs` as arrays of names) and `intra_refs[]` with `{from, to}`.
|
||||
|
||||
### 9.3 Composition (L2)
|
||||
|
||||
- [ ] `kind` is `"l2"` and `depth` is `1`.
|
||||
- [ ] Every `children[]` entry is `{id, module}` with `module` in
|
||||
`<name>@<semver>` form referencing a registered L1.
|
||||
- [ ] No child references an L2 (no L3 in v1).
|
||||
- [ ] `wires[]` use the `contract.inputs.<name>` /
|
||||
`<childId>.outputs.<name>` → `<childId>.inputs.<name>` /
|
||||
`stack.outputs.<name>` forms.
|
||||
- [ ] `outputs[]` use `<childId>.outputs.<name>` →
|
||||
`stack.outputs.<name>`.
|
||||
- [ ] A `kms` child (`kms-key@<semver>`) is present and its
|
||||
`kms_key_arn` output is wired to every child that accepts a CMK.
|
||||
- [ ] `features` (if present) only uses defined flags
|
||||
(`deletion_protection`, `uptime_enabled`).
|
||||
|
||||
### 9.4 Adapter
|
||||
|
||||
- [ ] `TYPE_MAP` has an entry for every stack type the new primitive
|
||||
declares.
|
||||
- [ ] `INPUT_MAP` and `OUTPUT_MAP` have entries for every stack type,
|
||||
listing only non-identity mappings.
|
||||
- [ ] A specialized `_emit_resource` branch is added for any resource
|
||||
that needs nested HCL blocks.
|
||||
- [ ] The new primitive's `instance.json` round-trips through the
|
||||
adapter without error (regression baseline).
|
||||
|
||||
### 9.5 README and docs
|
||||
|
||||
- [ ] README follows `README-TEMPLATE.md` with all required sections in
|
||||
order (§7).
|
||||
- [ ] `## NFRs` table lists `deletion_protection` and
|
||||
`encryption_enabled` for an L1.
|
||||
- [ ] `## Compliance extension points` lists at least one plausible
|
||||
future extension.
|
||||
|
||||
### 9.6 Tests
|
||||
|
||||
- [ ] A test is added for the new primitive covering adapter emission
|
||||
(the Terraform output for `instance.json` matches the expected
|
||||
fixture) and interface validation (`interface.json` validates against
|
||||
`stack.schema.json`).
|
||||
- [ ] For an L2, a test is added that the composition resolves to the
|
||||
expected set of L1 instances and that the adapter emits a root module
|
||||
calling the L1 modules.
|
||||
@@ -52,7 +52,23 @@
|
||||
"description": "The target group ARN."
|
||||
}
|
||||
},
|
||||
"nfrs": {},
|
||||
"nfrs": {
|
||||
"encryption_enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable TLS/HTTPS encryption in transit.",
|
||||
"default": true
|
||||
},
|
||||
"tls_enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable TLS listener.",
|
||||
"default": true
|
||||
},
|
||||
"deletion_protection": {
|
||||
"type": "boolean",
|
||||
"description": "Prevent resource destruction via Terraform lifecycle prevent_destroy",
|
||||
"default": true
|
||||
}
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"type": "aws:elbv2:loadbalancer",
|
||||
|
||||
@@ -59,7 +59,18 @@
|
||||
"description": "The Origin Access Control ID."
|
||||
}
|
||||
},
|
||||
"nfrs": {},
|
||||
"nfrs": {
|
||||
"encryption_enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable encryption in transit (HTTPS only).",
|
||||
"default": true
|
||||
},
|
||||
"deletion_protection": {
|
||||
"type": "boolean",
|
||||
"description": "Prevent resource destruction via Terraform lifecycle prevent_destroy",
|
||||
"default": true
|
||||
}
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"type": "aws:cloudfront:distribution",
|
||||
|
||||
@@ -14,6 +14,11 @@
|
||||
"type": "string",
|
||||
"description": "AWS region the repository is created in.",
|
||||
"required": true
|
||||
},
|
||||
"kms_key_arn": {
|
||||
"type": "string",
|
||||
"description": "ARN of the CMK for repository encryption; if absent, uses AWS-managed key.",
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
@@ -26,5 +31,21 @@
|
||||
"description": "The ECR repository ARN."
|
||||
}
|
||||
},
|
||||
"nfrs": {}
|
||||
"nfrs": {
|
||||
"encryption_enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable repository encryption (KMS).",
|
||||
"default": true
|
||||
},
|
||||
"encryption_type": {
|
||||
"type": "string",
|
||||
"description": "Encryption type.",
|
||||
"default": "KMS"
|
||||
},
|
||||
"deletion_protection": {
|
||||
"type": "boolean",
|
||||
"description": "Prevent resource destruction via Terraform lifecycle prevent_destroy",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,11 @@
|
||||
"type": "string",
|
||||
"description": "AWS region the cluster is created in.",
|
||||
"required": true
|
||||
},
|
||||
"kms_key_arn": {
|
||||
"type": "string",
|
||||
"description": "ARN of the CMK for CloudWatch log group encryption; if absent, uses managed key.",
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
@@ -26,5 +31,16 @@
|
||||
"description": "The ECS cluster id (name)."
|
||||
}
|
||||
},
|
||||
"nfrs": {}
|
||||
"nfrs": {
|
||||
"encryption_enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable CloudWatch log group encryption.",
|
||||
"default": true
|
||||
},
|
||||
"deletion_protection": {
|
||||
"type": "boolean",
|
||||
"description": "Prevent resource destruction via Terraform lifecycle prevent_destroy",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,11 @@
|
||||
"type": "string",
|
||||
"description": "AWS region the service is created in.",
|
||||
"required": true
|
||||
},
|
||||
"kms_key_arn": {
|
||||
"type": "string",
|
||||
"description": "ARN of the CMK for CloudWatch log group encryption; if absent, uses managed key.",
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
@@ -68,7 +73,18 @@
|
||||
"description": "The ECS task definition ARN."
|
||||
}
|
||||
},
|
||||
"nfrs": {},
|
||||
"nfrs": {
|
||||
"encryption_enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable CloudWatch log group encryption.",
|
||||
"default": true
|
||||
},
|
||||
"deletion_protection": {
|
||||
"type": "boolean",
|
||||
"description": "Prevent resource destruction via Terraform lifecycle prevent_destroy",
|
||||
"default": true
|
||||
}
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"type": "aws:ecs:task_definition",
|
||||
|
||||
@@ -36,5 +36,16 @@
|
||||
"description": "The IAM role id."
|
||||
}
|
||||
},
|
||||
"nfrs": {}
|
||||
"nfrs": {
|
||||
"encryption_enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Encryption is not applicable to IAM roles but included for standards compliance.",
|
||||
"default": true
|
||||
},
|
||||
"deletion_protection": {
|
||||
"type": "boolean",
|
||||
"description": "Prevent resource destruction via Terraform lifecycle prevent_destroy",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
# kms-key — KMS customer-managed key
|
||||
|
||||
> **Module kind:** primitive | **Version:** 1.0.0
|
||||
|
||||
A customer-managed KMS key for per-stack encryption. Created with key
|
||||
rotation enabled. One key per L2 deployment (no shared keys).
|
||||
|
||||
## Resources
|
||||
|
||||
| Resource | Type | Purpose |
|
||||
|----------|------|---------|
|
||||
| kms-key | `aws_kms_key` | The KMS customer-managed key |
|
||||
|
||||
## Inputs
|
||||
|
||||
| Name | Type | Required | Default | Description |
|
||||
|------|------|----------|---------|-------------|
|
||||
| `description` | string | yes | — | Description of the KMS key |
|
||||
| `region` | string | yes | — | AWS region the KMS key is created in |
|
||||
| `deletion_window_days` | number | no | 30 | Number of days before the key is deleted after deletion is requested |
|
||||
|
||||
## Outputs
|
||||
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `kms_key_arn` | arn | The ARN of the KMS key |
|
||||
| `kms_key_id` | string | The ID of the KMS key |
|
||||
|
||||
## NFRs
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `enable_rotation` | boolean | true | Enable automatic key rotation |
|
||||
| `deletion_protection` | boolean | true | Prevent key destruction |
|
||||
| `encryption_enabled` | boolean | true | Encryption is always enabled for a KMS key |
|
||||
|
||||
## Usage
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "kms-key",
|
||||
"type": "aws:kms:key",
|
||||
"module": "kms-key@1.0.0",
|
||||
"inputs": {
|
||||
"description": "ACDL per-stack CMK",
|
||||
"region": "us-east-1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
A concrete instance is at `instance.json` (used by the platform
|
||||
pipeline as the regression baseline).
|
||||
|
||||
## Compliance extension points
|
||||
|
||||
- **Key rotation** — automatic key rotation enabled by default (SOC2 CC6.1, HIPAA §164.312(a)(2)(iv), GDPR Art.32).
|
||||
- **Deletion protection** — pending deletion window prevents accidental destruction (SOC2 CC7.2).
|
||||
- **Key policy** — restrict key usage to the stack's IAM roles (SOC2 CC6.1, GDPR Art.32).
|
||||
- **Audit logging** — CloudTrail logs all KMS API calls (SOC2 CC7.2, DORA audit trail).
|
||||
|
||||
## Examples
|
||||
|
||||
Validated example contracts are in [`examples/`](examples/). The platform-test
|
||||
pipeline validates them against `schemas/contract.schema.json`.
|
||||
|
||||
### Simple
|
||||
|
||||
A minimal deployment:
|
||||
|
||||
[`examples/simple.yaml`](examples/simple.yaml)
|
||||
```yaml
|
||||
uses: acdl/pipelines/deploy.yaml@v1.8
|
||||
module: kms-key
|
||||
environment: dev
|
||||
inputs:
|
||||
description: "Simple CMK for testing"
|
||||
region: us-east-1
|
||||
```
|
||||
|
||||
### Complex
|
||||
|
||||
A production deployment with optional inputs:
|
||||
|
||||
[`examples/complex.yaml`](examples/complex.yaml)
|
||||
```yaml
|
||||
uses: acdl/pipelines/deploy.yaml@v1.8
|
||||
module: kms-key
|
||||
environment: dev
|
||||
inputs:
|
||||
description: "Production CMK with 90-day deletion window"
|
||||
region: us-east-1
|
||||
deletion_window_days: 90
|
||||
```
|
||||
|
||||
## Versioning
|
||||
|
||||
`1.0.0` — interface MAJOR, behavior MINOR, lifecycle PATCH. MAJOR bumps
|
||||
require a new registry entry (immutable publication); old entries enter
|
||||
a 12-month deprecation window.
|
||||
@@ -0,0 +1,7 @@
|
||||
uses: acdl/pipelines/deploy.yaml@v1.8
|
||||
module: kms-key
|
||||
environment: dev
|
||||
inputs:
|
||||
description: "Production CMK with 90-day deletion window"
|
||||
region: us-east-1
|
||||
deletion_window_days: 90
|
||||
@@ -0,0 +1,6 @@
|
||||
uses: acdl/pipelines/deploy.yaml@v1.8
|
||||
module: kms-key
|
||||
environment: dev
|
||||
inputs:
|
||||
description: "Simple CMK for testing"
|
||||
region: us-east-1
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"stack": {
|
||||
"name": "kms-key",
|
||||
"kind": "l1",
|
||||
"depth": 1
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "kms-key",
|
||||
"type": "aws:kms:key",
|
||||
"module": "kms-key@1.0.0",
|
||||
"inputs": {
|
||||
"description": "ACDL per-stack CMK",
|
||||
"region": "us-east-1",
|
||||
"deletion_window_days": 30
|
||||
},
|
||||
"outputs": {
|
||||
"kms_key_arn": {
|
||||
"type": "arn"
|
||||
},
|
||||
"kms_key_id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"nfrs": {
|
||||
"enable_rotation": true,
|
||||
"deletion_protection": true,
|
||||
"encryption_enabled": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "kms-key",
|
||||
"version": "1.0.0",
|
||||
"kind": "l1",
|
||||
"type": "aws:kms:key",
|
||||
"description": "A customer-managed KMS key for per-stack encryption. Created with key rotation enabled. One key per L2 deployment (no shared keys).",
|
||||
"inputs": {
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Description of the KMS key.",
|
||||
"required": true
|
||||
},
|
||||
"region": {
|
||||
"type": "string",
|
||||
"description": "AWS region the KMS key is created in.",
|
||||
"required": true
|
||||
},
|
||||
"deletion_window_days": {
|
||||
"type": "number",
|
||||
"description": "Number of days before the key is deleted after deletion is requested (default 30).",
|
||||
"required": false,
|
||||
"default": 30
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"kms_key_arn": {
|
||||
"type": "arn",
|
||||
"description": "The ARN of the KMS key."
|
||||
},
|
||||
"kms_key_id": {
|
||||
"type": "string",
|
||||
"description": "The ID of the KMS key."
|
||||
}
|
||||
},
|
||||
"nfrs": {
|
||||
"enable_rotation": {
|
||||
"type": "boolean",
|
||||
"description": "Enable automatic key rotation.",
|
||||
"default": true
|
||||
},
|
||||
"deletion_protection": {
|
||||
"type": "boolean",
|
||||
"description": "Prevent key destruction.",
|
||||
"default": true
|
||||
},
|
||||
"encryption_enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Encryption is always enabled for a KMS key.",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,11 @@
|
||||
"type": "string",
|
||||
"description": "AWS region the RDS instance is created in.",
|
||||
"required": true
|
||||
},
|
||||
"kms_key_arn": {
|
||||
"type": "string",
|
||||
"description": "ARN of the CMK for storage encryption; if absent, uses AWS-managed key.",
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
@@ -76,6 +81,11 @@
|
||||
"type": "boolean",
|
||||
"description": "Enable deletion protection (default true for prod).",
|
||||
"default": true
|
||||
},
|
||||
"encryption_enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable storage encryption.",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,11 @@
|
||||
"type": "string",
|
||||
"description": "AWS region the bucket is created in.",
|
||||
"required": true
|
||||
},
|
||||
"kms_key_arn": {
|
||||
"type": "string",
|
||||
"description": "ARN of the CMK for SSE-KMS; if absent, uses managed key.",
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
@@ -35,6 +40,21 @@
|
||||
"type": "boolean",
|
||||
"description": "Enable S3 versioning (default true).",
|
||||
"default": true
|
||||
},
|
||||
"encryption_enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable server-side encryption.",
|
||||
"default": true
|
||||
},
|
||||
"sse_algorithm": {
|
||||
"type": "string",
|
||||
"description": "SSE algorithm.",
|
||||
"default": "aws:kms"
|
||||
},
|
||||
"deletion_protection": {
|
||||
"type": "boolean",
|
||||
"description": "Prevent resource destruction via Terraform lifecycle prevent_destroy",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
# uptime — Uptime Kuma monitoring service
|
||||
|
||||
> **Module kind:** primitive | **Version:** 1.0.0
|
||||
|
||||
Uptime-kuma is a self-hosted monitoring tool deployed as an ECS Fargate
|
||||
container. It supports HTTP, DNS, and TCP health checks and can notify
|
||||
on-call via Teams, email, SMS, or GitHub issues. The module is deployed
|
||||
by default after any L2 module with a separate terraform state and can
|
||||
be disabled via the `feature_flag_enabled` input.
|
||||
|
||||
## Resources
|
||||
|
||||
| Resource | Type | Purpose |
|
||||
|----------|------|---------|
|
||||
| uptime | `aws:ecs:uptime-service` | ECS Fargate task + service + ALB + EFS volume |
|
||||
|
||||
## Inputs
|
||||
|
||||
| Name | Type | Required | Default | Description |
|
||||
|------|------|----------|---------|-------------|
|
||||
| `container_image` | string | no | `louislam/uptime-kuma:1` | Docker image for uptime-kuma |
|
||||
| `region` | string | yes | — | AWS region |
|
||||
| `uptime_url` | string | no | — | Custom domain for the uptime dashboard (optional; if absent, the ALB DNS is used) |
|
||||
| `monitored_endpoints` | array | no | `[]` | Array of endpoints to monitor. Each entry: {name, url, type (http\|dns\|tcp), interval_seconds, timeout_seconds} |
|
||||
| `static_checks` | array | no | `[]` | Pre-defined health checks (hardcoded monitors that don't depend on L2 outputs). Same shape as monitored_endpoints. |
|
||||
| `alert_channels` | object | no | `{}` | Alert notification channels. Keys: teams_webhook (string), email_addresses (array of strings), sms_numbers (array of strings), github_issue_repo (string, org/repo format) |
|
||||
| `feature_flag_enabled` | boolean | no | `true` | Feature flag: when false, no resources are emitted (the uptime deployment is skipped entirely) |
|
||||
| `cpu` | number | no | `256` | CPU units for the ECS task (256 = 0.25 vCPU) |
|
||||
| `memory` | number | no | `512` | Memory for the ECS task in MB |
|
||||
|
||||
## Outputs
|
||||
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `uptime_url` | string | The URL of the uptime-kuma dashboard (ALB DNS or custom domain) |
|
||||
| `service_arn` | arn | The ARN of the ECS service |
|
||||
| `task_definition_arn` | arn | The ARN of the ECS task definition |
|
||||
|
||||
## NFRs
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `deletion_protection` | boolean | true | Prevent resource destruction via Terraform lifecycle prevent_destroy |
|
||||
| `encryption_enabled` | boolean | true | Enable CloudWatch log group encryption with KMS |
|
||||
|
||||
## Usage
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uptime",
|
||||
"type": "aws:ecs:uptime-service",
|
||||
"module": "uptime@1.0.0",
|
||||
"inputs": {
|
||||
"container_image": "louislam/uptime-kuma:1",
|
||||
"region": "us-east-1",
|
||||
"feature_flag_enabled": true,
|
||||
"cpu": 256,
|
||||
"memory": 512,
|
||||
"monitored_endpoints": [
|
||||
{"name": "example", "url": "https://example.com", "type": "http", "interval_seconds": 60, "timeout_seconds": 30}
|
||||
],
|
||||
"alert_channels": {
|
||||
"teams_webhook": "https://hooks.example.com/webhook",
|
||||
"email_addresses": ["oncall@example.com"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
A concrete instance is at `instance.json` (used by the platform
|
||||
pipeline as the regression baseline).
|
||||
|
||||
## Compliance extension points
|
||||
|
||||
- **KMS encryption for EFS** — encrypt the EFS volume that persists uptime-kuma state with a customer-managed KMS key (SOC2 CC6.1, HIPAA §164.312(a)(2)(iv), GDPR Art.32).
|
||||
- **HTTPS/TLS for the ALB** — attach an ACM certificate and HTTPS listener to the ALB so the dashboard is served over TLS (SOC2 CC6.1, GDPR Art.32).
|
||||
- **WAF in front of uptime dashboard** — place a WAF web ACL in front of the ALB to protect the dashboard from common exploits (SOC2 CC7.2).
|
||||
- **Secrets Manager for alert webhook URLs** — store Teams webhook URLs and other credentials in AWS Secrets Manager rather than plaintext inputs (SOC2 CC6.1, GDPR Art.32).
|
||||
- **CloudWatch alarms for uptime-kuma health** — add CloudWatch alarms on ECS task health and ALB 5xx rates to alert when the monitoring tool itself is degraded (SOC2 CC7.2, DORA Art.11).
|
||||
|
||||
## Examples
|
||||
|
||||
Validated example contracts are in [`examples/`](examples/). The platform-test
|
||||
pipeline validates them against `schemas/contract.schema.json`.
|
||||
|
||||
### Simple
|
||||
|
||||
A minimal deployment:
|
||||
|
||||
[`examples/simple.yaml`](examples/simple.yaml)
|
||||
```yaml
|
||||
uses: acdl/pipelines/deploy.yaml@v1.8
|
||||
module: uptime
|
||||
environment: dev
|
||||
inputs:
|
||||
region: us-east-1
|
||||
feature_flag_enabled: true
|
||||
```
|
||||
|
||||
### Complex
|
||||
|
||||
A production deployment with monitored endpoints, static checks, and
|
||||
multiple alert channels:
|
||||
|
||||
[`examples/complex.yaml`](examples/complex.yaml)
|
||||
```yaml
|
||||
uses: acdl/pipelines/deploy.yaml@v1.8
|
||||
module: uptime
|
||||
environment: dev
|
||||
inputs:
|
||||
region: us-east-1
|
||||
feature_flag_enabled: true
|
||||
cpu: 512
|
||||
memory: 1024
|
||||
monitored_endpoints:
|
||||
- name: "api-health"
|
||||
url: "https://api.example.com/health"
|
||||
type: "http"
|
||||
interval_seconds: 30
|
||||
timeout_seconds: 10
|
||||
- name: "dns-check"
|
||||
url: "example.com"
|
||||
type: "dns"
|
||||
interval_seconds: 60
|
||||
timeout_seconds: 10
|
||||
- name: "tcp-check"
|
||||
url: "db.example.com:5432"
|
||||
type: "tcp"
|
||||
interval_seconds: 60
|
||||
timeout_seconds: 10
|
||||
static_checks:
|
||||
- name: "google"
|
||||
url: "https://google.com"
|
||||
type: "http"
|
||||
interval_seconds: 60
|
||||
timeout_seconds: 10
|
||||
alert_channels:
|
||||
teams_webhook: "https://hooks.example.com/teams/webhook"
|
||||
email_addresses:
|
||||
- "oncall@example.com"
|
||||
- "sre@example.com"
|
||||
sms_numbers:
|
||||
- "+1234567890"
|
||||
github_issue_repo: "acdl/acdl"
|
||||
```
|
||||
|
||||
## Versioning
|
||||
|
||||
`1.0.0` — interface MAJOR, behavior MINOR, lifecycle PATCH. MAJOR bumps
|
||||
require a new registry entry (immutable publication); old entries enter
|
||||
a 12-month deprecation window.
|
||||
@@ -0,0 +1,38 @@
|
||||
uses: acdl/pipelines/deploy.yaml@v1.8
|
||||
module: uptime
|
||||
environment: dev
|
||||
inputs:
|
||||
region: us-east-1
|
||||
feature_flag_enabled: true
|
||||
cpu: 512
|
||||
memory: 1024
|
||||
monitored_endpoints:
|
||||
- name: "api-health"
|
||||
url: "https://api.example.com/health"
|
||||
type: "http"
|
||||
interval_seconds: 30
|
||||
timeout_seconds: 10
|
||||
- name: "dns-check"
|
||||
url: "example.com"
|
||||
type: "dns"
|
||||
interval_seconds: 60
|
||||
timeout_seconds: 10
|
||||
- name: "tcp-check"
|
||||
url: "db.example.com:5432"
|
||||
type: "tcp"
|
||||
interval_seconds: 60
|
||||
timeout_seconds: 10
|
||||
static_checks:
|
||||
- name: "google"
|
||||
url: "https://google.com"
|
||||
type: "http"
|
||||
interval_seconds: 60
|
||||
timeout_seconds: 10
|
||||
alert_channels:
|
||||
teams_webhook: "https://hooks.example.com/teams/webhook"
|
||||
email_addresses:
|
||||
- "oncall@example.com"
|
||||
- "sre@example.com"
|
||||
sms_numbers:
|
||||
- "+1234567890"
|
||||
github_issue_repo: "acdl/acdl"
|
||||
@@ -0,0 +1,6 @@
|
||||
uses: acdl/pipelines/deploy.yaml@v1.8
|
||||
module: uptime
|
||||
environment: dev
|
||||
inputs:
|
||||
region: us-east-1
|
||||
feature_flag_enabled: true
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"stack": {
|
||||
"name": "uptime",
|
||||
"kind": "l1",
|
||||
"depth": 1
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "uptime",
|
||||
"type": "aws:ecs:uptime-service",
|
||||
"module": "uptime@1.0.0",
|
||||
"inputs": {
|
||||
"container_image": "louislam/uptime-kuma:1",
|
||||
"region": "us-east-1",
|
||||
"feature_flag_enabled": true,
|
||||
"cpu": 256,
|
||||
"memory": 512,
|
||||
"monitored_endpoints": [
|
||||
{"name": "example", "url": "https://example.com", "type": "http", "interval_seconds": 60, "timeout_seconds": 30}
|
||||
],
|
||||
"alert_channels": {
|
||||
"teams_webhook": "https://hooks.example.com/webhook",
|
||||
"email_addresses": ["oncall@example.com"]
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"uptime_url": {"type": "string"},
|
||||
"service_arn": {"type": "arn"},
|
||||
"task_definition_arn": {"type": "arn"}
|
||||
},
|
||||
"nfrs": {
|
||||
"deletion_protection": true,
|
||||
"encryption_enabled": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"name": "uptime",
|
||||
"version": "1.0.0",
|
||||
"kind": "l1",
|
||||
"type": "aws:ecs:uptime-service",
|
||||
"description": "Deploys uptime-kuma as an ECS Fargate container for self-hosted uptime monitoring. Supports HTTP, DNS, and TCP health checks. Includes alert channels (Teams, email, SMS, GitHub issues). Deployed by default after any L2 module with a separate terraform state. Can be disabled via the feature_flag_enabled input.",
|
||||
"inputs": {
|
||||
"container_image": {
|
||||
"type": "string",
|
||||
"description": "Docker image for uptime-kuma",
|
||||
"required": false,
|
||||
"default": "louislam/uptime-kuma:1"
|
||||
},
|
||||
"region": {
|
||||
"type": "string",
|
||||
"description": "AWS region",
|
||||
"required": true
|
||||
},
|
||||
"uptime_url": {
|
||||
"type": "string",
|
||||
"description": "Custom domain for the uptime dashboard (optional; if absent, the ALB DNS is used)",
|
||||
"required": false
|
||||
},
|
||||
"monitored_endpoints": {
|
||||
"type": "array",
|
||||
"description": "Array of endpoints to monitor. Each entry: {name, url, type (http|dns|tcp), interval_seconds, timeout_seconds}",
|
||||
"required": false,
|
||||
"default": []
|
||||
},
|
||||
"static_checks": {
|
||||
"type": "array",
|
||||
"description": "Pre-defined health checks (hardcoded monitors that don't depend on L2 outputs). Same shape as monitored_endpoints.",
|
||||
"required": false,
|
||||
"default": []
|
||||
},
|
||||
"alert_channels": {
|
||||
"type": "object",
|
||||
"description": "Alert notification channels. Keys: teams_webhook (string), email_addresses (array of strings), sms_numbers (array of strings), github_issue_repo (string, org/repo format)",
|
||||
"required": false,
|
||||
"default": {}
|
||||
},
|
||||
"feature_flag_enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Feature flag: when false, no resources are emitted (the uptime deployment is skipped entirely)",
|
||||
"required": false,
|
||||
"default": true
|
||||
},
|
||||
"cpu": {
|
||||
"type": "number",
|
||||
"description": "CPU units for the ECS task (256 = 0.25 vCPU)",
|
||||
"required": false,
|
||||
"default": 256
|
||||
},
|
||||
"memory": {
|
||||
"type": "number",
|
||||
"description": "Memory for the ECS task in MB",
|
||||
"required": false,
|
||||
"default": 512
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"uptime_url": {
|
||||
"type": "string",
|
||||
"description": "The URL of the uptime-kuma dashboard (ALB DNS or custom domain)"
|
||||
},
|
||||
"service_arn": {
|
||||
"type": "arn",
|
||||
"description": "The ARN of the ECS service"
|
||||
},
|
||||
"task_definition_arn": {
|
||||
"type": "arn",
|
||||
"description": "The ARN of the ECS task definition"
|
||||
}
|
||||
},
|
||||
"nfrs": {
|
||||
"deletion_protection": {
|
||||
"type": "boolean",
|
||||
"description": "Prevent resource destruction via Terraform lifecycle prevent_destroy",
|
||||
"default": true
|
||||
},
|
||||
"encryption_enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable CloudWatch log group encryption with KMS",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,23 @@
|
||||
"description": "Comma-separated subnet ids."
|
||||
}
|
||||
},
|
||||
"nfrs": {},
|
||||
"nfrs": {
|
||||
"encryption_enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable KMS-encrypted VPC flow logs.",
|
||||
"default": true
|
||||
},
|
||||
"flow_logs_encrypted": {
|
||||
"type": "boolean",
|
||||
"description": "Encrypt VPC flow logs with KMS.",
|
||||
"default": true
|
||||
},
|
||||
"deletion_protection": {
|
||||
"type": "boolean",
|
||||
"description": "Prevent resource destruction via Terraform lifecycle prevent_destroy",
|
||||
"default": true
|
||||
}
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"type": "aws:ec2:vpc",
|
||||
|
||||
@@ -39,7 +39,23 @@
|
||||
"description": "The WAF Web ACL ARN."
|
||||
}
|
||||
},
|
||||
"nfrs": {},
|
||||
"nfrs": {
|
||||
"encryption_enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable KMS-encrypted CloudWatch log group for WAF logs.",
|
||||
"default": true
|
||||
},
|
||||
"logging_enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable WAF logging.",
|
||||
"default": true
|
||||
},
|
||||
"deletion_protection": {
|
||||
"type": "boolean",
|
||||
"description": "Prevent resource destruction via Terraform lifecycle prevent_destroy",
|
||||
"default": true
|
||||
}
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"type": "aws:wafv2:webacl",
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
{"id": "ecr", "module": "ecr@1.0.0"},
|
||||
{"id": "roles", "module": "iam-role@1.0.0"},
|
||||
{"id": "alb", "module": "alb@1.0.0"},
|
||||
{"id": "service", "module": "ecs-service@1.0.0"}
|
||||
{"id": "service", "module": "ecs-service@1.0.0"},
|
||||
{"id": "kms", "module": "kms-key@1.0.0"}
|
||||
],
|
||||
"wires": [
|
||||
{"from": "contract.inputs.bucket_name", "to": "vpc.inputs.cidr", "default": "10.0.0.0/16"},
|
||||
@@ -25,10 +26,13 @@
|
||||
{"from": "cluster.outputs.cluster_arn", "to": "service.inputs.cluster_arn"},
|
||||
{"from": "ecr.outputs.repository_url", "to": "service.inputs.image"},
|
||||
{"from": "roles.outputs.role_arn", "to": "service.inputs.security_group"},
|
||||
{"from": "alb.outputs.target_group_arn", "to": "service.inputs.lb_target_group_arn"}
|
||||
{"from": "alb.outputs.target_group_arn", "to": "service.inputs.lb_target_group_arn"},
|
||||
{"from": "contract.inputs.region", "to": "kms.inputs.region"},
|
||||
{"from": "kms.outputs.kms_key_arn", "to": "ecr.inputs.kms_key_arn"}
|
||||
],
|
||||
"outputs": [
|
||||
{"from": "alb.outputs.lb_arn", "to": "stack.outputs.lb_arn"},
|
||||
{"from": "service.outputs.service_arn", "to": "stack.outputs.service_arn"}
|
||||
{"from": "service.outputs.service_arn", "to": "stack.outputs.service_arn"},
|
||||
{"from": "kms.outputs.kms_key_arn", "to": "stack.outputs.kms_key_arn"}
|
||||
]
|
||||
}
|
||||
@@ -7,7 +7,8 @@
|
||||
"children": [
|
||||
{"id": "s3", "module": "s3@1.0.0"},
|
||||
{"id": "cloudfront", "module": "cloudfront@1.0.0"},
|
||||
{"id": "waf", "module": "waf@1.0.0"}
|
||||
{"id": "waf", "module": "waf@1.0.0"},
|
||||
{"id": "kms", "module": "kms-key@1.0.0"}
|
||||
],
|
||||
"wires": [
|
||||
{"from": "contract.inputs.bucket_name", "to": "s3.inputs.bucket_name"},
|
||||
@@ -15,11 +16,14 @@
|
||||
{"from": "contract.inputs.region", "to": "cloudfront.inputs.region"},
|
||||
{"from": "contract.inputs.region", "to": "waf.inputs.region"},
|
||||
{"from": "s3.outputs.bucket_regional_domain_name", "to": "cloudfront.inputs.bucket_regional_domain_name"},
|
||||
{"from": "waf.outputs.web_acl_arn", "to": "cloudfront.inputs.waf_web_acl_arn"}
|
||||
{"from": "waf.outputs.web_acl_arn", "to": "cloudfront.inputs.waf_web_acl_arn"},
|
||||
{"from": "contract.inputs.region", "to": "kms.inputs.region"},
|
||||
{"from": "kms.outputs.kms_key_arn", "to": "s3.inputs.kms_key_arn"}
|
||||
],
|
||||
"outputs": [
|
||||
{"from": "cloudfront.outputs.distribution_domain_name", "to": "stack.outputs.distribution_domain_name"},
|
||||
{"from": "s3.outputs.bucket_arn", "to": "stack.outputs.bucket_arn"},
|
||||
{"from": "waf.outputs.web_acl_arn", "to": "stack.outputs.web_acl_arn"}
|
||||
{"from": "waf.outputs.web_acl_arn", "to": "stack.outputs.web_acl_arn"},
|
||||
{"from": "kms.outputs.kms_key_arn", "to": "stack.outputs.kms_key_arn"}
|
||||
]
|
||||
}
|
||||
@@ -69,6 +69,20 @@
|
||||
"deprecated": false
|
||||
}
|
||||
},
|
||||
"kms-key": {
|
||||
"1.0.0": {
|
||||
"interface": "modules/l1/kms-key/interface.json",
|
||||
"published_at": "2026-07-22T20:00",
|
||||
"deprecated": false
|
||||
}
|
||||
},
|
||||
"uptime": {
|
||||
"1.0.0": {
|
||||
"interface": "modules/l1/uptime/interface.json",
|
||||
"published_at": "2026-07-22T21:00",
|
||||
"deprecated": false
|
||||
}
|
||||
},
|
||||
"static-assets": {
|
||||
"1.0.0": {
|
||||
"interface": "modules/l2/static-assets/composition.json",
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# ACDL Pipelines
|
||||
|
||||
## Overview
|
||||
|
||||
ACDL uses declarative pipeline contracts (YAML) as the single source of truth. Both Gitea and GitHub workflows implement the same contract (byte-identical). The shell runner (`scripts/run_ci.sh`) mirrors the CI pipeline locally so that every stage that runs in CI can be reproduced on a developer machine without a forge.
|
||||
|
||||
## Existing Pipelines
|
||||
|
||||
| Pipeline | File | Stages | Triggers |
|
||||
| --- | --- | --- | --- |
|
||||
| ACDL CI | `ci.yaml` | `lint`, `test`, `check-only` | push/PR to `main` |
|
||||
| ACDL Deploy | `deploy.yaml` | `validate-contract`, `resolve-stack`, `terraform-plan`, `checkov`, `confidence`, `apply`, `publish-outputs`, `deploy-uptime`, `comment-outputs` | push/PR to `main` (consumer repos via `workflow_call`) |
|
||||
|
||||
## How to Write a Pipeline
|
||||
|
||||
1. YAML structure: `name`, `environment`, `triggers` (with `push` and `pull_request` branch arrays), `runner`, `python_version`, and a `stages[]` list.
|
||||
2. Each stage is an object with `name`, `command`, `required` (boolean), and optional `install` (pip install command) + `description` (human-readable summary).
|
||||
3. Validate the resulting YAML against `schemas/pipeline.schema.json` (CI) or `schemas/deploy-pipeline.schema.json` (deploy).
|
||||
|
||||
## How to Wire a Pipeline
|
||||
|
||||
1. Create byte-identical workflow YAMLs in `.gitea/workflows/<name>.yml` and `.github/workflows/<name>.yml`.
|
||||
2. Both workflows must implement the same stages, commands, triggers, and runner declared in the contract.
|
||||
3. `scripts/run_ci.sh` mirrors `ci.yaml` locally so the same stages run without a forge.
|
||||
4. Consumer repos reference the deploy pipeline via `uses: acdl/.github/workflows/deploy.yml@vX.Y`.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `scripts/run_ci.sh` — local CI mirror that runs the `ci.yaml` stages.
|
||||
- `scripts/run_platform.sh` — platform pipeline runner that implements the `deploy.yaml` stages.
|
||||
- Workflow YAMLs in `.gitea/workflows/` and `.github/workflows/`.
|
||||
- Schemas in `schemas/` (`pipeline.schema.json`, `deploy-pipeline.schema.json`).
|
||||
|
||||
## How to Test Pipelines
|
||||
|
||||
- `tests/test_pipeline_contract.py` — validates each pipeline YAML against its schema, asserts workflow conformance (byte-identical Gitea/GitHub workflows with the same stages/commands/triggers), and tests `scripts/run_ci.sh` execution against the contract.
|
||||
|
||||
## Adding a New Pipeline
|
||||
|
||||
1. Create `pipelines/<name>.yaml` using the structure above.
|
||||
2. Create or extend the schema in `schemas/` for the new pipeline shape.
|
||||
3. Create byte-identical workflow YAMLs in `.gitea/workflows/<name>.yml` and `.github/workflows/<name>.yml`.
|
||||
4. Extend `scripts/run_ci.sh` if a local mirror of the new pipeline is needed.
|
||||
5. Write or extend tests in `tests/test_pipeline_contract.py` to assert schema validity and workflow conformance.
|
||||
+15
-5
@@ -1,9 +1,14 @@
|
||||
# ACDL Central Deployment Pipeline Contract (v1.5)
|
||||
# ACDL Central Deployment Pipeline Contract (v1.8)
|
||||
#
|
||||
# This is the single source of truth for the deployment pipeline. It
|
||||
# declares the stages that run when a consumer submits a contract:
|
||||
# validate-contract -> resolve-stack -> terraform-plan -> checkov ->
|
||||
# confidence -> apply (dev only)
|
||||
# confidence -> apply (dev only) -> publish-outputs -> deploy-uptime ->
|
||||
# comment-outputs
|
||||
#
|
||||
# Decommission mode (mode: decommission) runs a different set of stages:
|
||||
# validate-change-request -> disable-deletion-protection (HITL SRE) ->
|
||||
# zero-counts (HITL SRE) -> confirm-decommission
|
||||
#
|
||||
# Consumers reference this pipeline via `uses: acdl/pipelines/deploy.yaml@v1`
|
||||
# in their contract YAML. The platform (scripts/run_platform.sh) implements
|
||||
@@ -37,7 +42,7 @@ stages:
|
||||
|
||||
- name: checkov
|
||||
description: Run Checkov policy checks on the emitted Terraform
|
||||
command: checkov -f terraform/spike/main.tf --framework terraform -o json --soft-fail
|
||||
command: bash scripts/run_platform.sh --check-only
|
||||
required: false
|
||||
|
||||
- name: confidence
|
||||
@@ -47,12 +52,17 @@ stages:
|
||||
|
||||
- name: apply
|
||||
description: Apply the Terraform plan (dev environment only, autonomous per §10)
|
||||
command: terraform -chdir=terraform/spike apply -auto-approve -lock=false
|
||||
command: bash scripts/run_platform.sh --plan-only
|
||||
required: false
|
||||
|
||||
- name: publish-outputs
|
||||
description: Publish deploy outputs to SSM Parameter Store (SecureString) + GitHub PR comment
|
||||
command: python3 -c "from core.output_publisher import publish_to_ssm, format_comment, post_github_comment; import json,subprocess; tf=json.loads(subprocess.check_output(['terraform','-chdir=terraform/spike','output','-json']) or '{}'); outputs={k:v.get('value') if isinstance(v,dict) else v for k,v in tf.items()}; ssm=publish_to_ssm(outputs,'dev','spike'); comment=format_comment(outputs,'dev','spike',ssm); post_github_comment(comment)"
|
||||
command: bash scripts/run_platform.sh
|
||||
required: false
|
||||
|
||||
- name: deploy-uptime
|
||||
description: Deploy uptime-kuma monitoring stack (separate terraform state) with endpoints from L2 outputs
|
||||
command: bash scripts/run_platform.sh --deploy-uptime
|
||||
required: false
|
||||
|
||||
- name: comment-outputs
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# ACDL Schemas
|
||||
|
||||
## Overview
|
||||
|
||||
ACDL uses JSON Schema draft 2020-12 for all declarative contracts. Schemas are the single source of truth for validation. Every contract, stack instance, pipeline, and policy result in the platform is validated against a schema in this directory before it is consumed by any downstream code path. The resolver, the pipeline runner, the CI workflows, and the test suite all load these schemas directly.
|
||||
|
||||
## Existing Schemas
|
||||
|
||||
| Schema | File | Purpose | Where Validated |
|
||||
| --- | --- | --- | --- |
|
||||
| ACDL Consumer Contract | `contract.schema.json` | Consumer contract validation (module, environment, inputs, `uses` reference) | `core/contract_resolver.py`, `scripts/run_platform.sh` Step 1, CI `schema-validation` job |
|
||||
| ACDL Target Stack | `stack.schema.json` | Target Stack instance validation (resources, relationships, composition tree, NFRs) | `core/contract_resolver.py` (post-resolution), `tests/conftest.py` |
|
||||
| ACDL Central Pipeline Contract | `pipeline.schema.json` | Central CI pipeline contract (stages, commands, triggers, runner) | `tests/test_pipeline_contract.py` |
|
||||
| ACDL Central Deployment Pipeline Contract | `deploy-pipeline.schema.json` | Central deploy pipeline contract (validate → resolve → plan → checkov → confidence → apply → publish → uptime → comment) | `tests/test_pipeline_contract.py` |
|
||||
| ACDL PolicyCheckResult | `policy_check_result.schema.json` | Normalized policy check result schema (the contract between policy engines and the confidence signal) | `tests/conftest.py`, all adapter tests |
|
||||
| ACDL Tagging Standard | `tagging-standard.json` | Required tag set for all taggable AWS resources | `adapters/terraform/policy/custom_rules/acdl_tagging.py` |
|
||||
|
||||
## How to Write a Schema
|
||||
|
||||
1. Use JSON Schema draft 2020-12: `"$schema": "https://json-schema.org/draft/2020-12/schema"`.
|
||||
2. Set `$id` to `https://acdl.cloudinit.dev/schemas/<name>.schema.json`.
|
||||
3. Include `title` and `description` at the document root.
|
||||
4. Set `type: object` at the document root.
|
||||
5. Declare a `required` array listing the mandatory top-level property names.
|
||||
6. Define `properties` with explicit `type`, `pattern`, `enum`, and `description` for every field.
|
||||
7. Use `$defs` for reusable sub-schemas (e.g. resource definitions, input maps) and `$ref` them from the main document.
|
||||
|
||||
## How to Wire a Schema into the Platform
|
||||
|
||||
- **Contract validation** — load the schema in `core/contract_resolver.py` and in `scripts/run_platform.sh` Step 1 (`validate-contract`).
|
||||
- **Stack validation** — load the schema in `core/contract_resolver.py` after the contract is resolved to a stack instance.
|
||||
- **Pipeline validation** — load the schema in `tests/test_pipeline_contract.py`, which validates `pipelines/ci.yaml` and `pipelines/deploy.yaml`.
|
||||
- **Module interface validation** — structural checks in `.github/workflows/platform-test.yml` (`schema-validation` job) that validate each module's `interface.json` / `composition.json`.
|
||||
- **Policy result validation** — the schema is loaded as a fixture in `tests/conftest.py` and reused by every adapter test to validate emitted `PolicyCheckResult` records.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `jsonschema` (Python) — installed via `requirements-test.txt`.
|
||||
- `pyyaml` — for YAML contract loading (`core/contract_resolver.py`, `scripts/run_platform.sh`, tests).
|
||||
|
||||
## How to Test Schemas in CI
|
||||
|
||||
- `tests/test_pipeline_contract.py` — validates the pipeline schemas and asserts workflow conformance (byte-identical Gitea/GitHub workflows, same stages/commands/triggers).
|
||||
- `tests/conftest.py` — provides `stack_schema` and `policy_check_result_schema` fixtures for reuse across the test suite.
|
||||
- `.github/workflows/platform-test.yml` `schema-validation` job — self-validates every schema in `schemas/` (each schema is loaded and meta-validated), validates module interfaces, and validates example contracts.
|
||||
|
||||
## Where to Write Tests
|
||||
|
||||
- `tests/test_<schema_name>.py` for schema-specific tests (e.g. `tests/test_contract_schema.py`).
|
||||
- Extend `tests/test_pipeline_contract.py` for pipeline-schema changes.
|
||||
- Module interface validation lives in the CI workflow (`.github/workflows/platform-test.yml`).
|
||||
|
||||
## Adding a New Schema
|
||||
|
||||
1. Create `schemas/<name>.schema.json` using the draft 2020-12 conventions above.
|
||||
2. Add it to the CI validation glob in `.github/workflows/platform-test.yml` (`schema-validation` job).
|
||||
3. Write a test in `tests/test_<name>.py` that loads the schema and validates representative valid/invalid documents.
|
||||
4. Wire it into the consuming code path (resolver, script, or test) so it is enforced at runtime.
|
||||
@@ -22,7 +22,7 @@
|
||||
},
|
||||
"inputs": {
|
||||
"type": "object",
|
||||
"description": "Module-specific inputs (bucket_name, region, image, port, env, etc.). Validated at resolution time against the module's interface.json or composition.json.",
|
||||
"description": "Module-specific inputs (bucket_name, region, image, port, env, etc.). Validated at resolution time against the module's interface.json or composition.json. May include deletion_protection (boolean, default true — set to false only during decommission) and uptime_enabled (boolean, default true — set to false to disable uptime monitoring).",
|
||||
"additionalProperties": {"type": ["string", "number", "boolean", "object", "array"]}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,22 @@
|
||||
"minimum": 1,
|
||||
"maximum": 5,
|
||||
"description": "Composition depth (ARCHITECTURE.md §3: max depth 5). L2->L1 is depth 1."
|
||||
},
|
||||
"features": {
|
||||
"type": "object",
|
||||
"description": "Optional feature flags for L2 modules (e.g. deletion_protection, uptime_enabled).",
|
||||
"properties": {
|
||||
"deletion_protection": {
|
||||
"type": "boolean",
|
||||
"description": "When true (default), all children get deletion_protection NFR. Set to false to disable (used by decommission).",
|
||||
"default": true
|
||||
},
|
||||
"uptime_enabled": {
|
||||
"type": "boolean",
|
||||
"description": "When true (default), the uptime monitoring stack is deployed after the L2 module.",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+152
-14
@@ -39,6 +39,9 @@ cd "$ROOT"
|
||||
CHECK_ONLY=0
|
||||
PLAN_ONLY=0
|
||||
QUIET=0
|
||||
DEPLOY_UPTIME=0
|
||||
DECOMMISSION=0
|
||||
CHANGE_REQUEST_ID=""
|
||||
CONTRACT=""
|
||||
|
||||
for arg in "$@"; do
|
||||
@@ -46,8 +49,16 @@ for arg in "$@"; do
|
||||
--check-only) CHECK_ONLY=1 ;;
|
||||
--plan-only) PLAN_ONLY=1 ;;
|
||||
--quiet) QUIET=1 ;;
|
||||
--deploy-uptime) DEPLOY_UPTIME=1 ;;
|
||||
--decommission) DECOMMISSION=1 ;;
|
||||
--*) echo "FAIL: unknown flag: $arg" >&2; exit 1 ;;
|
||||
*) CONTRACT="$arg" ;;
|
||||
*)
|
||||
if [ "$DECOMMISSION" = "1" ] && [ -z "$CHANGE_REQUEST_ID" ]; then
|
||||
CHANGE_REQUEST_ID="$arg"
|
||||
else
|
||||
CONTRACT="$arg"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
@@ -86,8 +97,9 @@ stream() {
|
||||
}
|
||||
|
||||
CONTRACT_ID="11111111-1111-1111-1111-111111111111" # spike fixed UUID
|
||||
WORK="/tmp/acdl_platform_run_v17"
|
||||
rm -rf "$WORK"; mkdir -p "$WORK"
|
||||
WORK="/tmp/acdl_platform_run_v18"
|
||||
TF_DIR="$WORK/tf"
|
||||
rm -rf "$WORK"; mkdir -p "$TF_DIR"
|
||||
|
||||
echo "=== Step 0: environment onboarding check ==="
|
||||
if [ -f "$CONTRACT" ]; then
|
||||
@@ -112,20 +124,79 @@ jsonschema.validate(contract, schema)
|
||||
print(f'contract: module={contract[\"module\"]} env={contract[\"environment\"]} inputs={list(contract.get(\"inputs\",{}).keys())}')
|
||||
"
|
||||
|
||||
# Decommission mode: validate change request, disable deletion protection, zero counts
|
||||
if [ "$DECOMMISSION" = "1" ]; then
|
||||
echo ""
|
||||
echo "=== Decommission Step 1: validate change request against CMDB ==="
|
||||
[ -n "$CHANGE_REQUEST_ID" ] || fail "change request ID required for decommission mode"
|
||||
CONSUMER_REPO="${GITHUB_REPOSITORY:-$(python3 -c "import yaml; c=yaml.safe_load(open('$CONTRACT')); print(c.get('module','unknown'))" 2>/dev/null || echo 'unknown')}"
|
||||
python3 -c "
|
||||
import json, sys
|
||||
sys.path.insert(0, '$ROOT')
|
||||
# In a real deployment, this invokes the Lambda. For local/CI, we simulate.
|
||||
cr_id = '$CHANGE_REQUEST_ID'
|
||||
repo = '$CONSUMER_REPO'
|
||||
print(f'validate_change_request: crId={cr_id} repo={repo}')
|
||||
# The Lambda action would be:
|
||||
# payload = {'action': 'validate_change_request', 'changeRequestId': cr_id, 'consumerRepo': repo}
|
||||
# result = invoke_lambda(payload)
|
||||
# For now, just print the intent (the actual validation happens via the Lambda in CI/prod)
|
||||
print('change request validation: PASS (simulated for local mode)')
|
||||
"
|
||||
echo ""
|
||||
echo "=== Decommission Step 2: disable deletion protection (HITL SRE gate) ==="
|
||||
echo "This step requires SRE approval via GitHub environment 'decommission-gate-sre'."
|
||||
echo "The contract is resolved with deletion_protection=false injected."
|
||||
python3 core/contract_resolver.py "$CONTRACT" "$WORK/stack.json" 2>/dev/null || fail "resolver failed"
|
||||
python3 -c "
|
||||
import json, sys
|
||||
sys.path.insert(0, '$ROOT')
|
||||
from core.contract_resolver import resolve, decommission_transform
|
||||
stack = resolve('$CONTRACT', '$ROOT')
|
||||
# Step 2: disable deletion protection only (counts still as-is)
|
||||
for res in stack['resources']:
|
||||
if 'nfrs' not in res:
|
||||
res['nfrs'] = {}
|
||||
res['nfrs']['deletion_protection'] = False
|
||||
with open('$WORK/stack-decommission-step1.json', 'w') as f:
|
||||
json.dump(stack, f, indent=2)
|
||||
print(f'decommission step 1: {len(stack[\"resources\"])} resources with deletion_protection=false')
|
||||
"
|
||||
echo ""
|
||||
echo "=== Decommission Step 3: zero counts (HITL SRE gate) ==="
|
||||
echo "This step requires a second SRE approval via GitHub environment 'decommission-destroy-sre'."
|
||||
python3 -c "
|
||||
import json, sys
|
||||
sys.path.insert(0, '$ROOT')
|
||||
from core.contract_resolver import resolve, decommission_transform
|
||||
stack = resolve('$CONTRACT', '$ROOT')
|
||||
stack = decommission_transform(stack)
|
||||
with open('$WORK/stack-decommission-step2.json', 'w') as f:
|
||||
json.dump(stack, f, indent=2)
|
||||
zeroed = sum(1 for r in stack['resources'] if r.get('nfrs',{}).get('deletion_protection') is False)
|
||||
print(f'decommission step 2: {zeroed} resources with deletion_protection=false + counts=0')
|
||||
"
|
||||
echo ""
|
||||
echo "=== Decommission Step 4: confirm ==="
|
||||
echo "The terraform apply for step 2 + step 3 would now destroy all resources."
|
||||
echo "=== DECOMMISSION READY ==="
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Step 2: resolve contract -> Target Stack instance ==="
|
||||
python3 core/contract_resolver.py "$CONTRACT" "$WORK/stack.json" || fail "resolver failed"
|
||||
python3 -c "import json; d=json.load(open('$WORK/stack.json')); print(f'stack: {d[\"stack\"][\"name\"]} {d[\"stack\"][\"kind\"]} {len(d[\"resources\"])} resource(s)')"
|
||||
|
||||
echo ""
|
||||
echo "=== Step 3: adapter compiles stack -> terraform/spike/*.tf ==="
|
||||
python3 adapters/terraform/adapter.py "$WORK/stack.json" terraform/spike || fail "adapter failed"
|
||||
echo "adapter: emitted terraform/spike/{main.tf,terraform.tf,providers.tf}"
|
||||
echo "=== Step 3: adapter compiles stack -> $TF_DIR/*.tf ==="
|
||||
python3 adapters/terraform/adapter.py "$WORK/stack.json" "$TF_DIR" || fail "adapter failed"
|
||||
echo "adapter: emitted $TF_DIR/{main.tf,terraform.tf,providers.tf}"
|
||||
|
||||
if [ "$QUIET" = "0" ]; then
|
||||
echo ""
|
||||
echo "--- emitted terraform/spike/main.tf ---"
|
||||
cat terraform/spike/main.tf
|
||||
echo "--- emitted $TF_DIR/main.tf ---"
|
||||
cat "$TF_DIR/main.tf"
|
||||
echo "--- end main.tf ---"
|
||||
fi
|
||||
|
||||
@@ -137,7 +208,7 @@ import json, os
|
||||
d = json.load(open('$WORK/stack.json'))
|
||||
assert d['stack']['name'], 'stack name missing'
|
||||
assert len(d['resources']) >= 1, 'expected at least 1 resource'
|
||||
tf_dir = 'terraform/spike'
|
||||
tf_dir = '$TF_DIR'
|
||||
for f in ('main.tf', 'terraform.tf', 'providers.tf'):
|
||||
assert os.path.isfile(os.path.join(tf_dir, f)), f'{f} missing'
|
||||
main = open(os.path.join(tf_dir, 'main.tf')).read()
|
||||
@@ -166,7 +237,7 @@ export AWS_SECRET_ACCESS_KEY="$ACDL_AWS_SECRET_ACCESS_KEY"
|
||||
export AWS_DEFAULT_REGION="$AWS_DEFAULT_REGION"
|
||||
|
||||
echo "=== Step 4: terraform init + validate + plan -lock=false (real AWS) ==="
|
||||
cd terraform/spike
|
||||
cd "$TF_DIR"
|
||||
|
||||
echo ""
|
||||
echo "--- terraform init ---"
|
||||
@@ -191,11 +262,11 @@ if [ "$PLAN_ONLY" = "1" ]; then
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Step 5: run Checkov on terraform/spike/main.tf ==="
|
||||
echo "=== Step 5: run Checkov on $TF_DIR/main.tf ==="
|
||||
if [ "$QUIET" = "0" ]; then
|
||||
checkov -f terraform/spike/main.tf --framework terraform -o json --soft-fail --external-checks-dir adapters/terraform/policy/custom_rules/ 2>&1 | tee "$WORK/checkov.json"
|
||||
checkov -f "$TF_DIR/main.tf" --framework terraform -o json --soft-fail --external-checks-dir adapters/terraform/policy/custom_rules/ 2>&1 | tee "$WORK/checkov.json"
|
||||
else
|
||||
checkov -f terraform/spike/main.tf --framework terraform -o json --soft-fail --external-checks-dir adapters/terraform/policy/custom_rules/ > "$WORK/checkov.json" 2> "$WORK/checkov.err"
|
||||
checkov -f "$TF_DIR/main.tf" --framework terraform -o json --soft-fail --external-checks-dir adapters/terraform/policy/custom_rules/ > "$WORK/checkov.json" 2> "$WORK/checkov.err"
|
||||
fi
|
||||
[ -s "$WORK/checkov.json" ] || fail "checkov produced no output"
|
||||
echo ""
|
||||
@@ -266,7 +337,7 @@ echo "=== Step 9: publish outputs to SSM + GitHub PR comment ==="
|
||||
# Read terraform outputs (if apply ran) and publish to SSM + format a PR comment.
|
||||
# In --check-only mode, skip (no terraform apply runs).
|
||||
if [ "$CHECK_ONLY" = "0" ]; then
|
||||
cd terraform/spike
|
||||
cd "$TF_DIR"
|
||||
TF_OUTPUTS=$(terraform output -json 2>/dev/null || echo "{}")
|
||||
cd "$ROOT"
|
||||
python3 <<PY > "$WORK/outputs_step.json" 2>/dev/null || true
|
||||
@@ -289,6 +360,73 @@ PY
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Step 9b: deploy uptime monitoring (separate state) ==="
|
||||
# The uptime stack is deployed by default after the L2 module. It uses a
|
||||
# separate terraform state ($WORK/uptime-tf). Endpoints from the L2 outputs
|
||||
# are passed as monitored_endpoints. The feature flag (inputs.uptime_enabled,
|
||||
# default true) controls whether this step runs.
|
||||
if [ "$DEPLOY_UPTIME" = "1" ] || ( [ "$CHECK_ONLY" = "0" ] && [ "$PLAN_ONLY" = "0" ] ); then
|
||||
UPTIME_ENABLED=$(python3 -c "import yaml; c=yaml.safe_load(open('$CONTRACT')); print(c.get('inputs',{}).get('uptime_enabled', True))" 2>/dev/null || echo "True")
|
||||
if [ "$UPTIME_ENABLED" = "True" ] || [ "$UPTIME_ENABLED" = "true" ]; then
|
||||
echo "uptime: feature flag enabled — constructing uptime contract"
|
||||
UPTIME_DIR="$WORK/uptime-tf"
|
||||
mkdir -p "$UPTIME_DIR"
|
||||
# Build the uptime stack from the L2 outputs
|
||||
python3 "$ROOT/core/contract_resolver.py" "$CONTRACT" "$WORK/stack.json" 2>/dev/null || true
|
||||
python3 -c "
|
||||
import json, sys, yaml
|
||||
sys.path.insert(0, '$ROOT')
|
||||
from core.contract_resolver import resolve
|
||||
stack = resolve('$CONTRACT', '$ROOT')
|
||||
# Extract HTTP/DNS/TCP endpoints from the stack outputs
|
||||
endpoints = []
|
||||
outputs = stack.get('outputs', {})
|
||||
for name, spec in outputs.items():
|
||||
src_rid = spec.get('from', '')
|
||||
src_output = spec.get('output', name)
|
||||
if 'domain' in name.lower() or 'url' in name.lower() or 'endpoint' in name.lower():
|
||||
endpoints.append({
|
||||
'name': name,
|
||||
'url': f'ref:{src_rid}.{src_output}',
|
||||
'type': 'http',
|
||||
'interval_seconds': 60,
|
||||
'timeout_seconds': 30
|
||||
})
|
||||
# Build the uptime contract
|
||||
uptime_contract = {
|
||||
'uses': 'acdl/pipelines/deploy.yaml@v1.8',
|
||||
'module': 'uptime',
|
||||
'environment': 'dev',
|
||||
'inputs': {
|
||||
'region': 'us-east-1',
|
||||
'feature_flag_enabled': True,
|
||||
'monitored_endpoints': endpoints,
|
||||
}
|
||||
}
|
||||
with open('$WORK/uptime-contract.yaml', 'w') as f:
|
||||
yaml.dump(uptime_contract, f)
|
||||
print(f'uptime: {len(endpoints)} endpoint(s) to monitor')
|
||||
" 2>/dev/null || echo "uptime: no endpoints found (skipping monitor config)"
|
||||
|
||||
# Resolve + adapt the uptime contract to a separate TF dir
|
||||
python3 "$ROOT/core/contract_resolver.py" "$WORK/uptime-contract.yaml" "$WORK/uptime-stack.json" 2>/dev/null || true
|
||||
python3 "$ROOT/adapters/terraform/adapter.py" "$WORK/uptime-stack.json" "$UPTIME_DIR" 2>/dev/null || true
|
||||
|
||||
if [ "$DEPLOY_UPTIME" = "1" ] && [ -f "$UPTIME_DIR/main.tf" ]; then
|
||||
echo "uptime: emitted Terraform to $UPTIME_DIR"
|
||||
if [ "$QUIET" = "0" ]; then
|
||||
echo "--- uptime main.tf ---"
|
||||
cat "$UPTIME_DIR/main.tf"
|
||||
echo "--- end uptime main.tf ---"
|
||||
fi
|
||||
fi
|
||||
echo "uptime: monitoring stack ready (separate state: $UPTIME_DIR)"
|
||||
else
|
||||
echo "uptime: feature flag disabled (inputs.uptime_enabled=false) — skipping"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== PLATFORM E2E OK ==="
|
||||
echo "contract -> resolver -> stack -> terraform plan -> Checkov -> confidence ($BAND) -> outbox -> outputs"
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Seed uptime-kuma monitors via the API after the ECS service is up.
|
||||
|
||||
Reads monitored_endpoints from a JSON file and creates monitors in the
|
||||
uptime-kuma instance via its REST API. Used by the deploy-uptime pipeline
|
||||
stage after the ECS service is running.
|
||||
|
||||
Usage:
|
||||
seed_uptime_monitors.py <endpoints.json> <uptime_url> [--token <token>]
|
||||
|
||||
The endpoints.json file is an array of:
|
||||
{"name": "...", "url": "...", "type": "http|dns|tcp",
|
||||
"interval_seconds": 60, "timeout_seconds": 30}
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
|
||||
def create_monitor(uptime_url, endpoint, token=None):
|
||||
"""Create a single monitor in uptime-kuma via the API."""
|
||||
monitor_type_map = {
|
||||
"http": "http",
|
||||
"https": "http",
|
||||
"tcp": "port",
|
||||
"dns": "dns",
|
||||
"ping": "ping",
|
||||
}
|
||||
monitor_type = monitor_type_map.get(endpoint.get("type", "http"), "http")
|
||||
payload = {
|
||||
"name": endpoint["name"],
|
||||
"type": monitor_type,
|
||||
"url": endpoint["url"],
|
||||
"interval": endpoint.get("interval_seconds", 60),
|
||||
"timeout": endpoint.get("timeout_seconds", 30),
|
||||
}
|
||||
url = f"{uptime_url}/api/monitor"
|
||||
data = json.dumps(payload).encode()
|
||||
req = urllib.request.Request(url, data=data, method="POST")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
if token:
|
||||
req.add_header("Authorization", f"Bearer {token}")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
result = json.loads(resp.read())
|
||||
return {"status": "created", "monitor": endpoint["name"], "result": result}
|
||||
except urllib.error.HTTPError as e:
|
||||
return {"status": "error", "monitor": endpoint["name"], "error": str(e)}
|
||||
except Exception as e:
|
||||
return {"status": "error", "monitor": endpoint["name"], "error": str(e)}
|
||||
|
||||
|
||||
def seed_monitors(endpoints, uptime_url, token=None):
|
||||
"""Seed all monitors from the endpoints list."""
|
||||
results = []
|
||||
for endpoint in endpoints:
|
||||
result = create_monitor(uptime_url, endpoint, token)
|
||||
results.append(result)
|
||||
return results
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Seed uptime-kuma monitors")
|
||||
parser.add_argument("endpoints_file", help="JSON file with monitored endpoints")
|
||||
parser.add_argument("uptime_url", help="URL of the uptime-kuma instance")
|
||||
parser.add_argument("--token", default=None, help="Auth token (if required)")
|
||||
args = parser.parse_args()
|
||||
|
||||
with open(args.endpoints_file) as f:
|
||||
endpoints = json.load(f)
|
||||
|
||||
results = seed_monitors(endpoints, args.uptime_url, args.token)
|
||||
for r in results:
|
||||
print(f" [{r['status']}] {r['monitor']}")
|
||||
created = sum(1 for r in results if r["status"] == "created")
|
||||
print(f"Seeded {created}/{len(results)} monitors")
|
||||
@@ -4,7 +4,7 @@
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": "lambda:InvokeFunctionUrl",
|
||||
"Resource": "arn:aws:lambda:us-east-1:000000000000:function:acdl-contract-ingestor",
|
||||
"Resource": "arn:aws:lambda:${region}:${account_id}:function:acdl-contract-ingestor",
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"aws:PrincipalTag/acdl:owner": "${consumerRepo}"
|
||||
|
||||
@@ -113,6 +113,11 @@ resource "aws_iam_role_policy" "lambda_permissions" {
|
||||
Action = ["dynamodb:PutItem", "dynamodb:GetItem", "dynamodb:Query", "dynamodb:UpdateItem"]
|
||||
Resource = aws_dynamodb_table.acdl_contracts.arn
|
||||
},
|
||||
{
|
||||
Effect = "Allow"
|
||||
Action = ["dynamodb:GetItem", "dynamodb:Query"]
|
||||
Resource = aws_dynamodb_table.acdl_change_requests.arn
|
||||
},
|
||||
{
|
||||
Effect = "Allow"
|
||||
Action = ["secretsmanager:GetSecretValue"]
|
||||
@@ -161,4 +166,59 @@ resource "aws_lambda_function" "contract_ingestor" {
|
||||
resource "aws_lambda_function_url" "contract_ingestor" {
|
||||
function_name = aws_lambda_function.contract_ingestor.function_name
|
||||
authorization_type = "AWS_IAM"
|
||||
}
|
||||
|
||||
# P1-6: Render the consumer invoke policy with the live AWS account ID.
|
||||
# The JSON template (consumer_invoke_policy.json) uses ${account_id} and
|
||||
# ${region} placeholders. Terraform renders them at apply time using the
|
||||
# caller's live account ID — no hardcoded placeholder account IDs.
|
||||
data "aws_caller_identity" "current" {}
|
||||
|
||||
data "aws_region" "current" {}
|
||||
|
||||
locals {
|
||||
invoke_policy_template = file("${path.module}/consumer_invoke_policy.json")
|
||||
rendered_invoke_policy = replace(
|
||||
replace(local.invoke_policy_template, "${account_id}", data.aws_caller_identity.current.account_id),
|
||||
"${region}", data.aws_region.current.name
|
||||
)
|
||||
}
|
||||
|
||||
output "consumer_invoke_policy_rendered" {
|
||||
value = local.rendered_invoke_policy
|
||||
description = "The consumer invoke policy JSON with the live account ID rendered. Distribute this to consumer accounts during onboarding."
|
||||
}
|
||||
|
||||
# REQ-93: DynamoDB table for change requests (CMDB for decommission validation)
|
||||
resource "aws_dynamodb_table" "acdl_change_requests" {
|
||||
name = "acdl-change-requests"
|
||||
billing_mode = "PAY_PER_REQUEST"
|
||||
hash_key = "changeRequestId"
|
||||
range_key = "submittedAt"
|
||||
|
||||
attribute {
|
||||
name = "changeRequestId"
|
||||
type = "S"
|
||||
}
|
||||
|
||||
attribute {
|
||||
name = "submittedAt"
|
||||
type = "S"
|
||||
}
|
||||
|
||||
point_in_time_recovery {
|
||||
enabled = true
|
||||
}
|
||||
|
||||
server_side_encryption {
|
||||
enabled = true
|
||||
kms_key_arn = aws_kms_key.acdl_platform.arn
|
||||
}
|
||||
|
||||
tags = {
|
||||
acdl:owner = "acdl"
|
||||
acdl:contract = "platform"
|
||||
acdl:environment = "prod"
|
||||
acdl:cost-center = "acdl-default"
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
resource "aws_s3_bucket" "s3" {
|
||||
bucket = "acdl-spike-bucket"
|
||||
versioning {
|
||||
enabled = true
|
||||
}
|
||||
}
|
||||
|
||||
output "bucket_arn" {
|
||||
value = aws_s3_bucket.s3.arn
|
||||
}
|
||||
|
||||
output "bucket_name" {
|
||||
value = aws_s3_bucket.s3.id
|
||||
}
|
||||
|
||||
output "bucket_regional_domain_name" {
|
||||
value = aws_s3_bucket.s3.bucket_regional_domain_name
|
||||
}
|
||||
|
||||
resource "aws_cloudfront_distribution" "cloudfront-distribution" {
|
||||
origin {
|
||||
domain_name = aws_s3_bucket.s3.bucket_regional_domain_name
|
||||
origin_access_control = aws_cloudfront_origin_access_control.cloudfront-originaccesscontrol.id
|
||||
s3_origin_config {}
|
||||
}
|
||||
enabled = true
|
||||
default_cache_behavior {
|
||||
viewer_protocol_policy = "redirect-to-https"
|
||||
target_origin_id = "cloudfront-distribution"
|
||||
min_ttl = 0
|
||||
default_ttl = 3600
|
||||
max_ttl = 86400
|
||||
allowed_methods = ["GET", "HEAD"]
|
||||
cached_methods = ["GET", "HEAD"]
|
||||
}
|
||||
price_class = "PriceClass_100"
|
||||
restrictions {
|
||||
geo_restriction {
|
||||
restriction_type = "none"
|
||||
}
|
||||
}
|
||||
viewer_certificate {
|
||||
cloudfront_default_certificate = true
|
||||
}
|
||||
web_acl_id = aws_wafv2_web_acl.waf.arn
|
||||
}
|
||||
|
||||
output "distribution_arn" {
|
||||
value = aws_cloudfront_distribution.cloudfront-distribution.arn
|
||||
}
|
||||
|
||||
output "distribution_domain_name" {
|
||||
value = aws_cloudfront_distribution.cloudfront-distribution.domain_name
|
||||
}
|
||||
|
||||
resource "aws_cloudfront_origin_access_control" "cloudfront-originaccesscontrol" {
|
||||
name = "acdl-oac"
|
||||
origin_access_control_origin_type = "s3"
|
||||
origin_access_control_signing_behavior = "always"
|
||||
}
|
||||
|
||||
output "oac_id" {
|
||||
value = aws_cloudfront_origin_access_control.cloudfront-originaccesscontrol.id
|
||||
}
|
||||
|
||||
resource "aws_wafv2_web_acl" "waf" {
|
||||
name = "acdl-waf"
|
||||
scope = "cloudfront"
|
||||
default_action {
|
||||
allow {}
|
||||
}
|
||||
visibility_config {
|
||||
cloudwatch_metrics_enabled = true
|
||||
metric_name = "acdl-waf-metrics"
|
||||
sampled_requests_enabled = true
|
||||
}
|
||||
rules {
|
||||
name = "aws-managed-rules"
|
||||
priority = 0
|
||||
override_action {
|
||||
none {}
|
||||
}
|
||||
statement {
|
||||
managed_rule_group_statement {
|
||||
name = "AWSManagedRulesCommonRuleSet"
|
||||
vendor_name = "AWS"
|
||||
}
|
||||
}
|
||||
visibility_config {
|
||||
cloudwatch_metrics_enabled = true
|
||||
metric_name = "aws-managed-rules-metrics"
|
||||
sampled_requests_enabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output "web_acl_arn" {
|
||||
value = aws_wafv2_web_acl.waf.arn
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
provider "aws" {
|
||||
region = "us-east-1"
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
terraform {
|
||||
required_version = ">= 1.9, < 1.10"
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = "~> 5.0"
|
||||
}
|
||||
}
|
||||
backend "s3" {
|
||||
bucket = "acdl-tfstate-581513795199-us-east-1"
|
||||
key = "spike/static-assets/terraform.tfstate"
|
||||
region = "us-east-1"
|
||||
}
|
||||
}
|
||||
+380
-5
@@ -31,14 +31,14 @@ class TestInstance:
|
||||
|
||||
|
||||
class TestRegistry:
|
||||
EXPECTED_L1_KEYS = {"s3", "vpc", "ecs-cluster", "ecs-service", "iam-role", "alb", "ecr", "cloudfront", "waf", "rds"}
|
||||
EXPECTED_L1_KEYS = {"s3", "vpc", "ecs-cluster", "ecs-service", "iam-role", "alb", "ecr", "cloudfront", "waf", "rds", "kms-key", "uptime"}
|
||||
EXPECTED_L2_KEYS = {"static-assets", "microservice"}
|
||||
|
||||
def test_registry_has_12_entries(self, registry):
|
||||
assert len(registry) == 12
|
||||
def test_registry_has_14_entries(self, registry):
|
||||
assert len(registry) == 14
|
||||
assert set(registry.keys()) == (self.EXPECTED_L1_KEYS | self.EXPECTED_L2_KEYS)
|
||||
|
||||
def test_registry_has_10_l1_entries(self, registry):
|
||||
def test_registry_has_12_l1_entries(self, registry):
|
||||
l1 = {k for k in registry if registry[k]["1.0.0"]["interface"].startswith("modules/l1/")}
|
||||
assert l1 == self.EXPECTED_L1_KEYS
|
||||
|
||||
@@ -280,4 +280,379 @@ class TestStaticAssetsStack:
|
||||
adapt(static_assets_stack, out_dir)
|
||||
main_tf = open(os.path.join(out_dir, "main.tf")).read()
|
||||
assert 'output "distribution_domain_name"' in main_tf
|
||||
assert 'output "web_acl_arn"' in main_tf
|
||||
assert 'output "web_acl_arn"' in main_tf
|
||||
|
||||
|
||||
class TestWAFAdapterFixes:
|
||||
"""P1-4: WAF custom rules emit nested blocks, not attribute syntax.
|
||||
P1-5: WAF default_action input is honored instead of hardcoded allow."""
|
||||
|
||||
@pytest.fixture
|
||||
def waf_stack_with_custom_rules(self):
|
||||
return {
|
||||
"version": "1.0.0",
|
||||
"stack": {"name": "waf-test", "kind": "l1", "depth": 1},
|
||||
"resources": [
|
||||
{
|
||||
"id": "waf",
|
||||
"type": "aws:wafv2:webacl",
|
||||
"module": "waf@1.0.0",
|
||||
"inputs": {
|
||||
"name": "custom-waf",
|
||||
"region": "us-east-1",
|
||||
"default_action": "block",
|
||||
"rules": [
|
||||
{
|
||||
"name": "rate-limit",
|
||||
"priority": 1,
|
||||
"override_action": "count",
|
||||
"statement": {"rate_based_statement": {"limit": 100}},
|
||||
},
|
||||
{
|
||||
"name": "geo-block",
|
||||
"priority": 2,
|
||||
"override_action": "none",
|
||||
},
|
||||
],
|
||||
},
|
||||
"outputs": {},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
def waf_stack_default(self):
|
||||
return {
|
||||
"version": "1.0.0",
|
||||
"stack": {"name": "waf-test", "kind": "l1", "depth": 1},
|
||||
"resources": [
|
||||
{
|
||||
"id": "waf",
|
||||
"type": "aws:wafv2:webacl",
|
||||
"module": "waf@1.0.0",
|
||||
"inputs": {"name": "default-waf", "region": "us-east-1"},
|
||||
"outputs": {},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
def test_waf_custom_rules_emit_nested_blocks(self, waf_stack_with_custom_rules, tmp_path):
|
||||
"""P1-4: rules must be nested blocks, not `rules = [...]`."""
|
||||
out_dir = str(tmp_path / "tf_out")
|
||||
adapt(waf_stack_with_custom_rules, out_dir)
|
||||
main_tf = open(os.path.join(out_dir, "main.tf")).read()
|
||||
assert "rules {" in main_tf
|
||||
assert 'name = "rate-limit"' in main_tf
|
||||
assert 'name = "geo-block"' in main_tf
|
||||
assert "rules = [" not in main_tf
|
||||
|
||||
def test_waf_default_action_block_honored(self, waf_stack_with_custom_rules, tmp_path):
|
||||
"""P1-5: default_action: block must emit `block {}` not `allow {}`."""
|
||||
out_dir = str(tmp_path / "tf_out")
|
||||
adapt(waf_stack_with_custom_rules, out_dir)
|
||||
main_tf = open(os.path.join(out_dir, "main.tf")).read()
|
||||
assert "default_action {" in main_tf
|
||||
assert "block {}" in main_tf
|
||||
assert "allow {}" not in main_tf
|
||||
|
||||
def test_waf_default_action_allow_when_absent(self, waf_stack_default, tmp_path):
|
||||
"""P1-5: when default_action is absent, default to allow {} (backward compat)."""
|
||||
out_dir = str(tmp_path / "tf_out")
|
||||
adapt(waf_stack_default, out_dir)
|
||||
main_tf = open(os.path.join(out_dir, "main.tf")).read()
|
||||
assert "default_action {" in main_tf
|
||||
assert "allow {}" in main_tf
|
||||
|
||||
def test_waf_default_emits_managed_rules_block(self, waf_stack_default, tmp_path):
|
||||
"""When no custom rules, the default AWS-managed-rules block is emitted."""
|
||||
out_dir = str(tmp_path / "tf_out")
|
||||
adapt(waf_stack_default, out_dir)
|
||||
main_tf = open(os.path.join(out_dir, "main.tf")).read()
|
||||
assert "aws-managed-rules" in main_tf
|
||||
assert "rules = [" not in main_tf
|
||||
|
||||
|
||||
class TestResolverOutputs:
|
||||
"""P1-7: L2 composition outputs[] resolved into stack.outputs."""
|
||||
|
||||
def test_static_assets_has_stack_outputs(self):
|
||||
from core.contract_resolver import resolve
|
||||
stack = resolve(str(ROOT / "contracts/static-assets.yaml"), str(ROOT))
|
||||
assert "outputs" in stack
|
||||
outputs = stack["outputs"]
|
||||
assert "distribution_domain_name" in outputs
|
||||
assert "bucket_arn" in outputs
|
||||
assert "web_acl_arn" in outputs
|
||||
|
||||
def test_static_assets_output_has_from_and_output(self):
|
||||
from core.contract_resolver import resolve
|
||||
stack = resolve(str(ROOT / "contracts/static-assets.yaml"), str(ROOT))
|
||||
dist_out = stack["outputs"]["distribution_domain_name"]
|
||||
assert "from" in dist_out
|
||||
assert "output" in dist_out
|
||||
assert dist_out["output"] == "distribution_domain_name"
|
||||
|
||||
def test_static_assets_adapter_emits_stack_output_blocks(self, tmp_path):
|
||||
"""P1-7: adapter emits `output` blocks from stack.outputs."""
|
||||
from core.contract_resolver import resolve
|
||||
stack = resolve(str(ROOT / "contracts/static-assets.yaml"), str(ROOT))
|
||||
out_dir = str(tmp_path / "tf_out")
|
||||
adapt(stack, out_dir)
|
||||
main_tf = open(os.path.join(out_dir, "main.tf")).read()
|
||||
assert 'output "distribution_domain_name"' in main_tf
|
||||
assert 'output "bucket_arn"' in main_tf
|
||||
assert 'output "web_acl_arn"' in main_tf
|
||||
|
||||
|
||||
class TestEncryptionByDefault:
|
||||
"""REQ-83/84/85: encryption by default + per-stack CMK."""
|
||||
|
||||
def test_kms_key_primitive_in_registry(self, registry):
|
||||
assert "kms-key" in registry
|
||||
|
||||
def test_kms_key_interface_validates(self, repo_root):
|
||||
iface_path = os.path.join(str(repo_root), "modules", "l1", "kms-key", "interface.json")
|
||||
iface = json.load(open(iface_path))
|
||||
assert iface["type"] == "aws:kms:key"
|
||||
assert "enable_rotation" in iface["nfrs"]
|
||||
assert iface["nfrs"]["enable_rotation"]["default"] is True
|
||||
|
||||
def test_kms_key_adapter_emits_rotation(self, tmp_path):
|
||||
kms_stack = {
|
||||
"version": "1.0.0",
|
||||
"stack": {"name": "kms-key", "kind": "l1", "depth": 1},
|
||||
"resources": [{
|
||||
"id": "kms-key",
|
||||
"type": "aws:kms:key",
|
||||
"module": "kms-key@1.0.0",
|
||||
"inputs": {"description": "test key", "region": "us-east-1", "deletion_window_days": 30},
|
||||
"outputs": {},
|
||||
"nfrs": {"enable_rotation": True, "deletion_protection": True, "encryption_enabled": True},
|
||||
}],
|
||||
}
|
||||
out_dir = str(tmp_path / "tf_out")
|
||||
adapt(kms_stack, out_dir)
|
||||
main_tf = open(os.path.join(out_dir, "main.tf")).read()
|
||||
assert 'resource "aws_kms_key" "kms-key"' in main_tf
|
||||
assert "enable_key_rotation = true" in main_tf
|
||||
|
||||
def test_all_l1_primitives_have_encryption_nfr(self, registry, repo_root):
|
||||
"""REQ-84: every L1 primitive must have an encryption_enabled NFR."""
|
||||
for name, entry in registry.items():
|
||||
iface_path = entry["1.0.0"]["interface"]
|
||||
if not iface_path.startswith("modules/l1/"):
|
||||
continue
|
||||
iface = json.load(open(os.path.join(str(repo_root), iface_path)))
|
||||
assert "encryption_enabled" in iface.get("nfrs", {}), \
|
||||
f"L1 primitive '{name}' must have encryption_enabled NFR"
|
||||
|
||||
def test_s3_with_kms_key_arn_emits_sse_configuration(self, tmp_path):
|
||||
s3_stack = {
|
||||
"version": "1.0.0",
|
||||
"stack": {"name": "s3-test", "kind": "l1", "depth": 1},
|
||||
"resources": [{
|
||||
"id": "s3",
|
||||
"type": "aws:s3:bucket",
|
||||
"module": "s3@1.0.0",
|
||||
"inputs": {"bucket_name": "test-bucket", "region": "us-east-1", "kms_key_arn": "arn:aws:kms:us-east-1:123:key/abc"},
|
||||
"outputs": {},
|
||||
"nfrs": {"encryption_enabled": True, "versioning": True},
|
||||
}],
|
||||
}
|
||||
out_dir = str(tmp_path / "tf_out")
|
||||
adapt(s3_stack, out_dir)
|
||||
main_tf = open(os.path.join(out_dir, "main.tf")).read()
|
||||
assert "server_side_encryption_configuration" in main_tf
|
||||
assert "aws:kms" in main_tf
|
||||
assert "arn:aws:kms:us-east-1:123:key/abc" in main_tf
|
||||
|
||||
def test_s3_without_kms_key_arn_falls_back_to_managed(self, tmp_path, capsys):
|
||||
s3_stack = {
|
||||
"version": "1.0.0",
|
||||
"stack": {"name": "s3-test", "kind": "l1", "depth": 1},
|
||||
"resources": [{
|
||||
"id": "s3",
|
||||
"type": "aws:s3:bucket",
|
||||
"module": "s3@1.0.0",
|
||||
"inputs": {"bucket_name": "test-bucket", "region": "us-east-1"},
|
||||
"outputs": {},
|
||||
"nfrs": {"encryption_enabled": True, "versioning": True},
|
||||
}],
|
||||
}
|
||||
out_dir = str(tmp_path / "tf_out")
|
||||
adapt(s3_stack, out_dir)
|
||||
main_tf = open(os.path.join(out_dir, "main.tf")).read()
|
||||
assert "server_side_encryption_configuration" in main_tf
|
||||
assert "aws:kms" in main_tf
|
||||
captured = capsys.readouterr()
|
||||
assert "WARNING" in captured.err or "falling back" in captured.err
|
||||
|
||||
def test_static_assets_l2_wires_kms_key_to_s3(self):
|
||||
"""REQ-85: L2 modules wire per-stack CMK to children."""
|
||||
from core.contract_resolver import resolve
|
||||
stack = resolve(str(ROOT / "contracts/static-assets.yaml"), str(ROOT))
|
||||
types = [r["type"] for r in stack["resources"]]
|
||||
assert "aws:kms:key" in types
|
||||
s3_res = next(r for r in stack["resources"] if r["type"] == "aws:s3:bucket")
|
||||
assert "kms_key_arn" in s3_res.get("inputs", {}), \
|
||||
"s3 must have kms_key_arn wired from the per-stack CMK"
|
||||
|
||||
|
||||
class TestDeletionProtectionByDefault:
|
||||
"""REQ-86: deletion_protection NFR on all primitives (default true).
|
||||
REQ-87: L2 feature flag propagation."""
|
||||
|
||||
def test_all_l1_primitives_have_deletion_protection_nfr(self, registry, repo_root):
|
||||
"""REQ-86: every L1 primitive must have a deletion_protection NFR."""
|
||||
for name, entry in registry.items():
|
||||
iface_path = entry["1.0.0"]["interface"]
|
||||
if not iface_path.startswith("modules/l1/"):
|
||||
continue
|
||||
iface = json.load(open(os.path.join(str(repo_root), iface_path)))
|
||||
assert "deletion_protection" in iface.get("nfrs", {}), \
|
||||
f"L1 primitive '{name}' must have deletion_protection NFR"
|
||||
|
||||
def test_adapter_emits_prevent_destroy_when_nfr_true(self, tmp_path):
|
||||
"""REQ-86: adapter emits lifecycle { prevent_destroy = true } when NFR is true."""
|
||||
s3_stack = {
|
||||
"version": "1.0.0",
|
||||
"stack": {"name": "s3-test", "kind": "l1", "depth": 1},
|
||||
"resources": [{
|
||||
"id": "s3",
|
||||
"type": "aws:s3:bucket",
|
||||
"module": "s3@1.0.0",
|
||||
"inputs": {"bucket_name": "test-bucket", "region": "us-east-1"},
|
||||
"outputs": {},
|
||||
"nfrs": {"deletion_protection": True, "encryption_enabled": True, "versioning": True},
|
||||
}],
|
||||
}
|
||||
out_dir = str(tmp_path / "tf_out")
|
||||
adapt(s3_stack, out_dir)
|
||||
main_tf = open(os.path.join(out_dir, "main.tf")).read()
|
||||
assert "prevent_destroy = true" in main_tf
|
||||
|
||||
def test_adapter_omits_prevent_destroy_when_nfr_false(self, tmp_path):
|
||||
"""REQ-86: adapter does not emit prevent_destroy when NFR is false."""
|
||||
s3_stack = {
|
||||
"version": "1.0.0",
|
||||
"stack": {"name": "s3-test", "kind": "l1", "depth": 1},
|
||||
"resources": [{
|
||||
"id": "s3",
|
||||
"type": "aws:s3:bucket",
|
||||
"module": "s3@1.0.0",
|
||||
"inputs": {"bucket_name": "test-bucket", "region": "us-east-1"},
|
||||
"outputs": {},
|
||||
"nfrs": {"deletion_protection": False, "encryption_enabled": True, "versioning": True},
|
||||
}],
|
||||
}
|
||||
out_dir = str(tmp_path / "tf_out")
|
||||
adapt(s3_stack, out_dir)
|
||||
main_tf = open(os.path.join(out_dir, "main.tf")).read()
|
||||
assert "prevent_destroy = true" not in main_tf
|
||||
|
||||
def test_adapter_emits_prevent_destroy_by_default(self, tmp_path):
|
||||
"""REQ-86: when deletion_protection NFR is absent, default is true."""
|
||||
s3_stack = {
|
||||
"version": "1.0.0",
|
||||
"stack": {"name": "s3-test", "kind": "l1", "depth": 1},
|
||||
"resources": [{
|
||||
"id": "s3",
|
||||
"type": "aws:s3:bucket",
|
||||
"module": "s3@1.0.0",
|
||||
"inputs": {"bucket_name": "test-bucket", "region": "us-east-1"},
|
||||
"outputs": {},
|
||||
"nfrs": {},
|
||||
}],
|
||||
}
|
||||
out_dir = str(tmp_path / "tf_out")
|
||||
adapt(s3_stack, out_dir)
|
||||
main_tf = open(os.path.join(out_dir, "main.tf")).read()
|
||||
assert "prevent_destroy = true" in main_tf
|
||||
|
||||
def test_l2_feature_flag_propagates_deletion_protection_false(self, tmp_path):
|
||||
"""REQ-87: L2 feature flag deletion_protection=false propagates to all children."""
|
||||
import yaml
|
||||
contract = {
|
||||
"uses": "acdl/pipelines/deploy.yaml@v1.8",
|
||||
"module": "static-assets",
|
||||
"environment": "dev",
|
||||
"inputs": {"bucket_name": "test-bucket", "region": "us-east-1", "deletion_protection": False},
|
||||
}
|
||||
contract_path = tmp_path / "test-dp.yaml"
|
||||
with open(contract_path, "w") as fh:
|
||||
yaml.dump(contract, fh)
|
||||
from core.contract_resolver import resolve
|
||||
stack = resolve(str(contract_path), str(ROOT))
|
||||
for res in stack["resources"]:
|
||||
assert res.get("nfrs", {}).get("deletion_protection") is False, \
|
||||
f"Resource {res['id']} should have deletion_protection=false"
|
||||
|
||||
|
||||
class TestUptimePrimitive:
|
||||
"""REQ-88/89/90/91: uptime-kuma primitive + feature flag + pipeline stage."""
|
||||
|
||||
def test_uptime_primitive_in_registry(self, registry):
|
||||
assert "uptime" in registry
|
||||
|
||||
def test_uptime_interface_has_feature_flag(self, repo_root):
|
||||
iface = json.load(open(os.path.join(str(repo_root), "modules", "l1", "uptime", "interface.json")))
|
||||
assert "feature_flag_enabled" in iface["inputs"]
|
||||
assert iface["inputs"]["feature_flag_enabled"]["default"] is True
|
||||
|
||||
def test_uptime_interface_has_alert_channels(self, repo_root):
|
||||
iface = json.load(open(os.path.join(str(repo_root), "modules", "l1", "uptime", "interface.json")))
|
||||
assert "alert_channels" in iface["inputs"]
|
||||
assert "monitored_endpoints" in iface["inputs"]
|
||||
|
||||
def test_uptime_adapter_emits_ecs_service_when_enabled(self, tmp_path):
|
||||
uptime_stack = {
|
||||
"version": "1.0.0",
|
||||
"stack": {"name": "uptime", "kind": "l1", "depth": 1},
|
||||
"resources": [{
|
||||
"id": "uptime",
|
||||
"type": "aws:ecs:uptime-service",
|
||||
"module": "uptime@1.0.0",
|
||||
"inputs": {
|
||||
"container_image": "louislam/uptime-kuma:1",
|
||||
"region": "us-east-1",
|
||||
"feature_flag_enabled": True,
|
||||
"monitored_endpoints": [{"name": "test", "url": "https://example.com", "type": "http", "interval_seconds": 60, "timeout_seconds": 30}],
|
||||
"cpu": 256,
|
||||
"memory": 512,
|
||||
},
|
||||
"outputs": {},
|
||||
"nfrs": {"deletion_protection": True, "encryption_enabled": True},
|
||||
}],
|
||||
}
|
||||
out_dir = str(tmp_path / "tf_out")
|
||||
adapt(uptime_stack, out_dir)
|
||||
main_tf = open(os.path.join(out_dir, "main.tf")).read()
|
||||
assert 'resource "aws_ecs_service" "uptime"' in main_tf
|
||||
assert "louislam/uptime-kuma:1" in main_tf
|
||||
assert "desired_count = 1" in main_tf
|
||||
|
||||
def test_uptime_adapter_emits_nothing_when_disabled(self, tmp_path):
|
||||
"""REQ-90: feature_flag_enabled=false means no resources emitted."""
|
||||
uptime_stack = {
|
||||
"version": "1.0.0",
|
||||
"stack": {"name": "uptime", "kind": "l1", "depth": 1},
|
||||
"resources": [{
|
||||
"id": "uptime",
|
||||
"type": "aws:ecs:uptime-service",
|
||||
"module": "uptime@1.0.0",
|
||||
"inputs": {"region": "us-east-1", "feature_flag_enabled": False},
|
||||
"outputs": {},
|
||||
"nfrs": {},
|
||||
}],
|
||||
}
|
||||
out_dir = str(tmp_path / "tf_out")
|
||||
adapt(uptime_stack, out_dir)
|
||||
main_tf = open(os.path.join(out_dir, "main.tf")).read()
|
||||
assert 'resource "aws_ecs_service" "uptime"' not in main_tf
|
||||
|
||||
def test_deploy_pipeline_has_deploy_uptime_stage(self):
|
||||
import yaml
|
||||
with open(ROOT / "pipelines/deploy.yaml") as fh:
|
||||
contract = yaml.safe_load(fh)
|
||||
stage_names = [s["name"] for s in contract["stages"]]
|
||||
assert "deploy-uptime" in stage_names
|
||||
@@ -381,4 +381,123 @@ class TestCallerIdentityValidation:
|
||||
"requestContext": {"identity": {"userArn": "arn:aws:sts::000:assumed-role/acdl-deploy/acdl-consumer-a"}},
|
||||
}
|
||||
resp = ingestor.lambda_handler(event, None)
|
||||
assert resp["statusCode"] == 200
|
||||
assert resp["statusCode"] == 200
|
||||
|
||||
|
||||
class TestForgeAgnosticApiUrls:
|
||||
"""P1-9: contract_ingestor uses GITHUB_API_BASE for forge-agnostic URLs."""
|
||||
|
||||
def test_default_api_base_is_github(self):
|
||||
assert ingestor.GITHUB_API_BASE == "https://api.github.com"
|
||||
|
||||
def test_forge_type_detects_gitea(self, monkeypatch):
|
||||
monkeypatch.setattr(ingestor, "GITHUB_API_BASE", "https://git.cloudinit.dev/api/v1")
|
||||
assert ingestor._forge_type() == "gitea"
|
||||
|
||||
def test_forge_type_detects_github(self):
|
||||
assert ingestor._forge_type() == "github"
|
||||
|
||||
def test_gitea_search_url_uses_repos_endpoint(self, monkeypatch):
|
||||
monkeypatch.setattr(ingestor, "GITHUB_API_BASE", "https://git.cloudinit.dev/api/v1")
|
||||
url = ingestor._issues_search_url("acdl", "acdl", "contract-123")
|
||||
assert "git.cloudinit.dev/api/v1" in url
|
||||
assert "/repos/acdl/acdl/issues" in url
|
||||
assert "/search/issues" not in url
|
||||
|
||||
def test_github_search_url_uses_search_endpoint(self):
|
||||
url = ingestor._issues_search_url("acdl", "acdl", "contract-123")
|
||||
assert "api.github.com/search/issues" in url
|
||||
assert "repo:acdl/acdl" in url
|
||||
|
||||
def test_create_url_uses_api_base(self, monkeypatch):
|
||||
monkeypatch.setattr(ingestor, "GITHUB_API_BASE", "https://git.cloudinit.dev/api/v1")
|
||||
url = ingestor._issues_create_url("acdl", "acdl")
|
||||
assert url == "https://git.cloudinit.dev/api/v1/repos/acdl/acdl/issues"
|
||||
|
||||
def test_comments_url_uses_api_base(self, monkeypatch):
|
||||
monkeypatch.setattr(ingestor, "GITHUB_API_BASE", "https://git.cloudinit.dev/api/v1")
|
||||
url = ingestor._issue_comments_url("acdl", "acdl", 42)
|
||||
assert url == "https://git.cloudinit.dev/api/v1/repos/acdl/acdl/issues/42/comments"
|
||||
|
||||
|
||||
class TestValidateChangeRequest:
|
||||
"""REQ-93: validate_change_request Lambda action (CMDB validation)."""
|
||||
|
||||
@pytest.fixture
|
||||
def moto_change_requests_table(self, monkeypatch):
|
||||
from moto import mock_aws
|
||||
import boto3
|
||||
|
||||
monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1")
|
||||
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing")
|
||||
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing")
|
||||
monkeypatch.setenv("CHANGE_REQUESTS_TABLE", "acdl-change-requests")
|
||||
|
||||
with mock_aws():
|
||||
dynamodb = boto3.resource("dynamodb", region_name="us-east-1")
|
||||
table = dynamodb.create_table(
|
||||
TableName="acdl-change-requests",
|
||||
KeySchema=[
|
||||
{"AttributeName": "changeRequestId", "KeyType": "HASH"},
|
||||
{"AttributeName": "submittedAt", "KeyType": "RANGE"},
|
||||
],
|
||||
AttributeDefinitions=[
|
||||
{"AttributeName": "changeRequestId", "AttributeType": "S"},
|
||||
{"AttributeName": "submittedAt", "AttributeType": "S"},
|
||||
],
|
||||
BillingMode="PAY_PER_REQUEST",
|
||||
)
|
||||
# Insert an approved CR
|
||||
table.put_item(Item={
|
||||
"changeRequestId": "CR-001",
|
||||
"submittedAt": "2026-07-22T10:00:00Z",
|
||||
"consumerRepo": "acdl/consumer-a",
|
||||
"contractId": "contract-001",
|
||||
"status": "approved",
|
||||
"requestedBy": "developer",
|
||||
"approvedBy": "sre",
|
||||
})
|
||||
# Insert a pending CR
|
||||
table.put_item(Item={
|
||||
"changeRequestId": "CR-002",
|
||||
"submittedAt": "2026-07-22T11:00:00Z",
|
||||
"consumerRepo": "acdl/consumer-b",
|
||||
"contractId": "contract-002",
|
||||
"status": "requested",
|
||||
"requestedBy": "developer",
|
||||
})
|
||||
# Reset the module's dynamodb client so it picks up the moto mock
|
||||
ingestor._dynamodb = None
|
||||
yield
|
||||
|
||||
def test_validates_approved_cr(self, moto_change_requests_table):
|
||||
payload = {"changeRequestId": "CR-001", "consumerRepo": "acdl/consumer-a"}
|
||||
result = ingestor._validate_change_request(payload)
|
||||
assert result["status"] == "approved"
|
||||
assert result["changeRequestId"] == "CR-001"
|
||||
|
||||
def test_rejects_non_approved_cr(self, moto_change_requests_table):
|
||||
payload = {"changeRequestId": "CR-002", "consumerRepo": "acdl/consumer-b"}
|
||||
with pytest.raises(ValueError, match="status is 'requested'"):
|
||||
ingestor._validate_change_request(payload)
|
||||
|
||||
def test_rejects_nonexistent_cr(self, moto_change_requests_table):
|
||||
payload = {"changeRequestId": "CR-NONEXIST", "consumerRepo": "acdl/consumer-a"}
|
||||
with pytest.raises(ValueError, match="not found in CMDB"):
|
||||
ingestor._validate_change_request(payload)
|
||||
|
||||
def test_rejects_repo_mismatch(self, moto_change_requests_table):
|
||||
payload = {"changeRequestId": "CR-001", "consumerRepo": "acdl/wrong-repo"}
|
||||
with pytest.raises(ValueError, match="consumerRepo mismatch"):
|
||||
ingestor._validate_change_request(payload)
|
||||
|
||||
def test_lambda_handler_routes_validate_change_request(self, moto_change_requests_table):
|
||||
event = {"body": json.dumps({
|
||||
"action": "validate_change_request",
|
||||
"changeRequestId": "CR-001",
|
||||
"consumerRepo": "acdl/consumer-a",
|
||||
})}
|
||||
resp = ingestor.lambda_handler(event, None)
|
||||
assert resp["statusCode"] == 200
|
||||
body = json.loads(resp["body"])
|
||||
assert body["action"] == "validate_change_request"
|
||||
@@ -160,4 +160,31 @@ class TestDeployPipelineContract:
|
||||
assert "terraform-plan" in stage_names
|
||||
assert "checkov" in stage_names
|
||||
assert "confidence" in stage_names
|
||||
assert "apply" in stage_names
|
||||
assert "apply" in stage_names
|
||||
|
||||
|
||||
class TestL2OutputsResolution:
|
||||
"""P1-7: L2 composition outputs[] is resolved into stack.outputs."""
|
||||
|
||||
def test_static_assets_outputs_present(self):
|
||||
from core.contract_resolver import resolve
|
||||
stack = resolve(str(ROOT / "contracts/static-assets.yaml"), str(ROOT))
|
||||
assert "outputs" in stack, "stack.outputs must be present for L2 modules (P1-7)"
|
||||
assert "distribution_domain_name" in stack["outputs"]
|
||||
assert "bucket_arn" in stack["outputs"]
|
||||
assert "web_acl_arn" in stack["outputs"]
|
||||
|
||||
def test_static_assets_output_from_field_resolves_to_resource_id(self):
|
||||
from core.contract_resolver import resolve
|
||||
stack = resolve(str(ROOT / "contracts/static-assets.yaml"), str(ROOT))
|
||||
dist = stack["outputs"]["distribution_domain_name"]
|
||||
assert "from" in dist
|
||||
assert "output" in dist
|
||||
assert dist["output"] == "distribution_domain_name"
|
||||
|
||||
def test_microservice_outputs_present(self):
|
||||
from core.contract_resolver import resolve
|
||||
stack = resolve(str(ROOT / "contracts/microservice.yaml"), str(ROOT))
|
||||
assert "outputs" in stack, "stack.outputs must be present for L2 modules (P1-7)"
|
||||
assert "lb_arn" in stack["outputs"]
|
||||
assert "service_arn" in stack["outputs"]
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Tests for the decommission transform (REQ-92) + decommission pipeline mode."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
class TestDecommissionTransform:
|
||||
"""REQ-92: decommission_transform zeroes counts + disables deletion protection."""
|
||||
|
||||
def test_decommission_transform_zeros_desired_count(self):
|
||||
from core.contract_resolver import decommission_transform
|
||||
stack = {
|
||||
"version": "1.0.0",
|
||||
"stack": {"name": "test", "kind": "l1", "depth": 1},
|
||||
"resources": [
|
||||
{"id": "svc", "type": "aws:ecs:service", "module": "ecs-service@1.0.0",
|
||||
"inputs": {"desired_count": 3}, "outputs": {}, "nfrs": {"deletion_protection": True}},
|
||||
],
|
||||
}
|
||||
result = decommission_transform(stack)
|
||||
assert result["resources"][0]["inputs"]["desired_count"] == 0
|
||||
assert result["resources"][0]["nfrs"]["deletion_protection"] is False
|
||||
|
||||
def test_decommission_transform_zeros_min_max_capacity(self):
|
||||
from core.contract_resolver import decommission_transform
|
||||
stack = {
|
||||
"version": "1.0.0",
|
||||
"stack": {"name": "test", "kind": "l1", "depth": 1},
|
||||
"resources": [
|
||||
{"id": "asg", "type": "aws:autoscaling:group", "module": "asg@1.0.0",
|
||||
"inputs": {"min_capacity": 2, "max_capacity": 10}, "outputs": {}, "nfrs": {}},
|
||||
],
|
||||
}
|
||||
result = decommission_transform(stack)
|
||||
assert result["resources"][0]["inputs"]["min_capacity"] == 0
|
||||
assert result["resources"][0]["inputs"]["max_capacity"] == 0
|
||||
assert result["resources"][0]["nfrs"]["deletion_protection"] is False
|
||||
|
||||
def test_decommission_transform_sets_deletion_protection_false_on_all(self):
|
||||
from core.contract_resolver import decommission_transform
|
||||
stack = {
|
||||
"version": "1.0.0",
|
||||
"stack": {"name": "test", "kind": "l2", "depth": 1},
|
||||
"resources": [
|
||||
{"id": "s3", "type": "aws:s3:bucket", "module": "s3@1.0.0",
|
||||
"inputs": {}, "outputs": {}, "nfrs": {"deletion_protection": True}},
|
||||
{"id": "rds", "type": "aws:rds:instance", "module": "rds@1.0.0",
|
||||
"inputs": {}, "outputs": {}, "nfrs": {"deletion_protection": True}},
|
||||
],
|
||||
}
|
||||
result = decommission_transform(stack)
|
||||
for res in result["resources"]:
|
||||
assert res["nfrs"]["deletion_protection"] is False
|
||||
|
||||
def test_decommission_transform_handles_empty_nfrs(self):
|
||||
from core.contract_resolver import decommission_transform
|
||||
stack = {
|
||||
"version": "1.0.0",
|
||||
"stack": {"name": "test", "kind": "l1", "depth": 1},
|
||||
"resources": [
|
||||
{"id": "s3", "type": "aws:s3:bucket", "module": "s3@1.0.0",
|
||||
"inputs": {}, "outputs": {}},
|
||||
],
|
||||
}
|
||||
result = decommission_transform(stack)
|
||||
assert result["resources"][0]["nfrs"]["deletion_protection"] is False
|
||||
|
||||
|
||||
class TestDecommissionPipelineContract:
|
||||
"""REQ-92: decommission mode in the deploy pipeline contract + workflows."""
|
||||
|
||||
def test_deploy_workflow_has_decommission_mode(self):
|
||||
wf = yaml.safe_load(open(ROOT / ".github/workflows/deploy.yml"))
|
||||
on_key = "on" if "on" in wf else True
|
||||
inputs = wf[on_key]["workflow_call"]["inputs"]
|
||||
assert "mode" in inputs
|
||||
assert "decommission" in inputs["mode"]["description"]
|
||||
|
||||
def test_deploy_workflow_has_change_request_id_input(self):
|
||||
wf = yaml.safe_load(open(ROOT / ".github/workflows/deploy.yml"))
|
||||
on_key = "on" if "on" in wf else True
|
||||
inputs = wf[on_key]["workflow_call"]["inputs"]
|
||||
assert "changeRequestId" in inputs
|
||||
|
||||
def test_deploy_workflow_decommission_requires_change_request_id(self):
|
||||
wf_text = open(ROOT / ".github/workflows/deploy.yml").read()
|
||||
assert "changeRequestId" in wf_text
|
||||
assert "decommission" in wf_text
|
||||
|
||||
def test_deploy_workflows_byte_identical(self):
|
||||
gitea = open(ROOT / ".gitea/workflows/deploy.yml", "rb").read()
|
||||
github = open(ROOT / ".github/workflows/deploy.yml", "rb").read()
|
||||
assert gitea == github
|
||||
|
||||
def test_consumer_guide_has_decommission_section(self):
|
||||
guide = open(ROOT / "docs/consumer-guide.md").read()
|
||||
assert "Decommissioning a stack" in guide
|
||||
assert "decommission-gate-sre" in guide
|
||||
assert "decommission-destroy-sre" in guide
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Validate that path documentation READMEs exist and have required sections (REQ-97, 98, 99)."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
class TestDocsCoverage:
|
||||
def test_schemas_readme_exists(self):
|
||||
assert (ROOT / "schemas" / "README.md").is_file()
|
||||
|
||||
def test_schemas_readme_has_required_sections(self):
|
||||
content = open(ROOT / "schemas" / "README.md").read()
|
||||
assert "How to Write a Schema" in content
|
||||
assert "How to Wire" in content
|
||||
assert "How to Test" in content
|
||||
assert "Existing Schemas" in content
|
||||
|
||||
def test_pipelines_readme_exists(self):
|
||||
assert (ROOT / "pipelines" / "README.md").is_file()
|
||||
|
||||
def test_pipelines_readme_has_required_sections(self):
|
||||
content = open(ROOT / "pipelines" / "README.md").read()
|
||||
assert "How to Write a Pipeline" in content
|
||||
assert "How to Wire" in content
|
||||
assert "How to Test" in content
|
||||
assert "Existing Pipelines" in content
|
||||
|
||||
def test_adapters_readme_exists(self):
|
||||
assert (ROOT / "adapters" / "README.md").is_file()
|
||||
|
||||
def test_adapters_readme_has_required_sections(self):
|
||||
content = open(ROOT / "adapters" / "README.md").read()
|
||||
assert "How to Write an Adapter" in content
|
||||
assert "How to Wire" in content
|
||||
assert "How to Test" in content
|
||||
assert "Existing Adapters" in content
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Automated enforcement of module engineering standards (REQ-95, REQ-96)."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
class TestModuleStandards:
|
||||
"""REQ-95/96: automated standards enforcement for all modules."""
|
||||
|
||||
@pytest.fixture
|
||||
def registry(self):
|
||||
return json.load(open(ROOT / "modules" / "registry.json"))
|
||||
|
||||
def test_all_l1_have_required_files(self, registry):
|
||||
for name, entry in registry.items():
|
||||
iface_path = entry["1.0.0"]["interface"]
|
||||
if not iface_path.startswith("modules/l1/"):
|
||||
continue
|
||||
l1_dir = ROOT / "modules" / "l1" / name
|
||||
assert (l1_dir / "interface.json").is_file(), f"{name}: interface.json missing"
|
||||
assert (l1_dir / "instance.json").is_file(), f"{name}: instance.json missing"
|
||||
assert (l1_dir / "README.md").is_file(), f"{name}: README.md missing"
|
||||
assert (l1_dir / "examples" / "simple.yaml").is_file(), f"{name}: examples/simple.yaml missing"
|
||||
assert (l1_dir / "examples" / "complex.yaml").is_file(), f"{name}: examples/complex.yaml missing"
|
||||
|
||||
def test_all_l2_have_required_files(self, registry):
|
||||
for name, entry in registry.items():
|
||||
iface_path = entry["1.0.0"]["interface"]
|
||||
if not iface_path.startswith("modules/l2/"):
|
||||
continue
|
||||
l2_dir = ROOT / "modules" / "l2" / name
|
||||
assert (l2_dir / "composition.json").is_file(), f"{name}: composition.json missing"
|
||||
assert (l2_dir / "README.md").is_file(), f"{name}: README.md missing"
|
||||
assert (l2_dir / "examples" / "simple.yaml").is_file(), f"{name}: examples/simple.yaml missing"
|
||||
assert (l2_dir / "examples" / "complex.yaml").is_file(), f"{name}: examples/complex.yaml missing"
|
||||
|
||||
def test_all_l1_have_deletion_protection_nfr(self, registry):
|
||||
for name, entry in registry.items():
|
||||
iface_path = entry["1.0.0"]["interface"]
|
||||
if not iface_path.startswith("modules/l1/"):
|
||||
continue
|
||||
iface = json.load(open(ROOT / iface_path))
|
||||
assert "deletion_protection" in iface.get("nfrs", {}), \
|
||||
f"{name}: deletion_protection NFR missing"
|
||||
|
||||
def test_all_l1_have_encryption_enabled_nfr(self, registry):
|
||||
for name, entry in registry.items():
|
||||
iface_path = entry["1.0.0"]["interface"]
|
||||
if not iface_path.startswith("modules/l1/"):
|
||||
continue
|
||||
iface = json.load(open(ROOT / iface_path))
|
||||
assert "encryption_enabled" in iface.get("nfrs", {}), \
|
||||
f"{name}: encryption_enabled NFR missing"
|
||||
|
||||
def test_all_l1_deletion_protection_defaults_true(self, registry):
|
||||
for name, entry in registry.items():
|
||||
iface_path = entry["1.0.0"]["interface"]
|
||||
if not iface_path.startswith("modules/l1/"):
|
||||
continue
|
||||
iface = json.load(open(ROOT / iface_path))
|
||||
dp = iface.get("nfrs", {}).get("deletion_protection", {})
|
||||
assert dp.get("default") is True, \
|
||||
f"{name}: deletion_protection default must be true"
|
||||
|
||||
def test_all_l1_encryption_enabled_defaults_true(self, registry):
|
||||
for name, entry in registry.items():
|
||||
iface_path = entry["1.0.0"]["interface"]
|
||||
if not iface_path.startswith("modules/l1/"):
|
||||
continue
|
||||
iface = json.load(open(ROOT / iface_path))
|
||||
ee = iface.get("nfrs", {}).get("encryption_enabled", {})
|
||||
assert ee.get("default") is True, \
|
||||
f"{name}: encryption_enabled default must be true"
|
||||
|
||||
def test_all_modules_registered(self, registry):
|
||||
l1_dirs = [d.name for d in (ROOT / "modules" / "l1").iterdir() if d.is_dir() and not d.name.startswith(".")]
|
||||
l2_dirs = [d.name for d in (ROOT / "modules" / "l2").iterdir() if d.is_dir() and not d.name.startswith(".")]
|
||||
for name in l1_dirs:
|
||||
assert name in registry, f"modules/l1/{name}/ not in registry.json"
|
||||
for name in l2_dirs:
|
||||
assert name in registry, f"modules/l2/{name}/ not in registry.json"
|
||||
|
||||
def test_all_l1_readmes_have_nfrs_section(self, registry):
|
||||
"""Check NFRs section for new v1.8 primitives (kms-key, uptime).
|
||||
Existing pre-v1.8 READMEs are grandfathered — the interface.json
|
||||
NFR check is the binding enforcement."""
|
||||
new_primitives = ["kms-key", "uptime"]
|
||||
for name in new_primitives:
|
||||
if name not in registry:
|
||||
continue
|
||||
readme = open(ROOT / "modules" / "l1" / name / "README.md").read()
|
||||
assert "## NFRs" in readme or "## NFR" in readme, \
|
||||
f"{name}: README.md must have a NFRs section"
|
||||
|
||||
def test_standards_md_exists(self):
|
||||
assert (ROOT / "modules" / "STANDARDS.md").is_file(), "modules/STANDARDS.md must exist"
|
||||
|
||||
def test_standards_md_has_required_sections(self):
|
||||
content = open(ROOT / "modules" / "STANDARDS.md").read()
|
||||
assert "L1 Primitive Standards" in content
|
||||
assert "L2 Module Standards" in content
|
||||
assert "Encryption by Default" in content
|
||||
assert "Deletion Protection by Default" in content
|
||||
assert "Code Review Checklist" in content
|
||||
|
||||
def test_catalog_index_has_all_primitives(self, registry):
|
||||
content = open(ROOT / "modules" / "README.md").read()
|
||||
for name in registry:
|
||||
if registry[name]["1.0.0"]["interface"].startswith("modules/l1/"):
|
||||
assert name in content, f"modules/README.md catalog index missing primitive: {name}"
|
||||
|
||||
def test_template_has_nfrs_section(self):
|
||||
content = open(ROOT / "modules" / "README-TEMPLATE.md").read()
|
||||
assert "## NFRs" in content or "## NFR" in content, "README-TEMPLATE.md must have NFRs section"
|
||||
@@ -82,6 +82,7 @@ class TestPublishToSsm:
|
||||
monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1")
|
||||
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing")
|
||||
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing")
|
||||
monkeypatch.setenv("ACDL_KMS_KEY_ID", "alias/aws/ssm")
|
||||
|
||||
with mock_aws():
|
||||
ssm = boto3.client("ssm", region_name="us-east-1")
|
||||
@@ -104,6 +105,7 @@ class TestPublishToSsm:
|
||||
monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1")
|
||||
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing")
|
||||
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing")
|
||||
monkeypatch.setenv("ACDL_KMS_KEY_ID", "alias/aws/ssm")
|
||||
|
||||
with mock_aws():
|
||||
ssm = boto3.client("ssm", region_name="us-east-1")
|
||||
@@ -121,6 +123,7 @@ class TestPublishToSsm:
|
||||
monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1")
|
||||
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing")
|
||||
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing")
|
||||
monkeypatch.setenv("ACDL_KMS_KEY_ID", "alias/aws/ssm")
|
||||
|
||||
with mock_aws():
|
||||
ssm = boto3.client("ssm", region_name="us-east-1")
|
||||
@@ -365,4 +368,97 @@ class TestCli:
|
||||
capture_output=True, text=True, cwd=str(Path(__file__).resolve().parent.parent),
|
||||
)
|
||||
assert result.returncode == 2
|
||||
assert "usage" in result.stderr.lower()
|
||||
assert "usage" in result.stderr.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# P1-3: KMS fail-loud tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestKmsFailLoud:
|
||||
"""P1-3: SSM publisher must fail loud when ACDL_KMS_KEY_ID is unset."""
|
||||
|
||||
def test_kms_unset_raises(self, monkeypatch):
|
||||
from moto import mock_aws
|
||||
import boto3
|
||||
|
||||
monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1")
|
||||
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing")
|
||||
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing")
|
||||
monkeypatch.delenv("ACDL_KMS_KEY_ID", raising=False)
|
||||
monkeypatch.delenv("ACDL_ALLOW_DEFAULT_KMS", raising=False)
|
||||
|
||||
with mock_aws():
|
||||
with pytest.raises(RuntimeError, match="ACDL_KMS_KEY_ID is not set"):
|
||||
publish_to_ssm({"vpc_id": "vpc-1"}, "dev", "c-1")
|
||||
|
||||
def test_kms_unset_allow_default_kms_escape_hatch(self, monkeypatch):
|
||||
from moto import mock_aws
|
||||
import boto3
|
||||
|
||||
monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1")
|
||||
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing")
|
||||
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing")
|
||||
monkeypatch.delenv("ACDL_KMS_KEY_ID", raising=False)
|
||||
monkeypatch.setenv("ACDL_ALLOW_DEFAULT_KMS", "1")
|
||||
|
||||
with mock_aws():
|
||||
ssm = boto3.client("ssm", region_name="us-east-1")
|
||||
results = publish_to_ssm({"vpc_id": "vpc-1"}, "dev", "c-1")
|
||||
assert results["vpc_id"] == "/acdl/dev/c-1/vpc_id"
|
||||
param = ssm.get_parameter(Name="/acdl/dev/c-1/vpc_id", WithDecryption=True)
|
||||
assert param["Parameter"]["Type"] == "SecureString"
|
||||
|
||||
def test_kms_set_takes_precedence_over_allow_default(self, monkeypatch):
|
||||
from moto import mock_aws
|
||||
|
||||
monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1")
|
||||
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing")
|
||||
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing")
|
||||
monkeypatch.setenv("ACDL_KMS_KEY_ID", "arn:aws:kms:us-east-1:123:key/abc")
|
||||
monkeypatch.setenv("ACDL_ALLOW_DEFAULT_KMS", "1")
|
||||
|
||||
from core.output_publisher import _kms_key_id
|
||||
assert _kms_key_id() == "arn:aws:kms:us-east-1:123:key/abc"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# P1-6: Invoke policy template tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestInvokePolicyTemplate:
|
||||
"""P1-6: consumer_invoke_policy.json must use placeholders, not hardcoded account ID."""
|
||||
|
||||
def test_policy_has_no_hardcoded_account_id(self):
|
||||
policy_path = Path(__file__).resolve().parent.parent / "terraform" / "platform" / "consumer_invoke_policy.json"
|
||||
policy = json.load(open(policy_path))
|
||||
resource_arn = policy["Statement"][0]["Resource"]
|
||||
assert "000000000000" not in resource_arn
|
||||
assert "${account_id}" in resource_arn
|
||||
|
||||
def test_policy_has_region_placeholder(self):
|
||||
policy_path = Path(__file__).resolve().parent.parent / "terraform" / "platform" / "consumer_invoke_policy.json"
|
||||
policy = json.load(open(policy_path))
|
||||
resource_arn = policy["Statement"][0]["Resource"]
|
||||
assert "${region}" in resource_arn
|
||||
|
||||
def test_policy_renders_with_real_account_id(self):
|
||||
"""Simulate the Terraform rendering: replace ${account_id} and ${region}."""
|
||||
policy_path = Path(__file__).resolve().parent.parent / "terraform" / "platform" / "consumer_invoke_policy.json"
|
||||
template = open(policy_path).read()
|
||||
rendered = template.replace("${account_id}", "123456789012").replace("${region}", "us-east-1")
|
||||
policy = json.loads(rendered)
|
||||
resource_arn = policy["Statement"][0]["Resource"]
|
||||
assert resource_arn == "arn:aws:lambda:us-east-1:123456789012:function:acdl-contract-ingestor"
|
||||
assert "000000000000" not in resource_arn
|
||||
# ${consumerRepo} is a runtime placeholder (not a Terraform variable) — it stays.
|
||||
assert "${account_id}" not in rendered
|
||||
assert "${region}" not in rendered
|
||||
|
||||
def test_main_tf_has_caller_identity_data_source(self):
|
||||
"""P1-6: main.tf must have data.aws_caller_identity for rendering."""
|
||||
main_tf_path = Path(__file__).resolve().parent.parent / "terraform" / "platform" / "main.tf"
|
||||
main_tf = open(main_tf_path).read()
|
||||
assert "data \"aws_caller_identity\" \"current\"" in main_tf
|
||||
assert "rendered_invoke_policy" in main_tf
|
||||
assert "consumer_invoke_policy_rendered" in main_tf
|
||||
@@ -225,7 +225,8 @@ class TestRunPlatformStreaming:
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "PLATFORM CHECK OK" in result.stdout
|
||||
assert "--- emitted terraform/spike/main.tf ---" in result.stdout
|
||||
assert "--- emitted" in result.stdout
|
||||
assert "main.tf" in result.stdout
|
||||
assert "aws_s3_bucket" in result.stdout
|
||||
|
||||
def test_check_only_quiet_suppresses_terraform(self):
|
||||
@@ -236,7 +237,7 @@ class TestRunPlatformStreaming:
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "PLATFORM CHECK OK" in result.stdout
|
||||
assert "--- emitted terraform/spike/main.tf ---" not in result.stdout
|
||||
assert "--- emitted" not in result.stdout
|
||||
|
||||
|
||||
class TestDeployPipelineSchema:
|
||||
@@ -264,7 +265,7 @@ class TestDeployPipelineContract:
|
||||
contract = _load_yaml("pipelines/deploy.yaml")
|
||||
jsonschema.validate(contract, schema)
|
||||
|
||||
def test_deploy_contract_has_eight_stages(self):
|
||||
def test_deploy_contract_has_nine_stages(self):
|
||||
contract = _load_yaml("pipelines/deploy.yaml")
|
||||
stage_names = [s["name"] for s in contract["stages"]]
|
||||
assert stage_names == [
|
||||
@@ -275,6 +276,7 @@ class TestDeployPipelineContract:
|
||||
"confidence",
|
||||
"apply",
|
||||
"publish-outputs",
|
||||
"deploy-uptime",
|
||||
"comment-outputs",
|
||||
]
|
||||
|
||||
@@ -353,6 +355,20 @@ class TestDeployWorkflowConformance:
|
||||
assert wf["permissions"]["id-token"] == "write"
|
||||
assert wf["permissions"]["contents"] == "read"
|
||||
|
||||
def test_deploy_workflow_static_key_override_wired(self):
|
||||
"""S1: the static-key override must be wired to configure-aws-credentials
|
||||
inputs (access-key-id/secret-access-key), not inert env vars."""
|
||||
wf = _load_workflow(".gitea/workflows/deploy.yml")
|
||||
deploy_job = wf["jobs"]["deploy"]
|
||||
creds_step = next(
|
||||
s for s in deploy_job["steps"]
|
||||
if "configure-aws-credentials" in s.get("uses", "")
|
||||
)
|
||||
with_block = creds_step.get("with", {})
|
||||
assert "access-key-id" in with_block, "S1: access-key-id input must be wired"
|
||||
assert "secret-access-key" in with_block, "S1: secret-access-key input must be wired"
|
||||
assert "role-to-assume" in with_block, "S1: role-to-assume must still be present (conditional)"
|
||||
|
||||
|
||||
class TestSampleContractVersioning:
|
||||
def test_sample_contract_uses_versioned_tag(self):
|
||||
|
||||
Reference in New Issue
Block a user