eb4fade710
---ci--- project: acdl phase: 2 milestone: v1.28 status: execute persona: backend-engineer --- Extract dispatch_action() shared business-logic dispatch + _to_http_response error mapper. lambda_handler (Lambda) + cli_main (CLI) become thin input parsers that both delegate to dispatch_action. The action routing, contract validation, DynamoDB write, error reporting live in shared functions — single source of truth (NFR-7). tests/test_dual_use.py verifies both paths produce the same output for the same input, both call dispatch_action, and code share >=80% (CAP-026). 41 existing ingestor tests still pass.
605 lines
24 KiB
Python
605 lines
24 KiB
Python
"""Platform Lambda — contract ingestor.
|
|
|
|
Invoked via a Function URL (IAM auth) by consumer pipelines (one-way
|
|
communication, D-051). Accepts { consumerRepo, contractId, contract,
|
|
environment, action } and writes contracts to DynamoDB table nova-contracts
|
|
(PK consumerRepo, SK contractId#submittedAt).
|
|
|
|
The report_error action (D-055) creates a GitHub issue on the platform repo
|
|
via the GitHub API, using a token from Secrets Manager. It is idempotent: if
|
|
an open issue with the same title exists, it comments rather than duplicating.
|
|
|
|
Cross-account: the Lambda's Function URL uses IAM auth; the consumer's
|
|
deploy role (granted during onboarding) invokes it via SigV4-signed
|
|
requests. The invoke policy is scoped via ABAC (consumer repo identity).
|
|
"""
|
|
|
|
import datetime
|
|
import json
|
|
import os
|
|
import urllib.error
|
|
import urllib.parse
|
|
|
|
import boto3
|
|
|
|
TABLE_NAME = os.environ.get("CONTRACTS_TABLE", "nova-contracts")
|
|
CHANGE_REQUESTS_TABLE = os.environ.get("CHANGE_REQUESTS_TABLE", "nova-change-requests")
|
|
GITHUB_TOKEN_SECRET_ID = os.environ.get("GITHUB_TOKEN_SECRET_ID", "nova/github-token")
|
|
PLATFORM_REPO = os.environ.get("PLATFORM_REPO", "nova/acdl")
|
|
# P1-9: Forge-agnostic API base URL. Defaults to GitHub; set GITHUB_API_BASE
|
|
# to a compatible forge API root (e.g. https://forge.example.com/api/v1).
|
|
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
|
|
_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():
|
|
global _dynamodb
|
|
if _dynamodb is None:
|
|
_dynamodb = boto3.resource("dynamodb")
|
|
return _dynamodb
|
|
|
|
|
|
def _get_secrets_client():
|
|
global _secrets_client
|
|
if _secrets_client is None:
|
|
_secrets_client = boto3.client("secretsmanager")
|
|
return _secrets_client
|
|
|
|
|
|
def _iso8601_now():
|
|
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
|
|
|
|
def _forge_type():
|
|
"""Detect whether the API base is GitHub or a compatible forge.
|
|
|
|
Compatible forge API roots contain '/api/v1'; GitHub's is 'api.github.com'.
|
|
"""
|
|
if "/api/v1" in GITHUB_API_BASE:
|
|
return "generic_forge"
|
|
return "github"
|
|
|
|
|
|
def _issues_search_url(owner, repo, encoded_query):
|
|
"""Build the issue search URL based on forge type.
|
|
|
|
GitHub uses /search/issues?q=...; compatible forges use /repos/{owner}/{repo}/issues?...
|
|
with query params (no /search/issues endpoint).
|
|
"""
|
|
if _forge_type() == "generic_forge":
|
|
return (
|
|
f"{GITHUB_API_BASE}/repos/{owner}/{repo}/issues"
|
|
f"?state=open&type=issues&q={encoded_query}"
|
|
)
|
|
return (
|
|
f"{GITHUB_API_BASE}/search/issues?q=repo:{owner}/{repo}"
|
|
f"+is:issue+is:open+in:title+%22{encoded_query}%22"
|
|
)
|
|
|
|
|
|
def _issues_create_url(owner, repo):
|
|
"""URL for creating an issue (same pattern across forges)."""
|
|
return f"{GITHUB_API_BASE}/repos/{owner}/{repo}/issues"
|
|
|
|
|
|
def _issue_comments_url(owner, repo, issue_number):
|
|
"""URL for posting a comment on an issue (same for both forges)."""
|
|
return f"{GITHUB_API_BASE}/repos/{owner}/{repo}/issues/{issue_number}/comments"
|
|
|
|
|
|
def _submit_contract(payload):
|
|
consumer_repo = payload["consumerRepo"]
|
|
contract_id = payload["contractId"]
|
|
contract = payload["contract"]
|
|
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()
|
|
table = _get_dynamodb().Table(TABLE_NAME)
|
|
item = {
|
|
"consumerRepo": consumer_repo,
|
|
"contractId#submittedAt": f"{contract_id}#{submitted_at}",
|
|
"contractId": contract_id,
|
|
"contract": contract,
|
|
"environment": environment,
|
|
"status": "submitted",
|
|
"submittedAt": submitted_at,
|
|
}
|
|
table.put_item(TableName=TABLE_NAME, Item=item)
|
|
return {
|
|
"status": "ok",
|
|
"contractId": contract_id,
|
|
"action": "submit_contract",
|
|
"submittedAt": submitted_at,
|
|
}
|
|
|
|
|
|
def _report_error(payload):
|
|
"""Create a GitHub issue on the platform repo for a deploy failure (D-055).
|
|
|
|
Uses the GitHub token from Secrets Manager. Idempotent: if an open
|
|
issue with the same title exists, comments on it rather than duplicating.
|
|
"""
|
|
import urllib.request
|
|
|
|
required = ["consumerRepo", "contractId", "error"]
|
|
for field in required:
|
|
if field not in payload:
|
|
raise ValueError(f"report_error requires '{field}'")
|
|
|
|
consumer_repo = payload["consumerRepo"]
|
|
contract_id = payload["contractId"]
|
|
error = payload.get("error", "unknown error")
|
|
run_url = payload.get("runUrl", "")
|
|
stack_trace = payload.get("stackTrace", "")[:MAX_ERROR_FIELD_CHARS] # P11: aligned cap
|
|
|
|
# Get the GitHub token from Secrets Manager
|
|
secrets = _get_secrets_client()
|
|
try:
|
|
secret_response = secrets.get_secret_value(SecretId=GITHUB_TOKEN_SECRET_ID)
|
|
github_token = secret_response["SecretString"]
|
|
except Exception as e:
|
|
raise RuntimeError(f"failed to read GitHub token from Secrets Manager: {e}")
|
|
|
|
owner, repo = PLATFORM_REPO.split("/")
|
|
title = f"[NOVA-ALERT] Deploy failure: {consumer_repo} / {contract_id}"
|
|
|
|
# Check for an existing open issue with the same title (idempotency)
|
|
# URL-encode the contract_id to prevent search-query injection (P1-1).
|
|
encoded_contract_id = urllib.parse.quote(contract_id, safe="")
|
|
search_url = _issues_search_url(owner, repo, encoded_contract_id)
|
|
req = urllib.request.Request(search_url)
|
|
req.add_header("Authorization", f"token {github_token}")
|
|
req.add_header("Accept", "application/vnd.github+json")
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
search_result = json.loads(resp.read())
|
|
existing = search_result.get("items", [])
|
|
except urllib.error.HTTPError as e:
|
|
if e.code == 404:
|
|
existing = []
|
|
else:
|
|
import sys
|
|
print(f"WARNING: GitHub issue search failed (HTTP {e.code}): {e}", file=sys.stderr)
|
|
existing = []
|
|
except urllib.error.URLError as e:
|
|
import sys
|
|
print(f"WARNING: GitHub issue search network error: {e}", file=sys.stderr)
|
|
existing = []
|
|
|
|
body = f"""## Deploy Failure Report
|
|
|
|
| Field | Value |
|
|
|-------|-------|
|
|
| **Consumer repo** | `{consumer_repo}` |
|
|
| **Contract ID** | `{contract_id}` |
|
|
| **Run URL** | {run_url if run_url else "_(not provided)_"} |
|
|
| **Environment** | {payload.get('environment', 'unknown')} |
|
|
|
|
## Error
|
|
|
|
```
|
|
{error}
|
|
```
|
|
|
|
## Stack Trace
|
|
|
|
```
|
|
{stack_trace}
|
|
```
|
|
|
|
_This issue was auto-created by the Nova platform Lambda (D-055). The consumer's onboarding-granted Lambda-invoke permission is the only grant needed._
|
|
"""
|
|
|
|
if existing:
|
|
# Comment on the existing issue
|
|
issue_number = existing[0]["number"]
|
|
url = _issue_comments_url(owner, repo, issue_number)
|
|
data = json.dumps({"body": body}).encode()
|
|
req = urllib.request.Request(url, data=data, method="POST")
|
|
req.add_header("Authorization", f"token {github_token}")
|
|
req.add_header("Accept", "application/vnd.github+json")
|
|
urllib.request.urlopen(req, timeout=10)
|
|
return {
|
|
"status": "commented_on_existing",
|
|
"issueNumber": issue_number,
|
|
"contractId": contract_id,
|
|
"action": "report_error",
|
|
}
|
|
else:
|
|
# Create a new issue
|
|
url = _issues_create_url(owner, repo)
|
|
data = json.dumps({
|
|
"title": title,
|
|
"body": body,
|
|
"labels": ["platform-alert", "auto-generated"],
|
|
}).encode()
|
|
req = urllib.request.Request(url, data=data, method="POST")
|
|
req.add_header("Authorization", f"token {github_token}")
|
|
req.add_header("Accept", "application/vnd.github+json")
|
|
resp = urllib.request.urlopen(req, timeout=10)
|
|
issue = json.loads(resp.read())
|
|
return {
|
|
"status": "issue_created",
|
|
"issueNumber": issue["number"],
|
|
"issueUrl": issue["html_url"],
|
|
"contractId": contract_id,
|
|
"action": "report_error",
|
|
}
|
|
|
|
|
|
def _validate_caller_identity(event, payload):
|
|
"""Validate that the payload's consumerRepo matches the invoking principal (P1-2).
|
|
|
|
The Lambda's Function URL uses IAM auth. The caller's identity is available
|
|
in event["requestContext"]["identity"]. We validate that the consumerRepo
|
|
in the payload matches the principal's ARN-derived source identity, preventing
|
|
one consumer from impersonating another.
|
|
|
|
P10 (REQ-174): if the IAM identity is absent (no callerArn), the function
|
|
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
|
|
error length. P10 (REQ-174): the environment enum is derived from the
|
|
core/environments/ directory (not hardcoded), so a new env JSON is the
|
|
single source of truth. The ABAC reliance is documented here: the
|
|
Function URL IAM identity does not expose principal tags in the event,
|
|
so full enforcement of consumerRepo ownership is at the IAM layer (ABAC
|
|
via aws:PrincipalTag/nova:owner). This function validates format only,
|
|
not ownership.
|
|
"""
|
|
identity = event.get("requestContext", {}).get("identity", {})
|
|
caller_arn = identity.get("userArn", "")
|
|
if not caller_arn:
|
|
# 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", "")
|
|
if payload_repo:
|
|
# consumerRepo must be org/repo format, <=128 chars
|
|
if "/" not in payload_repo or len(payload_repo) > 128:
|
|
raise ValueError(f"invalid consumerRepo format: {payload_repo!r}")
|
|
|
|
# v1.14 (REQ-144): contractId format validation
|
|
contract_id = payload.get("contractId", "")
|
|
if contract_id:
|
|
import re
|
|
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)")
|
|
|
|
# P10 (REQ-174): environment enum derived from core/environments/ (not
|
|
# hardcoded) — the directory is the single source of truth.
|
|
environment = payload.get("environment", "")
|
|
if environment:
|
|
valid_envs = _discover_environments()
|
|
if environment not in 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)
|
|
error_msg = payload.get("error", "")
|
|
if error_msg and len(str(error_msg)) > MAX_ERROR_FIELD_CHARS:
|
|
payload["error"] = str(error_msg)[:MAX_ERROR_FIELD_CHARS]
|
|
|
|
|
|
def _validate_change_request(payload):
|
|
"""REQ-93: Validate a change request ID against the CMDB (DynamoDB).
|
|
|
|
Queries the nova-change-requests table for the given changeRequestId.
|
|
Returns the CR details if status is 'approved' and the consumerRepo matches.
|
|
Raises ValueError if the CR is not found, not approved, or the repo doesn't match.
|
|
"""
|
|
required = ["changeRequestId", "consumerRepo"]
|
|
for field in required:
|
|
if field not in payload:
|
|
raise ValueError(f"validate_change_request requires '{field}'")
|
|
|
|
change_request_id = payload["changeRequestId"]
|
|
consumer_repo = payload["consumerRepo"]
|
|
|
|
table = _get_dynamodb().Table(CHANGE_REQUESTS_TABLE)
|
|
response = table.query(
|
|
KeyConditionExpression="changeRequestId = :crId",
|
|
ExpressionAttributeValues={":crId": change_request_id},
|
|
Limit=1,
|
|
)
|
|
items = response.get("Items", [])
|
|
if not items:
|
|
raise ValueError(f"change request '{change_request_id}' not found in CMDB")
|
|
|
|
cr = items[0]
|
|
if cr.get("status") != "approved":
|
|
raise ValueError(
|
|
f"change request '{change_request_id}' status is '{cr.get('status')}', expected 'approved'"
|
|
)
|
|
|
|
if cr.get("consumerRepo") != consumer_repo:
|
|
raise ValueError(
|
|
f"change request '{change_request_id}' consumerRepo mismatch: "
|
|
f"CR has '{cr.get('consumerRepo')}', request has '{consumer_repo}'"
|
|
)
|
|
|
|
return {
|
|
"status": "approved",
|
|
"changeRequestId": change_request_id,
|
|
"consumerRepo": consumer_repo,
|
|
"contractId": cr.get("contractId", ""),
|
|
"action": "validate_change_request",
|
|
}
|
|
|
|
|
|
def _onboard_consumer(payload):
|
|
"""P18 (REQ-182): accept a self-service onboarding request.
|
|
|
|
Validates the payload against schemas/onboarding.schema.json, then
|
|
writes a 'pending' row to nova-contracts (D-119). No AWS resources
|
|
are created by this action (D-113); the cross-account role + ABAC
|
|
tag grant is offline-proven Terraform (P20/REQ-184).
|
|
"""
|
|
import jsonschema
|
|
schema_path = os.path.join(os.path.dirname(os.path.dirname(
|
|
os.path.dirname(os.path.abspath(__file__)))),
|
|
"schemas", "onboarding.schema.json")
|
|
try:
|
|
with open(schema_path) as f:
|
|
schema = json.load(f)
|
|
# Strip the Lambda dispatch envelope (action) before validating
|
|
# against the onboarding schema (the schema is about the request,
|
|
# not the Lambda wrapper).
|
|
onboarding_payload = {k: v for k, v in payload.items() if k != "action"}
|
|
jsonschema.validate(instance=onboarding_payload, schema=schema)
|
|
except OSError:
|
|
raise ValueError("onboarding schema unavailable")
|
|
except jsonschema.ValidationError as e:
|
|
raise ValueError(f"onboarding payload invalid: {e.message}")
|
|
|
|
consumer_repo = payload["consumerRepo"]
|
|
requested_env = payload["requestedEnvironment"]
|
|
owner_id = payload["ownerId"]
|
|
billing_tag = payload["billingTag"]
|
|
submitted_at = _iso8601_now()
|
|
|
|
# Write a pending CMDB row (PK consumerRepo, SK onboarding#env#timestamp).
|
|
table = _get_dynamodb().Table(TABLE_NAME)
|
|
item = {
|
|
"consumerRepo": consumer_repo,
|
|
"contractId#submittedAt": f"onboarding#{requested_env}#{submitted_at}",
|
|
"contractId": f"onboarding-{requested_env}",
|
|
"environment": requested_env,
|
|
"status": "pending",
|
|
"ownerId": owner_id,
|
|
"billingTag": billing_tag,
|
|
"notes": payload.get("notes", ""),
|
|
"submittedAt": submitted_at,
|
|
}
|
|
table.put_item(TableName=TABLE_NAME, Item=item)
|
|
return {
|
|
"status": "pending",
|
|
"consumerRepo": consumer_repo,
|
|
"requestedEnvironment": requested_env,
|
|
"action": "onboard_consumer",
|
|
"submittedAt": submitted_at,
|
|
"message": (
|
|
"Onboarding request received. The platform team will provision "
|
|
"the environment binding + cross-account role. Track the status "
|
|
"via the nova-contracts table (status=pending → granted)."
|
|
),
|
|
}
|
|
|
|
|
|
def dispatch_action(payload, event=None):
|
|
"""Shared business-logic dispatch for the contract ingestor (REQ-329).
|
|
|
|
Both the AWS Lambda handler (``lambda_handler``) and the CLI path
|
|
(``cli_main`` / ``__main__``) call this function so the two paths share
|
|
a single source of truth for action routing, contract validation, the
|
|
DynamoDB write, and error reporting (NFR-7 — dual-use, single source).
|
|
|
|
Args:
|
|
payload: the decoded action envelope dict
|
|
``{ consumerRepo, contractId, contract, environment, action }``.
|
|
event: the raw Lambda Function-URL event (used for IAM caller
|
|
identity validation). When ``None`` (the CLI path), the identity
|
|
check uses the ``NOVA_LAMBDA_LOCAL_BYPASS`` env var — CLI invocations
|
|
are local-only and do not carry an IAM principal.
|
|
|
|
Returns:
|
|
The action result dict (e.g. ``{status, contractId, action, ...}``)
|
|
on success. Raises ``ValueError`` for validation failures and other
|
|
exceptions for downstream errors — the caller is responsible for
|
|
mapping these to the appropriate status code / exit code.
|
|
"""
|
|
action = payload.get("action", "submit_contract")
|
|
# Validate caller identity against the payload (P1-2). The CLI path
|
|
# passes event=None; the fail-closed check honours the local bypass.
|
|
_validate_caller_identity(event or {}, payload)
|
|
if action == "submit_contract":
|
|
# Validate required fields up front for a clean 400.
|
|
for field in ("consumerRepo", "contractId", "contract", "environment"):
|
|
if field not in payload:
|
|
raise ValueError(f"missing field: {field}")
|
|
result = _submit_contract(payload)
|
|
elif action == "report_error":
|
|
result = _report_error(payload)
|
|
elif action == "validate_change_request":
|
|
result = _validate_change_request(payload)
|
|
elif action == "onboard_consumer":
|
|
result = _onboard_consumer(payload)
|
|
else:
|
|
raise ValueError(f"unknown action: {action}")
|
|
return result
|
|
|
|
|
|
def _to_http_response(result_or_error):
|
|
"""Map a dispatch_action result / exception to a Lambda HTTP response.
|
|
|
|
Shared error→status mapping so both Lambda + CLI paths interpret errors
|
|
identically (REQ-329 dual-use).
|
|
"""
|
|
if isinstance(result_or_error, Exception):
|
|
msg = str(result_or_error)
|
|
if isinstance(result_or_error, ValueError):
|
|
if "missing IAM caller identity" in msg:
|
|
return {"statusCode": 401, "body": json.dumps({"error": msg})}
|
|
return {"statusCode": 400, "body": json.dumps({"error": msg})}
|
|
return {"statusCode": 500, "body": json.dumps({"error": msg})}
|
|
return {"statusCode": 200, "body": json.dumps(result_or_error)}
|
|
|
|
|
|
def lambda_handler(event, context):
|
|
"""AWS Lambda handler entry point (thin wrapper, REQ-329 dual-use).
|
|
|
|
Accepts a Function-URL-style event whose ``body`` is a JSON string
|
|
containing ``{ consumerRepo, contractId, contract, environment, action }``.
|
|
Parses the Lambda-specific envelope then delegates to the shared
|
|
``dispatch_action`` business logic.
|
|
"""
|
|
try:
|
|
body = event.get("body", "{}")
|
|
payload = json.loads(body) if isinstance(body, str) else body
|
|
result = dispatch_action(payload, event=event)
|
|
return _to_http_response(result)
|
|
except Exception as e: # pragma: no cover - defensive top-level guard
|
|
return _to_http_response(e)
|
|
|
|
|
|
def cli_main(argv=None):
|
|
"""CLI entry point for the contract ingestor (REQ-329 dual-use).
|
|
|
|
Usage:
|
|
python3 -m core.lambda.contract_ingestor --dispatch <payload.json>
|
|
python3 -m core.lambda.contract_ingestor --dispatch-stdin < <payload.json>
|
|
|
|
Parses the CLI-specific input (a JSON file path or stdin) then delegates
|
|
to the shared ``dispatch_action`` business logic — the same path as the
|
|
Lambda handler. Returns a process exit code (0 success, 1 validation
|
|
error, 2 internal error).
|
|
"""
|
|
import sys
|
|
raw = argv if argv is not None else sys.argv[1:]
|
|
# The --dispatch flag consumes the next positional arg as a payload path;
|
|
# --dispatch-stdin reads the payload from stdin.
|
|
if "--dispatch-stdin" in raw:
|
|
payload = json.loads(sys.stdin.read())
|
|
elif "--dispatch" in raw:
|
|
idx = raw.index("--dispatch")
|
|
path = raw[idx + 1] if idx + 1 < len(raw) else None
|
|
if not path:
|
|
print("Usage: --dispatch <payload.json>", file=sys.stderr)
|
|
return 2
|
|
with open(path) as fh:
|
|
payload = json.loads(fh.read())
|
|
else:
|
|
print(
|
|
"Usage: python3 -m core.lambda.contract_ingestor --dispatch <payload.json>",
|
|
file=sys.stderr,
|
|
)
|
|
return 2
|
|
try:
|
|
result = dispatch_action(payload, event=None)
|
|
sys.stdout.write(json.dumps(result, indent=2) + "\n")
|
|
return 0
|
|
except ValueError as e:
|
|
sys.stderr.write(f"error: {e}\n")
|
|
return 1
|
|
except Exception as e: # pragma: no cover - defensive top-level guard
|
|
sys.stderr.write(f"internal error: {e}\n")
|
|
return 2
|
|
|
|
|
|
# --- CLI: --check-readiness (D-133, REQ-218) + --dispatch (REQ-329) ----
|
|
# Invoked as:
|
|
# python3 -m core.lambda.contract_ingestor --check-readiness <submission.json>
|
|
# python3 -m core.lambda.contract_ingestor --dispatch <payload.json>
|
|
# The --check-readiness path delegates to core.submission_readiness; the
|
|
# --dispatch path is the dual-use CLI entry (REQ-329) that calls the same
|
|
# dispatch_action() as the Lambda handler.
|
|
if __name__ == "__main__": # pragma: no cover - CLI entry
|
|
import sys
|
|
if "--check-readiness" in sys.argv:
|
|
sys.path.insert(
|
|
0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
)
|
|
from core.submission_readiness import cli_main as _readiness_cli
|
|
|
|
# Strip the --check-readiness flag; pass the file path.
|
|
rest = [a for a in sys.argv[1:] if a != "--check-readiness"]
|
|
sys.exit(_readiness_cli(["check-readiness"] + rest))
|
|
elif "--dispatch" in sys.argv or "--dispatch-stdin" in sys.argv:
|
|
sys.exit(cli_main())
|
|
else:
|
|
print(
|
|
"Usage: python3 -m core.lambda.contract_ingestor "
|
|
"--check-readiness <submission.json> | --dispatch <payload.json>",
|
|
file=sys.stderr,
|
|
) |