Compare commits

..

6 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
Jon Chery 76714bebc4 verify(P10): contract-ingestor-defense-in-depth — 4-layer verify PASS + ship
VERIFY: structural — fail-closed + env discovery; behavioral — 49 tests + CI PASS; security — defense-in-depth on IAM identity.

---ci---
project: acdl
phase: 10
milestone: v1.16
status: complete
phase_role: execution
requirements:
  covered: [REQ-174]
  partial: []
---/ci---
2026-08-01 12:58:23 +00:00
11 changed files with 401 additions and 92 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
+93 -14
View File
@@ -30,10 +30,53 @@ 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
def _discover_environments():
"""P10 (REQ-174): derive the valid environment names from
core/environments/*.json (the directory is the single source of truth,
not a hardcoded set). Falls back to {'dev','qa','prod','dr'} if the
directory is not readable (e.g. packaged Lambda without the dir).
"""
env_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__)))), "core", "environments")
try:
names = {f[:-5] for f in os.listdir(env_dir) if f.endswith(".json")}
return names or {"dev", "qa", "prod", "dr"}
except OSError:
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:
@@ -94,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 = {
@@ -131,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()
@@ -236,20 +298,33 @@ def _validate_caller_identity(event, payload):
in the payload matches the principal's ARN-derived source identity, preventing in the payload matches the principal's ARN-derived source identity, preventing
one consumer from impersonating another. one consumer from impersonating another.
If the identity is not available (e.g. local testing or non-IAM auth), the P10 (REQ-174): if the IAM identity is absent (no callerArn), the function
check is skipped (the ABAC policy at the IAM layer enforces the scope). FAILS CLOSED (raises ValueError) rather than silently passing. The ABAC
policy at the IAM layer is the primary enforcement; this is defense-in-
depth so a misconfigured Function URL (no IAM auth) does not allow
unauthenticated contract submission. Local testing must set a test ARN
via the event requestContext or the LOCAL_LAMBDA_STUB env bypass.
v1.14 (REQ-144): also validates contractId format, environment enum, and v1.14 (REQ-144): also validates contractId format, environment enum, and
error length. The ABAC reliance is documented here: the Function URL IAM error length. P10 (REQ-174): the environment enum is derived from the
identity does not expose principal tags in the event, so full enforcement core/environments/ directory (not hardcoded), so a new env JSON is the
of consumerRepo ownership is at the IAM layer (ABAC via single source of truth. The ABAC reliance is documented here: the
aws:PrincipalTag/nova:owner). This function validates format only, not Function URL IAM identity does not expose principal tags in the event,
ownership. so full enforcement of consumerRepo ownership is at the IAM layer (ABAC
via aws:PrincipalTag/nova:owner). This function validates format only,
not ownership.
""" """
identity = event.get("requestContext", {}).get("identity", {}) identity = event.get("requestContext", {}).get("identity", {})
caller_arn = identity.get("userArn", "") caller_arn = identity.get("userArn", "")
if not caller_arn: if not caller_arn:
pass # no identity available — rely on IAM ABAC enforcement # P10 (REQ-174): fail closed. A local-test bypass is allowed via
# the NOVA_LAMBDA_LOCAL_BYPASS env var (set by the LocalLambdaStub).
import os as _os
if not _os.environ.get("NOVA_LAMBDA_LOCAL_BYPASS"):
raise ValueError(
"missing IAM caller identity (requestContext.identity.userArn) — "
"the Function URL must use IAM auth; refusing unauthenticated submission"
)
payload_repo = payload.get("consumerRepo", "") payload_repo = payload.get("consumerRepo", "")
if payload_repo: if payload_repo:
# consumerRepo must be org/repo format, <=128 chars # consumerRepo must be org/repo format, <=128 chars
@@ -263,17 +338,18 @@ def _validate_caller_identity(event, payload):
if not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$', contract_id): if not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$', contract_id):
raise ValueError(f"invalid contractId format: {contract_id!r} (alphanumeric, hyphen, underscore; max 64 chars)") raise ValueError(f"invalid contractId format: {contract_id!r} (alphanumeric, hyphen, underscore; max 64 chars)")
# v1.14 (REQ-144): environment enum validation # P10 (REQ-174): environment enum derived from core/environments/ (not
# hardcoded) — the directory is the single source of truth.
environment = payload.get("environment", "") environment = payload.get("environment", "")
if environment: if environment:
valid_envs = {"dev", "qa", "prod", "dr"} valid_envs = _discover_environments()
if environment not in valid_envs: if environment not in valid_envs:
raise ValueError(f"invalid environment: {environment!r} (must be one of {valid_envs})") raise ValueError(f"invalid environment: {environment!r} (must be one of {sorted(valid_envs)})")
# 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):
@@ -357,6 +433,9 @@ def lambda_handler(event, context):
} }
return {"statusCode": 200, "body": json.dumps(result)} return {"statusCode": 200, "body": json.dumps(result)}
except ValueError as e: except ValueError as e:
# P10 (REQ-174): identity failures are 401, field validation is 400.
if "missing IAM caller identity" in str(e):
return {"statusCode": 401, "body": json.dumps({"error": str(e)})}
return {"statusCode": 400, "body": json.dumps({"error": str(e)})} return {"statusCode": 400, "body": json.dumps({"error": str(e)})}
except Exception as e: # pragma: no cover - defensive top-level guard except Exception as e: # pragma: no cover - defensive top-level guard
return {"statusCode": 500, "body": json.dumps({"error": str(e)})} return {"statusCode": 500, "body": json.dumps({"error": str(e)})}
+12
View File
@@ -392,12 +392,24 @@ class LocalLambdaStub:
"httpContext": {"authorizer": {"iam": {"userId": "local-stub"}}} "httpContext": {"authorizer": {"iam": {"userId": "local-stub"}}}
}, },
} }
# P10 (REQ-174): the local stub has no real IAM identity; set
# the bypass so the fail-closed identity check passes for local
# tier testing. The ABAC layer is the primary enforcement in
# real AWS; the stub is defense-in-depth-testable via the
# explicit TestCallerIdentityValidation tests.
import os as _os
_prev_bypass = _os.environ.get("NOVA_LAMBDA_LOCAL_BYPASS")
_os.environ["NOVA_LAMBDA_LOCAL_BYPASS"] = "1"
result = ci.lambda_handler(event, None) result = ci.lambda_handler(event, None)
finally: finally:
ci._get_dynamodb = original_get ci._get_dynamodb = original_get
if original_urlopen is not None: if original_urlopen is not None:
import urllib.request import urllib.request
urllib.request.urlopen = original_urlopen urllib.request.urlopen = original_urlopen
if _prev_bypass is None:
_os.environ.pop("NOVA_LAMBDA_LOCAL_BYPASS", None)
else:
_os.environ["NOVA_LAMBDA_LOCAL_BYPASS"] = _prev_bypass
return result return result
+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 ;;
+80 -26
View File
@@ -37,12 +37,29 @@ _spec.loader.exec_module(ingestor)
# Fixtures # Fixtures
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _local_lambda_bypass(monkeypatch):
"""P10 (REQ-174): set NOVA_LAMBDA_LOCAL_BYPASS for all ingestor tests
so the fail-closed identity check doesn't block handler-routing tests.
Tests that explicitly exercise the identity check (TestCallerIdentity
Validation) override this per-test."""
monkeypatch.setenv("NOVA_LAMBDA_LOCAL_BYPASS", "1")
@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",
} }
@@ -50,7 +67,8 @@ def sample_payload():
@pytest.fixture @pytest.fixture
def function_url_event(sample_payload): def function_url_event(sample_payload):
return {"body": json.dumps(sample_payload)} # P10 (REQ-174): include a test IAM identity so the fail-closed check passes.
return {"body": json.dumps(sample_payload), "requestContext": {"identity": {"userArn": "arn:aws:sts::000:assumed-role/nova-deploy/test"}}}
@pytest.fixture @pytest.fixture
@@ -123,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)
@@ -143,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
@@ -264,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.
@@ -361,9 +397,21 @@ class TestLambdaHandler:
class TestCallerIdentityValidation: class TestCallerIdentityValidation:
"""P1-2: the Lambda validates consumerRepo against the invoking principal.""" """P1-2: the Lambda validates consumerRepo against the invoking principal."""
def test_no_identity_skips_check(self, moto_contracts_table, function_url_event): def test_no_identity_fails_closed(self, moto_contracts_table, sample_payload, monkeypatch):
# No requestContext.identity in the event — check is skipped (relies on IAM ABAC). # P10 (REQ-174): no requestContext.identity → fail closed (defense-in-
resp = ingestor.lambda_handler(function_url_event, None) # depth). The old behavior (silent pass) is replaced with a 401.
monkeypatch.delenv("NOVA_LAMBDA_LOCAL_BYPASS", raising=False)
event = {"body": json.dumps(sample_payload), "requestContext": {}}
resp = ingestor.lambda_handler(event, None)
assert resp["statusCode"] == 401
assert "missing IAM caller identity" in json.loads(resp["body"])["error"]
def test_no_identity_passes_with_local_bypass(self, moto_contracts_table, sample_payload, monkeypatch):
# P10 (REQ-174): the NOVA_LAMBDA_LOCAL_BYPASS env allows local/stub
# testing without an IAM identity (the LocalLambdaStub sets it).
monkeypatch.setenv("NOVA_LAMBDA_LOCAL_BYPASS", "1")
event = {"body": json.dumps(sample_payload), "requestContext": {}}
resp = ingestor.lambda_handler(event, None)
assert resp["statusCode"] == 200 assert resp["statusCode"] == 200
def test_invalid_consumer_repo_format_rejected(self, moto_contracts_table, sample_payload): def test_invalid_consumer_repo_format_rejected(self, moto_contracts_table, sample_payload):
@@ -496,7 +544,7 @@ class TestValidateChangeRequest:
"action": "validate_change_request", "action": "validate_change_request",
"changeRequestId": "CHG0678912", "changeRequestId": "CHG0678912",
"consumerRepo": "acdl/consumer-a", "consumerRepo": "acdl/consumer-a",
})} }), "requestContext": {"identity": {"userArn": "arn:aws:sts::000:assumed-role/nova-deploy/test"}}}
resp = ingestor.lambda_handler(event, None) resp = ingestor.lambda_handler(event, None)
assert resp["statusCode"] == 200 assert resp["statusCode"] == 200
body = json.loads(resp["body"]) body = json.loads(resp["body"])
@@ -505,33 +553,39 @@ class TestValidateChangeRequest:
class TestV14IdentityValidation: class TestV14IdentityValidation:
"""v1.14 (REQ-144): contractId format, environment enum, error length """v1.14 (REQ-144): contractId format, environment enum, error length
validation + spoofing resistance.""" validation + spoofing resistance.
P10 (REQ-174): these tests supply a valid userArn so the fail-closed
identity check passes and the field validation is reached."""
_ARN = "arn:aws:sts::000:assumed-role/nova-deploy/test-session"
def test_invalid_contract_id_rejected(self, moto_contracts_table, sample_payload): def test_invalid_contract_id_rejected(self, moto_contracts_table, sample_payload):
sample_payload["contractId"] = "bad contract!@#" sample_payload["contractId"] = "bad contract!@#"
event = {"body": json.dumps(sample_payload), "requestContext": {}} event = {"body": json.dumps(sample_payload), "requestContext": {"identity": {"userArn": self._ARN}}}
resp = ingestor.lambda_handler(event, None) resp = ingestor.lambda_handler(event, None)
assert resp["statusCode"] == 400 assert resp["statusCode"] == 400
assert "invalid contractId" in resp["body"] assert "invalid contractId" in resp["body"]
def test_contract_id_too_long_rejected(self, moto_contracts_table, sample_payload): def test_contract_id_too_long_rejected(self, moto_contracts_table, sample_payload):
sample_payload["contractId"] = "a" * 65 sample_payload["contractId"] = "a" * 65
event = {"body": json.dumps(sample_payload), "requestContext": {}} event = {"body": json.dumps(sample_payload), "requestContext": {"identity": {"userArn": self._ARN}}}
resp = ingestor.lambda_handler(event, None) resp = ingestor.lambda_handler(event, None)
assert resp["statusCode"] == 400 assert resp["statusCode"] == 400
assert "invalid contractId" in resp["body"] assert "invalid contractId" in resp["body"]
def test_invalid_environment_rejected(self, moto_contracts_table, sample_payload): def test_invalid_environment_rejected(self, moto_contracts_table, sample_payload):
sample_payload["environment"] = "staging" sample_payload["environment"] = "staging"
event = {"body": json.dumps(sample_payload), "requestContext": {}} event = {"body": json.dumps(sample_payload), "requestContext": {"identity": {"userArn": self._ARN}}}
resp = ingestor.lambda_handler(event, None) resp = ingestor.lambda_handler(event, None)
assert resp["statusCode"] == 400 assert resp["statusCode"] == 400
assert "invalid environment" in resp["body"] assert "invalid environment" in resp["body"]
def test_valid_environments_accepted(self, moto_contracts_table, sample_payload): def test_valid_environments_accepted(self, moto_contracts_table, sample_payload):
arn = "arn:aws:sts::000:assumed-role/nova-deploy/test"
for env in ["dev", "qa", "prod", "dr"]: for env in ["dev", "qa", "prod", "dr"]:
sample_payload["environment"] = env sample_payload["environment"] = env
event = {"body": json.dumps(sample_payload), "requestContext": {}} event = {"body": json.dumps(sample_payload), "requestContext": {"identity": {"userArn": arn}}}
resp = ingestor.lambda_handler(event, None) resp = ingestor.lambda_handler(event, None)
assert resp["statusCode"] == 200 assert resp["statusCode"] == 200