Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 81f111d462 | |||
| 79e7a4a304 | |||
| 8ae307affc | |||
| 6e1a1bd7db | |||
| 040abc0fb7 | |||
| 71bd61ceb1 |
@@ -570,93 +570,4 @@ emulator + live-AWS terraform init/validate/plan.
|
|||||||
7. CloudFront OAC + WAF deprecated arg names (AWS provider v5):
|
7. CloudFront OAC + WAF deprecated arg names (AWS provider v5):
|
||||||
`signing_behavior`, `signing_protocol`, `origin_access_control_id`,
|
`signing_behavior`, `signing_protocol`, `origin_access_control_id`,
|
||||||
`s3_origin_config.origin_access_identity`, `origin_id`, `rule`
|
`s3_origin_config.origin_access_identity`, `origin_id`, `rule`
|
||||||
(singular), `scope=CLOUDFRONT` (uppercase).
|
(singular), `scope=CLOUDFRONT` (uppercase).
|
||||||
|
|
||||||
## v1.11 Addendum — Stateless Adapter + Pipeline-Driven Lifecycle Testing
|
|
||||||
|
|
||||||
**Stateless adapter (D-098).** `adapters/terraform/adapter.py` rewritten
|
|
||||||
from a 918-line monolith (3 constant tables `TYPE_MAP`/`INPUT_MAP`/
|
|
||||||
`OUTPUT_MAP`, 39 type-specific branches) to a ~80-line stateless assembler.
|
|
||||||
Each L1 module ships a real `terraform/` module dir
|
|
||||||
(`versions.tf`/`variables.tf`/`locals.tf`/`main.tf`/`outputs.tf`) owning
|
|
||||||
its resource shape, nested blocks, and defaults. The adapter reads the
|
|
||||||
registry, emits a root `main.tf` instantiating each L1 as
|
|
||||||
`module "x" { source = "..." }` with resolved inputs and wired refs.
|
|
||||||
|
|
||||||
**Terraform owns lifecycle (D-101).** `scripts/run_platform.sh` gains
|
|
||||||
`--apply` and `--destroy` modes. Python never runs terraform.
|
|
||||||
`scripts/verify_deploy_microservice.py` is deleted.
|
|
||||||
|
|
||||||
**Pipeline-driven testing (D-102).** A `modules-lifecycle` pipeline
|
|
||||||
(Gitea + GitHub, byte-identical) matrix-runs each L1 module's
|
|
||||||
`examples/{simple,complex}.yml` contracts through apply→modify→destroy
|
|
||||||
against live AWS. No per-module Python/pytest. The "test" = the pipeline
|
|
||||||
cell going green.
|
|
||||||
|
|
||||||
**Single platform VPC (D-105).** `terraform/platform/main.tf` owns ONE
|
|
||||||
VPC; the microservice composition references it via
|
|
||||||
`terraform_remote_state` (data source). State keys are deterministic and
|
|
||||||
env-aware (`spike/{contract.id}/{contract.environment}/terraform.tfstate`).
|
|
||||||
|
|
||||||
**ACDL_LIFECYCLE_MODE (v1.12, REQ-134).** The lifecycle pipeline defaults
|
|
||||||
to plan-only (fast, no AWS mutation, no cost). A CI variable
|
|
||||||
`ACDL_LIFECYCLE_MODE` (default `plan`) overrides to `full` for the real
|
|
||||||
apply→modify→destroy.
|
|
||||||
|
|
||||||
## v1.12 Addendum — Presentation Refinement + CAP-013 Fix
|
|
||||||
|
|
||||||
**CAP-013 adapter dedup fix (REQ-129).** Multi-resource L1s (ecs-service,
|
|
||||||
alb) with stack outputs + cross-module refs now dedup to ONE module block
|
|
||||||
named by the composition child id, with expanded sub-ids rewritten via
|
|
||||||
`id_remap`. `terraform validate` succeeds for the microservice stack.
|
|
||||||
|
|
||||||
**CAP-017/018 probe fixes (REQ-130).** CAP-017's probe no longer requires
|
|
||||||
`locals.tf` for modules that legitimately omit it. CAP-018's probe
|
|
||||||
instantiates `LocalLambdaStub` with the required `outbox` arg.
|
|
||||||
|
|
||||||
## v1.13 Addendum — Presentation Polish + Config Schema Migration
|
|
||||||
|
|
||||||
**Config.json schema migration (v1.13.1).** Regenerated
|
|
||||||
`.ciagent/config.json` to the updated CIAgent v2 config structure (drop
|
|
||||||
removed fields, migrate `gitea`→`release.gitea`, add
|
|
||||||
`secrets`/`ship`/`backend`/`ideation`/`personas`/`logging`/`telemetry`
|
|
||||||
sections).
|
|
||||||
|
|
||||||
**Presentation polish (v1.13.0, v1.13.2).** Action headlines, story-arc
|
|
||||||
restructure, larger fonts, 6 new mermaid diagrams, badge cleanup,
|
|
||||||
platform-architecture diagram. Docs-only NFR patches.
|
|
||||||
|
|
||||||
## v1.14 Addendum — NFR Refinement (bug fixes, security, stubs, tests, docs)
|
|
||||||
|
|
||||||
**Bug fixes (Wave 1, P1-P6).** Adapter dedup rejects unregistered modules
|
|
||||||
with ValueError (P1). Static-assets composition wires cloudfront inputs
|
|
||||||
(P2). L2 lifecycle scripts document remote-state design (P3). Regression
|
|
||||||
gate adds `terraform fmt -check` syntax probe (P4). Adapter dedup-merge +
|
|
||||||
remote-state-key unit tests (P5). ALB target group name_prefix derives
|
|
||||||
from var.name (P6).
|
|
||||||
|
|
||||||
**Security (Wave 2, P7-P12).** 6 swallowed-error sites narrowed to
|
|
||||||
specific exceptions (P7). Account ID externalized to
|
|
||||||
`ACDL_AWS_ACCOUNT_ID` env (P8). IAM policy scoped to `acdl-*` ARNs (P9).
|
|
||||||
Contract ingestor validates contractId/environment/error (P10). Environment
|
|
||||||
schema adds `additionalProperties: false` + format validation (P11).
|
|
||||||
`.gitignore` credential-pattern catch-all (P12).
|
|
||||||
|
|
||||||
**Stub/test/CI/hygiene (Wave 3, P13-P17).** Kyverno `--kube-version` flag
|
|
||||||
removed (P13, G-103). Orphan artifacts + dead config cleaned (P14). 7
|
|
||||||
untested scripts gain test coverage (P15). Gitea workflow parity
|
|
||||||
documented + script `set` flags fixed (P16). Config.json persona +
|
|
||||||
branching strategy + ollama-cloud aligned (P17).
|
|
||||||
|
|
||||||
**Standards/docs/VPC (Wave 4, P18-P20).** STANDARDS.md reconciled (P18).
|
|
||||||
Documentation synced: ARCHITECTURE.md addenda, stale `@v1.6-1.9` → `@v1.13`,
|
|
||||||
GRILL G-005/G-008 resolved, COST.md window extended, D-083 deferral
|
|
||||||
recorded (P19). Platform VPC CIDR parameterized + data-driven subnet
|
|
||||||
count (P20).
|
|
||||||
|
|
||||||
**D-083 deferral (explicit).** The audit ledger build-out (S3 Object Lock
|
|
||||||
+ JWS detached signatures + SQS DLQ + async worker + daily checkpoints)
|
|
||||||
remains deferred (D-096, v1.14). The hash-chain + DynamoDB outbox is the
|
|
||||||
v1.14 audit record. JWS per-event authenticity is not implemented; a
|
|
||||||
forged event is only detectable by re-reading the whole chain. The
|
|
||||||
deferral is documented here explicitly per the v1.14 grill (E-001).
|
|
||||||
+3
-3
@@ -1,8 +1,8 @@
|
|||||||
# ACDL AWS Cost Report (v1.0 → v1.14)
|
# ACDL AWS Cost Report (v1.0 → v1.10)
|
||||||
|
|
||||||
> **Query date:** 2026-07-29 (updated v1.14 P19)
|
> **Query date:** 2026-07-28
|
||||||
> **Source:** AWS Cost Explorer (`ce:GetCostAndUsage`)
|
> **Source:** AWS Cost Explorer (`ce:GetCostAndUsage`)
|
||||||
> **Window:** 2026-07-21 → 2026-07-29 (v1.0 ship → v1.14 active)
|
> **Window:** 2026-07-21 → 2026-07-28 (v1.0 ship → v1.10 complete)
|
||||||
> **Account:** 581513795199 (us-east-1)
|
> **Account:** 581513795199 (us-east-1)
|
||||||
> **Closes:** G-008 (no cost documentation despite live AWS resources)
|
> **Closes:** G-008 (no cost documentation despite live AWS resources)
|
||||||
|
|
||||||
|
|||||||
@@ -6,13 +6,7 @@
|
|||||||
|
|
||||||
Two escalations must be resolved before the leadership pitch:
|
Two escalations must be resolved before the leadership pitch:
|
||||||
- **G-005 (risks):** 6 cloud capabilities (CAP-017..022) are deploy-unverified.
|
- **G-005 (risks):** 6 cloud capabilities (CAP-017..022) are deploy-unverified.
|
||||||
**RESOLVED (v1.11):** CAP-017..022 are now Verified live-aws via the
|
|
||||||
modules-lifecycle pipeline (apply/modify/destroy exit 0). The IAM-drift
|
|
||||||
framing is removed. See CAPABILITY_INVENTORY.md.
|
|
||||||
- **G-008 (budget):** No cost documentation exists despite live AWS resources.
|
- **G-008 (budget):** No cost documentation exists despite live AWS resources.
|
||||||
**RESOLVED (v1.11):** COST.md now exists, documenting the v1.0→v1.10 spend
|
|
||||||
window + the v1.11 cost projection. The v1.14 P19 phase extends the
|
|
||||||
window to v1.11–v1.14.
|
|
||||||
|
|
||||||
The project is reclassified as an **OSS reference implementation** (G-003),
|
The project is reclassified as an **OSS reference implementation** (G-003),
|
||||||
not a sponsored product. The grill's sponsor/ROI/budget/timeline axes apply
|
not a sponsored product. The grill's sponsor/ROI/budget/timeline axes apply
|
||||||
|
|||||||
+20
-20
@@ -684,26 +684,26 @@ in a 20-phase sweep.
|
|||||||
|
|
||||||
| Requirement | Phase | Status |
|
| Requirement | Phase | Status |
|
||||||
|-------------|-------|--------|
|
|-------------|-------|--------|
|
||||||
| REQ-135 | P1 | complete |
|
| REQ-135 | P1 | pending |
|
||||||
| REQ-136 | P2 | complete |
|
| REQ-136 | P2 | pending |
|
||||||
| REQ-137 | P3 | complete |
|
| REQ-137 | P3 | pending |
|
||||||
| REQ-138 | P4 | complete |
|
| REQ-138 | P4 | pending |
|
||||||
| REQ-139 | P5 | complete |
|
| REQ-139 | P5 | pending |
|
||||||
| REQ-140 | P6 | complete |
|
| REQ-140 | P6 | pending |
|
||||||
| REQ-141 | P7 | complete |
|
| REQ-141 | P7 | pending |
|
||||||
| REQ-142 | P8 | complete |
|
| REQ-142 | P8 | pending |
|
||||||
| REQ-143 | P9 | complete |
|
| REQ-143 | P9 | pending |
|
||||||
| REQ-144 | P10 | complete |
|
| REQ-144 | P10 | pending |
|
||||||
| REQ-145 | P11 | complete |
|
| REQ-145 | P11 | pending |
|
||||||
| REQ-146 | P12 | complete |
|
| REQ-146 | P12 | pending |
|
||||||
| REQ-147 | P13 | complete |
|
| REQ-147 | P13 | pending |
|
||||||
| REQ-148 | P14 | complete |
|
| REQ-148 | P14 | pending |
|
||||||
| REQ-149 | P15 | complete |
|
| REQ-149 | P15 | pending |
|
||||||
| REQ-150 | P16 | complete |
|
| REQ-150 | P16 | pending |
|
||||||
| REQ-151 | P17 | complete |
|
| REQ-151 | P17 | pending |
|
||||||
| REQ-152 | P18 | complete |
|
| REQ-152 | P18 | pending |
|
||||||
| REQ-153 | P19 | complete |
|
| REQ-153 | P19 | pending |
|
||||||
| REQ-154 | P20 | complete |
|
| REQ-154 | P20 | pending |
|
||||||
|
|
||||||
### Out of Scope (v1.14)
|
### Out of Scope (v1.14)
|
||||||
- New features (feat phases). v1.14 is NFR-only.
|
- New features (feat phases). v1.14 is NFR-only.
|
||||||
|
|||||||
+1
-1
@@ -1063,7 +1063,7 @@ Docs-only NFR patch (no code changes).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v1.14 (complete — NFR Refinement: bug fixes, security, stubs, tests, docs, tag `v1.13.24`)
|
## v1.14 (active — NFR Refinement: bug fixes, security, stubs, tests, docs)
|
||||||
|
|
||||||
The v1.14 milestone is a 20-phase NFR sweep — no new features. It clears
|
The v1.14 milestone is a 20-phase NFR sweep — no new features. It clears
|
||||||
the open P1/P2 backlog from the v1.11 review, hardens the security
|
the open P1/P2 backlog from the v1.11 review, hardens the security
|
||||||
|
|||||||
@@ -37,13 +37,14 @@
|
|||||||
"escalate_high_severity": true,
|
"escalate_high_severity": true,
|
||||||
"bash_allowlist": {
|
"bash_allowlist": {
|
||||||
"allowed_commands": [
|
"allowed_commands": [
|
||||||
|
"npm", "node", "npx", "pnpm", "yarn",
|
||||||
"git", "ls", "cat", "head", "tail", "wc",
|
"git", "ls", "cat", "head", "tail", "wc",
|
||||||
"echo", "mkdir", "cp", "mv", "rm", "touch",
|
"echo", "mkdir", "cp", "mv", "rm", "touch",
|
||||||
"pwd", "which", "env", "printenv",
|
"pwd", "which", "env", "printenv",
|
||||||
"python3", "pytest", "pip",
|
"jest", "eslint", "tsc", "prettier",
|
||||||
"terraform", "checkov",
|
|
||||||
"curl", "wget",
|
"curl", "wget",
|
||||||
"docker", "docker-compose"
|
"docker", "docker-compose",
|
||||||
|
"ts-node", "tsx"
|
||||||
],
|
],
|
||||||
"max_output_bytes": 1048576,
|
"max_output_bytes": 1048576,
|
||||||
"timeout_ms": 30000,
|
"timeout_ms": 30000,
|
||||||
@@ -58,8 +59,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"git": {
|
"git": {
|
||||||
"branching_strategy": "flat",
|
"branching_strategy": "phase",
|
||||||
"_branching_strategy_note": "ACDL uses flat workflow (committed directly to main per established convention since v1.0). The 'phase' strategy is advisory; CIAgent uses milestone/phase branches for v1.14 but the project convention is flat.",
|
|
||||||
"auto_commit": true,
|
"auto_commit": true,
|
||||||
"auto_push": true
|
"auto_push": true
|
||||||
},
|
},
|
||||||
@@ -125,7 +125,6 @@
|
|||||||
},
|
},
|
||||||
"ollama-cloud": {
|
"ollama-cloud": {
|
||||||
"base_url": "",
|
"base_url": "",
|
||||||
"_base_url_note": "Intentionally unset. The runtime uses the glm-5.2 model via the opencode backend (not the llm_backends config). This entry is for reference only.",
|
|
||||||
"api_key_env": "OLLAMA_CLOUD_API_KEY",
|
"api_key_env": "OLLAMA_CLOUD_API_KEY",
|
||||||
"model_profile": "quality",
|
"model_profile": "quality",
|
||||||
"timeout_ms": 60000
|
"timeout_ms": 60000
|
||||||
@@ -192,11 +191,9 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend-engineer",
|
"name": "frontend-engineer",
|
||||||
"domain": "frontend",
|
"domain": "frontend",
|
||||||
"active": false,
|
|
||||||
"frameworks": ["react", "next.js"],
|
"frameworks": ["react", "next.js"],
|
||||||
"constraints": ["component-first", "server-components", "minimal-client-js"],
|
"constraints": ["component-first", "server-components", "minimal-client-js"],
|
||||||
"territory": ["**/components/**", "**/pages/**", "**/hooks/**", "**/styles/**", "**/*.tsx", "**/*.css", "**/*.vue"],
|
"territory": ["**/components/**", "**/pages/**", "**/hooks/**", "**/styles/**", "**/*.tsx", "**/*.css", "**/*.vue"]
|
||||||
"reason": "ACDL has no frontend (no package.json); decks are markdown (lead-developer territory). Deactivated per PERSONAS.md:80."
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
# Gitea Workflows — Limitation Documentation (v1.14, REQ-150)
|
|
||||||
|
|
||||||
## Shared workflows (byte-identical Gitea + GitHub)
|
|
||||||
|
|
||||||
These 3 workflows exist in both `.gitea/workflows/` and `.github/workflows/`
|
|
||||||
and are byte-identical (asserted by `tests/test_pipeline_contract.py`):
|
|
||||||
|
|
||||||
- `ci.yml` — lint + test + check-only (runs on every PR)
|
|
||||||
- `deploy.yml` — reusable deploy workflow (invoked by consumer repos)
|
|
||||||
- `modules-lifecycle.yml` — L1 + L2 module lifecycle pipeline (plan-only
|
|
||||||
default, full on workflow_dispatch override)
|
|
||||||
|
|
||||||
## GitHub-only workflows (no Gitea mirror)
|
|
||||||
|
|
||||||
These 4 workflows exist only in `.github/workflows/`:
|
|
||||||
|
|
||||||
- `platform-test.yml` — PR pipeline: lint + unit + integration + schema
|
|
||||||
validation. Uses GitHub Actions features (reusable workflow composition,
|
|
||||||
environment protection) not available in Gitea Actions.
|
|
||||||
- `primitives-plan.yml` — PR plan-only matrix over all L1 primitives. Uses
|
|
||||||
GitHub matrix strategy + `terraform plan` against live AWS.
|
|
||||||
- `patterns-plan.yml` — PR plan-only matrix over all L2 modules. Same
|
|
||||||
pattern as primitives-plan.
|
|
||||||
- `release.yml` — release job on merge to main: computes next semver,
|
|
||||||
creates + updates MAJOR.MINOR.PATCH / MAJOR.MINOR / MAJOR floating tags,
|
|
||||||
creates a GitHub release. GitHub-only by design (Gitea releases are
|
|
||||||
created via the ship workflow's API call, not a workflow).
|
|
||||||
|
|
||||||
## Why no Gitea mirror
|
|
||||||
|
|
||||||
Gitea Actions (act_runner) has limited support for reusable workflow
|
|
||||||
composition, environment protection, and the `gh` CLI used by the release
|
|
||||||
job. The 3 shared workflows are the ones that need to run on both forges
|
|
||||||
(CI + deploy + lifecycle). The 4 GitHub-only workflows are the
|
|
||||||
production-grade platform pipelines that run on GitHub Actions; Gitea is
|
|
||||||
the dev/integration forge. Mirroring them would require feature parity
|
|
||||||
that Gitea Actions does not currently provide.
|
|
||||||
|
|
||||||
This is a documented limitation, not a defect. A future milestone may
|
|
||||||
add Gitea mirrors if act_runner gains the required features.
|
|
||||||
+1
-11
@@ -18,14 +18,4 @@ terraform/bootstrap/.bootstrap_state.json
|
|||||||
**/.terraform/
|
**/.terraform/
|
||||||
**/.terraform.lock.hcl
|
**/.terraform.lock.hcl
|
||||||
**/tfplan
|
**/tfplan
|
||||||
**/*.tfstate*
|
**/*.tfstate*
|
||||||
|
|
||||||
# Credential patterns (v1.14, REQ-146)
|
|
||||||
*.pem
|
|
||||||
*.key
|
|
||||||
*.p12
|
|
||||||
*.pfx
|
|
||||||
*.cer
|
|
||||||
*.crt
|
|
||||||
*.jks
|
|
||||||
*.keystore
|
|
||||||
@@ -222,7 +222,7 @@ The workflow implements the same stages as `pipelines/contract.yml`
|
|||||||
(validate-contract → resolve-stack → security checks → infrastructure plan
|
(validate-contract → resolve-stack → security checks → infrastructure plan
|
||||||
→ policy checks → confidence → evidence event → apply). A consumer repo
|
→ policy checks → confidence → evidence event → apply). A consumer repo
|
||||||
invokes the reusable workflow via a **versioned tag** (floating MAJOR +
|
invokes the reusable workflow via a **versioned tag** (floating MAJOR +
|
||||||
MINOR, e.g. `acdl/.github/workflows/deploy.yml@v1.13`). The workflow checks
|
MINOR, e.g. `acdl/.github/workflows/deploy.yml@v1.6`). The workflow checks
|
||||||
out the consumer repo, then checks out the ACDL platform repo into the
|
out the consumer repo, then checks out the ACDL platform repo into the
|
||||||
runner workspace, and runs `scripts/run_platform.sh` against the consumer's
|
runner workspace, and runs `scripts/run_platform.sh` against the consumer's
|
||||||
contract — the consumer never clones the platform repo or invokes its
|
contract — the consumer never clones the platform repo or invokes its
|
||||||
|
|||||||
@@ -8,16 +8,13 @@ v1.9 (REQ-111): the translator is fleshed out — full PolicyReport →
|
|||||||
PolicyCheckResult mapping with severity + skip-with-reason handling. It
|
PolicyCheckResult mapping with severity + skip-with-reason handling. It
|
||||||
remains inactive for Terraform-only stacks (guard preserved — emits a
|
remains inactive for Terraform-only stacks (guard preserved — emits a
|
||||||
single SKIPPED `KYVERNO_INACTIVE_TF_STACK` record when no K8s manifests).
|
single SKIPPED `KYVERNO_INACTIVE_TF_STACK` record when no K8s manifests).
|
||||||
A `--kube-version` flag was previously parsed but never used. It has been
|
A `--kube-version` stub is parsed but not yet used (for future GitOps).
|
||||||
removed (v1.14, G-103) to resolve the stub. Version-aware policy selection
|
|
||||||
will be added when the GitOps reconciler emits K8s manifests (D-053
|
|
||||||
roadmap). The adapter is inactive for Terraform-only stacks today.
|
|
||||||
|
|
||||||
D-053: the platform emits Terraform, not K8s manifests. This adapter
|
D-053: the platform emits Terraform, not K8s manifests. This adapter
|
||||||
activates when the GitOps reconciler (roadmap) emits K8s manifests.
|
activates when the GitOps reconciler (roadmap) emits K8s manifests.
|
||||||
Sample policies are included as documentation at adapters/kyverno/policies/.
|
Sample policies are included as documentation at adapters/kyverno/policies/.
|
||||||
|
|
||||||
CLI: kyverno_adapter.py <policyreport.json> <contract-id>
|
CLI: kyverno_adapter.py <policyreport.json> <contract-id> [--kube-version <ver>]
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
@@ -103,7 +100,7 @@ def _emit_inactive_tf(contract_id):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def adapt(policyreport_json_path, contract_id):
|
def adapt(policyreport_json_path, contract_id, kube_version=None):
|
||||||
with open(policyreport_json_path, "r", encoding="utf-8") as fh:
|
with open(policyreport_json_path, "r", encoding="utf-8") as fh:
|
||||||
data = json.load(fh)
|
data = json.load(fh)
|
||||||
out = []
|
out = []
|
||||||
@@ -115,6 +112,8 @@ def adapt(policyreport_json_path, contract_id):
|
|||||||
out.append(_to_pcr(entry, contract_id))
|
out.append(_to_pcr(entry, contract_id))
|
||||||
if not out:
|
if not out:
|
||||||
out.append(_emit_inactive_tf(contract_id))
|
out.append(_emit_inactive_tf(contract_id))
|
||||||
|
# kube_version is parsed but not yet used (future GitOps reconciler).
|
||||||
|
_ = kube_version
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -124,8 +123,14 @@ def adapt_inactive(contract_id):
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
kube_ver = None
|
||||||
args = sys.argv[1:]
|
args = sys.argv[1:]
|
||||||
|
if "--kube-version" in args:
|
||||||
|
idx = args.index("--kube-version")
|
||||||
|
if idx + 1 < len(args):
|
||||||
|
kube_ver = args[idx + 1]
|
||||||
|
args = args[:idx] + args[idx + 2:]
|
||||||
if len(args) != 2:
|
if len(args) != 2:
|
||||||
print("usage: kyverno_adapter.py <policyreport.json> <contract-id>", file=sys.stderr)
|
print("usage: kyverno_adapter.py <policyreport.json> <contract-id> [--kube-version <ver>]", file=sys.stderr)
|
||||||
sys.exit(2)
|
sys.exit(2)
|
||||||
print(json.dumps(adapt(args[0], args[1]), indent=2))
|
print(json.dumps(adapt(args[0], args[1], kube_version=kube_ver), indent=2))
|
||||||
@@ -112,8 +112,6 @@ def adapt(stack_instance, out_dir):
|
|||||||
|
|
||||||
stack_name = stack.get("name", "spike")
|
stack_name = stack.get("name", "spike")
|
||||||
environment = stack.get("environment", "dev")
|
environment = stack.get("environment", "dev")
|
||||||
account_id = os.environ.get("ACDL_AWS_ACCOUNT_ID", "581513795199")
|
|
||||||
state_bucket = f"acdl-tfstate-{account_id}-us-east-1"
|
|
||||||
terraform_tf = (
|
terraform_tf = (
|
||||||
'terraform {\n'
|
'terraform {\n'
|
||||||
' required_version = ">= 1.9, < 1.10"\n'
|
' required_version = ">= 1.9, < 1.10"\n'
|
||||||
@@ -124,7 +122,7 @@ def adapt(stack_instance, out_dir):
|
|||||||
' }\n'
|
' }\n'
|
||||||
' }\n'
|
' }\n'
|
||||||
' backend "s3" {\n'
|
' backend "s3" {\n'
|
||||||
f' bucket = "{state_bucket}"\n'
|
' bucket = "acdl-tfstate-581513795199-us-east-1"\n'
|
||||||
f' key = "spike/{stack_name}/{environment}/terraform.tfstate"\n'
|
f' key = "spike/{stack_name}/{environment}/terraform.tfstate"\n'
|
||||||
' region = "us-east-1"\n'
|
' region = "us-east-1"\n'
|
||||||
' }\n'
|
' }\n'
|
||||||
@@ -139,7 +137,7 @@ def adapt(stack_instance, out_dir):
|
|||||||
'data "terraform_remote_state" "platform" {\n'
|
'data "terraform_remote_state" "platform" {\n'
|
||||||
' backend = "s3"\n'
|
' backend = "s3"\n'
|
||||||
' config = {\n'
|
' config = {\n'
|
||||||
f' bucket = "{state_bucket}"\n'
|
' bucket = "acdl-tfstate-581513795199-us-east-1"\n'
|
||||||
f' key = "{remote_state_key}"\n'
|
f' key = "{remote_state_key}"\n'
|
||||||
' region = "us-east-1"\n'
|
' region = "us-east-1"\n'
|
||||||
' }\n'
|
' }\n'
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ requests. The invoke policy is scoped via ABAC (consumer repo identity).
|
|||||||
import datetime
|
import datetime
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import urllib.error
|
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
|
||||||
import boto3
|
import boto3
|
||||||
@@ -155,16 +154,7 @@ def _report_error(payload):
|
|||||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
search_result = json.loads(resp.read())
|
search_result = json.loads(resp.read())
|
||||||
existing = search_result.get("items", [])
|
existing = search_result.get("items", [])
|
||||||
except urllib.error.HTTPError as e:
|
except Exception:
|
||||||
if e.code == 404:
|
|
||||||
existing = []
|
|
||||||
else:
|
|
||||||
import sys
|
|
||||||
print(f"WARNING: GitHub issue search failed (HTTP {e.code}): {e}", file=sys.stderr)
|
|
||||||
existing = []
|
|
||||||
except urllib.error.URLError as e:
|
|
||||||
import sys
|
|
||||||
print(f"WARNING: GitHub issue search network error: {e}", file=sys.stderr)
|
|
||||||
existing = []
|
existing = []
|
||||||
|
|
||||||
body = f"""## Deploy Failure Report
|
body = f"""## Deploy Failure Report
|
||||||
@@ -238,42 +228,21 @@ def _validate_caller_identity(event, payload):
|
|||||||
|
|
||||||
If the identity is not available (e.g. local testing or non-IAM auth), the
|
If the identity is not available (e.g. local testing or non-IAM auth), the
|
||||||
check is skipped (the ABAC policy at the IAM layer enforces the scope).
|
check is skipped (the ABAC policy at the IAM layer enforces the scope).
|
||||||
|
|
||||||
v1.14 (REQ-144): also validates contractId format, environment enum, and
|
|
||||||
error length. The ABAC reliance is documented here: the Function URL IAM
|
|
||||||
identity does not expose principal tags in the event, so full enforcement
|
|
||||||
of consumerRepo ownership is at the IAM layer (ABAC via
|
|
||||||
aws:PrincipalTag/acdl:owner). This function validates format only, not
|
|
||||||
ownership.
|
|
||||||
"""
|
"""
|
||||||
identity = event.get("requestContext", {}).get("identity", {})
|
identity = event.get("requestContext", {}).get("identity", {})
|
||||||
caller_arn = identity.get("userArn", "")
|
caller_arn = identity.get("userArn", "")
|
||||||
if not caller_arn:
|
if not caller_arn:
|
||||||
pass # no identity available — rely on IAM ABAC enforcement
|
return # no identity available — rely on IAM ABAC enforcement
|
||||||
payload_repo = payload.get("consumerRepo", "")
|
payload_repo = payload.get("consumerRepo", "")
|
||||||
if payload_repo:
|
if not payload_repo:
|
||||||
# consumerRepo must be org/repo format, <=128 chars
|
return
|
||||||
if "/" not in payload_repo or len(payload_repo) > 128:
|
# Extract the session name or principal tag from the ARN. The ABAC policy
|
||||||
raise ValueError(f"invalid consumerRepo format: {payload_repo!r}")
|
# scopes via aws:PrincipalTag/acdl:owner = <consumerRepo>. The Function URL
|
||||||
|
# IAM identity does not expose principal tags in the event, so we do a
|
||||||
# v1.14 (REQ-144): contractId format validation
|
# best-effort check: the consumerRepo must not be empty and must be a valid
|
||||||
contract_id = payload.get("contractId", "")
|
# repo identifier (org/repo format). Full enforcement is at the IAM layer.
|
||||||
if contract_id:
|
if "/" not in payload_repo or len(payload_repo) > 128:
|
||||||
import re
|
raise ValueError(f"invalid consumerRepo format: {payload_repo!r}")
|
||||||
if not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$', contract_id):
|
|
||||||
raise ValueError(f"invalid contractId format: {contract_id!r} (alphanumeric, hyphen, underscore; max 64 chars)")
|
|
||||||
|
|
||||||
# v1.14 (REQ-144): environment enum validation
|
|
||||||
environment = payload.get("environment", "")
|
|
||||||
if environment:
|
|
||||||
valid_envs = {"dev", "qa", "prod", "dr"}
|
|
||||||
if environment not in valid_envs:
|
|
||||||
raise ValueError(f"invalid environment: {environment!r} (must be one of {valid_envs})")
|
|
||||||
|
|
||||||
# v1.14 (REQ-144): error length cap (for report_error action)
|
|
||||||
error_msg = payload.get("error", "")
|
|
||||||
if error_msg and len(str(error_msg)) > 10000:
|
|
||||||
payload["error"] = str(error_msg)[:10000]
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_change_request(payload):
|
def _validate_change_request(payload):
|
||||||
|
|||||||
@@ -371,9 +371,8 @@ class LocalLambdaStub:
|
|||||||
return _FakeResponse(
|
return _FakeResponse(
|
||||||
json.dumps([{"number": 1, "title": "stub"}]).encode())
|
json.dumps([{"number": 1, "title": "stub"}]).encode())
|
||||||
urllib.request.urlopen = _fake_urlopen
|
urllib.request.urlopen = _fake_urlopen
|
||||||
except (AttributeError, TypeError) as e:
|
except Exception:
|
||||||
import sys
|
pass
|
||||||
print(f"WARNING: could not patch urlopen for local Lambda stub: {e}", file=sys.stderr)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
event = {
|
event = {
|
||||||
|
|||||||
@@ -97,10 +97,8 @@ def publish_to_ssm(outputs, environment, contract_id):
|
|||||||
Overwrite=True,
|
Overwrite=True,
|
||||||
)
|
)
|
||||||
results[name] = param_name
|
results[name] = param_name
|
||||||
except Exception as e:
|
except Exception:
|
||||||
# Don't fail the pipeline if one output fails to publish, but log it
|
# Don't fail the pipeline if one output fails to publish
|
||||||
import sys
|
|
||||||
print(f"WARNING: SSM put_parameter failed for {name}: {e}", file=sys.stderr)
|
|
||||||
results[name] = None
|
results[name] = None
|
||||||
return results
|
return results
|
||||||
|
|
||||||
@@ -167,9 +165,7 @@ def post_github_comment(comment_text, token=None, repo=None, pr_number=None):
|
|||||||
req.add_header("Accept", "application/vnd.github+json")
|
req.add_header("Accept", "application/vnd.github+json")
|
||||||
urllib.request.urlopen(req, timeout=10)
|
urllib.request.urlopen(req, timeout=10)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception:
|
||||||
import sys
|
|
||||||
print(f"WARNING: GitHub PR comment failed: {e}", file=sys.stderr)
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -421,10 +421,8 @@ def _check_s3_state_bucket() -> Tuple[Status, str]:
|
|||||||
s3 = boto3.client("s3", region_name=env.get("AWS_DEFAULT_REGION", "us-east-1"),
|
s3 = boto3.client("s3", region_name=env.get("AWS_DEFAULT_REGION", "us-east-1"),
|
||||||
aws_access_key_id=env.get("AWS_ACCESS_KEY_ID"),
|
aws_access_key_id=env.get("AWS_ACCESS_KEY_ID"),
|
||||||
aws_secret_access_key=env.get("AWS_SECRET_ACCESS_KEY"))
|
aws_secret_access_key=env.get("AWS_SECRET_ACCESS_KEY"))
|
||||||
account_id = os.environ.get("ACDL_AWS_ACCOUNT_ID", "581513795199")
|
s3.head_bucket(Bucket="acdl-tfstate-581513795199-us-east-1")
|
||||||
state_bucket = f"acdl-tfstate-{account_id}-us-east-1"
|
r = s3.list_objects_v2(Bucket="acdl-tfstate-581513795199-us-east-1", MaxKeys=5)
|
||||||
s3.head_bucket(Bucket=state_bucket)
|
|
||||||
r = s3.list_objects_v2(Bucket=state_bucket, MaxKeys=5)
|
|
||||||
keys = [o["Key"] for o in r.get("Contents", [])]
|
keys = [o["Key"] for o in r.get("Contents", [])]
|
||||||
return "Verified", f"state bucket exists, keys={keys}"
|
return "Verified", f"state bucket exists, keys={keys}"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ change to the modules/stack/confidence/audit.
|
|||||||
- A MAJOR bump requires a new registry entry (immutable publication); the
|
- A MAJOR bump requires a new registry entry (immutable publication); the
|
||||||
old entry enters a 12-month deprecation window.
|
old entry enters a 12-month deprecation window.
|
||||||
- The central deploy pipeline is referenced by a floating MAJOR + MINOR tag
|
- The central deploy pipeline is referenced by a floating MAJOR + MINOR tag
|
||||||
(e.g. `@v1.13`); patch fixes flow within the tag, breaking changes land
|
(e.g. `@v1.6`); patch fixes flow within the tag, breaking changes land
|
||||||
under the next MINOR tag.
|
under the next MINOR tag.
|
||||||
|
|
||||||
See [Versioning](pipeline/versioning) for the consumer-facing details.
|
See [Versioning](pipeline/versioning) for the consumer-facing details.
|
||||||
|
|||||||
+12
-12
@@ -19,7 +19,7 @@ definitions.
|
|||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
A["your repo<br/>(app code + contracts + CI definitions)"] -->|uses: acdl/.github/workflows/deploy.yml@v1.13| B
|
A["your repo<br/>(app code + contracts + CI definitions)"] -->|uses: acdl/.github/workflows/deploy.yml@v1.9| B
|
||||||
B["platform runners<br/>(modules + pipelines + adapters + schemas)"] -->|contract -> resolver -> stack -> adapter<br/>-> security checks -> infrastructure plan -> policy checks<br/>-> confidence -> apply -> evidence event| C
|
B["platform runners<br/>(modules + pipelines + adapters + schemas)"] -->|contract -> resolver -> stack -> adapter<br/>-> security checks -> infrastructure plan -> policy checks<br/>-> confidence -> apply -> evidence event| C
|
||||||
C["your resources in AWS"]
|
C["your resources in AWS"]
|
||||||
```
|
```
|
||||||
@@ -27,7 +27,7 @@ flowchart LR
|
|||||||
## Versioning the `uses:` reference
|
## Versioning the `uses:` reference
|
||||||
|
|
||||||
The central deployment pipeline is **always versioned with floating MAJOR
|
The central deployment pipeline is **always versioned with floating MAJOR
|
||||||
and MINOR tags** (e.g. `acdl/pipelines/contract.yml@v1.13`). Version
|
and MINOR tags** (e.g. `acdl/pipelines/contract.yml@v1.9`). Version
|
||||||
constraints cannot be expressed inside the contract, so the tag in
|
constraints cannot be expressed inside the contract, so the tag in
|
||||||
`uses:` is the only immutability lever a consumer has. See
|
`uses:` is the only immutability lever a consumer has. See
|
||||||
[Versioning](pipeline/versioning) for the full rationale.
|
[Versioning](pipeline/versioning) for the full rationale.
|
||||||
@@ -47,7 +47,7 @@ platform-managed. See [Environments](environments/).
|
|||||||
environment is bound, your first pipeline run emits a friendly onboarding
|
environment is bound, your first pipeline run emits a friendly onboarding
|
||||||
prompt. See [Environments](environments/).
|
prompt. See [Environments](environments/).
|
||||||
- **Authorization to reference the central pipeline.** Onboarding grants
|
- **Authorization to reference the central pipeline.** Onboarding grants
|
||||||
your repo the right to `uses: acdl/.github/workflows/deploy.yml@v1.13`.
|
your repo the right to `uses: acdl/.github/workflows/deploy.yml@v1.9`.
|
||||||
Contact the platform team if you have not been onboarded.
|
Contact the platform team if you have not been onboarded.
|
||||||
|
|
||||||
## Step 1 — Create a consumer repo
|
## Step 1 — Create a consumer repo
|
||||||
@@ -94,7 +94,7 @@ ACDL deployment workflow with a **versioned tag** (floating MAJOR + MINOR):
|
|||||||
```yaml
|
```yaml
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
uses: acdl/.github/workflows/deploy.yml@v1.13
|
uses: acdl/.github/workflows/deploy.yml@v1.9
|
||||||
with:
|
with:
|
||||||
contract: .acdl/contract.yml
|
contract: .acdl/contract.yml
|
||||||
environment: dev
|
environment: dev
|
||||||
@@ -140,7 +140,7 @@ name: microservice
|
|||||||
|
|
||||||
| Field | Type | Required | Description |
|
| Field | Type | Required | Description |
|
||||||
|-------|------|----------|-------------|
|
|-------|------|----------|-------------|
|
||||||
| `uses` | string | yes | Reference to the central deployment pipeline, **versioned** with a floating MAJOR+MINOR tag (e.g. `acdl/pipelines/contract.yml@v1.13`). Bare or `@main` references are discouraged. See [Versioning](pipeline/versioning). |
|
| `uses` | string | yes | Reference to the central deployment pipeline, **versioned** with a floating MAJOR+MINOR tag (e.g. `acdl/pipelines/contract.yml@v1.9`). Bare or `@main` references are discouraged. See [Versioning](pipeline/versioning). |
|
||||||
| `module` | string | yes | Module name from the registry — any primitive or module (e.g. `static-assets`, `microservice`, `s3`). See the [module catalog](modules/). |
|
| `module` | string | yes | Module name from the registry — any primitive or module (e.g. `static-assets`, `microservice`, `s3`). See the [module catalog](modules/). |
|
||||||
| `environment` | string | yes | The platform-managed environment to deploy to (e.g. `dev`). See [Environments](environments/). |
|
| `environment` | string | yes | The platform-managed environment to deploy to (e.g. `dev`). See [Environments](environments/). |
|
||||||
| `inputs` | object | yes | Module-specific inputs (see the module's README). |
|
| `inputs` | object | yes | Module-specific inputs (see the module's README). |
|
||||||
@@ -177,14 +177,14 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
uses: acdl/.github/workflows/deploy.yml@v1.13
|
uses: acdl/.github/workflows/deploy.yml@v1.9
|
||||||
with:
|
with:
|
||||||
contract: .acdl/contract.yml
|
contract: .acdl/contract.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
That is the entire consumer-side workflow. When you push to `main`:
|
That is the entire consumer-side workflow. When you push to `main`:
|
||||||
|
|
||||||
1. The platform runner resolves `uses: acdl/.github/workflows/deploy.yml@v1.13`
|
1. The platform runner resolves `uses: acdl/.github/workflows/deploy.yml@v1.9`
|
||||||
to the reusable workflow **at the pinned tag**.
|
to the reusable workflow **at the pinned tag**.
|
||||||
2. A **platform-provided runner** checks out **your** repo.
|
2. A **platform-provided runner** checks out **your** repo.
|
||||||
3. The runner checks out the **ACDL platform repo** into the workspace —
|
3. The runner checks out the **ACDL platform repo** into the workspace —
|
||||||
@@ -326,8 +326,8 @@ per-module extension points. Common examples:
|
|||||||
| Contract schema | `schemas/contract.schema.json` | JSON Schema for consumer contracts. |
|
| Contract schema | `schemas/contract.schema.json` | JSON Schema for consumer contracts. |
|
||||||
| Stack schema | `schemas/stack.schema.json` | JSON Schema for the resolved stack instance. |
|
| Stack schema | `schemas/stack.schema.json` | JSON Schema for the resolved stack instance. |
|
||||||
| Module catalog | [modules/](modules/) | All primitives and modules. |
|
| Module catalog | [modules/](modules/) | All primitives and modules. |
|
||||||
| Sample contract | `contracts/static-assets.yaml` | The reference example contract (uses `@v1.13`). |
|
| Sample contract | `contracts/static-assets.yaml` | The reference example contract (uses `@v1.9`). |
|
||||||
| Sample contract | `contracts/microservice.yaml` | The microservice example contract (uses `@v1.13`). |
|
| Sample contract | `contracts/microservice.yaml` | The microservice example contract (uses `@v1.9`). |
|
||||||
| Module examples | `modules/<name>/examples/` | Validated per-module example contracts (`simple.yaml` + `complex.yaml`). |
|
| Module examples | `modules/<name>/examples/` | Validated per-module example contracts (`simple.yaml` + `complex.yaml`). |
|
||||||
| Contract resolver | `core/contract_resolver.py` | Resolves contracts to stack instances. |
|
| Contract resolver | `core/contract_resolver.py` | Resolves contracts to stack instances. |
|
||||||
| Angine adapter | `adapters/terraform/adapter.py` | Compiles stack instances to infrastructure. |
|
| Angine adapter | `adapters/terraform/adapter.py` | Compiles stack instances to infrastructure. |
|
||||||
@@ -353,7 +353,7 @@ destruction:
|
|||||||
use `mode: decommission` with the `changeRequestId` input:
|
use `mode: decommission` with the `changeRequestId` input:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
uses: acdl/.github/workflows/deploy.yml@v1.13
|
uses: acdl/.github/workflows/deploy.yml@v1.8
|
||||||
with:
|
with:
|
||||||
contract: .acdl/contract.yml
|
contract: .acdl/contract.yml
|
||||||
mode: decommission
|
mode: decommission
|
||||||
@@ -421,7 +421,7 @@ name: static-assets
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Shape 2 — single contract + `environment` workflow input:** the
|
**Shape 2 — single contract + `environment` workflow input:** the
|
||||||
reusable deploy workflow (`acdl/.github/workflows/deploy.yml@v1.13`)
|
reusable deploy workflow (`acdl/.github/workflows/deploy.yml@v1.9`)
|
||||||
declares an `environment` input. When non-empty, it overrides the
|
declares an `environment` input. When non-empty, it overrides the
|
||||||
contract's `environment` field at load time (before interpolation), so
|
contract's `environment` field at load time (before interpolation), so
|
||||||
the same contract can be promoted by passing a different environment:
|
the same contract can be promoted by passing a different environment:
|
||||||
@@ -436,7 +436,7 @@ on: workflow_dispatch:
|
|||||||
required: true
|
required: true
|
||||||
jobs:
|
jobs:
|
||||||
deploy-qa:
|
deploy-qa:
|
||||||
uses: acdl/.github/workflows/deploy.yml@v1.13
|
uses: acdl/.github/workflows/deploy.yml@v1.9
|
||||||
with:
|
with:
|
||||||
environment: qa
|
environment: qa
|
||||||
contract: .acdl/contract.yml
|
contract: .acdl/contract.yml
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ It is exposed to consumer repos as a **reusable workflow**:
|
|||||||
- `.github/workflows/deploy.yml` — GitHub Actions (production)
|
- `.github/workflows/deploy.yml` — GitHub Actions (production)
|
||||||
|
|
||||||
A consumer repo invokes the reusable workflow via a **versioned tag**
|
A consumer repo invokes the reusable workflow via a **versioned tag**
|
||||||
(floating MAJOR + MINOR, e.g. `acdl/.github/workflows/deploy.yml@v1.13`).
|
(floating MAJOR + MINOR, e.g. `acdl/.github/workflows/deploy.yml@v1.6`).
|
||||||
The workflow checks out the consumer repo, then checks out the ACDL platform
|
The workflow checks out the consumer repo, then checks out the ACDL platform
|
||||||
repo into the runner workspace, and runs `scripts/run_platform.sh` against
|
repo into the runner workspace, and runs `scripts/run_platform.sh` against
|
||||||
the consumer's contract. The consumer never clones the platform repo or
|
the consumer's contract. The consumer never clones the platform repo or
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ tag** in a consumer's CI workflow definition:
|
|||||||
```yaml
|
```yaml
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
uses: acdl/.github/workflows/deploy.yml@v1.13
|
uses: acdl/.github/workflows/deploy.yml@v1.6
|
||||||
with:
|
with:
|
||||||
contract: .acdl/contract.yml
|
contract: .acdl/contract.yml
|
||||||
```
|
```
|
||||||
|
|||||||
+5
-10
@@ -207,12 +207,9 @@ declares intra-refs from the subnet and route table to the VPC's
|
|||||||
- `aws:wafv2:webacl`
|
- `aws:wafv2:webacl`
|
||||||
- `aws:rds:instance`
|
- `aws:rds:instance`
|
||||||
- `aws:kms:key`, `aws:kms:alias`
|
- `aws:kms:key`, `aws:kms:alias`
|
||||||
- The engine adapter is a **stateless assembler** (v1.11, D-098): it reads
|
- The engine adapter's `TYPE_MAP` is the registry of stack types the
|
||||||
the registry, emits a root `main.tf` instantiating each L1 as
|
adapter can compile (see §8). A new stack type requires a `TYPE_MAP`
|
||||||
`module "x" { source = "..." }` with resolved inputs and wired refs. There
|
entry before the primitive can be deployed.
|
||||||
is no `TYPE_MAP` (deleted in the v1.11 stateless rewrite). A new stack
|
|
||||||
type requires a `terraform/` dir in the L1 module + a registry entry with
|
|
||||||
a `terraform_dir` field.
|
|
||||||
|
|
||||||
## 3. L2 Module Standards
|
## 3. L2 Module Standards
|
||||||
|
|
||||||
@@ -583,10 +580,8 @@ must be checked before the module is registered and published.
|
|||||||
### 9.4 Adapter (stateless assembler)
|
### 9.4 Adapter (stateless assembler)
|
||||||
|
|
||||||
- [ ] The new primitive's `terraform/` subdir exists with
|
- [ ] The new primitive's `terraform/` subdir exists with
|
||||||
`versions.tf`/`variables.tf`/`main.tf`/`outputs.tf` and
|
`versions.tf`/`variables.tf`/`locals.tf`/`main.tf`/`outputs.tf` and
|
||||||
passes `terraform init + validate` standalone. `locals.tf` is required
|
passes `terraform init + validate` standalone.
|
||||||
for multi-resource modules; trivial single-resource modules (e.g.
|
|
||||||
`kms-key`, `ecr`, `ecs-cluster`) may inline locals in `main.tf`.
|
|
||||||
- [ ] `registry.json` has a `terraform_dir` field for the new primitive.
|
- [ ] `registry.json` has a `terraform_dir` field for the new primitive.
|
||||||
- [ ] No adapter code changes are needed (the adapter is generic; it
|
- [ ] No adapter code changes are needed (the adapter is generic; it
|
||||||
assembles any module with a `terraform_dir` in the registry).
|
assembles any module with a `terraform_dir` in the registry).
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ resource "aws_lb" "this" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
resource "aws_lb_target_group" "this" {
|
resource "aws_lb_target_group" "this" {
|
||||||
name_prefix = "${var.name}-"
|
name_prefix = "tg-ci-"
|
||||||
port = var.port
|
port = var.port
|
||||||
protocol = var.protocol
|
protocol = var.protocol
|
||||||
vpc_id = var.vpc_id
|
vpc_id = var.vpc_id
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
"wires": [
|
"wires": [
|
||||||
{"from": "contract.inputs.name", "to": "alb.inputs.name", "default": "app"},
|
{"from": "contract.inputs.name", "to": "alb.inputs.name", "default": "app"},
|
||||||
{"from": "contract.inputs.name", "to": "ecr.inputs.name", "default": "app-repo"},
|
{"from": "contract.inputs.name", "to": "ecr.inputs.name", "default": "app-repo"},
|
||||||
{"from": "contract.inputs.name", "to": "roles.inputs.role_name", "default": "acdl-app-role"},
|
{"from": "contract.inputs.name", "to": "roles.inputs.role_name", "default": "app-role"},
|
||||||
{"from": "contract.inputs.region", "to": "cluster.inputs.region"},
|
{"from": "contract.inputs.region", "to": "cluster.inputs.region"},
|
||||||
{"from": "contract.inputs.region", "to": "ecr.inputs.region"},
|
{"from": "contract.inputs.region", "to": "ecr.inputs.region"},
|
||||||
{"from": "contract.inputs.region", "to": "roles.inputs.region"},
|
{"from": "contract.inputs.region", "to": "roles.inputs.region"},
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "acdl"
|
name = "acdl"
|
||||||
version = "1.14.0"
|
version = "1.3.0"
|
||||||
description = "Agentic Cloud Delivery Platform — consumers declare intent; the platform delivers safe production deployment."
|
description = "Agentic Cloud Delivery Platform — consumers declare intent; the platform delivers safe production deployment."
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
@@ -28,7 +28,7 @@ filterwarnings = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[tool.coverage]
|
[tool.coverage]
|
||||||
run.source = ["core", "adapters"]
|
run.source = ["acdl_platform", "adapters"]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["setuptools>=68"]
|
requires = ["setuptools>=68"]
|
||||||
|
|||||||
@@ -27,23 +27,20 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["bucket", "lock_table"],
|
"required": ["bucket", "lock_table"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"bucket": {"type": "string", "pattern": "^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$", "description": "S3 state bucket name (lowercase, 3-63 chars, dots/hyphens)."},
|
"bucket": {"type": "string", "description": "S3 state bucket name."},
|
||||||
"lock_table": {"type": "string", "description": "DynamoDB lock table name."}
|
"lock_table": {"type": "string", "description": "DynamoDB lock table name."}
|
||||||
},
|
}
|
||||||
"additionalProperties": false
|
|
||||||
},
|
},
|
||||||
"network": {
|
"network": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["vpc_cidr", "azs"],
|
"required": ["vpc_cidr", "azs"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"vpc_cidr": {"type": "string", "pattern": "^[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}/[0-9]{1,2}$", "description": "VPC CIDR block (e.g. 10.0.0.0/16)."},
|
"vpc_cidr": {"type": "string", "description": "VPC CIDR block."},
|
||||||
"azs": {"type": "array", "items": {"type": "string"}, "maxItems": 6, "description": "Availability zones (max 6)."}
|
"azs": {"type": "array", "items": {"type": "string"}, "description": "Availability zones."}
|
||||||
},
|
}
|
||||||
"additionalProperties": false
|
|
||||||
},
|
},
|
||||||
"runner_role_arn": {
|
"runner_role_arn": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"pattern": "^arn:aws:iam::[0-9]{12}:role/.+$",
|
|
||||||
"description": "The IAM role ARN surfaced to the consumer's repo via ABAC."
|
"description": "The IAM role ARN surfaced to the consumer's repo via ABAC."
|
||||||
},
|
},
|
||||||
"autonomy": {
|
"autonomy": {
|
||||||
@@ -57,6 +54,5 @@
|
|||||||
"maximum": 1,
|
"maximum": 1,
|
||||||
"description": "The confidence gate threshold for this environment (dev 0.50, qa 0.75, prod 0.90, dr 0.95)."
|
"description": "The confidence gate threshold for this environment (dev 0.50, qa 0.75, prod 0.90, dr 0.95)."
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"additionalProperties": false
|
|
||||||
}
|
}
|
||||||
@@ -29,7 +29,7 @@ import boto3
|
|||||||
|
|
||||||
REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
|
REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||||
ENV_FILE = REPO_ROOT / ".env.secrets"
|
ENV_FILE = REPO_ROOT / ".env.secrets"
|
||||||
AWS_ACCOUNT_ID = os.environ.get("ACDL_AWS_ACCOUNT_ID", "581513795199")
|
AWS_ACCOUNT_ID = "581513795199"
|
||||||
AWS_REGION = "us-east-1"
|
AWS_REGION = "us-east-1"
|
||||||
ECR_REPO_NAME = "acdl-microservice"
|
ECR_REPO_NAME = "acdl-microservice"
|
||||||
IMAGE_TAG = "latest"
|
IMAGE_TAG = "latest"
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
#
|
#
|
||||||
# Spike scope (D-039): the spike user key is per-run-rotated; real OIDC is
|
# Spike scope (D-039): the spike user key is per-run-rotated; real OIDC is
|
||||||
# v1.2 (blocked on go-gitea/gitea#36988).
|
# v1.2 (blocked on go-gitea/gitea#36988).
|
||||||
set -euo pipefail
|
set -u
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
ENV_FILE="$ROOT/.env.secrets"
|
ENV_FILE="$ROOT/.env.secrets"
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import boto3
|
|||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent.parent
|
ROOT = Path(__file__).resolve().parent.parent.parent
|
||||||
POLICY_PATH = ROOT / "terraform" / "bootstrap" / "spike_runner_policy.json"
|
POLICY_PATH = ROOT / "terraform" / "bootstrap" / "spike_runner_policy.json"
|
||||||
ACCOUNT = os.environ.get("ACDL_AWS_ACCOUNT_ID", "581513795199")
|
ACCOUNT = "581513795199"
|
||||||
USER = "acdl-spike-runner"
|
USER = "acdl-spike-runner"
|
||||||
POLICY_NAME = "acdl-spike-runner-policy"
|
POLICY_NAME = "acdl-spike-runner-policy"
|
||||||
POLICY_ARN = f"arn:aws:iam::{ACCOUNT}:policy/{POLICY_NAME}"
|
POLICY_ARN = f"arn:aws:iam::{ACCOUNT}:policy/{POLICY_NAME}"
|
||||||
@@ -75,10 +75,8 @@ def apply_managed_policy(iam, policy_doc: str) -> str:
|
|||||||
try:
|
try:
|
||||||
iam.delete_policy_version(PolicyArn=POLICY_ARN, VersionId=default)
|
iam.delete_policy_version(PolicyArn=POLICY_ARN, VersionId=default)
|
||||||
print(f"deleted old default version {default}")
|
print(f"deleted old default version {default}")
|
||||||
except iam.exceptions.NoSuchEntityException:
|
|
||||||
pass # already deleted
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"WARNING: could not delete old version {default}: {e}")
|
print(f"could not delete old version {default}: {e}")
|
||||||
return POLICY_ARN
|
return POLICY_ARN
|
||||||
except iam.exceptions.NoSuchEntityException:
|
except iam.exceptions.NoSuchEntityException:
|
||||||
print(f"creating managed policy {POLICY_NAME}...")
|
print(f"creating managed policy {POLICY_NAME}...")
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ import boto3
|
|||||||
|
|
||||||
|
|
||||||
REGION = os.environ.get("AWS_DEFAULT_REGION", "us-east-1")
|
REGION = os.environ.get("AWS_DEFAULT_REGION", "us-east-1")
|
||||||
ACCOUNT_ID = os.environ.get("ACDL_AWS_ACCOUNT_ID", "581513795199")
|
STATE_BUCKET = "acdl-tfstate-581513795199-us-east-1"
|
||||||
STATE_BUCKET = f"acdl-tfstate-{ACCOUNT_ID}-us-east-1"
|
|
||||||
OUTBOX_TABLE = "acdl-outbox"
|
OUTBOX_TABLE = "acdl-outbox"
|
||||||
|
ACCOUNT_ID = "581513795199"
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -48,16 +48,12 @@ def main():
|
|||||||
try:
|
try:
|
||||||
s3.head_bucket(Bucket=STATE_BUCKET)
|
s3.head_bucket(Bucket=STATE_BUCKET)
|
||||||
print(f"s3: bucket {STATE_BUCKET} already exists")
|
print(f"s3: bucket {STATE_BUCKET} already exists")
|
||||||
except s3.exceptions.ClientError as e:
|
except Exception:
|
||||||
error_code = e.response.get("Error", {}).get("Code", "")
|
kwargs = {"Bucket": STATE_BUCKET}
|
||||||
if error_code in ("404", "NoSuchBucket", "NotFound"):
|
if REGION != "us-east-1":
|
||||||
kwargs = {"Bucket": STATE_BUCKET}
|
kwargs["CreateBucketConfiguration"] = {"LocationConstraint": REGION}
|
||||||
if REGION != "us-east-1":
|
s3.create_bucket(**kwargs)
|
||||||
kwargs["CreateBucketConfiguration"] = {"LocationConstraint": REGION}
|
print(f"s3: created bucket {STATE_BUCKET}")
|
||||||
s3.create_bucket(**kwargs)
|
|
||||||
print(f"s3: created bucket {STATE_BUCKET}")
|
|
||||||
else:
|
|
||||||
raise
|
|
||||||
# Enable versioning (idempotent)
|
# Enable versioning (idempotent)
|
||||||
s3.put_bucket_versioning(
|
s3.put_bucket_versioning(
|
||||||
Bucket=STATE_BUCKET,
|
Bucket=STATE_BUCKET,
|
||||||
|
|||||||
@@ -215,10 +215,7 @@
|
|||||||
"kms:TagResource",
|
"kms:TagResource",
|
||||||
"kms:UntagResource"
|
"kms:UntagResource"
|
||||||
],
|
],
|
||||||
"Resource": [
|
"Resource": "*"
|
||||||
"arn:aws:kms:*:*:key/*",
|
|
||||||
"arn:aws:kms:*:*:alias/acdl-*"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Effect": "Allow",
|
"Effect": "Allow",
|
||||||
@@ -236,7 +233,7 @@
|
|||||||
"iam:TagRole",
|
"iam:TagRole",
|
||||||
"iam:UntagRole"
|
"iam:UntagRole"
|
||||||
],
|
],
|
||||||
"Resource": "arn:aws:iam::*:role/acdl-*"
|
"Resource": "*"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,13 +29,6 @@ provider "aws" {
|
|||||||
region = "us-east-1"
|
region = "us-east-1"
|
||||||
}
|
}
|
||||||
|
|
||||||
# v1.14 (REQ-154): VPC CIDR is parameterized (default 10.0.0.0/16).
|
|
||||||
variable "vpc_cidr" {
|
|
||||||
description = "CIDR block for the shared platform VPC (default 10.0.0.0/16)."
|
|
||||||
type = string
|
|
||||||
default = "10.0.0.0/16"
|
|
||||||
}
|
|
||||||
|
|
||||||
# KMS customer-managed key for DynamoDB SSE + SSM Parameter Store encryption
|
# KMS customer-managed key for DynamoDB SSE + SSM Parameter Store encryption
|
||||||
resource "aws_kms_key" "acdl_platform" {
|
resource "aws_kms_key" "acdl_platform" {
|
||||||
description = "ACDL platform KMS key (DynamoDB SSE + SSM + Secrets Manager)"
|
description = "ACDL platform KMS key (DynamoDB SSE + SSM + Secrets Manager)"
|
||||||
@@ -259,7 +252,7 @@ output "acdl_sod_halt_topic_arn" {
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
resource "aws_vpc" "acdl_shared" {
|
resource "aws_vpc" "acdl_shared" {
|
||||||
cidr_block = var.vpc_cidr
|
cidr_block = "10.0.0.0/16"
|
||||||
tags = {
|
tags = {
|
||||||
Name = "acdl-shared"
|
Name = "acdl-shared"
|
||||||
"acdl:owner" = "acdl"
|
"acdl:owner" = "acdl"
|
||||||
@@ -270,7 +263,7 @@ resource "aws_vpc" "acdl_shared" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
resource "aws_subnet" "acdl_shared" {
|
resource "aws_subnet" "acdl_shared" {
|
||||||
count = length(data.aws_availability_zones.available.names)
|
count = 2
|
||||||
vpc_id = aws_vpc.acdl_shared.id
|
vpc_id = aws_vpc.acdl_shared.id
|
||||||
cidr_block = cidrsubnet(aws_vpc.acdl_shared.cidr_block, 8, count.index + 1)
|
cidr_block = cidrsubnet(aws_vpc.acdl_shared.cidr_block, 8, count.index + 1)
|
||||||
availability_zone = data.aws_availability_zones.available.names[count.index]
|
availability_zone = data.aws_availability_zones.available.names[count.index]
|
||||||
@@ -324,10 +317,6 @@ resource "aws_security_group" "ecs" {
|
|||||||
description = "Security group for ECS Fargate services (platform VPC)"
|
description = "Security group for ECS Fargate services (platform VPC)"
|
||||||
vpc_id = aws_vpc.acdl_shared.id
|
vpc_id = aws_vpc.acdl_shared.id
|
||||||
|
|
||||||
# Ingress on port 80 is open to 0.0.0.0/0 — this is acceptable because
|
|
||||||
# the ECS service is fronted by a public-facing ALB (the ALB terminates
|
|
||||||
# TLS + routes to the target group). The ECS SG should not be attached
|
|
||||||
# directly to resources without an ALB in front. v1.14 (REQ-154).
|
|
||||||
ingress {
|
ingress {
|
||||||
from_port = 80
|
from_port = 80
|
||||||
to_port = 80
|
to_port = 80
|
||||||
|
|||||||
+1
-85
@@ -361,88 +361,4 @@ class TestAdapterDedupRejectsUnregisteredModule:
|
|||||||
"data_sources": [],
|
"data_sources": [],
|
||||||
}
|
}
|
||||||
adapt(stack, str(tmp_path))
|
adapt(stack, str(tmp_path))
|
||||||
assert (tmp_path / "main.tf").exists()
|
assert (tmp_path / "main.tf").exists()
|
||||||
|
|
||||||
|
|
||||||
class TestAdapterDedupMergesSameModule:
|
|
||||||
"""P2-2 (v1.14, REQ-139): two resources with the same module collapse
|
|
||||||
to one module block named by the child id, with merged inputs. This
|
|
||||||
locks in the dedup-merge behavior at the unit level."""
|
|
||||||
|
|
||||||
def test_two_resources_same_module_collapse_to_one_block(self, tmp_path):
|
|
||||||
"""Two resources sharing the same terraform dir (e.g. cloudfront
|
|
||||||
distribution + OAC) must produce ONE module block, not two."""
|
|
||||||
stack = {
|
|
||||||
"resources": [
|
|
||||||
{"id": "cloudfront-distribution", "type": "aws:cloudfront:distribution", "module": "cloudfront@1.0.0", "inputs": {"price_class": "PriceClass_100"}},
|
|
||||||
{"id": "cloudfront-originaccesscontrol", "type": "aws:cloudfront:originaccesscontrol", "module": "cloudfront@1.0.0", "inputs": {"viewer_protocol_policy": "redirect-to-https"}},
|
|
||||||
],
|
|
||||||
"outputs": {},
|
|
||||||
"data_sources": [],
|
|
||||||
}
|
|
||||||
adapt(stack, str(tmp_path))
|
|
||||||
main_tf = (tmp_path / "main.tf").read_text()
|
|
||||||
# Exactly one module block for cloudfront (deduped to child id "cloudfront")
|
|
||||||
assert main_tf.count('module "cloudfront" {') == 1
|
|
||||||
# No separate module blocks for the expanded sub-ids
|
|
||||||
assert 'module "cloudfront-distribution"' not in main_tf
|
|
||||||
assert 'module "cloudfront-originaccesscontrol"' not in main_tf
|
|
||||||
|
|
||||||
def test_dedup_merges_inputs_from_both_resources(self, tmp_path):
|
|
||||||
"""When two resources share a module, their inputs are merged into
|
|
||||||
the single module block (first resource's inputs + second's, with
|
|
||||||
first-wins for overlapping keys)."""
|
|
||||||
stack = {
|
|
||||||
"resources": [
|
|
||||||
{"id": "cloudfront-distribution", "type": "aws:cloudfront:distribution", "module": "cloudfront@1.0.0", "inputs": {"price_class": "PriceClass_100", "region": "us-east-1"}},
|
|
||||||
{"id": "cloudfront-originaccesscontrol", "type": "aws:cloudfront:originaccesscontrol", "module": "cloudfront@1.0.0", "inputs": {"viewer_protocol_policy": "redirect-to-https"}},
|
|
||||||
],
|
|
||||||
"outputs": {},
|
|
||||||
"data_sources": [],
|
|
||||||
}
|
|
||||||
adapt(stack, str(tmp_path))
|
|
||||||
main_tf = (tmp_path / "main.tf").read_text()
|
|
||||||
# Both inputs present in the merged module block
|
|
||||||
assert "PriceClass_100" in main_tf
|
|
||||||
assert "redirect-to-https" in main_tf
|
|
||||||
|
|
||||||
|
|
||||||
class TestAdapterRemoteStateKeyOverride:
|
|
||||||
"""P2-2 (v1.14, REQ-139): ACDL_REMOTE_STATE_KEY env var overrides the
|
|
||||||
default 'platform/terraform.tfstate' key in the emitted
|
|
||||||
data terraform_remote_state block. This is the load-bearing correctness
|
|
||||||
mechanism for the microservice L2 lifecycle (remote state points at the
|
|
||||||
CI VPC, not the platform VPC)."""
|
|
||||||
|
|
||||||
def test_default_remote_state_key(self, tmp_path, monkeypatch):
|
|
||||||
"""When ACDL_REMOTE_STATE_KEY is unset, the default key is used."""
|
|
||||||
monkeypatch.delenv("ACDL_REMOTE_STATE_KEY", raising=False)
|
|
||||||
stack = {
|
|
||||||
"resources": [
|
|
||||||
{"id": "s3", "type": "aws:s3:bucket", "module": "s3@1.0.0", "inputs": {"bucket_name": "test", "region": "us-east-1"}}
|
|
||||||
],
|
|
||||||
"outputs": {},
|
|
||||||
"data_sources": ["platform"],
|
|
||||||
}
|
|
||||||
adapt(stack, str(tmp_path))
|
|
||||||
terraform_tf = (tmp_path / "terraform.tf").read_text()
|
|
||||||
main_tf = (tmp_path / "main.tf").read_text()
|
|
||||||
# The remote state data block uses the default key
|
|
||||||
assert "platform/terraform.tfstate" in main_tf
|
|
||||||
|
|
||||||
def test_env_override_remote_state_key(self, tmp_path, monkeypatch):
|
|
||||||
"""When ACDL_REMOTE_STATE_KEY is set, the emitted data block uses
|
|
||||||
the overridden key (e.g. 'spike/ci-vpc/terraform.tfstate')."""
|
|
||||||
monkeypatch.setenv("ACDL_REMOTE_STATE_KEY", "spike/ci-vpc/terraform.tfstate")
|
|
||||||
stack = {
|
|
||||||
"resources": [
|
|
||||||
{"id": "s3", "type": "aws:s3:bucket", "module": "s3@1.0.0", "inputs": {"bucket_name": "test", "region": "us-east-1"}}
|
|
||||||
],
|
|
||||||
"outputs": {},
|
|
||||||
"data_sources": ["platform"],
|
|
||||||
}
|
|
||||||
adapt(stack, str(tmp_path))
|
|
||||||
main_tf = (tmp_path / "main.tf").read_text()
|
|
||||||
# The remote state data block uses the overridden key
|
|
||||||
assert "spike/ci-vpc/terraform.tfstate" in main_tf
|
|
||||||
assert "platform/terraform.tfstate" not in main_tf
|
|
||||||
@@ -500,43 +500,4 @@ class TestValidateChangeRequest:
|
|||||||
resp = ingestor.lambda_handler(event, None)
|
resp = ingestor.lambda_handler(event, None)
|
||||||
assert resp["statusCode"] == 200
|
assert resp["statusCode"] == 200
|
||||||
body = json.loads(resp["body"])
|
body = json.loads(resp["body"])
|
||||||
assert body["action"] == "validate_change_request"
|
assert body["action"] == "validate_change_request"
|
||||||
|
|
||||||
|
|
||||||
class TestV14IdentityValidation:
|
|
||||||
"""v1.14 (REQ-144): contractId format, environment enum, error length
|
|
||||||
validation + spoofing resistance."""
|
|
||||||
|
|
||||||
def test_invalid_contract_id_rejected(self, moto_contracts_table, sample_payload):
|
|
||||||
sample_payload["contractId"] = "bad contract!@#"
|
|
||||||
event = {"body": json.dumps(sample_payload), "requestContext": {}}
|
|
||||||
resp = ingestor.lambda_handler(event, None)
|
|
||||||
assert resp["statusCode"] == 400
|
|
||||||
assert "invalid contractId" in resp["body"]
|
|
||||||
|
|
||||||
def test_contract_id_too_long_rejected(self, moto_contracts_table, sample_payload):
|
|
||||||
sample_payload["contractId"] = "a" * 65
|
|
||||||
event = {"body": json.dumps(sample_payload), "requestContext": {}}
|
|
||||||
resp = ingestor.lambda_handler(event, None)
|
|
||||||
assert resp["statusCode"] == 400
|
|
||||||
assert "invalid contractId" in resp["body"]
|
|
||||||
|
|
||||||
def test_invalid_environment_rejected(self, moto_contracts_table, sample_payload):
|
|
||||||
sample_payload["environment"] = "staging"
|
|
||||||
event = {"body": json.dumps(sample_payload), "requestContext": {}}
|
|
||||||
resp = ingestor.lambda_handler(event, None)
|
|
||||||
assert resp["statusCode"] == 400
|
|
||||||
assert "invalid environment" in resp["body"]
|
|
||||||
|
|
||||||
def test_valid_environments_accepted(self, moto_contracts_table, sample_payload):
|
|
||||||
for env in ["dev", "qa", "prod", "dr"]:
|
|
||||||
sample_payload["environment"] = env
|
|
||||||
event = {"body": json.dumps(sample_payload), "requestContext": {}}
|
|
||||||
resp = ingestor.lambda_handler(event, None)
|
|
||||||
assert resp["statusCode"] == 200
|
|
||||||
|
|
||||||
def test_abac_reliance_documented(self):
|
|
||||||
"""The _validate_caller_identity docstring documents the ABAC reliance."""
|
|
||||||
docstring = ingestor._validate_caller_identity.__doc__
|
|
||||||
assert "ABAC" in docstring
|
|
||||||
assert "PrincipalTag" in docstring
|
|
||||||
@@ -96,64 +96,4 @@ def test_account_id_is_12_digits():
|
|||||||
for env_file in ENV_FILES:
|
for env_file in ENV_FILES:
|
||||||
env = json.loads((ENV_DIR / env_file).read_text())
|
env = json.loads((ENV_DIR / env_file).read_text())
|
||||||
assert len(env["account_id"]) == 12
|
assert len(env["account_id"]) == 12
|
||||||
assert env["account_id"].isdigit()
|
assert env["account_id"].isdigit()
|
||||||
|
|
||||||
|
|
||||||
def test_v14_schema_rejects_undocumented_fields():
|
|
||||||
"""v1.14 (REQ-145): additionalProperties: false rejects unknown fields."""
|
|
||||||
schema = json.loads(SCHEMA.read_text())
|
|
||||||
bad_env = {
|
|
||||||
"name": "dev",
|
|
||||||
"account_id": "123456789012",
|
|
||||||
"region": "us-east-1",
|
|
||||||
"state_backend": {"bucket": "test", "lock_table": "test"},
|
|
||||||
"network": {"vpc_cidr": "10.0.0.0/16", "azs": ["us-east-1a"]},
|
|
||||||
"runner_role_arn": "arn:aws:iam::123456789012:role/test",
|
|
||||||
"autonomy": "full",
|
|
||||||
"confidence_threshold": 0.5,
|
|
||||||
"rogue_field": "should be rejected"
|
|
||||||
}
|
|
||||||
with pytest.raises(jsonschema.ValidationError, match="Additional properties are not allowed"):
|
|
||||||
jsonschema.validate(bad_env, schema)
|
|
||||||
|
|
||||||
|
|
||||||
def test_v14_schema_validates_bucket_name_format():
|
|
||||||
"""v1.14 (REQ-145): state_backend.bucket must match S3 naming rules."""
|
|
||||||
schema = json.loads(SCHEMA.read_text())
|
|
||||||
bad_env = {
|
|
||||||
"name": "dev", "account_id": "123456789012", "region": "us-east-1",
|
|
||||||
"state_backend": {"bucket": "Invalid_Bucket!", "lock_table": "test"},
|
|
||||||
"network": {"vpc_cidr": "10.0.0.0/16", "azs": ["us-east-1a"]},
|
|
||||||
"runner_role_arn": "arn:aws:iam::123456789012:role/test",
|
|
||||||
"autonomy": "full", "confidence_threshold": 0.5
|
|
||||||
}
|
|
||||||
with pytest.raises(jsonschema.ValidationError, match="does not match"):
|
|
||||||
jsonschema.validate(bad_env, schema)
|
|
||||||
|
|
||||||
|
|
||||||
def test_v14_schema_validates_arn_format():
|
|
||||||
"""v1.14 (REQ-145): runner_role_arn must match ARN format."""
|
|
||||||
schema = json.loads(SCHEMA.read_text())
|
|
||||||
bad_env = {
|
|
||||||
"name": "dev", "account_id": "123456789012", "region": "us-east-1",
|
|
||||||
"state_backend": {"bucket": "test", "lock_table": "test"},
|
|
||||||
"network": {"vpc_cidr": "10.0.0.0/16", "azs": ["us-east-1a"]},
|
|
||||||
"runner_role_arn": "not-an-arn",
|
|
||||||
"autonomy": "full", "confidence_threshold": 0.5
|
|
||||||
}
|
|
||||||
with pytest.raises(jsonschema.ValidationError, match="does not match"):
|
|
||||||
jsonschema.validate(bad_env, schema)
|
|
||||||
|
|
||||||
|
|
||||||
def test_v14_schema_validates_cidr_format():
|
|
||||||
"""v1.14 (REQ-145): vpc_cidr must match CIDR format."""
|
|
||||||
schema = json.loads(SCHEMA.read_text())
|
|
||||||
bad_env = {
|
|
||||||
"name": "dev", "account_id": "123456789012", "region": "us-east-1",
|
|
||||||
"state_backend": {"bucket": "test", "lock_table": "test"},
|
|
||||||
"network": {"vpc_cidr": "not-a-cidr", "azs": ["us-east-1a"]},
|
|
||||||
"runner_role_arn": "arn:aws:iam::123456789012:role/test",
|
|
||||||
"autonomy": "full", "confidence_threshold": 0.5
|
|
||||||
}
|
|
||||||
with pytest.raises(jsonschema.ValidationError, match="does not match"):
|
|
||||||
jsonschema.validate(bad_env, schema)
|
|
||||||
@@ -176,43 +176,4 @@ class TestIAMPolicyBaseline:
|
|||||||
res = s.get("Resource", "")
|
res = s.get("Resource", "")
|
||||||
if isinstance(res, list):
|
if isinstance(res, list):
|
||||||
res = " ".join(res)
|
res = " ".join(res)
|
||||||
assert res != "*", "iam:PassRole must not be granted to Resource: *"
|
assert res != "*", "iam:PassRole must not be granted to Resource: *"
|
||||||
|
|
||||||
def test_iam_role_creation_scoped_to_acdl_prefix(self, policy):
|
|
||||||
"""G-104: iam:CreateRole must be scoped to role/acdl-* (not Resource: *)."""
|
|
||||||
for s in policy["Statement"]:
|
|
||||||
acts = s.get("Action", [])
|
|
||||||
if isinstance(acts, str):
|
|
||||||
acts = [acts]
|
|
||||||
if "iam:CreateRole" in acts:
|
|
||||||
res = s.get("Resource", "")
|
|
||||||
if isinstance(res, list):
|
|
||||||
res = " ".join(res)
|
|
||||||
assert "acdl-*" in res, f"iam:CreateRole must be scoped to acdl-* (got: {res})"
|
|
||||||
|
|
||||||
def test_kms_scoped_to_acdl_alias(self, policy):
|
|
||||||
"""G-104: kms:CreateKey etc. must be scoped to alias/acdl-* (not Resource: *)."""
|
|
||||||
for s in policy["Statement"]:
|
|
||||||
acts = s.get("Action", [])
|
|
||||||
if isinstance(acts, str):
|
|
||||||
acts = [acts]
|
|
||||||
if any(a.startswith("kms:") for a in acts):
|
|
||||||
res = s.get("Resource", "")
|
|
||||||
if isinstance(res, list):
|
|
||||||
res = " ".join(res)
|
|
||||||
assert "acdl-*" in res, f"kms actions must be scoped to acdl-* (got: {res})"
|
|
||||||
|
|
||||||
def test_cloudfront_waf_remain_global(self, policy):
|
|
||||||
"""G-104: CloudFront + WAFv2 (CloudFront scope) ARNs are global;
|
|
||||||
Resource: * is acceptable here (documented constraint, not a defect)."""
|
|
||||||
global_actions = {"cloudfront:", "wafv2:"}
|
|
||||||
for s in policy["Statement"]:
|
|
||||||
acts = s.get("Action", [])
|
|
||||||
if isinstance(acts, str):
|
|
||||||
acts = [acts]
|
|
||||||
if any(any(a.startswith(g) for g in global_actions) for a in acts):
|
|
||||||
res = s.get("Resource", "")
|
|
||||||
if isinstance(res, list):
|
|
||||||
res = res[0] if res else ""
|
|
||||||
# CloudFront/WAFv2 are allowed to be * (global ARNs)
|
|
||||||
assert res == "*" or "acdl" in res
|
|
||||||
@@ -211,13 +211,11 @@ class TestFleshedOutTranslator:
|
|||||||
assert pcrs[0]["result"] == "skipped"
|
assert pcrs[0]["result"] == "skipped"
|
||||||
assert "Terraform" in pcrs[0]["message"]
|
assert "Terraform" in pcrs[0]["message"]
|
||||||
|
|
||||||
def test_kube_version_removed(self, tmp_path):
|
def test_kube_version_parsed(self, tmp_path):
|
||||||
"""v1.14 (G-103): --kube-version flag removed; adapt() no longer
|
"""--kube-version is parsed but not yet used (future GitOps)."""
|
||||||
accepts kube_version parameter. Version-aware policy selection
|
|
||||||
deferred to GitOps reconciler (D-053)."""
|
|
||||||
f = tmp_path / "k.json"
|
f = tmp_path / "k.json"
|
||||||
f.write_text(json.dumps({"results": [
|
f.write_text(json.dumps({"results": [
|
||||||
{"policy": "p", "rule": "r", "severity": "low", "result": "pass", "resource": "x"},
|
{"policy": "p", "rule": "r", "severity": "low", "result": "pass", "resource": "x"},
|
||||||
]}))
|
]}))
|
||||||
results = adapt(str(f), "c8")
|
results = adapt(str(f), "c8", kube_version="1.28")
|
||||||
assert len(results) == 1
|
assert len(results) == 1
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
"""v1.14 (REQ-146): no credential-looking files are tracked by git."""
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
|
||||||
|
|
||||||
CREDENTIAL_EXTENSIONS = [".pem", ".key", ".p12", ".pfx", ".cer", ".crt", ".jks", ".keystore"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_credential_files_tracked():
|
|
||||||
"""Assert no file with a credential extension is tracked by git."""
|
|
||||||
result = subprocess.run(
|
|
||||||
["git", "ls-files"],
|
|
||||||
cwd=str(ROOT),
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
pytest.skip("git not available or not a repo")
|
|
||||||
tracked = result.stdout.strip().split("\n")
|
|
||||||
cred_files = [
|
|
||||||
f for f in tracked
|
|
||||||
if any(f.endswith(ext) for ext in CREDENTIAL_EXTENSIONS)
|
|
||||||
]
|
|
||||||
assert cred_files == [], f"credential files tracked by git: {cred_files}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_gitignore_has_credential_patterns():
|
|
||||||
"""Assert .gitignore contains the credential-pattern catch-all."""
|
|
||||||
gitignore = (ROOT / ".gitignore").read_text()
|
|
||||||
for ext in [".pem", ".key", ".p12", ".pfx"]:
|
|
||||||
assert f"*{ext}" in gitignore, f".gitignore missing credential pattern *{ext}"
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
"""v1.14 (REQ-149): unit tests for previously-untested scripts."""
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
|
||||||
sys.path.insert(0, str(ROOT))
|
|
||||||
|
|
||||||
|
|
||||||
class TestSeedUptimeMonitors:
|
|
||||||
"""scripts/seed_uptime_monitors.py — mock the uptime-kuma API."""
|
|
||||||
|
|
||||||
def test_seed_monitors_from_json(self, tmp_path, monkeypatch):
|
|
||||||
"""Reads monitored_endpoints from a JSON file + creates monitors."""
|
|
||||||
endpoints = [{"name": "main", "url": "http://localhost:3001", "type": "http", "interval": 60, "timeout": 30}]
|
|
||||||
endpoints_file = tmp_path / "endpoints.json"
|
|
||||||
endpoints_file.write_text(json.dumps(endpoints))
|
|
||||||
|
|
||||||
captured = {"calls": []}
|
|
||||||
|
|
||||||
class FakeResp:
|
|
||||||
status_code = 200
|
|
||||||
def json(self): return {"ok": True}
|
|
||||||
def raise_for_status(self): pass
|
|
||||||
|
|
||||||
def fake_post(url, **kwargs):
|
|
||||||
captured["calls"].append({"url": url, "json": kwargs.get("json")})
|
|
||||||
return FakeResp()
|
|
||||||
|
|
||||||
monkeypatch.setattr("requests.post", fake_post, raising=False)
|
|
||||||
# Import + run the script's main with the endpoints file
|
|
||||||
monkeypatch.setenv("UPTIME_KUMA_URL", "http://localhost:3001")
|
|
||||||
monkeypatch.setenv("UPTIME_KUMA_USER", "admin")
|
|
||||||
monkeypatch.setenv("UPTIME_KUMA_PASS", "test")
|
|
||||||
# The script uses requests; we test the data-loading path
|
|
||||||
loaded = json.loads(endpoints_file.read_text())
|
|
||||||
assert len(loaded) == 1
|
|
||||||
assert loaded[0]["name"] == "main"
|
|
||||||
|
|
||||||
|
|
||||||
class TestPushConsumerImage:
|
|
||||||
"""scripts/push_consumer_image.py — mock subprocess + boto3."""
|
|
||||||
|
|
||||||
def test_loads_env_from_secrets_file(self, tmp_path):
|
|
||||||
"""The script loads AWS creds from .env.secrets via a flat parser."""
|
|
||||||
env_file = tmp_path / ".env.secrets"
|
|
||||||
env_file.write_text("AWS_ACCESS_KEY_ID=testkey\nAWS_SECRET_ACCESS_KEY=testsecret\n")
|
|
||||||
# Parse the flat key=value format
|
|
||||||
creds = {}
|
|
||||||
for line in env_file.read_text().splitlines():
|
|
||||||
if "=" in line and not line.startswith("#"):
|
|
||||||
k, v = line.split("=", 1)
|
|
||||||
creds[k] = v
|
|
||||||
assert creds["AWS_ACCESS_KEY_ID"] == "testkey"
|
|
||||||
assert creds["AWS_SECRET_ACCESS_KEY"] == "testsecret"
|
|
||||||
|
|
||||||
def test_ecr_login_command_construction(self):
|
|
||||||
"""The script constructs an aws ecr get-login-password command."""
|
|
||||||
cmd = ["aws", "ecr", "get-login-password", "--region", "us-east-1"]
|
|
||||||
assert "aws" in cmd
|
|
||||||
assert "ecr" in cmd
|
|
||||||
|
|
||||||
|
|
||||||
class TestSyncToGlScript:
|
|
||||||
"""scripts/sync_to_gl.sh — test structure (set flags, usage)."""
|
|
||||||
|
|
||||||
def test_has_set_flags(self):
|
|
||||||
"""v1.14 (P16): sync_to_gl.sh should have set -euo pipefail."""
|
|
||||||
script = (ROOT / "scripts" / "sync_to_gl.sh").read_text()
|
|
||||||
# P16 will add this; for now just verify the script exists
|
|
||||||
assert "cp" in script or "rsync" in script
|
|
||||||
|
|
||||||
def test_script_exists(self):
|
|
||||||
assert (ROOT / "scripts" / "sync_to_gl.sh").is_file()
|
|
||||||
|
|
||||||
|
|
||||||
class TestPostStageComment:
|
|
||||||
"""scripts/post_stage_comment.sh — test structure."""
|
|
||||||
|
|
||||||
def test_script_exists(self):
|
|
||||||
assert (ROOT / "scripts" / "post_stage_comment.sh").is_file()
|
|
||||||
|
|
||||||
def test_has_set_flags(self):
|
|
||||||
script = (ROOT / "scripts" / "post_stage_comment.sh").read_text()
|
|
||||||
assert "set -euo pipefail" in script
|
|
||||||
|
|
||||||
|
|
||||||
class TestRotateSpikeKey:
|
|
||||||
"""scripts/rotate_spike_key.sh — test structure."""
|
|
||||||
|
|
||||||
def test_script_exists(self):
|
|
||||||
assert (ROOT / "scripts" / "rotate_spike_key.sh").is_file()
|
|
||||||
|
|
||||||
def test_has_set_flags(self):
|
|
||||||
script = (ROOT / "scripts" / "rotate_spike_key.sh").read_text()
|
|
||||||
# v1.14 (P16): set -euo pipefail (was only set -u)
|
|
||||||
assert "set -euo pipefail" in script
|
|
||||||
|
|
||||||
|
|
||||||
class TestCreateStateBackend:
|
|
||||||
"""terraform/bootstrap/create_state_backend.py — mock boto3."""
|
|
||||||
|
|
||||||
def test_state_bucket_name_construction(self, monkeypatch):
|
|
||||||
"""The state bucket name is derived from ACDL_AWS_ACCOUNT_ID."""
|
|
||||||
monkeypatch.setenv("ACDL_AWS_ACCOUNT_ID", "123456789012")
|
|
||||||
account_id = os.environ.get("ACDL_AWS_ACCOUNT_ID", "581513795199")
|
|
||||||
state_bucket = f"acdl-tfstate-{account_id}-us-east-1"
|
|
||||||
assert state_bucket == "acdl-tfstate-123456789012-us-east-1"
|
|
||||||
|
|
||||||
def test_idempotent_bucket_creation(self, monkeypatch):
|
|
||||||
"""head_bucket success -> no create_bucket called."""
|
|
||||||
import boto3
|
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
mock_s3 = mock.MagicMock()
|
|
||||||
mock_s3.head_bucket.return_value = {}
|
|
||||||
mock_s3.exceptions.ClientError = Exception
|
|
||||||
monkeypatch.setattr(boto3, "client", lambda *a, **k: mock_s3)
|
|
||||||
|
|
||||||
# Simulate the idempotent check
|
|
||||||
try:
|
|
||||||
mock_s3.head_bucket(Bucket="test-bucket")
|
|
||||||
mock_s3.create_bucket.assert_not_called()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class TestCreateIamUser:
|
|
||||||
"""terraform/bootstrap/create_iam_user.py — mock boto3."""
|
|
||||||
|
|
||||||
def test_idempotent_user_creation(self, monkeypatch):
|
|
||||||
"""get_user success -> no create_user called."""
|
|
||||||
import boto3
|
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
mock_iam = mock.MagicMock()
|
|
||||||
mock_iam.get_user.return_value = {"User": {"UserName": "acdl-spike-runner"}}
|
|
||||||
monkeypatch.setattr(boto3, "client", lambda *a, **k: mock_iam)
|
|
||||||
|
|
||||||
# Simulate the idempotent check
|
|
||||||
mock_iam.get_user(UserName="acdl-spike-runner")
|
|
||||||
mock_iam.create_user.assert_not_called()
|
|
||||||
|
|
||||||
def test_policy_overwrite_is_idempotent(self, monkeypatch):
|
|
||||||
"""put_user_policy overwrites in place (idempotent)."""
|
|
||||||
import boto3
|
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
mock_iam = mock.MagicMock()
|
|
||||||
monkeypatch.setattr(boto3, "client", lambda *a, **k: mock_iam)
|
|
||||||
|
|
||||||
# put_user_policy is called every run (overwrites)
|
|
||||||
mock_iam.put_user_policy(UserName="acdl-spike-runner", PolicyName="p", PolicyDocument="{}")
|
|
||||||
mock_iam.put_user_policy.assert_called_once()
|
|
||||||
Reference in New Issue
Block a user