diff --git a/.ciagent/REGRESSION_REPORT.json b/.ciagent/REGRESSION_REPORT.json index 94ff9d2..66a3de8 100644 --- a/.ciagent/REGRESSION_REPORT.json +++ b/.ciagent/REGRESSION_REPORT.json @@ -1,10 +1,10 @@ { - "run_id": "regr-1785172853", - "run_at_utc": "2026-07-27T17:20:53Z", + "run_id": "regr-1785173824", + "run_at_utc": "2026-07-27T17:37:04Z", "milestone": "v1.10", "phase": 52, "summary": { - "Verified": 10, + "Verified": 12, "Decayed": 0, "Broken": 0 }, @@ -16,7 +16,7 @@ "status": "Verified", "detail": "exit 0; 2 sample contracts validate", "tier": "local", - "duration_ms": 219 + "duration_ms": 232 }, { "capability_id": "CAP-002", @@ -24,7 +24,7 @@ "status": "Verified", "detail": "exit 0; env schema validates", "tier": "local", - "duration_ms": 215 + "duration_ms": 197 }, { "capability_id": "CAP-003", @@ -32,7 +32,7 @@ "status": "Verified", "detail": "exit 0; ", "tier": "local", - "duration_ms": 236 + "duration_ms": 258 }, { "capability_id": "CAP-004", @@ -40,7 +40,7 @@ "status": "Verified", "detail": "exit 0; ", "tier": "local", - "duration_ms": 245 + "duration_ms": 243 }, { "capability_id": "CAP-005", @@ -48,7 +48,7 @@ "status": "Verified", "detail": "exit 0; ", "tier": "local", - "duration_ms": 300 + "duration_ms": 337 }, { "capability_id": "CAP-006", @@ -56,7 +56,7 @@ "status": "Verified", "detail": "exit 0; interpolation ok", "tier": "local", - "duration_ms": 216 + "duration_ms": 210 }, { "capability_id": "CAP-007", @@ -64,7 +64,7 @@ "status": "Verified", "detail": "exit 0; confidence band=pass", "tier": "local", - "duration_ms": 82 + "duration_ms": 86 }, { "capability_id": "CAP-008", @@ -72,15 +72,15 @@ "status": "Verified", "detail": "exit 0; outbox hash chain ok", "tier": "local", - "duration_ms": 330 + "duration_ms": 328 }, { "capability_id": "CAP-009", "name": "offline pytest suite passes", "status": "Verified", - "detail": "exit 0; [ 98%]\ntests/test_wiz_adapter_real_client.py ......... [100%]\n\n============================= 464 passed in 12.09s =============================", + "detail": "exit 0; [ 98%]\ntests/test_wiz_adapter_real_client.py ......... [100%]\n\n====================== 475 passed, 2 deselected in 14.10s ======================", "tier": "local", - "duration_ms": 13516 + "duration_ms": 15530 }, { "capability_id": "CAP-010", @@ -88,7 +88,23 @@ "status": "Verified", "detail": "exit 0; resource(s))\n\n=== PLATFORM CHECK OK ===\ncontract -> resolver -> stack -> adapter -> structure validated (offline, no AWS)\ncheck-only: OK\n\n=== CI PIPELINE OK ===\n3 stages passed: lint, test, check-only", "tier": "local", - "duration_ms": 17839 + "duration_ms": 19434 + }, + { + "capability_id": "CAP-011", + "name": "headline E2E runs against the local emulating tier (microservice)", + "status": "Verified", + "detail": "exit 0; al-emulator\",\n \"desired_count\": 1,\n \"running_count\": 1\n },\n \"outbox_dir\": \"/tmp/acdl_local_e2e_c4qi36nm/outbox\",\n \"outbox_events\": 2,\n \"outbox_chain_verified\": true,\n \"lambda_status\": 200\n}", + "tier": "local", + "duration_ms": 565 + }, + { + "capability_id": "CAP-012", + "name": "local E2E on the static-assets stack (no ECS)", + "status": "Verified", + "detail": "exit 0; acdl_local_e2e_e08m7qx1/tf\",\n \"backend\": \"local\",\n \"ecs\": null,\n \"outbox_dir\": \"/tmp/acdl_local_e2e_e08m7qx1/outbox\",\n \"outbox_events\": 2,\n \"outbox_chain_verified\": true,\n \"lambda_status\": 200\n}", + "tier": "local", + "duration_ms": 474 } ] } \ No newline at end of file diff --git a/.ciagent/REGRESSION_REPORT.md b/.ciagent/REGRESSION_REPORT.md index 7d525c8..f600457 100644 --- a/.ciagent/REGRESSION_REPORT.md +++ b/.ciagent/REGRESSION_REPORT.md @@ -1,28 +1,41 @@ # Regression Report — v1.10 Phase 52 -- **Run ID:** `regr-1785172853` -- **Run at (UTC):** 2026-07-27T17:20:53Z -- **Summary:** {'Verified': 10, 'Decayed': 0, 'Broken': 0} +- **Run ID:** `regr-1785173824` +- **Run at (UTC):** 2026-07-27T17:37:04Z +- **Summary:** {'Verified': 12, 'Decayed': 0, 'Broken': 0} - **Passed (milestone gate):** True | Capability | Name | Tier | Status | Duration (ms) | Detail | |-----------|------|------|--------|--------------|--------| -| CAP-001 | contract.schema.json validates sample contracts | local | **Verified** | 219 | exit 0; 2 sample contracts validate | -| CAP-002 | environment.schema.json validates env files | local | **Verified** | 215 | exit 0; env schema validates | -| CAP-003 | contract_resolver resolves static-assets | local | **Verified** | 236 | exit 0; | -| CAP-004 | contract_resolver resolves microservice | local | **Verified** | 245 | exit 0; | -| CAP-005 | terraform adapter emits .tf files | local | **Verified** | 300 | exit 0; | -| CAP-006 | contract interpolation expands env/contract tokens | local | **Verified** | 216 | exit 0; interpolation ok | -| CAP-007 | confidence_signal.compute returns a band | local | **Verified** | 82 | exit 0; confidence band=pass | -| CAP-008 | outbox_writer builds a hash-chained item | local | **Verified** | 330 | exit 0; outbox hash chain ok | -| CAP-009 | offline pytest suite passes | local | **Verified** | 13516 | exit 0; [ 98%] +| CAP-001 | contract.schema.json validates sample contracts | local | **Verified** | 232 | exit 0; 2 sample contracts validate | +| CAP-002 | environment.schema.json validates env files | local | **Verified** | 197 | exit 0; env schema validates | +| CAP-003 | contract_resolver resolves static-assets | local | **Verified** | 258 | exit 0; | +| CAP-004 | contract_resolver resolves microservice | local | **Verified** | 243 | exit 0; | +| CAP-005 | terraform adapter emits .tf files | local | **Verified** | 337 | exit 0; | +| CAP-006 | contract interpolation expands env/contract tokens | local | **Verified** | 210 | exit 0; interpolation ok | +| CAP-007 | confidence_signal.compute returns a band | local | **Verified** | 86 | exit 0; confidence band=pass | +| CAP-008 | outbox_writer builds a hash-chained item | local | **Verified** | 328 | exit 0; outbox hash chain ok | +| CAP-009 | offline pytest suite passes | local | **Verified** | 15530 | exit 0; [ 98%] tests/test_wiz_adapter_real_client.py ......... [100%] -============================= 46 | -| CAP-010 | run_ci.sh reproduces CI pipeline locally | local | **Verified** | 17839 | exit 0; resource(s)) +====================== 475 passe | +| CAP-010 | run_ci.sh reproduces CI pipeline locally | local | **Verified** | 19434 | exit 0; resource(s)) === PLATFORM CHECK OK === contract -> resolver -> stack -> adapter -> structure validated (offline, no AWS) check-only: OK === CI PIPELIN | +| CAP-011 | headline E2E runs against the local emulating tier (microservice) | local | **Verified** | 565 | exit 0; al-emulator", + "desired_count": 1, + "running_count": 1 + }, + "outbox_dir": "/tmp/acdl_local_e2e_c4qi36nm/outbox", + "outbox_events": 2, + "outbox | +| CAP-012 | local E2E on the static-assets stack (no ECS) | local | **Verified** | 474 | exit 0; acdl_local_e2e_e08m7qx1/tf", + "backend": "local", + "ecs": null, + "outbox_dir": "/tmp/acdl_local_e2e_e08m7qx1/outbox", + "outbox_events": 2, + "outbox | diff --git a/.ciagent/REQUIREMENTS.md b/.ciagent/REQUIREMENTS.md index 31ef4bf..4510ce3 100644 --- a/.ciagent/REQUIREMENTS.md +++ b/.ciagent/REQUIREMENTS.md @@ -452,6 +452,6 @@ | Requirement | Phase | Status | |-------------|-------|--------| | REQ-112 | 52 | complete (v1.9.9) | -| REQ-113 | 53 | pending | +| REQ-113 | 53 | complete (v1.9.10) | | REQ-114 | 54 | pending | | REQ-115 | 55 | pending | diff --git a/.ciagent/ROADMAP.md b/.ciagent/ROADMAP.md index e6a5bd6..9a5417b 100644 --- a/.ciagent/ROADMAP.md +++ b/.ciagent/ROADMAP.md @@ -675,7 +675,7 @@ adapters), D-093 (re-verify v1.1→v1.8; v1.0 demo excluded), D-094 ### Phase 53 — local-emulating-adapters - **Description:** Build local emulating adapters so the platform is fully locally testable without cloud credentials: flat-file DynamoDB outbox, local ECS emulator (synthetic HTTP 200 from local shell), local S3 state backend (flat-file tfstate), local Lambda stub (in-process handler invocation). Same interfaces as the live adapters. -- **Status:** pending (v1.9.10) +- **Status:** complete (v1.9.10) - **Depends on:** [52] - **Requirements:** REQ-113 - **Success Criteria:** diff --git a/.ciagent/VERIFY.md b/.ciagent/VERIFY.md index 19b4b25..b3ce04d 100644 --- a/.ciagent/VERIFY.md +++ b/.ciagent/VERIFY.md @@ -1,58 +1,62 @@ -# Phase 52 — Verify (v1.10) — Pipeline Regression-VERIFY Fix +# Phase 53 — Verify (v1.10) — Local Emulating Adapters ## Structural -- `core/regression_verify.py` — new module implementing regression-class - VERIFY (D-091). 10 seeded local-tier capability checks (CAP-001..CAP-010). -- `scripts/run_regression.sh` — shell wrapper invoking the module; writes - `.ciagent/REGRESSION_REPORT.md` + `.json`; exits non-zero on any - non-Verified capability (fails closed). -- `tests/test_verify_regression_mode.py` — 11 tests (8 fast + 3 slow). -- `pyproject.toml` — `slow` marker registered; `run_ci.sh` excludes slow - tests to avoid recursion. -- Existing diff-scoped VERIFY artifacts (`run_ci.sh`, `run_platform.sh`, - `.ciagent/VERIFY.md` per-phase record) preserved unchanged in behavior. +- `core/local_emulators.py` — new module with four local emulating + adapters (D-092, REQ-113): + - `FlatFileOutbox` — flat-file DynamoDB outbox emulator (hash-chained + JSONL; resumable across instances; chain verification). + - `LocalEcsEmulator` — local ECS Fargate HTTP 200 emulator (free-port + binding; health check; clean destroy). + - `LocalS3StateBackend` — rewrites the terraform S3 backend to a local + backend (per-stack tfstate in a temp folder). + - `LocalLambdaStub` — invokes the contract_ingestor handler in-process + (patches `_get_dynamodb` / `_get_secrets_client` / `urllib.urlopen`; + DynamoDB writes redirected to the FlatFileOutbox). + - `run_local_e2e()` — runs the full headline E2E against the local tier. +- `scripts/run_platform.sh` — `--local` flag added; short-circuits to the + local emulating tier (no AWS credentials, no Checkov, no DynamoDB). +- `tests/test_local_emulating_adapters.py` — 13 tests (11 fast + 2 slow). +- `core/regression_verify.py` — CAP-011 + CAP-012 added (local E2E for + microservice + static-assets stacks). +- `scripts/run_regression.sh` — now covers 12 capabilities (was 10). **PASS.** ## Behavioral -- `pytest tests/ -m "not slow"`: 502 passed, 3 deselected (was 493 at - v1.9; +9 new fast tests). No regressions. -- `pytest tests/test_verify_regression_mode.py -m slow`: 3 passed - (integration: seeded registry runs honestly; regression mode is - additive; decay-surfacing confirms the gate fails closed). -- `bash scripts/run_regression.sh`: all 10 seeded local-tier - capabilities Verified against current code; gate passes; report - written to `.ciagent/REGRESSION_REPORT.{md,json}`. -- Decay-surfacing test (`test_regression_surfaces_decay_when_seeded_with_broken_check`) - injects a deliberately-broken cloud-backed check and confirms the run - tags it Broken and fails closed. **PASS.** +- `pytest tests/ -m "not slow"`: 513 passed, 5 deselected (was 502 at + Phase 52; +11 new fast local-emulator tests). No regressions. +- `pytest tests/test_local_emulating_adapters.py -m slow`: 2 passed + (headline E2E: microservice + static-assets against the local tier). +- `python3 core/local_emulators.py contracts/microservice.yaml`: full + local E2E runs end-to-end (contract -> resolver -> adapter -> local S3 + backend -> local ECS HTTP 200 -> flat-file outbox chain verified -> + local Lambda 200). No AWS credentials required. +- `bash scripts/run_platform.sh --local contracts/microservice.yaml`: + exits 0 with "LOCAL E2E OK". +- `bash scripts/run_regression.sh`: 12/12 capabilities Verified (was + 10/10; +2 local E2E checks). **PASS.** ## Security -- No new credentials, network calls, or cloud mutations introduced. -- The regression module runs subprocess checks in the local shell only; - the live-AWS tier is deferred to Phase 54 (D-093). -- No secrets logged; subprocess output is truncated to 200/300 chars in - report detail fields. **PASS.** +- No AWS credentials, network calls, or cloud mutations introduced. +- The local ECS emulator binds to 127.0.0.1 only (loopback; no external + exposure). The HTTP server is daemon-threaded and shut down on destroy. +- The local Lambda stub patches `urllib.urlopen` to a fake response so + the `report_error` action does not hit the GitHub/Gitea API. +- No secrets logged; the secrets stub returns a static "local-stub" token. + **PASS.** ## Quality -- `test_verify_regression_mode.py` covers: all-Verified passes; - one-Decayed blocks; one-Broken blocks; check-raising is Broken; - report serialization; md+json output; broken-subprocess is Broken; - missing-executable is Broken; seeded registry runs honestly; - regression mode is additive (diff-scoped behavior preserved); - decay-surfacing (gate fails closed on injected Broken). **PASS.** +- `test_local_emulating_adapters.py` covers: outbox write + chain link + + broken-chain detection + cross-instance resume; ECS HTTP 200 + destroy + stops server; S3->local backend rewrite + per-stack state path; Lambda + stub happy path + missing-field 400; `is_local_tier` flag; the full + headline E2E for both stacks (microservice with ECS, static-assets + without). **PASS.** ## Verdict -**VERIFY PASS** — regression-class VERIFY (D-091) implemented and tested. -The gate catches decay (fails closed). Existing diff-scoped VERIFY -behavior preserved. 502 offline tests pass; no AWS required for Phase 52. - -## Diff-scoped VERIFY defect (recorded for traceability) - -The prior VERIFY stage was diff-scoped: it checked the phase diff only -and never re-runs underlying platform capability. This let 8 NFR-patch -phases (v1.9.1→v1.9.8, deck rework) pass VERIFY while the platform they -described decayed underneath. The defect is recorded as D-091 and -remediated by `core/regression_verify.py`. The regression run is now a -milestone-completion gate (D-091). Cloud-backed capability re-verification -(live ECS, DynamoDB writes, Lambda invocation) lands in Phase 54 (D-093). \ No newline at end of file +**VERIFY PASS** — the platform is now fully locally testable without +cloud credentials (D-092). The headline E2E runs end-to-end against the +local emulating tier: contract -> resolver -> adapter -> local S3 backend +-> local ECS (HTTP 200) -> flat-file outbox (chain verified) -> local +Lambda (200). 513 offline tests pass; the regression gate covers 12 +capabilities including the local E2E. No AWS required for Phase 53. \ No newline at end of file diff --git a/core/local_emulators.py b/core/local_emulators.py new file mode 100644 index 0000000..493bd8e --- /dev/null +++ b/core/local_emulators.py @@ -0,0 +1,489 @@ +"""Local emulating adapters (D-092, REQ-113). + +The platform must be fully locally testable without cloud credentials. +These adapters emulate the four cloud-backed interactions the platform +uses, so the headline E2E (contract submission -> service live -> +evidence event) runs end-to-end against the local tier with no AWS: + + 1. FlatFileOutbox - emulates the DynamoDB outbox (core/outbox_writer.py) + 2. LocalEcsEmulator - emulates an ECS Fargate service returning HTTP 200 + 3. LocalS3StateBackend - rewrites the terraform S3 backend to a local backend + 4. LocalLambdaStub - invokes the contract_ingestor handler in-process + +Each adapter exposes the same interface as the live counterpart so the +caller code path is unchanged; only the I/O target swaps. Selection is +gated on the ACDL_LOCAL_TIER env var (set by run_platform.sh --local). +""" + +from __future__ import annotations + +import datetime +import hashlib +import http.server +import json +import os +import socket +import socketserver +import sys +import tempfile +import threading +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +ROOT = Path(__file__).resolve().parent.parent + + +def is_local_tier() -> bool: + """True when the local emulating tier is active.""" + return os.environ.get("ACDL_LOCAL_TIER", "") == "1" + + +# --------------------------------------------------------------------------- +# 1. Flat-file DynamoDB outbox emulator +# --------------------------------------------------------------------------- + +@dataclass +class FlatFileOutbox: + """Emulates the DynamoDB outbox with flat files in a temp folder. + + Same write/read interface contract as core.outbox_writer.write_event: + accepts an event dict, returns the item dict (with a hash-chained + `hash` field). The item is appended to a JSONL file + `/outbox.jsonl` so the chain is reconstructable. + """ + + dir: Path + _chain_tail_hash: str = "GENESIS" + + @classmethod + def create(cls, dir: Optional[Path] = None) -> "FlatFileOutbox": + d = Path(dir) if dir else Path(tempfile.mkdtemp(prefix="acdl_outbox_")) + d.mkdir(parents=True, exist_ok=True) + out = cls(dir=d) + # Re-read the chain tail if the file already exists. + jl = d / "outbox.jsonl" + if jl.exists(): + tail = None + for line in jl.read_text().splitlines(): + if line.strip(): + tail = json.loads(line) + if tail: + out._chain_tail_hash = tail["hash"] + return out + + def _canonical_hash(self, event: Dict) -> str: + canonical = json.dumps(event, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + def write_event(self, event: Dict[str, Any], + outbox_table: str = "acdl-outbox-local", + region: str = "local") -> Dict[str, Any]: + """Write an evidence event to the flat-file outbox. + + Mirrors core.outbox_writer.write_event signature. Returns the + item dict (single-valued, not DynamoDB-typed) so the caller can + inspect it without unwrapping.""" + contract_id = event["contractId"] + event_type = event.get("eventType", "CONFIDENCE_COMPUTED") + event_ts = event.get("ts") or datetime.datetime.now( + datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + sk = f"{event_type}#{event_ts}" + prev_hash = event.get("prev_event_hash", self._chain_tail_hash) + event_hash = self._canonical_hash(event) + item = { + "contractId": contract_id, + "eventType#eventTs": sk, + "payload": event, + "prev_event_hash": prev_hash, + "hash": event_hash, + "environment": str(event.get("environment", "")), + "stack": str(event.get("stack", "")), + "score": event.get("score", 0), + "band": str(event.get("band", "")), + "expire_at": int((datetime.datetime.now(datetime.timezone.utc) + + datetime.timedelta(days=365)).timestamp()), + } + jl = self.dir / "outbox.jsonl" + with jl.open("a") as f: + f.write(json.dumps(item, sort_keys=True) + "\n") + self._chain_tail_hash = event_hash + return item + + def read_all(self) -> List[Dict[str, Any]]: + """Read every event in the flat-file outbox (for verification).""" + jl = self.dir / "outbox.jsonl" + if not jl.exists(): + return [] + return [json.loads(line) for line in jl.read_text().splitlines() + if line.strip()] + + def verify_chain(self) -> bool: + """Verify the hash chain is intact (each prev_event_hash matches + the prior event's hash; the first event's prev is GENESIS).""" + events = self.read_all() + prev = "GENESIS" + for ev in events: + if ev["prev_event_hash"] != prev: + return False + # Recompute the hash and confirm it matches. + recomputed = self._canonical_hash(ev["payload"]) + if recomputed != ev["hash"]: + return False + prev = ev["hash"] + return True + + +# --------------------------------------------------------------------------- +# 2. Local ECS Fargate emulator +# --------------------------------------------------------------------------- + +@dataclass +class LocalEcsEmulator: + """Emulates an ECS Fargate service by serving HTTP 200 from a local + shell process. + + Records the service definition (so the caller can inspect what would + have been deployed) and starts a tiny HTTP server on a free port that + returns 200 OK for any path. The caller can then curl the endpoint to + confirm the service is "live" in the local tier. + """ + + service_name: str + service_definition: Dict[str, Any] + _server: Optional[socketserver.TCPServer] = None + _thread: Optional[threading.Thread] = None + _port: int = 0 + + def deploy(self) -> Dict[str, Any]: + """Start the local HTTP server; return the endpoint metadata.""" + service_name = self.service_name # capture for the handler closure + + class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self, *a, **k): + body = json.dumps({ + "service": service_name, + "status": "RUNNING", + "tier": "local-emulator", + "path": self.path, + }).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *a, **k): + pass # silence + + # Bind to a free port. + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.bind(("127.0.0.1", 0)) + self._port = sock.getsockname()[1] + sock.close() + self._server = socketserver.TCPServer( + ("127.0.0.1", self._port), Handler) + self._server.allow_reuse_address = True + self._thread = threading.Thread( + target=self._server.serve_forever, daemon=True) + self._thread.start() + return { + "service_arn": f"arn:local:ecs:us-east-1:000000000000:service/{self.service_name}", + "endpoint": f"http://127.0.0.1:{self._port}", + "status": "RUNNING", + "tier": "local-emulator", + "desired_count": self.service_definition.get("desired_count", 1), + "running_count": self.service_definition.get("desired_count", 1), + } + + def health_check(self, endpoint: str, timeout_s: float = 5.0) -> Tuple[bool, int]: + """curl the endpoint; return (ok, status_code).""" + import urllib.request + url = endpoint if endpoint.startswith("http") else f"http://{endpoint}" + t0 = time.monotonic() + while time.monotonic() - t0 < timeout_s: + try: + with urllib.request.urlopen(url, timeout=1.0) as r: + return (r.status == 200, r.status) + except Exception: + time.sleep(0.1) + return (False, 0) + + def destroy(self): + """Stop the local HTTP server.""" + if self._server is not None: + self._server.shutdown() + self._server.server_close() + self._server = None + if self._thread is not None: + self._thread.join(timeout=2.0) + self._thread = None + + +# --------------------------------------------------------------------------- +# 3. Local S3 state backend (terraform backend rewrite) +# --------------------------------------------------------------------------- + +@dataclass +class LocalS3StateBackend: + """Replaces the terraform S3 backend with a local backend. + + The adapter emits a `backend "s3" { ... }` block. In the local tier + we rewrite it to `backend "local" { path = "/terraform.tfstate" }` + so `terraform init/plan` runs without S3. The rewrite is applied to + the emitted terraform.tf file before terraform is invoked. + """ + + state_dir: Path + + @classmethod + def create(cls, dir: Optional[Path] = None) -> "LocalS3StateBackend": + d = Path(dir) if dir else Path(tempfile.mkdtemp(prefix="acdl_tfstate_")) + d.mkdir(parents=True, exist_ok=True) + return cls(state_dir=d) + + def state_path(self, stack_name: str) -> Path: + return self.state_dir / f"{stack_name}.tfstate" + + def rewrite_terraform_tf(self, tf_path: Path, stack_name: str) -> str: + """Rewrite the backend block in a terraform.tf file to local. + + Returns the new content (also written to disk).""" + import re + content = Path(tf_path).read_text() + # Replace the `backend "s3" { ... }` block with a local backend. + new_content = re.sub( + r'backend "s3" \{[^}]*\}', + f'backend "local" {{\n path = "{self.state_path(stack_name)}"\n }}', + content, + count=1, + flags=re.DOTALL, + ) + Path(tf_path).write_text(new_content) + return new_content + + +# --------------------------------------------------------------------------- +# 4. Local Lambda stub (in-process handler invocation) +# --------------------------------------------------------------------------- + +@dataclass +class LocalLambdaStub: + """Invokes the contract_ingestor handler in-process. + + Instead of calling AWS Lambda via boto3, this stub imports + core.lambda.contract_ingestor.lambda_handler and invokes it with a + synthesized Function-URL-style event. The DynamoDB write inside the + handler is redirected to a FlatFileOutbox so no AWS is required. + """ + + outbox: FlatFileOutbox + + def invoke(self, payload: Dict[str, Any]) -> Dict[str, Any]: + """Invoke the contract_ingestor handler in-process. + + Returns the handler's response dict + ({statusCode, body}). The handler's DynamoDB calls are + intercepted via the ACDL_LOCAL_TIER env var (the handler checks + _get_dynamodb(); under local tier it would need patching - we + patch the module's _get_dynamodb to return a local stub).""" + # Import the handler module (the dir is named `lambda`, a Python + # keyword, so use importlib instead of a dotted import). + import importlib + ci = importlib.import_module("core.lambda.contract_ingestor") + + # Patch the handler's DynamoDB resource with a local stub that + # writes to the flat-file outbox. The handler uses _get_dynamodb() + # which returns a boto3 resource; we replace it with a minimal + # object exposing .Table(name) with .put_item(Item=...). + original_get = ci._get_dynamodb + + class _LocalTable: + def __init__(self, name, outbox): + self.name = name + self.outbox = outbox + + def put_item(self, *, TableName=None, Item=None, **kwargs): + # The handler calls put_item(TableName=..., Item=...). + # DynamoDB-typed items ({'S': ...}, {'N': ...}) are + # flattened for the flat-file outbox. + Item = Item or {} + flat = {} + for k, v in Item.items(): + if isinstance(v, dict): + if "S" in v: + flat[k] = v["S"] + elif "N" in v: + flat[k] = v["N"] + else: + flat[k] = v + else: + flat[k] = v + self.outbox.write_event({ + "contractId": flat.get("contractId", "local"), + "eventType": f"LAMBDA_{self.name}", + "ts": datetime.datetime.now(datetime.timezone.utc) + .strftime("%Y-%m-%dT%H:%M:%SZ"), + "environment": flat.get("environment", "local"), + "stack": self.name, + "score": 0, + "band": "local", + "prev_event_hash": "GENESIS", + }) + return {} + + class _LocalDynamoResource: + def __init__(self, outbox): + self.outbox = outbox + + def Table(self, name): + return _LocalTable(name, self.outbox) + + class _LocalSecretsClient: + def get_secret_value(self, SecretId): + return {"SecretString": json.dumps({"token": "local-stub"})} + + ci._get_dynamodb = lambda: _LocalDynamoResource(self.outbox) + ci._get_secrets_client = lambda: _LocalSecretsClient() + # Stub the urllib GitHub API call so report_error doesn't hit the network. + original_urlopen = None + try: + import urllib.request + original_urlopen = urllib.request.urlopen + + class _FakeResponse: + def __init__(self, body=b"{}", status=200): + self._body = body + self.status = status + + def read(self): + return self._body + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def _fake_urlopen(url, *a, **k): + return _FakeResponse( + json.dumps([{"number": 1, "title": "stub"}]).encode()) + urllib.request.urlopen = _fake_urlopen + except Exception: + pass + + try: + event = { + "body": json.dumps(payload), + "requestContext": { + "httpContext": {"authorizer": {"iam": {"userId": "local-stub"}}} + }, + } + result = ci.lambda_handler(event, None) + finally: + ci._get_dynamodb = original_get + if original_urlopen is not None: + import urllib.request + urllib.request.urlopen = original_urlopen + return result + + +# --------------------------------------------------------------------------- +# Convenience: run the headline E2E against the local tier +# --------------------------------------------------------------------------- + +def run_local_e2e(contract_path: str, repo_root: Optional[Path] = None) -> Dict[str, Any]: + """Run the headline E2E against the local emulating tier. + + Steps: + 1. Resolve the contract -> Target Stack. + 2. Adapter compiles the stack -> terraform files (structure validated). + 3. LocalS3StateBackend rewrites the backend to local. + 4. LocalEcsEmulator deploys a synthetic HTTP 200 service (if the + stack has an ECS service) and confirms health. + 5. FlatFileOutbox writes a CONFIDENCE_COMPUTED event; chain verified. + 6. LocalLambdaStub invokes the contract_ingestor handler in-process. + + Returns a dict of results. Raises AssertionError on any failure. + """ + root = Path(repo_root) if repo_root else ROOT + os.chdir(str(root)) + sys.path.insert(0, str(root)) + from core.contract_resolver import resolve + import adapters.terraform.adapter as adapter + + stack = resolve(contract_path, str(root)) + stack_name = stack["stack"]["name"] + work = Path(tempfile.mkdtemp(prefix="acdl_local_e2e_")) + tf_dir = work / "tf" + tf_dir.mkdir(exist_ok=True) + adapter.adapt(stack, str(tf_dir)) + + # 3. Local S3 state backend rewrite. + backend = LocalS3StateBackend.create(dir=work / "tfstate") + tf_tf = tf_dir / "terraform.tf" + backend.rewrite_terraform_tf(tf_tf, stack_name) + assert "backend \"local\"" in tf_tf.read_text(), "backend not rewritten" + + # 4. Local ECS emulator (only if the stack has an ECS service). + ecs_result = None + has_ecs = any(r["type"] == "aws:ecs:service" for r in stack["resources"]) + if has_ecs: + ecs = LocalEcsEmulator( + service_name=stack_name, + service_definition={"desired_count": 1}, + ) + deploy_meta = ecs.deploy() + ok, status = ecs.health_check(deploy_meta["endpoint"]) + assert ok, f"ECS emulator health check failed: status={status}" + ecs_result = deploy_meta + ecs.destroy() + + # 5. Flat-file outbox: write a CONFIDENCE_COMPUTED event + verify chain. + outbox = FlatFileOutbox.create(dir=work / "outbox") + event = { + "contractId": "local-e2e-test", + "eventType": "CONFIDENCE_COMPUTED", + "ts": datetime.datetime.now(datetime.timezone.utc) + .strftime("%Y-%m-%dT%H:%M:%SZ"), + "environment": "dev", + "stack": stack_name, + "score": 0.9, + "band": "pass", + "prev_event_hash": "GENESIS", + } + item = outbox.write_event(event) + assert item["hash"], "outbox item missing hash" + assert outbox.verify_chain(), "outbox hash chain broken" + + # 6. Local Lambda stub: invoke the contract_ingestor handler. + lambda_stub = LocalLambdaStub(outbox=outbox) + lambda_result = lambda_stub.invoke({ + "action": "submit_contract", + "consumerRepo": "local-test/consumer", + "contractId": "local-e2e-test", + "contract": {"module": stack_name, "environment": "dev"}, + "environment": "dev", + }) + assert lambda_result["statusCode"] == 200, ( + f"lambda stub returned {lambda_result['statusCode']}: {lambda_result.get('body')}") + + return { + "stack_name": stack_name, + "tier": "local-emulator", + "tf_dir": str(tf_dir), + "backend": "local", + "ecs": ecs_result, + "outbox_dir": str(outbox.dir), + "outbox_events": len(outbox.read_all()), + "outbox_chain_verified": True, + "lambda_status": lambda_result["statusCode"], + } + + +if __name__ == "__main__": + contract = sys.argv[1] if len(sys.argv) > 1 else "contracts/microservice.yaml" + os.environ["ACDL_LOCAL_TIER"] = "1" + result = run_local_e2e(contract) + print(json.dumps(result, indent=2)) \ No newline at end of file diff --git a/core/regression_verify.py b/core/regression_verify.py index de5ab6f..7b01aff 100755 --- a/core/regression_verify.py +++ b/core/regression_verify.py @@ -266,6 +266,27 @@ def _check_run_ci_check_only() -> Tuple[Status, str]: ) +def _check_local_e2e_microservice() -> Tuple[Status, str]: + """CAP-011: headline E2E runs against the local emulating tier (D-092). + + The local tier emulates ECS, the DynamoDB outbox, S3 state, and the + contract-ingestor Lambda in-process. No AWS credentials required. + This is the local-tier half of the headline E2E; the live-AWS half + lands in Phase 54 (D-093).""" + return _check_subprocess( + ["python3", "core/local_emulators.py", "contracts/microservice.yaml"], + timeout=60, + ) + + +def _check_local_e2e_static_assets() -> Tuple[Status, str]: + """CAP-012: local E2E on the static-assets stack (no ECS service).""" + return _check_subprocess( + ["python3", "core/local_emulators.py", "contracts/static-assets.yaml"], + timeout=60, + ) + + # Registry: ordered, each entry is (capability_id, name, tier, check_fn). # Phase 52 seeds this with 10 local-tier checks; Phase 54 expands it to # cover every v1.1->v1.8 advertised capability and adds the live-AWS tier @@ -291,6 +312,10 @@ CAPABILITY_REGISTRY: List[Tuple[str, str, str, Callable[[], Tuple[Status, str]]] _check_pytest_offline), ("CAP-010", "run_ci.sh reproduces CI pipeline locally", "local", _check_run_ci_check_only), + ("CAP-011", "headline E2E runs against the local emulating tier (microservice)", "local", + _check_local_e2e_microservice), + ("CAP-012", "local E2E on the static-assets stack (no ECS)", "local", + _check_local_e2e_static_assets), ] diff --git a/scripts/run_platform.sh b/scripts/run_platform.sh index 6c4a243..40bb717 100755 --- a/scripts/run_platform.sh +++ b/scripts/run_platform.sh @@ -41,6 +41,7 @@ PLAN_ONLY=0 QUIET=0 DEPLOY_UPTIME=0 DECOMMISSION=0 +LOCAL_TIER=0 CHANGE_REQUEST_ID="" ENVIRONMENT_OVERRIDE="" CONTRACT="" @@ -60,6 +61,7 @@ for arg in "$@"; do --quiet) QUIET=1 ;; --deploy-uptime) DEPLOY_UPTIME=1 ;; --decommission) DECOMMISSION=1 ;; + --local) LOCAL_TIER=1 ;; --environment=*) ENVIRONMENT_OVERRIDE="${arg#*=}" ;; --environment) _prev="--environment" ;; --*) echo "FAIL: unknown flag: $arg" >&2; exit 1 ;; @@ -96,6 +98,22 @@ fi fail() { echo "FAIL: $*" >&2; exit 1; } +# --local: run the headline E2E against the local emulating tier (D-092). +# No AWS credentials, no Checkov, no DynamoDB. Emulates ECS, outbox, S3 +# state, and the contract-ingestor Lambda in-process. Exits 0 on success. +if [ "$LOCAL_TIER" = "1" ]; then + [ -n "$CONTRACT" ] || CONTRACT="contracts/microservice.yaml" + echo "=== ACDL Local Emulating Tier (D-092) ===" + echo "contract: $CONTRACT (no AWS credentials required)" + echo "" + ACDL_LOCAL_TIER=1 python3 core/local_emulators.py "$CONTRACT" \ + || fail "local E2E failed" + echo "" + echo "=== LOCAL E2E OK ===" + echo "contract -> resolver -> adapter -> local S3 backend -> local ECS (HTTP 200) -> flat-file outbox -> local Lambda" + exit 0 +fi + # stream: pipe a command's stdout+stderr to both a log file and the # terminal (unless --quiet). Usage: stream -- stream() { diff --git a/tests/test_local_emulating_adapters.py b/tests/test_local_emulating_adapters.py new file mode 100644 index 0000000..4283621 --- /dev/null +++ b/tests/test_local_emulating_adapters.py @@ -0,0 +1,217 @@ +"""Tests for the local emulating adapters (D-092, REQ-113). + +Verifies the four local adapters and the headline E2E run against the +local tier with no cloud credentials: + 1. FlatFileOutbox - flat-file DynamoDB outbox emulator + 2. LocalEcsEmulator - local ECS Fargate HTTP 200 emulator + 3. LocalS3StateBackend - terraform S3 -> local backend rewrite + 4. LocalLambdaStub - in-process contract_ingestor invocation + 5. run_local_e2e - the full headline E2E against the local tier +""" + +import json +import os +import sys +import tempfile +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + +import core.local_emulators as le # noqa: E402 + + +# --------------------------------------------------------------------------- +# 1. FlatFileOutbox +# --------------------------------------------------------------------------- + +def test_flat_file_outbox_writes_hash_chained_event(tmp_path): + outbox = le.FlatFileOutbox.create(dir=tmp_path) + event = { + "contractId": "c1", "eventType": "CONFIDENCE_COMPUTED", + "ts": "2026-07-27T00:00:00Z", "environment": "dev", + "stack": "s1", "score": 0.9, "band": "pass", + "prev_event_hash": "GENESIS", + } + item = outbox.write_event(event) + assert item["hash"] + assert len(item["hash"]) == 64 # SHA-256 hex + assert item["prev_event_hash"] == "GENESIS" + events = outbox.read_all() + assert len(events) == 1 + assert events[0]["hash"] == item["hash"] + + +def test_flat_file_outbox_chain_links_prior_hash(tmp_path): + outbox = le.FlatFileOutbox.create(dir=tmp_path) + e1 = {"contractId": "c1", "eventType": "E1", "ts": "t1", + "environment": "dev", "stack": "s", "score": 0.9, "band": "pass", + "prev_event_hash": "GENESIS"} + item1 = outbox.write_event(e1) + e2 = {"contractId": "c1", "eventType": "E2", "ts": "t2", + "environment": "dev", "stack": "s", "score": 0.9, "band": "pass", + "prev_event_hash": item1["hash"]} + item2 = outbox.write_event(e2) + assert item2["prev_event_hash"] == item1["hash"] + assert outbox.verify_chain() + + +def test_flat_file_outbox_detects_broken_chain(tmp_path): + outbox = le.FlatFileOutbox.create(dir=tmp_path) + e1 = {"contractId": "c1", "eventType": "E1", "ts": "t1", + "environment": "dev", "stack": "s", "score": 0.9, "band": "pass", + "prev_event_hash": "GENESIS"} + item1 = outbox.write_event(e1) + # Tamper: write a second event claiming the wrong prev hash. + e2 = {"contractId": "c1", "eventType": "E2", "ts": "t2", + "environment": "dev", "stack": "s", "score": 0.9, "band": "pass", + "prev_event_hash": "WRONG"} + outbox.write_event(e2) + assert outbox.verify_chain() is False + + +def test_flat_file_outbox_resumes_chain_across_instances(tmp_path): + outbox1 = le.FlatFileOutbox.create(dir=tmp_path) + e1 = {"contractId": "c1", "eventType": "E1", "ts": "t1", + "environment": "dev", "stack": "s", "score": 0.9, "band": "pass", + "prev_event_hash": "GENESIS"} + item1 = outbox1.write_event(e1) + # New instance pointing at the same dir must resume from item1's hash. + outbox2 = le.FlatFileOutbox.create(dir=tmp_path) + assert outbox2._chain_tail_hash == item1["hash"] + + +# --------------------------------------------------------------------------- +# 2. LocalEcsEmulator +# --------------------------------------------------------------------------- + +def test_local_ecs_emulator_returns_http_200(): + ecs = le.LocalEcsEmulator( + service_name="test-svc", + service_definition={"desired_count": 1}, + ) + try: + meta = ecs.deploy() + assert meta["status"] == "RUNNING" + assert meta["endpoint"].startswith("http://127.0.0.1:") + ok, status = ecs.health_check(meta["endpoint"]) + assert ok is True + assert status == 200 + finally: + ecs.destroy() + + +def test_local_ecs_emulator_destroy_stops_server(): + ecs = le.LocalEcsEmulator("svc", {"desired_count": 1}) + meta = ecs.deploy() + ecs.destroy() + # After destroy, the health check must fail (server stopped). + ok, status = ecs.health_check(meta["endpoint"], timeout_s=1.0) + assert ok is False + + +# --------------------------------------------------------------------------- +# 3. LocalS3StateBackend +# --------------------------------------------------------------------------- + +def test_local_s3_backend_rewrites_s3_to_local(tmp_path): + backend = le.LocalS3StateBackend.create(dir=tmp_path / "state") + tf = tmp_path / "terraform.tf" + tf.write_text( + 'terraform {\n required_version = ">= 1.9"\n backend "s3" {\n' + ' bucket = "acdl-tfstate-x"\n key = "spike/s.tfstate"\n' + ' region = "us-east-1"\n }\n}\n' + ) + backend.rewrite_terraform_tf(tf, "test-stack") + content = tf.read_text() + assert 'backend "local"' in content + assert 'backend "s3"' not in content + assert "test-stack.tfstate" in content + + +def test_local_s3_backend_state_path_is_unique_per_stack(tmp_path): + backend = le.LocalS3StateBackend.create(dir=tmp_path / "state") + p1 = backend.state_path("stack-a") + p2 = backend.state_path("stack-b") + assert p1 != p2 + assert p1.name == "stack-a.tfstate" + + +# --------------------------------------------------------------------------- +# 4. LocalLambdaStub +# --------------------------------------------------------------------------- + +def test_local_lambda_stub_invokes_contract_ingestor(tmp_path): + outbox = le.FlatFileOutbox.create(dir=tmp_path / "outbox") + stub = le.LocalLambdaStub(outbox=outbox) + result = stub.invoke({ + "action": "submit_contract", + "consumerRepo": "local-test/consumer", + "contractId": "lambda-test", + "contract": {"module": "microservice", "environment": "dev"}, + "environment": "dev", + }) + assert result["statusCode"] == 200 + body = json.loads(result["body"]) + assert "consumerRepo" in body or "contractId" in body + + +def test_local_lambda_stub_rejects_missing_field(tmp_path): + outbox = le.FlatFileOutbox.create(dir=tmp_path / "outbox") + stub = le.LocalLambdaStub(outbox=outbox) + result = stub.invoke({ + "action": "submit_contract", + "consumerRepo": "local-test/consumer", + # contractId intentionally missing + "contract": {"module": "microservice", "environment": "dev"}, + "environment": "dev", + }) + assert result["statusCode"] == 400 + + +# --------------------------------------------------------------------------- +# 5. run_local_e2e (the headline E2E against the local tier) +# --------------------------------------------------------------------------- + +@pytest.mark.slow +def test_run_local_e2e_microservice(): + """Headline E2E: contract -> resolver -> adapter -> local S3 backend + -> local ECS (HTTP 200) -> flat-file outbox -> local Lambda. No AWS.""" + os.environ["ACDL_LOCAL_TIER"] = "1" + try: + result = le.run_local_e2e("contracts/microservice.yaml") + finally: + os.environ.pop("ACDL_LOCAL_TIER", None) + assert result["tier"] == "local-emulator" + assert result["backend"] == "local" + assert result["ecs"] is not None + assert result["ecs"]["status"] == "RUNNING" + assert result["outbox_chain_verified"] is True + assert result["lambda_status"] == 200 + + +@pytest.mark.slow +def test_run_local_e2e_static_assets(): + """Static-assets stack has no ECS service; the local E2E must still + complete (ecs=None) and the outbox chain + Lambda stub must pass.""" + os.environ["ACDL_LOCAL_TIER"] = "1" + try: + result = le.run_local_e2e("contracts/static-assets.yaml") + finally: + os.environ.pop("ACDL_LOCAL_TIER", None) + assert result["tier"] == "local-emulator" + assert result["ecs"] is None # no ECS service in this stack + assert result["outbox_chain_verified"] is True + assert result["lambda_status"] == 200 + + +def test_is_local_tier_flag(): + assert le.is_local_tier() is False + os.environ["ACDL_LOCAL_TIER"] = "1" + try: + assert le.is_local_tier() is True + finally: + os.environ.pop("ACDL_LOCAL_TIER", None) + assert le.is_local_tier() is False \ No newline at end of file