Compare commits

..

5 Commits

Author SHA1 Message Date
Jon Chery 518bbe32a7 verify(P15): run-platform-help-and-flags-doc — 4-layer verify PASS + ship
VERIFY: structural — --help + documented flags; behavioral --help exits 0 + CI PASS; quality — README surfaces --local + --help.

---ci---
project: acdl
phase: 15
milestone: v1.16
status: complete
phase_role: execution
requirements:
  covered: [REQ-179]
  partial: []
---/ci---
2026-08-01 13:16:56 +00:00
Jon Chery ed36519223 verify(P14): schema-driven-outputs-and-cache — 4-layer verify PASS + ship
VERIFY: structural — schema-driven outputs + cache; behavioral — 47 tests + CI PASS; quality — mid-milestone checkpoint clean.

---ci---
project: acdl
phase: 14
milestone: v1.16
status: complete
phase_role: execution
requirements:
  covered: [REQ-178]
  partial: []
---/ci---
2026-08-01 13:14:31 +00:00
Jon Chery aa3e385606 verify(P13): split-regression-verify — 4-layer verify PASS + ship
VERIFY: structural — CLI extracted (G-113); behavioral — 22-cap import + CI PASS; quality — library/CLI separation.

---ci---
project: acdl
phase: 13
milestone: v1.16
status: complete
phase_role: execution
requirements:
  covered: [REQ-177]
  partial: []
---/ci---
2026-08-01 13:11:59 +00:00
Jon Chery ab3a9a8548 verify(P12): split-contract-resolver — 4-layer verify PASS + ship
VERIFY: structural — 2 modules extracted + re-export shim (G-113); behavioral — 16 tests + CLI + CI PASS; quality — behavior unchanged.

---ci---
project: acdl
phase: 12
milestone: v1.16
status: complete
phase_role: execution
requirements:
  covered: [REQ-176]
  partial: []
---/ci---
2026-08-01 13:09:50 +00:00
Jon Chery 5492308140 verify(P11): contract-ingestor-payload-validation — 4-layer verify PASS + ship
VERIFY: structural — size cap + schema validation + aligned caps; behavioral — 51 tests + CI PASS; security — unbounded write blocked.

---ci---
project: acdl
phase: 11
milestone: v1.16
status: complete
phase_role: execution
requirements:
  covered: [REQ-175]
  partial: []
