"""Nova Environment Transition — detect prior env + record applied env. When a consumer edits the `environment:` field on a stable contract `id` (Shape A promotion), the platform must destroy the prior environment's resources before building the new environment. This module provides the DynamoDB query logic to detect the prior environment and record the applied environment after a successful apply. Source of truth: the `nova-contracts` DynamoDB table (PK `consumerRepo`, SK `contractId#submittedAt`), written by `core/lambda/contract_ingestor.py`. detect_prior_env() queries the table for the last-applied environment for a given consumerRepo + contractId. If it differs from the new env, the prior env name is returned (so the pipeline can destroy it). If no record exists (first deploy or Shape B per-env caller), returns None. record_applied_env() writes a `#LAST_APPLIED` record after a successful apply, so the next run's detect step has a source of truth. Failures to reach DynamoDB (local/CI mode without the table) log a warning and return None (conservative — no false-positive destroys). This is the no-orphan-path guarantee: if we can't confirm a prior env, we don't destroy, but we also don't silently proceed in a way that orphans — the record step ensures future runs have the data. CLI: python3 core/env_transition.py detect --contract-id --consumer-repo --new-env python3 core/env_transition.py record --contract-id --consumer-repo --env """ import datetime import json import os import sys from typing import Optional try: import boto3 except ImportError: boto3 = None TABLE_NAME = os.environ.get("CONTRACTS_TABLE", "nova-contracts") REGION = os.environ.get("AWS_DEFAULT_REGION", "us-east-1") LAST_APPLIED_SUFFIX = "#LAST_APPLIED" def _get_table(): """Return the DynamoDB table resource, or raise if boto3 unavailable.""" if boto3 is None: raise RuntimeError("boto3 is required for env_transition") session = boto3.Session(region_name=REGION) dyn = session.resource("dynamodb") return dyn.Table(TABLE_NAME) def detect_prior_env(contract_id: str, consumer_repo: str, new_env: str) -> Optional[str]: """Query the nova-contracts table for the last-applied env. Returns the prior env name if it differs from new_env, else None. Failures to reach DynamoDB log a warning and return None (conservative). """ try: table = _get_table() sk_prefix = f"{contract_id}{LAST_APPLIED_SUFFIX}#" resp = table.query( KeyConditionExpression="consumerRepo = :repo AND begins_with(#sk, :prefix)", FilterExpression="#status = :status", ExpressionAttributeNames={ "#sk": "contractId#submittedAt", "#status": "status", }, ExpressionAttributeValues={ ":repo": consumer_repo, ":prefix": sk_prefix, ":status": "applied", }, ScanIndexForward=False, Limit=1, ) items = resp.get("Items", []) if not items: return None prior_env = items[0].get("environment") if prior_env and prior_env != new_env: return prior_env return None except Exception as exc: sys.stderr.write( f"WARNING: env_transition.detect_prior_env: could not query " f"DynamoDB table {TABLE_NAME} — {type(exc).__name__}: {exc}. " f"Assuming no prior env (conservative). This is expected in " f"local/CI mode without the nova-contracts table.\n" ) return None def record_applied_env(contract_id: str, consumer_repo: str, env: str) -> bool: """Write a LAST_APPLIED record to the nova-contracts table. Called after a successful apply. Idempotent (writes a new timestamped record each time; the detect step reads the latest by ScanIndexForward). Returns True on success, False on failure (non-fatal — the pipeline should not halt if the record write fails). """ try: table = _get_table() ts = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") sk = f"{contract_id}{LAST_APPLIED_SUFFIX}#{ts}" table.put_item( Item={ "consumerRepo": consumer_repo, "contractId#submittedAt": sk, "contractId": contract_id, "environment": env, "status": "applied", "appliedAt": ts, } ) return True except Exception as exc: sys.stderr.write( f"WARNING: env_transition.record_applied_env: could not write to " f"DynamoDB table {TABLE_NAME} — {type(exc).__name__}: {exc}. " f"The apply succeeded but the last-applied env record was not " f"persisted. Future env-transition detection may not work.\n" ) return False def main(argv): import argparse parser = argparse.ArgumentParser(description="Nova env-transition detect/record") sub = parser.add_subparsers(dest="command", required=True) p_detect = sub.add_parser("detect", help="Detect prior env for a contract") p_detect.add_argument("--contract-id", required=True) p_detect.add_argument("--consumer-repo", required=True) p_detect.add_argument("--new-env", required=True) p_record = sub.add_parser("record", help="Record the applied env for a contract") p_record.add_argument("--contract-id", required=True) p_record.add_argument("--consumer-repo", required=True) p_record.add_argument("--env", required=True) args = parser.parse_args(argv[1:]) if args.command == "detect": prior = detect_prior_env(args.contract_id, args.consumer_repo, args.new_env) print(json.dumps({"prior_env": prior})) return 0 if prior is None else 0 elif args.command == "record": ok = record_applied_env(args.contract_id, args.consumer_repo, args.env) print(json.dumps({"recorded": ok})) return 0 if ok else 1 if __name__ == "__main__": sys.exit(main(sys.argv))