feat(P53): local emulating adapters (D-092) — full local E2E, no AWS
The platform is now fully locally testable without cloud credentials.
The headline E2E (contract -> resolver -> adapter -> S3 state -> ECS
service -> DynamoDB outbox -> contract-ingestor Lambda) runs end-to-end
against the local emulating tier (D-092, REQ-113).
Four local emulating adapters in core/local_emulators.py:
- FlatFileOutbox: flat-file DynamoDB outbox emulator (hash-chained JSONL;
resumable across instances; chain verification).
- LocalEcsEmulator: local ECS Fargate HTTP 200 emulator (free-port
binding on 127.0.0.1; 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_platform.sh gains a --local flag that short-circuits to the local
emulating tier (no AWS, no Checkov, no DynamoDB).
Regression gate (D-091) now covers 12 capabilities (was 10): +CAP-011
(local E2E microservice) + CAP-012 (local E2E static-assets).
Verified: 513 fast tests pass (was 502; +11 new). 2 slow local E2E
tests pass. run_regression.sh reports 12/12 Verified. run_platform.sh
--local exits 0 with LOCAL E2E OK. No AWS credentials required.
---ci---
project: acdl
phase: 53
milestone: v1.10
status: verify
requirements:
covered: [REQ-113]
partial: []
decisions: [D-092]
regression:
- { capability: CAP-011, status: Verified }
- { capability: CAP-012, status: Verified }
---/ci---
This commit is contained in:
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
+1
-1
@@ -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:**
|
||||
|
||||
+50
-46
@@ -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).
|
||||
**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.
|
||||
@@ -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
|
||||
`<dir>/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 = "<temp>/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))
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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 <logfile> -- <command...>
|
||||
stream() {
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user