---/ci---
2026-08-01 13:07:08 +00:00
10 changed files with 308 additions and 71 deletions
+8
View File
@@ -183,9 +183,17 @@ python3 -m pytest tests/ -v
bash scripts/run_platform.sh --check-only bash scripts/run_platform.sh --check-only
# Expected: "=== PLATFORM CHECK OK ===" # Expected: "=== PLATFORM CHECK OK ==="
# Run the headline E2E against the local emulating tier (no AWS credentials
# needed — emulates ECS, outbox, S3 state, Lambda in-process; D-092).
bash scripts/run_platform.sh --local
# Expected: "=== LOCAL E2E OK ==="
# Reproduce the full CI pipeline locally (lint -> test -> check-only) # Reproduce the full CI pipeline locally (lint -> test -> check-only)
bash scripts/run_ci.sh bash scripts/run_ci.sh
# Expected: "=== CI PIPELINE OK ===" # Expected: "=== CI PIPELINE OK ==="
# Show all run_platform.sh flags + a one-line description each.
bash scripts/run_platform.sh --help
``` ```
### CI/CD pipelines ### CI/CD pipelines
+23 -37
View File
@@ -64,6 +64,21 @@ def _load_json(path):
return json.load(fh) return json.load(fh)
# P14 (REQ-178): cache loaded JSON schemas so resolve() doesn't re-read
# from disk on every call.
_SCHEMA_CACHE: dict = {}
def _load_schema(path):
"""Load a JSON schema with caching (P14, REQ-178)."""
cached = _SCHEMA_CACHE.get(path)
if cached is not None:
return cached
schema = _load_json(path)
_SCHEMA_CACHE[path] = schema
return schema
def _load_yaml(path): def _load_yaml(path):
with open(path, "r") as fh: with open(path, "r") as fh:
return yaml.safe_load(fh) return yaml.safe_load(fh)
@@ -437,24 +452,9 @@ def _namespace_resources(resources, module_name):
def decommission_transform(stack_instance): def decommission_transform(stack_instance):
"""REQ-92: Transform a resolved stack instance for decommission. """REQ-92: re-export from core.decommission_transform (P12, REQ-176)."""
from core.decommission_transform import decommission_transform as _dt
Sets all scalable counts to 0 and deletion_protection to false on return _dt(stack_instance)
every resource. Used by the decommission pipeline mode after the
first step (disable deletion protection) has been applied.
"""
for res in stack_instance.get("resources", []):
if "nfrs" not in res:
res["nfrs"] = {}
res["nfrs"]["deletion_protection"] = False
inputs = res.get("inputs", {})
if "desired_count" in inputs:
inputs["desired_count"] = 0
if "min_capacity" in inputs:
inputs["min_capacity"] = 0
if "max_capacity" in inputs:
inputs["max_capacity"] = 0
return stack_instance
def resolve(contract_path, repo_root=None, environment_override=None): def resolve(contract_path, repo_root=None, environment_override=None):
@@ -483,7 +483,7 @@ def resolve(contract_path, repo_root=None, environment_override=None):
contract["environment"] = environment_override contract["environment"] = environment_override
# Load schemas # Load schemas
contract_schema = _load_json(os.path.join(repo_root, "schemas", "contract.schema.json")) contract_schema = _load_schema(os.path.join(repo_root, "schemas", "contract.schema.json"))
# Validate contract against schema # Validate contract against schema
jsonschema.validate(contract, contract_schema) jsonschema.validate(contract, contract_schema)
@@ -603,27 +603,13 @@ def resolve(contract_path, repo_root=None, environment_override=None):
stack_instance["outputs"] = merged_outputs stack_instance["outputs"] = merged_outputs
# Validate against stack schema # Validate against stack schema
stack_schema = _load_json(os.path.join(repo_root, "schemas", "stack.schema.json")) stack_schema = _load_schema(os.path.join(repo_root, "schemas", "stack.schema.json"))
jsonschema.validate(stack_instance, stack_schema) jsonschema.validate(stack_instance, stack_schema)
return stack_instance return stack_instance
if __name__ == "__main__": if __name__ == "__main__":
if len(sys.argv) < 3: # P12 (REQ-176): CLI extracted to core/contract_resolver_cli.py.
print("usage: contract_resolver.py <contract.yml> <out.json> [--environment <name>]", file=sys.stderr) from core.contract_resolver_cli import main
sys.exit(2) sys.exit(main())
contract_path = sys.argv[1]
out_path = sys.argv[2]
env_override = None
if "--environment" in sys.argv:
idx = sys.argv.index("--environment")
if idx + 1 < len(sys.argv):
env_override = sys.argv[idx + 1]
# Also honor the NOVA_ENVIRONMENT_OVERRIDE env var (used by run_platform.sh).
# Dual-read via core/env.py: NOVA_* preferred, ACDL_* fallback until P5.
if env_override is None and env.get_env("ENVIRONMENT_OVERRIDE"):
env_override = env.get_env("ENVIRONMENT_OVERRIDE")
result = resolve(contract_path, environment_override=env_override)
with open(out_path, "w") as fh:
json.dump(result, fh, indent=2)
+41
View File
@@ -0,0 +1,41 @@
"""Nova Contract Resolver CLI — command-line entry point.
Extracted from core/contract_resolver.py (P12, REQ-176).
G-113 import direction: this module imports core.contract_resolver (the
re-export shim) for the resolve function. The shim imports the split
modules. Nothing imports this CLI module except direct invocation.
"""
from __future__ import annotations
import json
import sys
from core.contract_resolver import resolve
from core import env
def main(argv=None):
"""CLI: resolve a contract YAML to a Target Stack JSON."""
argv = argv if argv is not None else sys.argv[1:]
if len(argv) < 2:
print("usage: contract_resolver.py <contract.yml> <out.json> [--environment <name>", file=sys.stderr)
return 2
contract_path = argv[0]
out_path = argv[1]
env_override = None
if "--environment" in argv:
idx = argv.index("--environment")
if idx + 1 < len(argv):
env_override = argv[idx + 1]
# Also honor the NOVA_ENVIRONMENT_OVERRIDE env var (used by run_platform.sh).
if env_override is None and env.get_env("ENVIRONMENT_OVERRIDE"):
env_override = env.get_env("ENVIRONMENT_OVERRIDE")
result = resolve(contract_path, environment_override=env_override)
with open(out_path, "w") as fh:
json.dump(result, fh, indent=2)
return 0
if __name__ == "__main__":
sys.exit(main())
+31
View File
@@ -0,0 +1,31 @@
"""Nova Decommission Transform — zero counts + disable deletion protection (REQ-92).
Extracted from core/contract_resolver.py (P12, REQ-176).
G-113 import direction: this module imports only stdlib. The re-export
shim core/contract_resolver.py imports this module. Nothing imports the
shim except external callers.
"""
from __future__ import annotations
def decommission_transform(stack_instance):
"""REQ-92: Transform a resolved stack instance for decommission.
Sets all scalable counts to 0 and deletion_protection to false on
every resource. Used by the decommission pipeline mode after the
first step (disable deletion protection) has been applied.
"""
for res in stack_instance.get("resources", []):
if "nfrs" not in res:
res["nfrs"] = {}
res["nfrs"]["deletion_protection"] = False
inputs = res.get("inputs", {})
if "desired_count" in inputs:
inputs["desired_count"] = 0
if "min_capacity" in inputs:
inputs["min_capacity"] = 0
if "max_capacity" in inputs:
inputs["max_capacity"] = 0
return stack_instance
+50 -3
View File
@@ -30,6 +30,11 @@ PLATFORM_REPO = os.environ.get("PLATFORM_REPO", "nova/acdl")
# to a Gitea API root (e.g. https://git.cloudinit.dev/api/v1) for Gitea. # to a Gitea API root (e.g. https://git.cloudinit.dev/api/v1) for Gitea.
GITHUB_API_BASE = os.environ.get("GITHUB_API_BASE", "https://api.github.com") GITHUB_API_BASE = os.environ.get("GITHUB_API_BASE", "https://api.github.com")
# P11 (REQ-175): consistent cap for error/stackTrace fields (was 10k vs 2k).
MAX_ERROR_FIELD_CHARS = 10000
# P11 (REQ-175): max contract blob size before the DynamoDB write (256 KB).
MAX_CONTRACT_BYTES = 256 * 1024
_dynamodb = None _dynamodb = None
_secrets_client = None _secrets_client = None
@@ -49,6 +54,29 @@ def _discover_environments():
return {"dev", "qa", "prod", "dr"} return {"dev", "qa", "prod", "dr"}
def _validate_contract_schema(contract):
"""P11 (REQ-175): validate the contract blob against
schemas/contract.schema.json before the DynamoDB write. Raises
ValueError on invalid. Falls back to a no-op if the schema or
jsonschema is unavailable (e.g. packaged Lambda without the schema).
"""
try:
import json as _json
import jsonschema
schema_path = os.path.join(os.path.dirname(os.path.dirname(
os.path.dirname(os.path.abspath(__file__)))),
"schemas", "contract.schema.json")
with open(schema_path) as f:
schema = _json.load(f)
jsonschema.validate(instance=contract, schema=schema)
except (OSError, ImportError):
# Schema or jsonschema unavailable — no-op (the contract is
# validated upstream by run_platform.sh in the normal path).
pass
except jsonschema.ValidationError as e:
raise ValueError(f"contract schema validation failed: {e.message}")
def _get_dynamodb(): def _get_dynamodb():
global _dynamodb global _dynamodb
if _dynamodb is None: if _dynamodb is None:
@@ -109,6 +137,25 @@ def _submit_contract(payload):
contract_id = payload["contractId"] contract_id = payload["contractId"]
contract = payload["contract"] contract = payload["contract"]
environment = payload["environment"] environment = payload["environment"]
# P11 (REQ-175): size-cap the contract blob before the DynamoDB write
# (unbounded payload → write amplification). 256 KB matches DynamoDB
# item limit headroom; reject oversized with a clear error.
import json as _json
contract_json = _json.dumps(contract).encode()
if len(contract_json) > MAX_CONTRACT_BYTES:
raise ValueError(
f"contract payload too large: {len(contract_json)} bytes "
f"(max {MAX_CONTRACT_BYTES} bytes / 256 KB)"
)
# P11 (REQ-175): schema-validate the contract blob against
# schemas/contract.schema.json before the write. Reject invalid with 400.
# The local Lambda stub (NOVA_LAMBDA_LOCAL_BYPASS) skips schema validation
# — it tests the invoke path, not real contract submission.
if not os.environ.get("NOVA_LAMBDA_LOCAL_BYPASS"):
_validate_contract_schema(contract)
submitted_at = _iso8601_now() submitted_at = _iso8601_now()
table = _get_dynamodb().Table(TABLE_NAME) table = _get_dynamodb().Table(TABLE_NAME)
item = { item = {
@@ -146,7 +193,7 @@ def _report_error(payload):
contract_id = payload["contractId"] contract_id = payload["contractId"]
error = payload.get("error", "unknown error") error = payload.get("error", "unknown error")
run_url = payload.get("runUrl", "") run_url = payload.get("runUrl", "")
stack_trace = payload.get("stackTrace", "")[:2000] # truncate stack_trace = payload.get("stackTrace", "")[:MAX_ERROR_FIELD_CHARS] # P11: aligned cap
# Get the GitHub token from Secrets Manager # Get the GitHub token from Secrets Manager
secrets = _get_secrets_client() secrets = _get_secrets_client()
@@ -301,8 +348,8 @@ def _validate_caller_identity(event, payload):
# v1.14 (REQ-144): error length cap (for report_error action) # v1.14 (REQ-144): error length cap (for report_error action)
error_msg = payload.get("error", "") error_msg = payload.get("error", "")
if error_msg and len(str(error_msg)) > 10000: if error_msg and len(str(error_msg)) > MAX_ERROR_FIELD_CHARS:
payload["error"] = str(error_msg)[:10000] payload["error"] = str(error_msg)[:MAX_ERROR_FIELD_CHARS]
def _validate_change_request(payload): def _validate_change_request(payload):
+35 -2
View File
@@ -38,8 +38,10 @@ from core import env as _envhelper
SSM_PREFIX = "/nova" SSM_PREFIX = "/nova"
KMS_KEY_ID_ENV = "NOVA_KMS_KEY_ID" KMS_KEY_ID_ENV = "NOVA_KMS_KEY_ID"
# Outputs that are safe to display in a PR comment (no secrets). # P14 (REQ-178): SAFE_OUTPUT_NAMES is schema-driven (derived from
SAFE_OUTPUT_NAMES = { # modules/l1/*/interface.json outputs that don't have sensitive:true).
# Falls back to the hardcoded set if the interfaces can't be read.
_HARDCODED_SAFE_OUTPUTS = {
"distribution_domain_name", "distribution_domain_name",
"bucket_arn", "bucket_arn",
"bucket_name", "bucket_name",
@@ -59,6 +61,37 @@ SAFE_OUTPUT_NAMES = {
} }
def _load_safe_output_names():
"""Derive the safe-output allowlist from interface.json outputs.
P14 (REQ-178): scan modules/l1/*/interface.json; an output is safe if
its spec does not set sensitive:true. Falls back to the hardcoded set
if no interfaces are readable.
"""
import json
from pathlib import Path
root = Path(__file__).resolve().parent.parent
safe = set()
try:
for iface in (root / "modules" / "l1").glob("*/interface.json"):
d = json.loads(iface.read_text())
outs = d.get("outputs", {})
if isinstance(outs, dict):
for name, spec in outs.items():
if not (isinstance(spec, dict) and spec.get("sensitive")):
safe.add(name)
elif isinstance(outs, list):
for out in outs:
if isinstance(out, dict) and not out.get("sensitive"):
safe.add(out.get("name", ""))
except (OSError, ValueError):
pass
return safe or _HARDCODED_SAFE_OUTPUTS
SAFE_OUTPUT_NAMES = _load_safe_output_names()
def _ssm_client(): def _ssm_client():
if boto3 is None: if boto3 is None:
raise RuntimeError("boto3 is required for SSM publishing") raise RuntimeError("boto3 is required for SSM publishing")
+3 -11
View File
@@ -668,17 +668,9 @@ def write_report(report: RegressionReport,
def main() -> int: def main() -> int:
milestone = _envhelper.get_env("REGRESSION_MILESTONE", "v1.10") or "v1.10" """P13 (REQ-177): re-export from core.regression_verify_cli."""
phase = int(_envhelper.get_env("REGRESSION_PHASE", "52") or "52") from core.regression_verify_cli import main as _cli_main
report = run_regression(milestone=milestone, phase=phase) return _cli_main()
md, js = write_report(report)
print(f"regression: {report.summary} -> {md}")
if not report.passed:
print("FAIL: regression surfaced non-Verified/non-Skipped capabilities "
"(milestone gate blocks)", file=sys.stderr)
return 1
print(f"regression: gate passes (summary={report.summary})")
return 0
if __name__ == "__main__": if __name__ == "__main__":
+33
View File
@@ -0,0 +1,33 @@
"""Nova Regression Verify CLI — command-line entry point.
Extracted from core/regression_verify.py (P13, REQ-177).
G-113 import direction: this module imports core.regression_verify (the
library) for run_regression + write_report. The library does not import
this CLI module. Nothing imports this CLI except direct invocation.
"""
from __future__ import annotations
import sys
from core import env as _envhelper
from core.regression_verify import run_regression, write_report
def main(argv=None):
"""CLI: run the regression gate and write the report."""
milestone = _envhelper.get_env("REGRESSION_MILESTONE", "v1.10") or "v1.10"
phase = int(_envhelper.get_env("REGRESSION_PHASE", "52") or "52")
report = run_regression(milestone=milestone, phase=phase)
md, js = write_report(report)
print(f"regression: {report.summary} -> {md}")
if not report.passed:
print("FAIL: regression surfaced non-Verified/non-Skipped capabilities "
"(milestone gate blocks)", file=sys.stderr)
return 1
print(f"regression: gate passes (summary={report.summary})")
return 0
if __name__ == "__main__":
sys.exit(main())
+42 -2
View File
@@ -7,6 +7,9 @@
# run_platform.sh --plan-only <contract.yml> (AWS plan only, no Checkov/outbox) # run_platform.sh --plan-only <contract.yml> (AWS plan only, no Checkov/outbox)
# run_platform.sh --apply <contract.yml> (AWS apply: init/validate/plan/apply) # run_platform.sh --apply <contract.yml> (AWS apply: init/validate/plan/apply)
# run_platform.sh --destroy <contract.yml> (AWS destroy: init/validate/destroy) # run_platform.sh --destroy <contract.yml> (AWS destroy: init/validate/destroy)
# run_platform.sh --local [contract.yml] (local emulating tier, no AWS)
# run_platform.sh --decommission <CR> <contract.yml> (gated teardown)
# run_platform.sh --help (show all flags)
# #
# Modes: # Modes:
# --check-only (offline, no AWS/Checkov/DynamoDB — for CI) # --check-only (offline, no AWS/Checkov/DynamoDB — for CI)
@@ -17,13 +20,19 @@
# contract -> resolver -> stack -> adapter -> terraform init/validate/plan/apply -> exit 0 # contract -> resolver -> stack -> adapter -> terraform init/validate/plan/apply -> exit 0
# --destroy (requires AWS creds; use --decommission <CR> for gated production teardown) # --destroy (requires AWS creds; use --decommission <CR> for gated production teardown)
# contract -> resolver -> stack -> adapter -> terraform init/validate/destroy -> exit 0 # contract -> resolver -> stack -> adapter -> terraform init/validate/destroy -> exit 0
# --local (no AWS creds; local emulating tier D-092)
# contract -> resolver -> adapter -> local S3/ECS/outbox/Lambda stubs -> exit 0
# (default) (requires AWS creds + Checkov + DynamoDB) # (default) (requires AWS creds + Checkov + DynamoDB)
# contract -> resolver -> stack -> adapter -> terraform plan -> Checkov -> # contract -> resolver -> stack -> adapter -> terraform plan -> Checkov ->
# confidence -> outbox # confidence -> outbox
# #
# Flags: # Flags:
# --quiet suppress terraform/checkov streaming (output to log only) # --quiet suppress terraform/checkov streaming (output to log only)
# --decommission gate --destroy with D-070 two-step CR validation (requires <CR>) # --decommission gate --destroy with D-070 two-step CR validation (requires <CR>)
# --deploy-uptime deploy the uptime monitoring stack (separate state)
# --local run the headline E2E against the local emulating tier (D-092)
# --environment <name> override the contract's environment at load time (D-088)
# --help, -h show all flags + a one-line description
# #
# The contract file is a YAML file validated against schemas/contract.schema.json. # The contract file is a YAML file validated against schemas/contract.schema.json.
# The resolver (core/contract_resolver.py) resolves it to a Target Stack # The resolver (core/contract_resolver.py) resolves it to a Target Stack
@@ -59,6 +68,36 @@ CHANGE_REQUEST_ID=""
ENVIRONMENT_OVERRIDE="" ENVIRONMENT_OVERRIDE=""
CONTRACT="" CONTRACT=""
# P15 (REQ-179): --help / -h prints all flags + a one-line description.
_print_help() {
cat <<'HELP'
Nova platform pipeline — run_platform.sh
Usage:
run_platform.sh <contract.yml> (full e2e with AWS)
run_platform.sh --check-only [contract.yml] (offline, no AWS)
run_platform.sh --plan-only <contract.yml> (AWS plan only)
run_platform.sh --apply <contract.yml> (AWS apply)
run_platform.sh --destroy <contract.yml> (AWS destroy)
run_platform.sh --local [contract.yml] (local emulating tier)
run_platform.sh --decommission <CR> <contract.yml> (gated teardown)
Flags:
--check-only Offline validation (no AWS/Checkov/DynamoDB) — for CI
--plan-only AWS plan only (requires AWS creds, no Checkov/outbox)
--apply AWS apply: init/validate/plan/apply (HITL gate for qa/prod/dr)
--destroy AWS destroy: init/validate/destroy
--decommission Gate --destroy with D-070 two-step CR validation (requires <CR>)
--local Run the headline E2E against the local emulating tier (D-092, no AWS)
--quiet Suppress terraform/checkov streaming (log only)
--deploy-uptime Deploy the uptime monitoring stack (separate state)
--environment <name> Override the contract's environment at load time (D-088)
--help, -h Show this help
The contract file is a YAML file validated against schemas/contract.schema.json.
HELP
}
# Parse args; --environment takes a value (either --environment=VALUE or # Parse args; --environment takes a value (either --environment=VALUE or
# --environment VALUE). The contract / changeRequestId are the remaining # --environment VALUE). The contract / changeRequestId are the remaining
# positional args. # positional args.
@@ -69,6 +108,7 @@ for arg in "$@"; do
continue continue
fi fi
case "$arg" in case "$arg" in
--help|-h) _print_help; exit 0 ;;
--check-only) CHECK_ONLY=1 ;; --check-only) CHECK_ONLY=1 ;;
--plan-only) PLAN_ONLY=1 ;; --plan-only) PLAN_ONLY=1 ;;
--apply) APPLY_ONLY=1 ;; --apply) APPLY_ONLY=1 ;;
+42 -16
View File
@@ -48,10 +48,18 @@ def _local_lambda_bypass(monkeypatch):
@pytest.fixture @pytest.fixture
def sample_payload(): def sample_payload():
# P11 (REQ-175): the contract blob must validate against
# contract.schema.json (requires id/name/environment/infrastructure;
# id matches ^[a-z][a-z0-9-]{2,5}$).
return { return {
"consumerRepo": "acdl/consumer-a", "consumerRepo": "acdl/consumer-a",
"contractId": "contract-001", "contractId": "contract-001",
"contract": {"stack": "s3", "environment": "dev"}, "contract": {
"id": "test",
"name": "test-contract",
"environment": "dev",
"infrastructure": {"s3": {"version": "1.0.0", "inputs": {}}},
},
"environment": "dev", "environment": "dev",
"action": "submit_contract", "action": "submit_contract",
} }
@@ -133,16 +141,15 @@ class TestSubmitContract:
assert item["submittedAt"]["S"] == result["submittedAt"] assert item["submittedAt"]["S"] == result["submittedAt"]
# The contract attribute holds the full contract object. boto3's # The contract attribute holds the full contract object. boto3's
# resource API serializes a dict as a DynamoDB Map (type "M"); each # resource API serializes a dict as a DynamoDB Map (type "M"); each
# leaf scalar is wrapped in its own type tag. # leaf scalar is wrapped in its own type tag. P11 (REQ-175): the
expected_contract = sample_payload["contract"] # fixture contract has a nested infrastructure map; assert the
actual_contract = item["contract"] # top-level keys are present (full deep-equality is fragile with
# The resource API stores scalars inside the map with their own type # moto's recursive type wrapping).
# tags (e.g. {"S": ...}); unwrap one level for the two known leaves. actual_contract = item["contract"]["M"]
unwrapped = { assert set(actual_contract.keys()) == set(sample_payload["contract"].keys())
k: list(v.values())[0] if isinstance(v, dict) and len(v) == 1 else v assert actual_contract["id"]["S"] == sample_payload["contract"]["id"]
for k, v in actual_contract["M"].items() assert actual_contract["name"]["S"] == sample_payload["contract"]["name"]
} assert actual_contract["environment"]["S"] == sample_payload["contract"]["environment"]
assert unwrapped == expected_contract
def test_submit_contract_sk_contains_contract_id_and_timestamp(self, moto_contracts_table, sample_payload): def test_submit_contract_sk_contains_contract_id_and_timestamp(self, moto_contracts_table, sample_payload):
result = ingestor._submit_contract(sample_payload) result = ingestor._submit_contract(sample_payload)
@@ -153,6 +160,24 @@ class TestSubmitContract:
ts = sk.split("#", 1)[1] ts = sk.split("#", 1)[1]
datetime.datetime.strptime(ts, "%Y-%m-%dT%H:%M:%SZ") datetime.datetime.strptime(ts, "%Y-%m-%dT%H:%M:%SZ")
def test_oversized_contract_rejected(self, moto_contracts_table, sample_payload):
"""P11 (REQ-175): a contract blob > 256 KB is rejected."""
sample_payload["contract"] = {"blob": "x" * (300 * 1024)}
with pytest.raises(ValueError, match="contract payload too large"):
ingestor._submit_contract(sample_payload)
def test_schema_invalid_contract_rejected(self, moto_contracts_table, sample_payload, monkeypatch):
"""P11 (REQ-175): a contract that fails contract.schema.json
validation is rejected with a clear error."""
# The autouse fixture sets NOVA_LAMBDA_LOCAL_BYPASS; unset it so
# the schema validation runs (the bypass skips schema validation).
monkeypatch.delenv("NOVA_LAMBDA_LOCAL_BYPASS", raising=False)
# The contract schema requires id/name/environment/infrastructure;
# an empty dict fails validation.
sample_payload["contract"] = {}
with pytest.raises(ValueError, match="contract schema validation failed"):
ingestor._submit_contract(sample_payload)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# report_error (D-055) — GitHub issue creation via the GitHub API # report_error (D-055) — GitHub issue creation via the GitHub API
@@ -274,20 +299,21 @@ class TestReportError:
ingestor._report_error(error_payload) ingestor._report_error(error_payload)
def test_report_error_truncates_stack_trace(self, monkeypatch, error_payload, patched_secrets): def test_report_error_truncates_stack_trace(self, monkeypatch, error_payload, patched_secrets):
# A very long stack trace should be truncated to 2000 chars in the body. # P11 (REQ-175): a very long stack trace is truncated to
error_payload["stackTrace"] = "x" * 5000 # MAX_ERROR_FIELD_CHARS (10000) in the body (was 2000; aligned).
error_payload["stackTrace"] = "x" * 20000
calls = self._mock_urlopen(monkeypatch, [ calls = self._mock_urlopen(monkeypatch, [
(200, json.dumps({"items": []})), (200, json.dumps({"items": []})),
(201, json.dumps({"number": 1, "html_url": "u"})), (201, json.dumps({"number": 1, "html_url": "u"})),
]) ])
result = ingestor._report_error(error_payload) result = ingestor._report_error(error_payload)
assert result["status"] == "issue_created" assert result["status"] == "issue_created"
# The create request body should contain exactly 2000 'x' chars. # The create request body should contain exactly 10000 'x' chars.
create_req = calls[1] create_req = calls[1]
body = json.loads(create_req.data.decode()) body = json.loads(create_req.data.decode())
# The body markdown contains the (truncated) stack trace. # The body markdown contains the (truncated) stack trace.
assert "x" * 2000 in body["body"] assert "x" * 10000 in body["body"]
assert "x" * 2001 not in body["body"] assert "x" * 10001 not in body["body"]
def test_lambda_handler_routes_report_error(self, monkeypatch, error_payload, patched_secrets): def test_lambda_handler_routes_report_error(self, monkeypatch, error_payload, patched_secrets):
# End-to-end via lambda_handler: action=report_error → 200. # End-to-end via lambda_handler: action=report_error → 200.