#!/usr/bin/env python3 """ Interactive CDK installer for doc_redaction. Walks through demo vs production deployment profiles, writes config/cdk_config.env and cdk.json, optionally runs cdk deploy, post-deploy Cognito callback fixups (API), and post_cdk_build_quickstart.py. When CloudFront is enabled, a one-off refresh deploy of the regional stack runs after the domain is discovered so the response-headers policy (CSP/CORS) and app config use the real *.cloudfront.net domain instead of the placeholder. Usage examples:: # Full interactive install + deploy + quickstart python cdk_install.py # Demo sandbox, config only python cdk_install.py --profile demo --config-only --yes # Production with flags (partial non-interactive) python cdk_install.py --profile production --vpc-name my-vpc \\ --cert-arn arn:aws:acm:eu-west-2:123:certificate/abc \\ --domain redaction.example.com --yes # Initial deploy using existing config, skip quickstart # (cdk_install performs initial deploys only; if a stack already exists it # aborts — remove the stack yourself first, or run post_cdk_build_quickstart.py # for post-deploy tasks that don't need a stack change) python cdk_install.py --deploy-only --skip-quickstart # Remove existing stacks first, then do a clean initial install (non-interactive) python cdk_install.py --profile headless --vpc-name my-vpc \\ --force-delete-stacks --yes # Demo with agentic redaction (AgentCore CDK two-phase deploy is automatic) python cdk_install.py --profile demo --enable-agentic --yes # AgentCore two-phase deploy, phase 2 only: create the runtime once its ECR # image exists (CodeBuild success or manual push). Uses the AgentCore API # (boto3) — no cdk deploy / no stack update. python cdk_install.py --complete-agentcore # Demo with AgentCore URL (manual CLI deploy — see agent-redact/agentcore/README.md) python cdk_install.py --profile demo --enable-agentic --agent-orchestrator agentcore \\ --agentcore-runtime-url https://your-runtime.example --yes --config-only # Fallback: Pi coding agent orchestrator inside the Express container python cdk_install.py --profile demo --enable-agentic --agent-orchestrator pi --yes --config-only # Headless batch (S3 → Lambda → one-shot ECS direct mode) python cdk_install.py --profile headless --vpc-name my-vpc --yes python cdk_install.py --profile production --headless --vpc-name my-vpc --yes # Production with agentic redaction on dedicated hostname (legacy Fargate) python cdk_install.py --profile production --enable-agentic-legacy \\ --agentic-alb-routing host --agentic-host-header agent.example.com --yes """ from __future__ import annotations import argparse import ipaddress import json import os import re import secrets import shutil import subprocess import sys from dataclasses import dataclass, field from datetime import datetime from pathlib import Path from typing import Any, Dict, List, Optional, Sequence, Tuple CDK_DIR = Path(__file__).resolve().parent REPO_ROOT = CDK_DIR.parent CONFIG_DIR = CDK_DIR / "config" ENV_PATH = CONFIG_DIR / "cdk_config.env" APP_CONFIG_ENV_PATH = CONFIG_DIR / "app_config.env" APP_CONFIG_ENV_EXAMPLE = CONFIG_DIR / "app_config.env.example" # Agent runtime config file (renamed from pi_agent.env -> agent.env). PI_AGENT_ENV_PATH = CONFIG_DIR / "agent.env" PI_AGENT_ENV_EXAMPLE = REPO_ROOT / "config" / "agent.env.example" # Legacy path kept so an existing pi_agent.env is detected/migrated if present. PI_AGENT_ENV_LEGACY_PATH = CONFIG_DIR / "pi_agent.env" AGENTIC_ALB_ROUTING_MODES = ("path", "host", "both") AGENT_ORCHESTRATOR_CHOICES = ("pi", "langgraph", "agentcore") DEFAULT_POLICY_FILE_LOCATIONS = ( "policies/textract_policy.json", "policies/comprehend_policy.json", ) PI_AGENTCORE_INVOKE_POLICY_FILE = "policies/pi_agentcore_invoke_policy.json" # Bedrock models selectable for the AgentCore runtime engine. Mirrors # BEDROCK_MODELS in agent-redact/pi/pi_agent_config.py (kept local to avoid # importing the heavy Pi agent config from the installer). The runtime's model # is fixed at creation via the AGENT_DEFAULT_MODEL env var; changing it later # requires recreating the runtime. AGENTCORE_DEFAULT_BEDROCK_MODEL = "anthropic.claude-sonnet-4-6" AGENTCORE_BEDROCK_MODEL_CHOICES = ( ("anthropic.claude-sonnet-4-6", "Anthropic Claude Sonnet 4.6"), ("amazon.nova-pro-v1:0", "Amazon Nova Pro"), ("nvidia.nemotron-super-3-120b", "NVIDIA Nemotron Super 3 120B"), ("mistral.devstral-2-123b", "Mistral Devstral 2 123B"), ) AGENTCORE_CDK_DEPLOY_SUMMARY = ( "AgentCore CDK deploy: CodeBuild pushes the runtime image to ECR (phase 1), " "then the installer creates the Bedrock runtime and sets AGENTCORE_RUNTIME_URL " "automatically (phase 2)." ) AGENTCORE_MANUAL_DEPLOY_CHECKLIST = """ AgentCore manual CLI deploy: 1. Package runtime: python agent-redact/agentcore/package_runtime.py \\ --target /app/RedactionAgent (Windows/OneDrive: set UV_LINK_MODE=copy before agentcore deploy) 2. Deploy runtime: cd && agentcore deploy 3. Copy invocationUrl from `agentcore status` (base URL, no /invocations suffix) 4. Re-run cdk_install.py --config-only with --agentcore-runtime-url or update config/agent.env + upload to S3, then restart the agentic Express service """.strip() # Kept for backwards compatibility (tests / docs may reference the name). AGENTCORE_DEPLOY_CHECKLIST = ( f"{AGENTCORE_CDK_DEPLOY_SUMMARY}\n\n{AGENTCORE_MANUAL_DEPLOY_CHECKLIST}" ) CDK_JSON_PATH = CDK_DIR / "cdk.json" CDK_JSON_EXAMPLE = CDK_DIR / "cdk.json.example" QUICKSTART_SCRIPT = CDK_DIR / "post_cdk_build_quickstart.py" REGIONAL_STACK = "RedactionStack" CLOUDFRONT_STACK = "RedactionStackCloudfront" CLOUDFRONT_STACK_REGION = "us-east-1" APPREGISTRY_STACK_SUFFIX = "AppRegistryStack" DEMO_PRESET: Dict[str, str] = { "USE_ECS_EXPRESS_MODE": "True", "ECS_EXPRESS_USE_PUBLIC_SUBNETS": "True", "ENABLE_ECS_VPC_INTERFACE_ENDPOINTS": "True", "USE_CLOUDFRONT": "True", "CLOUDFRONT_AUTH_MODE": "magic-link", "ENABLE_CLOUDFRONT_WAF": "False", # Lock the Express managed ALB SG(s) to CloudFront after deploy (boto3 quickstart step). "RESTRICT_ALB_INGRESS_TO_CLOUDFRONT": "True", "RUN_USEAST_STACK": "False", "ENABLE_RESOURCE_DELETE_PROTECTION": "False", # Default OFF: AWS is retiring creation of new AppRegistry Applications. "ENABLE_APPREGISTRY": "False", "ACM_SSL_CERTIFICATE_ARN": "", "SSL_CERTIFICATE_DOMAIN": "", } PRODUCTION_PRESET: Dict[str, str] = { "USE_ECS_EXPRESS_MODE": "False", "USE_CLOUDFRONT": "True", "CLOUDFRONT_AUTH_MODE": "cognito", "ENABLE_CLOUDFRONT_WAF": "False", "RUN_USEAST_STACK": "False", "ENABLE_RESOURCE_DELETE_PROTECTION": "True", # Default OFF: AWS is retiring creation of new AppRegistry Applications. "ENABLE_APPREGISTRY": "False", } HEADLESS_PRESET: Dict[str, str] = { "USE_ECS_EXPRESS_MODE": "False", "ECS_EXPRESS_USE_PUBLIC_SUBNETS": "True", "USE_CLOUDFRONT": "False", "RUN_USEAST_STACK": "False", "ENABLE_RESOURCE_DELETE_PROTECTION": "False", # Default OFF: AWS is retiring creation of new AppRegistry Applications. "ENABLE_APPREGISTRY": "False", "ENABLE_HEADLESS_DEPLOYMENT": "True", "ENABLE_S3_BATCH_ECS_TRIGGER": "True", "COGNITO_AUTH": "False", "ACM_SSL_CERTIFICATE_ARN": "", "SSL_CERTIFICATE_DOMAIN": "", } DEFAULT_CDK_JSON_CONTEXT: Dict[str, Any] = { "@aws-cdk/aws-ec2:restrictDefaultSecurityGroup": False, } # --------------------------------------------------------------------------- # Wizard helpers # --------------------------------------------------------------------------- def ask(prompt: str, default: str = "") -> str: suffix = f" [{default}]" if default else "" value = input(f"{prompt}{suffix}: ").strip() return value or default def ask_yes_no(prompt: str, default: bool = True) -> bool: default_label = "Y/n" if default else "y/N" while True: raw = input(f"{prompt} ({default_label}): ").strip().lower() if not raw: return default if raw in ("y", "yes"): return True if raw in ("n", "no"): return False print("Please enter y or n.") def ask_choice(prompt: str, options: Sequence[str], default_index: int = 0) -> int: print(prompt) for i, opt in enumerate(options): marker = "*" if i == default_index else " " print(f" {marker} {i + 1}) {opt}") while True: raw = input( f"Choice [1-{len(options)}] (default {default_index + 1}): " ).strip() if not raw: return default_index try: idx = int(raw) - 1 if 0 <= idx < len(options): return idx except ValueError: pass print("Invalid choice.") def timestamp() -> str: return datetime.now().strftime("%Y%m%d-%H%M%S") def backup_file(path: Path) -> Optional[Path]: if not path.is_file(): return None backup = path.with_suffix(path.suffix + f".bak.{timestamp()}") shutil.copy2(path, backup) print(f"Backed up {path.name} -> {backup.name}") return backup # --------------------------------------------------------------------------- # Python / cdk.json # --------------------------------------------------------------------------- def _venv_python_paths() -> List[Path]: candidates: List[Path] = [] virtual_env = os.environ.get("VIRTUAL_ENV", "").strip() if virtual_env: root = Path(virtual_env) if os.name == "nt": candidates.append(root / "Scripts" / "python.exe") else: candidates.append(root / "bin" / "python") rel_venvs = [ CDK_DIR / ".venv", REPO_ROOT / ".venv", REPO_ROOT.parent / ".venv", ] for venv_root in rel_venvs: if os.name == "nt": candidates.append(venv_root / "Scripts" / "python.exe") else: candidates.append(venv_root / "bin" / "python") for name in ("python3", "python"): found = shutil.which(name) if found: candidates.append(Path(found)) seen: set[str] = set() unique: List[Path] = [] for p in [Path(sys.executable)] + candidates: key = str(p.resolve()) if p.exists() else str(p) if key not in seen: seen.add(key) unique.append(p) return unique def _resolve_node_executable() -> Optional[str]: return shutil.which("node") def _jsii_import_failure_hint(stderr: str) -> str: if "JSONDecodeError" not in stderr and "Expecting value" not in stderr: return "" return ( "\nThis usually means the JSII Node.js helper process failed to start.\n" "On the deployment machine, check:\n" " 1. Node.js is installed and on PATH: node --version (LTS 18–22 recommended)\n" " 2. Reinstall CDK Python deps: pip install --force-reinstall -r requirements.txt\n" " 3. Test manually: python -c \"from aws_cdk import App; print('ok')\"\n" " 4. If still failing, run: set JSII_DEBUG=1 (then retry synth for details)" ) def verify_node_for_jsii() -> str: """Return the node executable path, or exit with guidance if missing.""" node = _resolve_node_executable() if not node: raise SystemExit( "Node.js not found on PATH. aws-cdk-lib (JSII) requires Node.js.\n" "Install Node.js LTS from https://nodejs.org/ and ensure 'node' is on PATH." ) try: result = subprocess.run( [node, "--version"], capture_output=True, text=True, timeout=10, check=False, ) except (OSError, subprocess.TimeoutExpired) as exc: raise SystemExit(f"Could not run Node.js ({node}): {exc}") from exc if result.returncode != 0: raise SystemExit( f"Node.js ({node}) returned exit code {result.returncode}.\n" f"{(result.stderr or result.stdout or '').strip()}" ) version = (result.stdout or result.stderr or "").strip() print(f"Node.js for JSII: {node} ({version})") return node def _python_has_aws_cdk(python_exe: Path) -> Tuple[bool, str]: if not python_exe.is_file(): return False, "not found" cmd = [ str(python_exe), "-c", "import aws_cdk; print(getattr(aws_cdk, '__version__', 'unknown'))", ] try: result = subprocess.run( cmd, cwd=str(CDK_DIR), capture_output=True, text=True, timeout=30, check=False, ) if result.returncode == 0: version = (result.stdout or "").strip().splitlines()[-1] return True, version detail = (result.stderr or result.stdout or "import failed").strip() hint = _jsii_import_failure_hint(detail) if hint: detail = f"{detail[:400]}{hint}" return False, detail[:800] except (OSError, subprocess.TimeoutExpired) as exc: return False, str(exc) def discover_python_candidates() -> List[Tuple[Path, str]]: found: List[Tuple[Path, str]] = [] for candidate in _venv_python_paths(): ok, detail = _python_has_aws_cdk(candidate) if ok: found.append((candidate.resolve(), detail)) return found def resolve_python_executable( override: Optional[str] = None, interactive: bool = True, assume_yes: bool = False, ) -> Path: if override: path = Path(override).resolve() ok, detail = _python_has_aws_cdk(path) if not ok: raise SystemExit( f"Python at {path} cannot import aws-cdk-lib: {detail}\n" "Install CDK deps: pip install -r requirements.txt" ) print(f"Using Python: {path} (aws-cdk-lib {detail})") return path candidates = discover_python_candidates() if not candidates: raise SystemExit( "No Python interpreter with aws-cdk-lib found.\n" "Activate your venv and run: pip install -r requirements.txt\n" "Or pass --python /path/to/python" ) if len(candidates) == 1 or assume_yes or not interactive: path, version = candidates[0] print(f"Using Python: {path} (aws-cdk-lib {version})") return path print("Python interpreters with aws-cdk-lib:") labels = [f"{p} (aws-cdk-lib {v})" for p, v in candidates] idx = ask_choice("Select Python for cdk.json", labels, default_index=0) path, version = candidates[idx] print(f"Selected: {path} (aws-cdk-lib {version})") return path def format_cdk_app_command(python_exe: Path) -> str: """Format the cdk.json ``app`` command for the current OS.""" py = str(python_exe) if os.name == "nt": py = py.replace("/", "\\") return f"{py} app.py" def load_cdk_json_context(existing_path: Path) -> Dict[str, Any]: if existing_path.is_file(): try: data = json.loads(existing_path.read_text(encoding="utf-8")) ctx = data.get("context") if isinstance(ctx, dict): return dict(ctx) except json.JSONDecodeError: pass if CDK_JSON_EXAMPLE.is_file(): try: data = json.loads(CDK_JSON_EXAMPLE.read_text(encoding="utf-8")) ctx = data.get("context") if isinstance(ctx, dict): return dict(ctx) except json.JSONDecodeError: pass return dict(DEFAULT_CDK_JSON_CONTEXT) def write_cdk_json( python_exe: Path, *, force: bool = False, skip: bool = False, ) -> Optional[Path]: if skip: if not CDK_JSON_PATH.is_file(): raise SystemExit( f"{CDK_JSON_PATH.name} not found; cannot use --skip-cdk-json." ) print(f"Keeping existing {CDK_JSON_PATH.name} (--skip-cdk-json)") return CDK_JSON_PATH if CDK_JSON_PATH.is_file() and force: backup_file(CDK_JSON_PATH) elif not CDK_JSON_PATH.is_file(): pass else: # Update app only — backup first backup_file(CDK_JSON_PATH) context = load_cdk_json_context(CDK_JSON_PATH) payload = { "app": format_cdk_app_command(python_exe), "output": "cdk.out", "context": context, } CDK_JSON_PATH.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") print(f"Wrote {CDK_JSON_PATH}") return CDK_JSON_PATH def build_cdk_subprocess_env( values: Optional[Dict[str, str]] = None, ) -> Dict[str, str]: """ Environment for child Python / CDK processes. Merges config/cdk_config.env over the current process environment so stale empty defaults from an earlier cdk_config import during the wizard do not block load_dotenv (override=False) in app.py. """ env = os.environ.copy() env["CDK_CONFIG_PATH"] = str(ENV_PATH) cdk_folder = "" if values: cdk_folder = (values.get("CDK_FOLDER") or "").strip() if not cdk_folder and ENV_PATH.is_file(): cdk_folder = (read_env_file(ENV_PATH).get("CDK_FOLDER") or "").strip() if not cdk_folder: cdk_folder = str(CDK_DIR).replace("\\", "/") + "/" env["CDK_FOLDER"] = cdk_folder file_values: Dict[str, str] = {} if ENV_PATH.is_file(): file_values = read_env_file(ENV_PATH) elif values: file_values = dict(values) for key, val in file_values.items(): env[key] = str(val) return env def apply_cdk_runtime_env(values: Dict[str, str]) -> None: """Expose written config to app.py imports (smoke test, cdk synth/deploy).""" os.environ.update(build_cdk_subprocess_env(values)) def smoke_test_python_app(python_exe: Path) -> None: verify_node_for_jsii() cmd = [str(python_exe), "-c", "import aws_cdk; import app"] print("Smoke test: import aws_cdk and app ...") result = subprocess.run( cmd, cwd=str(CDK_DIR), env=build_cdk_subprocess_env(), capture_output=True, text=True, check=False, ) if result.returncode != 0: output = (result.stderr or result.stdout or "").strip() print(output) hint = _jsii_import_failure_hint(output) if hint: print(hint) raise SystemExit( "Python smoke test failed. Fix CDK dependencies on this machine before deploying." ) def run_smoke_test_if_needed( python_exe: Optional[Path], args: argparse.Namespace, ) -> None: if args.config_only or args.skip_cdk_json or not python_exe: return smoke_test_python_app(python_exe) # --------------------------------------------------------------------------- # AWS discovery # --------------------------------------------------------------------------- def get_aws_identity(region: Optional[str] = None) -> Tuple[str, str]: import boto3 session = boto3.Session(region_name=region) sts = session.client("sts") ident = sts.get_caller_identity() account = ident["Account"] resolved_region = ( region or session.region_name or os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION") or "eu-west-2" ) return account, resolved_region def resolve_cdk_executable() -> str: """Return the AWS CDK CLI on PATH (cdk.cmd on Windows).""" for name in ("cdk", "cdk.cmd", "cdk.exe"): found = shutil.which(name) if found: return found raise SystemExit( "AWS CDK CLI not found on PATH.\n" "Install: npm install -g aws-cdk (or use npx aws-cdk)" ) def check_cdk_cli() -> str: cdk_exe = resolve_cdk_executable() result = subprocess.run( [cdk_exe, "--version"], capture_output=True, text=True, check=False, ) if result.returncode != 0: raise SystemExit( "AWS CDK CLI not found on PATH.\n" "Install: npm install -g aws-cdk (or use npx aws-cdk)" ) version = (result.stdout or result.stderr or "").strip() if version.startswith("Python "): raise SystemExit( "CDK CLI check failed: 'cdk' resolved to the Python interpreter instead " "of the AWS CDK CLI. Install aws-cdk globally (npm install -g aws-cdk) " "and ensure it is on PATH before python.exe." ) return version def cdk_bootstrap_needed(account: str, region: str) -> bool: import boto3 from botocore.exceptions import ClientError cfn = boto3.client("cloudformation", region_name=region) try: cfn.describe_stacks(StackName="CDKToolkit") return False except ClientError as exc: if "does not exist" in str(exc): return True raise def list_vpcs(region: str) -> List[Dict[str, str]]: import boto3 ec2 = boto3.client("ec2", region_name=region) response = ec2.describe_vpcs() vpcs: List[Dict[str, str]] = [] for vpc in response.get("Vpcs", []): name = "" for tag in vpc.get("Tags", []): if tag.get("Key") == "Name": name = tag.get("Value", "") break cidrs = vpc_cidr_blocks_from_describe(vpc) vpcs.append( { "id": vpc["VpcId"], "name": name or vpc["VpcId"], "cidr": cidrs[0] if cidrs else "", "cidrs": ",".join(cidrs), } ) return sorted(vpcs, key=lambda x: x["name"]) def vpc_cidr_blocks_from_describe(vpc: Dict[str, Any]) -> List[str]: """Primary and associated IPv4 CIDR blocks for one ``describe_vpcs`` entry.""" blocks: List[str] = [] seen: set[str] = set() primary = (vpc.get("CidrBlock") or "").strip() if primary and primary not in seen: seen.add(primary) blocks.append(primary) for assoc in vpc.get("CidrBlockAssociationSet") or []: cidr = (assoc.get("CidrBlock") or "").strip() if cidr and cidr not in seen: seen.add(cidr) blocks.append(cidr) return blocks def list_vpc_cidr_blocks_in_region(region: str) -> List[str]: """All IPv4 VPC CIDR blocks in the account/region (primary + associations).""" import boto3 ec2 = boto3.client("ec2", region_name=region) blocks: List[str] = [] seen: set[str] = set() for vpc in ec2.describe_vpcs().get("Vpcs", []): for cidr in vpc_cidr_blocks_from_describe(vpc): if cidr not in seen: seen.add(cidr) blocks.append(cidr) return blocks VPC_CIDR_PREFIX_LEN = 24 SUBNET_CIDR_PREFIX_LEN = 28 # ECS Express provisions a managed ALB in each public subnet (8+ free IPs required). # /28 subnets (~11 usable IPs) exhaust quickly with VPC interface endpoints and tasks. EXPRESS_PUBLIC_SUBNET_CIDR_PREFIX_LEN = 27 PUBLIC_SUBNET_CIDR_PREFIX_LEN = 26 PRIVATE_SUBNET_CIDR_PREFIX_LEN = 28 EXPRESS_ALB_MIN_SUBNET_PREFIX_LEN = 27 _VPC_CIDR_SEARCH_SUPERNETS = ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16") def parse_ipv4_networks(cidrs: Sequence[str]) -> List[ipaddress.IPv4Network]: networks: List[ipaddress.IPv4Network] = [] for cidr in cidrs: raw = (cidr or "").strip() if not raw: continue try: networks.append(ipaddress.ip_network(raw, strict=False)) except ValueError: continue return networks def suggest_vpc_cidr_block( existing_vpc_cidrs: Sequence[str], *, prefix_len: int = VPC_CIDR_PREFIX_LEN, search_supernets: Sequence[str] = _VPC_CIDR_SEARCH_SUPERNETS, ) -> str: """Return the lowest non-overlapping ``prefix_len`` block in RFC1918 space.""" occupied = parse_ipv4_networks(existing_vpc_cidrs) for supernet_cidr in search_supernets: supernet = ipaddress.ip_network(supernet_cidr, strict=False) for candidate in supernet.subnets(new_prefix=prefix_len): if any(candidate.overlaps(block) for block in occupied): continue return str(candidate) raise ValueError( f"No unused /{prefix_len} VPC CIDR found in region " f"({len(occupied)} existing block(s))." ) def validate_new_vpc_cidr_format(cidr: str) -> Optional[str]: """Return an error when ``cidr`` is not a valid AWS VPC IPv4 block.""" raw = (cidr or "").strip() if not raw: return "NEW_VPC_CIDR is required when creating a new VPC." try: network = ipaddress.ip_network(raw, strict=False) except ValueError: return f"NEW_VPC_CIDR={raw!r} is not a valid IPv4 CIDR block." if network.version != 4: return f"NEW_VPC_CIDR must be IPv4 (got {raw!r})." if network.prefixlen < 16 or network.prefixlen > 28: return ( f"NEW_VPC_CIDR={raw!r} must use a prefix length between /16 and /28 " f"(got /{network.prefixlen})." ) if not network.is_private: return ( f"NEW_VPC_CIDR={raw!r} should be in RFC1918 private space " "(10.0.0.0/8, 172.16.0.0/12, or 192.168.0.0/16)." ) return None def validate_new_vpc_cidr_no_overlap( cidr: str, existing_vpc_cidrs: Sequence[str], ) -> Optional[str]: """Return an error when ``cidr`` overlaps an existing VPC block in the region.""" format_error = validate_new_vpc_cidr_format(cidr) if format_error: return format_error candidate = ipaddress.ip_network(cidr.strip(), strict=False) for block in parse_ipv4_networks(existing_vpc_cidrs): if candidate.overlaps(block): return ( f"NEW_VPC_CIDR={candidate!s} overlaps existing VPC CIDR {block!s} " "in this account/region." ) return None def validate_new_vpc_cidr( cidr: str, existing_vpc_cidrs: Sequence[str], ) -> Optional[str]: """Validate format and regional non-overlap for a new VPC CIDR.""" return validate_new_vpc_cidr_no_overlap(cidr, existing_vpc_cidrs) def canonicalize_vpc_cidr(cidr: str) -> str: """Normalize a VPC CIDR string (e.g. 10.0.0.0/24).""" return str(ipaddress.ip_network(cidr.strip(), strict=False)) def subnet_cidr_prefix_len_for_tier(answers: "InstallAnswers", tier: str) -> int: """Return installer subnet sizing for public/private tiers and deployment mode.""" if tier == "public": if answers_use_public_subnets_only(answers): return EXPRESS_PUBLIC_SUBNET_CIDR_PREFIX_LEN return PUBLIC_SUBNET_CIDR_PREFIX_LEN return PRIVATE_SUBNET_CIDR_PREFIX_LEN def validate_public_subnet_cidr_for_express(cidr: str) -> Optional[str]: """Return an error when a public subnet is too small for Express managed ALB.""" raw = (cidr or "").strip() if not raw: return None try: network = ipaddress.ip_network(raw, strict=False) except ValueError: return f"Public subnet CIDR {raw!r} is not valid." if network.prefixlen > EXPRESS_ALB_MIN_SUBNET_PREFIX_LEN: return ( f"Public subnet {network!s} is too small for ECS Express Mode: the managed " f"ALB needs at least 8 free IP addresses per subnet. Use /" f"{EXPRESS_ALB_MIN_SUBNET_PREFIX_LEN} or larger (e.g. /27 in a /24 VPC)." ) return None def validate_new_vpc_cidr_env_values(values: Dict[str, str]) -> List[str]: """Live regional overlap check for NEW_VPC_CIDR before deploy.""" new_cidr = (values.get("NEW_VPC_CIDR") or "").strip() if not new_cidr or (values.get("VPC_NAME") or "").strip(): return [] region = (values.get("AWS_REGION") or "").strip() if not region: return ["AWS_REGION is required to validate NEW_VPC_CIDR."] occupied = list_vpc_cidr_blocks_in_region(region) err = validate_new_vpc_cidr(new_cidr, occupied) return [err] if err else [] def prompt_new_vpc_cidr(answers: "InstallAnswers", *, interactive: bool) -> None: """Fill ``answers.new_vpc_cidr`` via auto-suggest and/or manual wizard prompt.""" region = answers.aws_region occupied = list_vpc_cidr_blocks_in_region(region) if answers.new_vpc_cidr: err = validate_new_vpc_cidr(answers.new_vpc_cidr, occupied) if err: raise SystemExit(err) answers.new_vpc_cidr = canonicalize_vpc_cidr(answers.new_vpc_cidr) return auto_prompt = ( f"Auto-select lowest available /{VPC_CIDR_PREFIX_LEN} VPC CIDR " f"in {region}?" ) def _select_suggested() -> str: suggested = suggest_vpc_cidr_block(occupied) print(f"Selected VPC CIDR: {suggested}") return suggested def _prompt_manual(default_cidr: str) -> str: while True: raw = ask("New VPC CIDR", default_cidr) err = validate_new_vpc_cidr(raw, occupied) if err: print(err) continue return canonicalize_vpc_cidr(raw) if interactive: try: default_cidr = suggest_vpc_cidr_block(occupied) can_auto_select = True except ValueError as exc: print(f"Warning: {exc}") default_cidr = "" can_auto_select = False if can_auto_select and ask_yes_no(auto_prompt, default=True): answers.new_vpc_cidr = _select_suggested() else: if not default_cidr: print( f"Enter a non-overlapping RFC1918 VPC CIDR (/16–/28) for {region}." ) answers.new_vpc_cidr = _prompt_manual(default_cidr) return try: answers.new_vpc_cidr = _select_suggested() except ValueError as exc: raise SystemExit( f"Could not auto-select a VPC CIDR in {region}. " f"Pass --new-vpc-cidr with a non-overlapping RFC1918 /16–/28 block. {exc}" ) from exc def suggest_subnet_cidr_blocks( vpc_cidr: str, existing_subnet_cidrs: Sequence[str], count: int, *, prefix_len: int = SUBNET_CIDR_PREFIX_LEN, reserved_cidrs: Optional[Sequence[str]] = None, ) -> List[str]: """Return ``count`` lowest non-overlapping ``prefix_len`` blocks inside ``vpc_cidr``.""" if count < 1: return [] vpc_net = ipaddress.ip_network(vpc_cidr.strip(), strict=False) occupied = parse_ipv4_networks(existing_subnet_cidrs) if reserved_cidrs: occupied.extend(parse_ipv4_networks(reserved_cidrs)) suggestions: List[str] = [] for candidate in vpc_net.subnets(new_prefix=prefix_len): if any(candidate.overlaps(block) for block in occupied): continue suggestions.append(str(candidate)) occupied.append(candidate) if len(suggestions) >= count: return suggestions raise ValueError( f"Only {len(suggestions)} available /{prefix_len} subnet block(s) in " f"{vpc_cidr}; need {count}." ) def list_availability_zones(region: str) -> List[str]: import boto3 ec2 = boto3.client("ec2", region_name=region) zones = ec2.describe_availability_zones( Filters=[{"Name": "state", "Values": ["available"]}] ) return [z["ZoneName"] for z in zones.get("AvailabilityZones", [])] def list_subnets_in_vpc(vpc_id: str, region: str) -> List[Dict[str, str]]: sys.path.insert(0, str(CDK_DIR)) from cdk_functions import _get_existing_subnets_in_vpc data = _get_existing_subnets_in_vpc(vpc_id) subnets: List[Dict[str, str]] = [] for name, info in data.get("by_name", {}).items(): subnets.append( { "name": name, "id": info.get("id", ""), "cidr": info.get("cidr", ""), "az": info.get("az", ""), } ) return sorted(subnets, key=lambda x: x["name"]) def list_acm_certificates(region: str) -> List[Dict[str, str]]: import boto3 acm = boto3.client("acm", region_name=region) certs: List[Dict[str, str]] = [] paginator = acm.get_paginator("list_certificates") for page in paginator.paginate(CertificateStatuses=["ISSUED"]): for summary in page.get("CertificateSummaryList", []): arn = summary["CertificateArn"] detail = acm.describe_certificate(CertificateArn=arn)["Certificate"] domain = detail.get("DomainName", "") sans = detail.get("SubjectAlternativeNames", []) label = domain if sans and sans[0] != domain: label = f"{domain} (+{len(sans) - 1} SANs)" certs.append({"arn": arn, "domain": domain, "label": label}) return certs def list_igws_for_vpc(vpc_id: str, region: str) -> List[Dict[str, str]]: import boto3 ec2 = boto3.client("ec2", region_name=region) response = ec2.describe_internet_gateways( Filters=[{"Name": "attachment.vpc-id", "Values": [vpc_id]}] ) return [ {"id": igw["InternetGatewayId"]} for igw in response.get("InternetGateways", []) ] def list_albs_in_vpc(vpc_id: str, region: str) -> List[Dict[str, str]]: import boto3 elbv2 = boto3.client("elbv2", region_name=region) response = elbv2.describe_load_balancers() albs: List[Dict[str, str]] = [] for lb in response.get("LoadBalancers", []): if lb.get("VpcId") == vpc_id: albs.append( { "arn": lb["LoadBalancerArn"], "dns": lb["DNSName"], "name": lb["LoadBalancerName"], } ) return albs # --------------------------------------------------------------------------- # Env builder / writer # --------------------------------------------------------------------------- def format_list_env(values: Sequence[str], *, use_single_quotes: bool = False) -> str: if not values: return "[]" if use_single_quotes: inner = ", ".join(f"'{v}'" for v in values) else: inner = ", ".join(f'"{v}"' for v in values) return f"[{inner}]" SUBNET_TIER_MODES = ("auto", "existing", "create") def parse_subnet_name_csv(raw: str) -> List[str]: return [part.strip() for part in raw.split(",") if part.strip()] def resolve_subnet_tier_modes(args: argparse.Namespace) -> Tuple[str, str]: """CLI: --subnet-mode sets both tiers; per-tier flags override individually.""" default = getattr(args, "subnet_mode", None) or "auto" public = getattr(args, "public_subnet_mode", None) or default private = getattr(args, "private_subnet_mode", None) or default return public, private def apply_subnet_cli_flags(args: argparse.Namespace, answers: "InstallAnswers") -> None: if getattr(args, "public_subnet_names", None): answers.public_subnet_names = parse_subnet_name_csv(args.public_subnet_names) if getattr(args, "private_subnet_names", None): answers.private_subnet_names = parse_subnet_name_csv(args.private_subnet_names) def lookup_subnets_by_name( vpc_subnets: Sequence[Dict[str, str]], ) -> Dict[str, Dict[str, str]]: return {subnet["name"]: subnet for subnet in vpc_subnets if subnet.get("name")} def fill_existing_subnet_tier_metadata( names: Sequence[str], lookup: Dict[str, Dict[str, str]], *, cidrs_out: List[str], azs_out: List[str], ) -> List[str]: """Resolve CIDR/AZ for existing subnet names (order preserved).""" errors: List[str] = [] cidrs_out.clear() azs_out.clear() for name in names: info = lookup.get(name) if not info: errors.append(f"Subnet '{name}' was not found in the VPC.") continue cidr = (info.get("cidr") or "").strip() az = (info.get("az") or "").strip() if not az: errors.append(f"Subnet '{name}' has no availability zone in AWS.") continue cidrs_out.append(cidr) azs_out.append(az) return errors def enrich_existing_subnet_details_from_aws(answers: "InstallAnswers") -> List[str]: """Look up CIDR/AZ for tiers using existing named subnets (wizard or CLI).""" if answers.vpc_mode != "existing" or not answers.vpc_name: return [] tiers: List[Tuple[str, str, List[str], List[str], List[str]]] = [] if answers.public_subnet_mode == "existing" and answers.public_subnet_names: tiers.append( ( "Public", answers.public_subnet_mode, answers.public_subnet_names, answers.public_subnet_cidrs, answers.public_subnet_azs, ) ) if answers.private_subnet_mode == "existing" and answers.private_subnet_names: tiers.append( ( "Private", answers.private_subnet_mode, answers.private_subnet_names, answers.private_subnet_cidrs, answers.private_subnet_azs, ) ) if not tiers: return [] vpc_list = list_vpcs(answers.aws_region) vpc_id = next( (vpc["id"] for vpc in vpc_list if vpc["name"] == answers.vpc_name), None, ) if not vpc_id: return [f"VPC '{answers.vpc_name}' not found in {answers.aws_region}."] lookup = lookup_subnets_by_name(list_subnets_in_vpc(vpc_id, answers.aws_region)) errors: List[str] = [] for _label, _mode, names, cidrs_out, azs_out in tiers: if len(cidrs_out) == len(names) == len(azs_out) and names: continue errors.extend( fill_existing_subnet_tier_metadata( names, lookup, cidrs_out=cidrs_out, azs_out=azs_out, ) ) return errors def apply_subnet_tier_env( values: Dict[str, str], answers: "InstallAnswers", *, tier: str, mode: str, ) -> None: """Write PUBLIC_* or PRIVATE_* subnet keys for one tier (auto | existing | create).""" prefix = "PUBLIC" if tier == "public" else "PRIVATE" names = ( answers.public_subnet_names if tier == "public" else answers.private_subnet_names ) cidrs = ( answers.public_subnet_cidrs if tier == "public" else answers.private_subnet_cidrs ) azs = answers.public_subnet_azs if tier == "public" else answers.private_subnet_azs if mode == "auto": values[f"{prefix}_SUBNETS_TO_USE"] = "" values[f"{prefix}_SUBNET_CIDR_BLOCKS"] = "" values[f"{prefix}_SUBNET_AVAILABILITY_ZONES"] = "" elif mode == "existing": values[f"{prefix}_SUBNETS_TO_USE"] = format_list_env(names) if names and len(cidrs) == len(names) == len(azs): values[f"{prefix}_SUBNET_CIDR_BLOCKS"] = format_list_env( cidrs, use_single_quotes=True ) values[f"{prefix}_SUBNET_AVAILABILITY_ZONES"] = format_list_env( azs, use_single_quotes=True ) else: values[f"{prefix}_SUBNET_CIDR_BLOCKS"] = "" values[f"{prefix}_SUBNET_AVAILABILITY_ZONES"] = "" else: values[f"{prefix}_SUBNETS_TO_USE"] = format_list_env(names) values[f"{prefix}_SUBNET_CIDR_BLOCKS"] = format_list_env( cidrs, use_single_quotes=True ) values[f"{prefix}_SUBNET_AVAILABILITY_ZONES"] = format_list_env( azs, use_single_quotes=True ) def validate_subnet_answers(answers: "InstallAnswers") -> List[str]: errors: List[str] = [] if answers.vpc_mode != "existing": return errors tiers: List[tuple] = [ ( "Public", answers.public_subnet_mode, answers.public_subnet_names, answers.public_subnet_cidrs, ), ] if not answers_use_express_mode(answers) and not answers_use_public_subnets_only( answers ): tiers.append( ( "Private", answers.private_subnet_mode, answers.private_subnet_names, answers.private_subnet_cidrs, ) ) for label, mode, names, cidrs in tiers: if mode == "existing" and not names: errors.append( f"{label} subnets: provide at least one existing subnet name." ) if mode == "create" and not names: errors.append(f"{label} subnets: create mode requires subnet names.") if mode == "create" and names and cidrs and len(names) != len(cidrs): errors.append(f"{label} subnets: CIDR count must match subnet name count.") if label == "Public" and answers_use_express_mode(answers) and cidrs: for cidr in cidrs: subnet_error = validate_public_subnet_cidr_for_express(cidr) if subnet_error: errors.append(subnet_error) return errors def ask_subnet_tier_mode(tier_label: str) -> str: idx = ask_choice( f"{tier_label} subnets", [ "Auto-discover suitable subnets", "Use existing named subnets", "Create new stack-specific subnets (name + CIDR + AZ)", ], default_index=0, ) return SUBNET_TIER_MODES[idx] def configure_subnet_tier( answers: "InstallAnswers", tier: str, mode: str, vpc_subnets: List[Dict[str, str]], azs: Sequence[str], *, interactive: bool, vpc_cidr: str = "", reserved_subnet_cidrs: Optional[Sequence[str]] = None, ) -> None: """Collect subnet names/CIDRs for one tier in the wizard.""" is_public = tier == "public" label = "Public" if is_public else "Private" prefix_label = "Public" if is_public else "Private" if mode == "auto": return if mode == "existing": names = ( answers.public_subnet_names if is_public else answers.private_subnet_names ) if interactive and not names: print( f"Enter {label.lower()} subnet names (comma-separated), or pick from:" ) for subnet in vpc_subnets: print(f" - {subnet['name']} ({subnet['cidr']}, {subnet['az']})") names = parse_subnet_name_csv(ask(f"{label} subnet names", "")) if is_public: answers.public_subnet_names = names cidrs_out = answers.public_subnet_cidrs azs_out = answers.public_subnet_azs else: answers.private_subnet_names = names cidrs_out = answers.private_subnet_cidrs azs_out = answers.private_subnet_azs if names: lookup = lookup_subnets_by_name(vpc_subnets) missing = fill_existing_subnet_tier_metadata( names, lookup, cidrs_out=cidrs_out, azs_out=azs_out, ) for err in missing: print(f"Warning: {err}") return n_az = len(azs) prefix_label = "Public" if tier == "public" else "Private" prefix_len = subnet_cidr_prefix_len_for_tier(answers, tier) names = [f"{answers.cdk_prefix}{prefix_label}Subnet{i + 1}" for i in range(n_az)] if is_public: answers.public_subnet_names = names cidrs_list = answers.public_subnet_cidrs azs_list = answers.public_subnet_azs cidr_base = 0 else: answers.private_subnet_names = names cidrs_list = answers.private_subnet_cidrs azs_list = answers.private_subnet_azs cidr_base = 10 if interactive: print(f"Suggested AZs for {label.lower()} subnets: {', '.join(azs)}") auto_assign = ask_yes_no( f"Auto-assign lowest available /{prefix_len} CIDR blocks " f"for {label.lower()} subnets?", default=True, ) else: auto_assign = bool(vpc_cidr) existing_subnet_cidrs = [ subnet.get("cidr", "") for subnet in vpc_subnets if subnet.get("cidr") ] suggested: Optional[List[str]] = None if auto_assign and vpc_cidr: try: suggested = suggest_subnet_cidr_blocks( vpc_cidr, existing_subnet_cidrs, len(names), prefix_len=prefix_len, reserved_cidrs=reserved_subnet_cidrs, ) except ValueError as exc: print(f"Warning: {exc}") if not interactive: raise SystemExit(str(exc)) from exc cidrs_list.clear() azs_list.clear() for i, name in enumerate(names): if suggested: cidr = suggested[i] if interactive: print(f" {name}: {cidr}") elif interactive: default_cidr = f"10.0.{cidr_base + i}.0/{prefix_len}" if vpc_cidr: try: default_cidr = suggest_subnet_cidr_blocks( vpc_cidr, existing_subnet_cidrs + cidrs_list, 1, prefix_len=prefix_len, reserved_cidrs=reserved_subnet_cidrs, )[0] except ValueError: pass cidr = ask(f"CIDR for {label.lower()} {name}", default_cidr) else: cidr = f"10.0.{cidr_base + i}.0/{prefix_len}" cidrs_list.append(cidr) azs_list.append(azs[i % len(azs)]) @dataclass class InstallAnswers: profile: str = "demo" aws_account_id: str = "" aws_region: str = "" cdk_prefix: str = "" cognito_domain_prefix: str = "" s3_log_bucket_name: str = "" s3_output_bucket_name: str = "" s3_malware_scan_bucket_name: str = "" github_branch: str = "main" vpc_mode: str = "existing" # new | existing vpc_name: str = "" new_vpc_cidr: str = "" public_subnet_mode: str = "auto" # auto | existing | create private_subnet_mode: str = "auto" # auto | existing | create public_subnet_names: List[str] = field(default_factory=list) private_subnet_names: List[str] = field(default_factory=list) public_subnet_cidrs: List[str] = field(default_factory=list) private_subnet_cidrs: List[str] = field(default_factory=list) public_subnet_azs: List[str] = field(default_factory=list) private_subnet_azs: List[str] = field(default_factory=list) existing_igw_id: str = "" existing_alb_arn: str = "" existing_alb_dns: str = "" acm_cert_arn: str = "" ssl_domain: str = "" cloudfront_geo: str = "" enable_agentic_express: bool = False enable_agentic_legacy: bool = False enable_service_connect: bool = False enable_s3_batch: bool = False enable_headless: bool = False ecs_memory: str = "8192" ecs_cpu: str = "2048" agentic_alb_routing: str = "path" agentic_alb_path_prefix: str = "/agent" agentic_alb_host_header: str = "" agentic_alb_listener_rule_priority: str = "" agentic_gradio_port: str = "7862" sc_discovery_name: str = "redaction" pi_default_provider: str = "amazon-bedrock" agent_orchestrator: str = "pi" enable_agentcore_runtime: bool = False enable_agentcore_cdk_deploy: bool = False enable_agentcore_cdk_runtime: bool = False agentcore_runtime_url: str = "" agentcore_api_key: str = "" agentcore_bedrock_model: str = "" allow_empty_agentcore_url: bool = False write_pi_agent_env: bool = True overwrite_pi_agent_env: bool = False write_app_config_env: bool = True overwrite_app_config_env: bool = False custom_overrides: Dict[str, str] = field(default_factory=dict) python_path: Optional[str] = None @property def agentic_enabled(self) -> bool: return self.enable_agentic_express or self.enable_agentic_legacy # Cognito hosted-UI prefix domains cannot contain these substrings (AWS docs). COGNITO_DOMAIN_RESERVED_SUBSTRINGS = ("amazon", "cognito", "aws") def sanitize_cognito_domain_prefix(raw: str, *, max_length: int = 63) -> str: """Normalize a Cognito domain prefix: allowed chars, no reserved words.""" prefix = re.sub(r"[^a-z0-9-]", "-", (raw or "").lower()) for reserved in COGNITO_DOMAIN_RESERVED_SUBSTRINGS: prefix = prefix.replace(reserved, "") prefix = re.sub(r"-{2,}", "-", prefix).strip("-") if max_length > 0: prefix = prefix[:max_length].strip("-") if not prefix or not re.match(r"^[a-z0-9]", prefix): return "redaction-app" prefix = prefix.rstrip("-") if not prefix: return "redaction-app" for reserved in COGNITO_DOMAIN_RESERVED_SUBSTRINGS: if reserved in prefix: return "redaction-app" return prefix def default_cognito_domain_prefix_from_cdk_prefix(cdk_prefix: str) -> str: """Derive a short installer default from CDK_PREFIX (globally unique hint).""" slug = re.sub(r"[^a-z0-9-]", "-", (cdk_prefix or "").lower()).strip("-") return sanitize_cognito_domain_prefix(slug, max_length=20) def validate_cognito_domain_prefix(prefix: str) -> Optional[str]: """Return an error message when prefix violates Cognito domain rules.""" cleaned = (prefix or "").strip().lower() if not cleaned: return "COGNITO_USER_POOL_DOMAIN_PREFIX is required." reserved = [word for word in COGNITO_DOMAIN_RESERVED_SUBSTRINGS if word in cleaned] if reserved: return ( "COGNITO_USER_POOL_DOMAIN_PREFIX cannot contain reserved words: " + ", ".join(reserved) + "." ) if not re.match(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$", cleaned): return ( "COGNITO_USER_POOL_DOMAIN_PREFIX must use lowercase letters, " "numbers, and hyphens only." ) return None def normalize_agentic_path_prefix(raw: str) -> str: segment = (raw or "agent").strip().strip("/") return f"/{segment}" if segment else "/agent" def default_agentic_listener_priority(use_cloudfront: bool = False) -> str: """Default agentic ALB path/host rule priority (1–2 reserved for CloudFront / Express rules).""" del use_cloudfront # kept for call-site compatibility return "3" def derive_ecs_resource_names(cdk_prefix: str) -> Dict[str, str]: """ECS/CodeBuild resource names from CDK_PREFIX (matches cdk_config.py defaults).""" prefix = (cdk_prefix or "").strip() if not prefix: return {} return { "CLUSTER_NAME": f"{prefix}Cluster", "ECS_SERVICE_NAME": f"{prefix}ECSService", "ECS_EXPRESS_SERVICE_NAME": f"{prefix}ECSService", "ECS_PI_EXPRESS_SERVICE_NAME": f"{prefix}AgentExpressService", "ECS_PI_SERVICE_NAME": f"{prefix}AgentService", "COGNITO_USER_POOL_CLIENT_SECRET_NAME": f"{prefix}ParamCognitoSecret", "CODEBUILD_PROJECT_NAME": f"{prefix}CodeBuildProject", "CODEBUILD_PI_PROJECT_NAME": f"{prefix}CodeBuildAgentProject", } def derive_s3_bucket_names(cdk_prefix: str) -> Dict[str, str]: """S3 bucket names from CDK_PREFIX (must be written to cdk_config.env explicitly).""" prefix = (cdk_prefix or "").strip().lower() if not prefix: return {} return { "S3_LOG_CONFIG_BUCKET_NAME": f"{prefix}s3-logs", "S3_OUTPUT_BUCKET_NAME": f"{prefix}s3-output", "S3_MALWARE_SCAN_BUCKET_NAME": f"{prefix}s3-malware-scan", } S3_BUCKET_NAME_MAX_LEN = 63 def normalize_s3_bucket_name(raw: str) -> str: """Lowercase S3 bucket name with allowed characters only (3–63 chars).""" name = re.sub(r"[^a-z0-9.-]", "-", (raw or "").lower()) name = re.sub(r"-{2,}", "-", name).strip("-") if len(name) < 3: name = f"{name}-bucket".strip("-") return name[:S3_BUCKET_NAME_MAX_LEN] def suggest_available_s3_bucket_name( preferred: str, account_id: str, *, s3_client: Any = None, max_attempts: int = 8, ) -> str: """Return a globally available S3 bucket name, preferring ``preferred``.""" from cdk_functions import resolve_s3_bucket_availability account = re.sub(r"[^0-9]", "", account_id or "") bases = [preferred] if account: bases.extend( [ f"{account}-{preferred}", f"{preferred}-{account}", ] ) seen: set[str] = set() for base in bases: candidate = normalize_s3_bucket_name(base) if not candidate or candidate in seen: continue seen.add(candidate) status, _ = resolve_s3_bucket_availability(candidate, s3_client=s3_client) if status == "available": return candidate for _ in range(max_attempts): suffix = secrets.token_hex(3) candidate = normalize_s3_bucket_name(f"{account}-{preferred}-{suffix}") if candidate in seen: continue seen.add(candidate) status, _ = resolve_s3_bucket_availability(candidate, s3_client=s3_client) if status == "available": return candidate raise RuntimeError( f"Could not find an available S3 bucket name near {preferred!r}. " "Set S3_LOG_CONFIG_BUCKET_NAME / S3_OUTPUT_BUCKET_NAME manually." ) def suggest_available_cognito_domain_prefix( preferred: str, account_id: str, region: str, *, cognito_client: Any = None, max_attempts: int = 8, ) -> str: """Return an available Cognito hosted UI domain prefix in ``region``.""" from cdk_functions import resolve_cognito_domain_prefix_availability account = re.sub(r"[^0-9]", "", account_id or "") preferred_clean = sanitize_cognito_domain_prefix(preferred, max_length=63) candidates = [preferred_clean] if account: candidates.extend( [ sanitize_cognito_domain_prefix( f"{account}-{preferred_clean}", max_length=63 ), sanitize_cognito_domain_prefix( f"{preferred_clean}-{account[-8:]}", max_length=63 ), ] ) seen: set[str] = set() for candidate in candidates: if not candidate or candidate in seen: continue seen.add(candidate) if ( resolve_cognito_domain_prefix_availability( candidate, region_name=region, cognito_client=cognito_client ) == "available" ): return candidate for _ in range(max_attempts): suffix = secrets.token_hex(2) candidate = sanitize_cognito_domain_prefix( f"{account}-{preferred_clean}-{suffix}", max_length=63 ) if not candidate or candidate in seen: continue seen.add(candidate) if ( resolve_cognito_domain_prefix_availability( candidate, region_name=region, cognito_client=cognito_client ) == "available" ): return candidate raise RuntimeError( f"Could not find an available Cognito domain prefix near {preferred!r}. " "Set COGNITO_USER_POOL_DOMAIN_PREFIX manually." ) def _prompt_globally_unique_s3_bucket( label: str, preferred: str, account_id: str, *, interactive: bool, assume_yes: bool, cli_override: str = "", s3_client: Any = None, ) -> str: from cdk_functions import resolve_s3_bucket_availability if cli_override: name = normalize_s3_bucket_name(cli_override) status, _ = resolve_s3_bucket_availability(name, s3_client=s3_client) if status == "globally_taken": raise SystemExit( f"{label}: bucket name {name!r} is taken globally by another AWS " "account. Choose a different --s3-log-bucket / --s3-output-bucket." ) return name preferred = normalize_s3_bucket_name(preferred) status, _ = resolve_s3_bucket_availability(preferred, s3_client=s3_client) if status == "owned": print(f"{label}: using existing bucket in this account: {preferred}") return preferred if status == "available": print(f"{label}: bucket name available: {preferred}") return preferred suggested = suggest_available_s3_bucket_name( preferred, account_id, s3_client=s3_client ) print( f"{label}: S3 bucket name {preferred!r} is taken globally by another AWS " f"account (S3 names are unique worldwide)." ) if not interactive: if assume_yes: print(f"{label}: using suggested name: {suggested}") return suggested raise SystemExit( f"{label}: bucket name {preferred!r} is taken globally. " f"Re-run with --yes to accept {suggested!r}, or pass an explicit bucket name." ) if ask_yes_no(f"Use suggested bucket name {suggested!r}?", default=True): return suggested while True: custom = ask(f"{label}: enter a globally unique bucket name", suggested) custom = normalize_s3_bucket_name(custom) custom_status, _ = resolve_s3_bucket_availability(custom, s3_client=s3_client) if custom_status == "available": return custom if custom_status == "owned": print(f"Bucket {custom!r} already exists in this account; will import it.") return custom print(f"Name {custom!r} is still taken globally. Try another name.") def _prompt_globally_unique_cognito_prefix( preferred: str, account_id: str, region: str, *, interactive: bool, assume_yes: bool, cli_override: str = "", cognito_client: Any = None, ) -> str: from cdk_functions import resolve_cognito_domain_prefix_availability if cli_override: override = sanitize_cognito_domain_prefix(cli_override) validation_error = validate_cognito_domain_prefix(override) if validation_error: raise SystemExit(validation_error) if ( resolve_cognito_domain_prefix_availability( override, region_name=region, cognito_client=cognito_client ) != "available" ): raise SystemExit( f"Cognito domain prefix {override!r} is not available in {region} " "(likely taken by another AWS account). Pass a different " "--cognito-prefix." ) return override preferred = sanitize_cognito_domain_prefix(preferred) availability = resolve_cognito_domain_prefix_availability( preferred, region_name=region, cognito_client=cognito_client ) if availability == "taken": suggested = suggest_available_cognito_domain_prefix( preferred, account_id, region, cognito_client=cognito_client ) print( f"Cognito hosted UI domain prefix {preferred!r} is not available in " f"{region} (likely taken by another AWS account)." ) if not interactive: if assume_yes: print(f"Using suggested Cognito domain prefix: {suggested}") return suggested raise SystemExit( f"Cognito domain prefix {preferred!r} is not available in {region}. " f"Re-run with --yes to accept {suggested!r}, or pass --cognito-prefix." ) if ask_yes_no( f"Use suggested Cognito domain prefix {suggested!r}?", default=True ): return suggested preferred = suggested if not interactive: print(f"Cognito domain prefix available: {preferred}") return preferred while True: raw = ask( "Cognito hosted UI domain prefix " "(must be globally unique in this region; " "cannot contain aws, amazon, or cognito)", preferred, ) choice = sanitize_cognito_domain_prefix(raw) validation_error = validate_cognito_domain_prefix(choice) if validation_error: print(validation_error) continue if ( resolve_cognito_domain_prefix_availability( choice, region_name=region, cognito_client=cognito_client ) == "available" ): if choice != preferred: print(f"Using Cognito domain prefix: {choice}") else: print(f"Cognito domain prefix available: {choice}") return choice print( f"Prefix {choice!r} is not available in {region} " "(likely taken by another AWS account). Try another prefix." ) def resolve_globally_unique_install_names( answers: InstallAnswers, *, interactive: bool, assume_yes: bool, args: Optional[argparse.Namespace] = None, ) -> None: """Check S3 bucket and Cognito domain names; prompt for alternatives when taken.""" import boto3 args = args or argparse.Namespace() region = answers.aws_region account_id = answers.aws_account_id s3_client = boto3.client("s3", region_name=region) cognito_client = boto3.client("cognito-idp", region_name=region) defaults = derive_s3_bucket_names(answers.cdk_prefix) answers.s3_log_bucket_name = _prompt_globally_unique_s3_bucket( "S3 log/config bucket", defaults.get("S3_LOG_CONFIG_BUCKET_NAME", ""), account_id, interactive=interactive, assume_yes=assume_yes, cli_override=getattr(args, "s3_log_bucket", "") or "", s3_client=s3_client, ) answers.s3_output_bucket_name = _prompt_globally_unique_s3_bucket( "S3 output bucket", defaults.get("S3_OUTPUT_BUCKET_NAME", ""), account_id, interactive=interactive, assume_yes=assume_yes, cli_override=getattr(args, "s3_output_bucket", "") or "", s3_client=s3_client, ) answers.s3_malware_scan_bucket_name = _prompt_globally_unique_s3_bucket( "S3 malware scan staging bucket", defaults.get("S3_MALWARE_SCAN_BUCKET_NAME", ""), account_id, interactive=interactive, assume_yes=assume_yes, cli_override=getattr(args, "s3_malware_scan_bucket", "") or "", s3_client=s3_client, ) if answers_use_headless(answers): answers.cognito_domain_prefix = "" return preferred_cognito = default_cognito_domain_prefix_from_cdk_prefix( answers.cdk_prefix ) answers.cognito_domain_prefix = _prompt_globally_unique_cognito_prefix( preferred_cognito, account_id, region, interactive=interactive, assume_yes=assume_yes, cli_override=getattr(args, "cognito_prefix", "") or "", cognito_client=cognito_client, ) def validate_globally_unique_env_values(values: Dict[str, str]) -> List[str]: """Live AWS checks for globally unique resource names before deploy.""" from cdk_functions import ( resolve_cognito_domain_prefix_availability, resolve_s3_bucket_availability, ) errors: List[str] = [] region = (values.get("AWS_REGION") or "").strip() for env_key in ( "S3_LOG_CONFIG_BUCKET_NAME", "S3_OUTPUT_BUCKET_NAME", "S3_MALWARE_SCAN_BUCKET_NAME", ): bucket_name = (values.get(env_key) or "").strip().lower() if not bucket_name: continue status, _ = resolve_s3_bucket_availability(bucket_name) if status == "globally_taken": errors.append( f"{env_key}={bucket_name!r} is taken globally by another AWS account. " "Re-run cdk_install.py to pick a unique name." ) if values.get("ENABLE_HEADLESS_DEPLOYMENT") != "True": cognito_prefix = (values.get("COGNITO_USER_POOL_DOMAIN_PREFIX") or "").strip() if cognito_prefix and region: if ( resolve_cognito_domain_prefix_availability( cognito_prefix, region_name=region ) == "taken" ): errors.append( f"COGNITO_USER_POOL_DOMAIN_PREFIX={cognito_prefix!r} is not " f"available in {region} (taken by another AWS account or existing " "pool). Re-run cdk_install.py to pick a unique prefix." ) return errors def resolve_fixup_env_values(values: Dict[str, str]) -> Dict[str, str]: """Fill missing ECS cluster/service keys from CDK_PREFIX for post-deploy boto3 calls.""" resolved = dict(values) for key, default in derive_ecs_resource_names( resolved.get("CDK_PREFIX", "") ).items(): if not (resolved.get(key) or "").strip(): resolved[key] = default for key, default in derive_s3_bucket_names(resolved.get("CDK_PREFIX", "")).items(): if not (resolved.get(key) or "").strip(): resolved[key] = default return resolved def merge_preset( profile: str, overrides: Optional[Dict[str, str]] = None ) -> Dict[str, str]: base: Dict[str, str] = {} if profile == "demo": base.update(DEMO_PRESET) elif profile == "production": base.update(PRODUCTION_PRESET) elif profile == "headless": base.update(HEADLESS_PRESET) if overrides: base.update(overrides) return base def answers_preset_profile(answers: "InstallAnswers") -> str: """Preset profile for env defaults (legacy ``headless`` maps to demo).""" if answers.profile == "headless": return "demo" return answers.profile def answers_use_headless(answers: "InstallAnswers") -> bool: return answers.profile == "headless" or answers.enable_headless def profile_allows_headless_add_on(answers: "InstallAnswers") -> bool: """Headless batch is incompatible with the Demonstration (Express) route.""" if answers.profile == "demo": return False if answers.profile in ("production", "headless"): return True if answers.profile == "custom": return answers.custom_overrides.get("USE_ECS_EXPRESS_MODE") != "True" return False def headless_profile_error(answers: "InstallAnswers") -> Optional[str]: if not answers_use_headless(answers): return None if answers.profile == "headless": return None if answers.profile == "demo": return ( "Headless batch mode is not available with the Demonstration (Express) " "profile. Use the Headless shortcut profile, Production with headless, " "or Custom without ECS Express." ) if ( answers.profile == "custom" and answers.custom_overrides.get("USE_ECS_EXPRESS_MODE") == "True" ): return ( "Headless batch mode requires USE_ECS_EXPRESS_MODE=False. " "Disable ECS Express in the custom profile or omit --headless." ) return None def normalize_agent_orchestrator(raw: str) -> str: value = (raw or "pi").strip().lower() if value in AGENT_ORCHESTRATOR_CHOICES: return value return "pi" def normalize_agentcore_runtime_url(raw: str) -> str: """Base runtime URL only — strip trailing slash and accidental /invocations suffix.""" from cdk_functions import normalize_agentcore_runtime_url as _normalize return _normalize(raw) def normalize_agentcore_bedrock_model(raw: str) -> str: """Return a trimmed Bedrock model id, defaulting to Sonnet 4.6 when empty.""" model = (raw or "").strip() return model or AGENTCORE_DEFAULT_BEDROCK_MODEL def validate_bedrock_model_access( model_id: str, region: str ) -> Tuple[Optional[bool], str]: """ Best-effort check that the calling AWS identity can use *model_id* in *region*. Returns ``(status, message)`` where ``status`` is ``True`` (access confirmed), ``False`` (definitively no access) or ``None`` (could not determine, e.g. missing boto3, throttling, or an ambiguous error). Never raises. Strategy: query the Bedrock control-plane for foundation-model availability (entitlement + authorization) first; if that is inconclusive, fall back to a minimal ``bedrock-runtime`` converse probe (1 token) that exercises the exact model string the runtime will use. """ model_id = (model_id or "").strip() if not model_id: return None, "No Bedrock model id provided." try: import boto3 # noqa: PLC0415 from botocore.exceptions import ( # noqa: PLC0415 BotoCoreError, ClientError, ) except Exception as exc: # noqa: BLE001 return None, f"boto3 unavailable, skipping Bedrock access check ({exc})." # 1) Control-plane entitlement/authorization (does not consume tokens). try: bedrock = boto3.client("bedrock", region_name=region) avail = bedrock.get_foundation_model_availability(modelId=model_id) entitlement = (avail.get("entitlementAvailability") or "").upper() authorization = (avail.get("authorizationStatus") or "").upper() region_av = (avail.get("regionAvailability") or "").upper() if region_av and region_av != "AVAILABLE": return ( False, f"Model '{model_id}' is not available in region {region} " f"(regionAvailability={region_av}).", ) if entitlement == "AVAILABLE" and authorization in ("", "AUTHORIZED"): return True, f"Bedrock access confirmed for '{model_id}' in {region}." if entitlement == "NOT_AVAILABLE": return ( False, f"Model access for '{model_id}' is not granted in this account. " "Request access in the Bedrock console (Model access), then retry.", ) # Otherwise fall through to the runtime probe for a definitive answer. except ClientError as exc: code = exc.response.get("Error", {}).get("Code", "") if code in ("AccessDeniedException", "UnrecognizedClientException"): return ( None, "Could not check model availability (missing bedrock:GetFoundation" "ModelAvailability permission); will try a runtime probe instead.", ) # ValidationException often means the id is an inference-profile id the # control plane doesn't accept — fall through to the runtime probe. except (BotoCoreError, Exception): # noqa: BLE001 pass # 2) Runtime converse probe (consumes ~1 output token). try: rt = boto3.client("bedrock-runtime", region_name=region) rt.converse( modelId=model_id, messages=[{"role": "user", "content": [{"text": "ping"}]}], inferenceConfig={"maxTokens": 1}, ) return True, f"Bedrock access confirmed for '{model_id}' in {region}." except ClientError as exc: code = exc.response.get("Error", {}).get("Code", "") msg = exc.response.get("Error", {}).get("Message", str(exc)) if code in ("AccessDeniedException",): return ( False, f"Access denied invoking '{model_id}' in {region}. Grant model " "access in the Bedrock console and ensure the caller has " "bedrock:InvokeModel / bedrock:Converse permissions.", ) if code in ("ResourceNotFoundException",): return ( False, f"Model '{model_id}' was not found in {region}. Check the model id " "and region, or use an inference-profile id (e.g. 'us.').", ) if code in ("ValidationException",) and "inference profile" in msg.lower(): return ( None, f"Model '{model_id}' requires an inference profile id in {region} " "(e.g. prefix with 'us.'). Access could not be confirmed automatically.", ) return None, f"Could not confirm access to '{model_id}' ({code or exc})." except (BotoCoreError, Exception) as exc: # noqa: BLE001 return None, f"Could not confirm access to '{model_id}' ({exc})." def report_bedrock_model_access( model_id: str, region: str, *, interactive: bool, assume_yes: bool, ) -> bool: """ Run :func:`validate_bedrock_model_access` and surface the result. Returns ``True`` to proceed, ``False`` to abort. Confirmed access proceeds silently-ish; a definitive "no access" prompts the user (interactive) or warns loudly and proceeds (non-interactive). Inconclusive results always proceed. """ status, message = validate_bedrock_model_access(model_id, region) if status is True: print(f" [ok] {message}") return True if status is None: print(f" [warn] {message}") return True # Definitive no-access. print(f"\n [ERROR] {message}") if interactive and not assume_yes: return ask_yes_no( "Continue anyway with this model (the AgentCore runtime will fail " "until access is granted)?", default=False, ) print( " Proceeding anyway (non-interactive). Grant Bedrock model access before " "invoking the AgentCore runtime, or re-run with a different " "--agentcore-model." ) return True def finalize_agentcore_bedrock_model( answers: InstallAnswers, args: argparse.Namespace, *, interactive: bool, assume_yes: bool, ) -> None: """ Resolve the AgentCore Bedrock model and (optionally) validate account access. Interactive runs prompt for a model (default Sonnet 4.6); non-interactive runs keep any CLI/answers value or fall back to the default. Access is validated for the calling AWS identity unless ``--skip-bedrock-model-check`` was passed. """ if interactive and not assume_yes and not getattr(args, "agentcore_model", None): print("\n--- AgentCore engine model (Bedrock) ---") print( "The AgentCore runtime's model is fixed when the runtime is created and " "cannot be changed from the Gradio UI later." ) labels = [ f"{label} ({model})" for model, label in AGENTCORE_BEDROCK_MODEL_CHOICES ] labels.append("Other (enter a custom Bedrock model id)") current = normalize_agentcore_bedrock_model(answers.agentcore_bedrock_model) default_idx = next( ( i for i, (model, _) in enumerate(AGENTCORE_BEDROCK_MODEL_CHOICES) if model == current ), 0, ) idx = ask_choice( "Which Bedrock model should the AgentCore engine use?", labels, default_index=default_idx, ) if idx < len(AGENTCORE_BEDROCK_MODEL_CHOICES): answers.agentcore_bedrock_model = AGENTCORE_BEDROCK_MODEL_CHOICES[idx][0] else: answers.agentcore_bedrock_model = normalize_agentcore_bedrock_model( ask("Bedrock model id", current) ) else: answers.agentcore_bedrock_model = normalize_agentcore_bedrock_model( answers.agentcore_bedrock_model ) model = answers.agentcore_bedrock_model if getattr(args, "skip_bedrock_model_check", False): print(f" AgentCore Bedrock model: {model} (access check skipped)") return region = (answers.aws_region or "").strip() or _default_region_for_check() print(f" Checking Bedrock access for '{model}' in {region} ...") proceed = report_bedrock_model_access( model, region, interactive=interactive, assume_yes=assume_yes ) if not proceed: raise SystemExit( "Aborted: no access to the selected AgentCore Bedrock model. " "Grant model access in the Bedrock console or choose another model." ) def _default_region_for_check() -> str: """Resolve a region for the Bedrock access probe (answers/env/AWS default).""" for key in ("AWS_REGION", "AWS_DEFAULT_REGION"): val = (os.environ.get(key) or "").strip() if val: return val try: import boto3 # noqa: PLC0415 region = boto3.session.Session().region_name if region: return str(region) except Exception: # noqa: BLE001 pass return "us-east-1" def default_agent_orchestrator_for_answers(answers: "InstallAnswers") -> str: """Demo Express agent mode defaults to AgentCore; production/legacy stays on Pi.""" if answers.profile == "demo" and answers.enable_agentic_express: return "agentcore" return "pi" def apply_demo_agentcore_orchestrator_defaults( answers: "InstallAnswers", args: argparse.Namespace ) -> None: """When demo + Express agent mode without an explicit orchestrator, use AgentCore.""" if answers.profile != "demo" or not answers.enable_agentic_express: return if getattr(args, "agent_orchestrator", None): return answers.agent_orchestrator = "agentcore" answers.enable_agentcore_runtime = True if not getattr(args, "agentcore_runtime_url", None): answers.enable_agentcore_cdk_deploy = True def merge_policy_file_locations( *, agentcore_enabled: bool, existing_raw: str = "", ) -> str: """ECS task-role JSON policy paths written to POLICY_FILE_LOCATIONS in cdk_config.env.""" try: from cdk_config import parse_comma_separated_list except ImportError: parse_comma_separated_list = lambda value: [ # noqa: E731 part.strip() for part in (value or "").split(",") if part.strip() ] policies: List[str] = [] if existing_raw.strip(): policies = list(parse_comma_separated_list(existing_raw)) if not policies: policies = list(DEFAULT_POLICY_FILE_LOCATIONS) if agentcore_enabled and PI_AGENTCORE_INVOKE_POLICY_FILE not in policies: policies.append(PI_AGENTCORE_INVOKE_POLICY_FILE) return json.dumps(policies) def validate_install_answers(answers: "InstallAnswers") -> List[str]: errors: List[str] = [] msg = headless_profile_error(answers) if msg: errors.append(msg) orchestrator = normalize_agent_orchestrator(answers.agent_orchestrator) if orchestrator not in AGENT_ORCHESTRATOR_CHOICES: errors.append( "AGENT_ORCHESTRATOR must be one of " f"{list(AGENT_ORCHESTRATOR_CHOICES)}; got '{answers.agent_orchestrator}'." ) if answers.agentic_enabled and orchestrator == "agentcore": if not (answers.agentcore_runtime_url or "").strip(): if not answers.allow_empty_agentcore_url and not ( answers.enable_agentcore_cdk_deploy or answers.enable_agentcore_cdk_runtime ): errors.append( "AGENTCORE_RUNTIME_URL is required when AGENT_ORCHESTRATOR=agentcore. " "Deploy the runtime first (see agent-redact/agentcore/README.md), pass " "--agentcore-runtime-url, or use --enable-agentcore-cdk-deploy for the " "CDK-native ECR path. Non-interactive installs must include the URL or " "enable the CDK deploy flags." ) return errors def answers_use_express_mode(answers: "InstallAnswers") -> bool: """True when the selected install profile/options enable ECS Express ingress.""" if answers_use_headless(answers): return False preset = merge_preset(answers.profile, answers.custom_overrides) return preset.get("USE_ECS_EXPRESS_MODE") == "True" def answers_use_public_subnets_only(answers: "InstallAnswers") -> bool: """Express ingress or demo-style headless batch (public subnets, no private install).""" if answers_use_express_mode(answers): return True if not answers_use_headless(answers): return False return answers_preset_profile(answers) in ("headless", "demo", "custom") def build_env_values(answers: InstallAnswers) -> Dict[str, str]: cdk_folder = str(CDK_DIR).replace("\\", "/") if not cdk_folder.endswith("/"): cdk_folder += "/" s3_bucket_names = derive_s3_bucket_names(answers.cdk_prefix) if answers.s3_log_bucket_name: s3_bucket_names["S3_LOG_CONFIG_BUCKET_NAME"] = answers.s3_log_bucket_name if answers.s3_output_bucket_name: s3_bucket_names["S3_OUTPUT_BUCKET_NAME"] = answers.s3_output_bucket_name if answers.s3_malware_scan_bucket_name: s3_bucket_names["S3_MALWARE_SCAN_BUCKET_NAME"] = ( answers.s3_malware_scan_bucket_name ) values: Dict[str, str] = merge_preset( answers_preset_profile(answers), answers.custom_overrides ) values.update( { "CDK_PREFIX": answers.cdk_prefix, "AWS_REGION": answers.aws_region, "AWS_ACCOUNT_ID": answers.aws_account_id, "CDK_FOLDER": cdk_folder, **derive_ecs_resource_names(answers.cdk_prefix), **s3_bucket_names, "CONTEXT_FILE": "precheck.context.json", "COGNITO_USER_POOL_DOMAIN_PREFIX": sanitize_cognito_domain_prefix( answers.cognito_domain_prefix ), "GITHUB_REPO_BRANCH": answers.github_branch, "ECS_TASK_CPU_SIZE": answers.ecs_cpu, "ECS_TASK_MEMORY_SIZE": answers.ecs_memory, "EXISTING_IGW_ID": answers.existing_igw_id, "EXISTING_LOAD_BALANCER_ARN": answers.existing_alb_arn, "EXISTING_LOAD_BALANCER_DNS": answers.existing_alb_dns or "placeholder_load_balancer_dns.net", "ENABLE_PI_AGENT_EXPRESS_SERVICE": ( "True" if answers.enable_agentic_express else "False" ), "ENABLE_PI_AGENT_ECS_SERVICE": ( "True" if answers.enable_agentic_legacy else "False" ), "ENABLE_ECS_SERVICE_CONNECT": ( "True" if answers.enable_service_connect else "False" ), "ENABLE_S3_BATCH_ECS_TRIGGER": ( "True" if answers.enable_s3_batch or answers_use_headless(answers) else "False" ), "ENABLE_HEADLESS_DEPLOYMENT": ( "True" if answers_use_headless(answers) else "False" ), } ) if answers_use_headless(answers): values.update( { "ENABLE_HEADLESS_DEPLOYMENT": "True", "ENABLE_S3_BATCH_ECS_TRIGGER": "True", "USE_ECS_EXPRESS_MODE": "False", "USE_CLOUDFRONT": "False", "RUN_USEAST_STACK": "False", "COGNITO_AUTH": "False", "ENABLE_PI_AGENT_EXPRESS_SERVICE": "False", "ENABLE_PI_AGENT_ECS_SERVICE": "False", "ENABLE_ECS_SERVICE_CONNECT": "False", } ) use_cloudfront = values.get("USE_CLOUDFRONT") == "True" if answers.agentic_enabled: orchestrator = normalize_agent_orchestrator(answers.agent_orchestrator) agentcore_enabled = ( answers.enable_agentcore_runtime or orchestrator == "agentcore" ) values.update( { "AGENT_GRADIO_PORT": answers.agentic_gradio_port, "ECS_SERVICE_CONNECT_DISCOVERY_NAME": answers.sc_discovery_name, "AGENT_ORCHESTRATOR": orchestrator, "ENABLE_AGENTCORE_RUNTIME": "True" if agentcore_enabled else "False", } ) if answers.agentcore_runtime_url.strip(): values["AGENTCORE_RUNTIME_URL"] = normalize_agentcore_runtime_url( answers.agentcore_runtime_url ) if answers.agentcore_api_key.strip(): values["AGENTCORE_API_KEY"] = answers.agentcore_api_key.strip() if agentcore_enabled: values["AGENTCORE_BEDROCK_MODEL"] = normalize_agentcore_bedrock_model( answers.agentcore_bedrock_model ) if answers.enable_agentcore_cdk_deploy: values["AGENTCORE_CDK_DEPLOY"] = "True" values["ENABLE_AGENTCORE_RUNTIME"] = "True" if answers.enable_agentcore_cdk_runtime: values["ENABLE_AGENTCORE_CDK_RUNTIME"] = "True" values["AGENTCORE_CDK_DEPLOY"] = "True" values["ENABLE_AGENTCORE_RUNTIME"] = "True" existing_policy_raw = "" if ENV_PATH.is_file(): existing_policy_raw = ( read_env_file(ENV_PATH).get("POLICY_FILE_LOCATIONS") or "" ) values["POLICY_FILE_LOCATIONS"] = merge_policy_file_locations( agentcore_enabled=agentcore_enabled, existing_raw=existing_policy_raw, ) if answers.enable_agentic_express: values["ECS_EXPRESS_SC_PORT_NAME"] = "port-7860" values["ECS_PI_EXPRESS_SC_PORT_NAME"] = ( f"port-{answers.agentic_gradio_port}" ) else: routing = answers.agentic_alb_routing.strip().lower() path_prefix = normalize_agentic_path_prefix(answers.agentic_alb_path_prefix) priority = ( answers.agentic_alb_listener_rule_priority.strip() or default_agentic_listener_priority(use_cloudfront) ) values.update( { "AGENT_ALB_ROUTING": routing, "AGENT_ALB_PATH_PREFIX": path_prefix, "AGENT_ALB_LISTENER_RULE_PRIORITY": priority, } ) if routing in ("host", "both"): values["AGENT_ALB_HOST_HEADER"] = ( answers.agentic_alb_host_header.strip() ) if answers.profile == "production": values["ACM_SSL_CERTIFICATE_ARN"] = answers.acm_cert_arn values["SSL_CERTIFICATE_DOMAIN"] = answers.ssl_domain values["CLOUDFRONT_DOMAIN"] = "cloudfront_placeholder.net" if answers.cloudfront_geo: values["CLOUDFRONT_GEO_RESTRICTION"] = answers.cloudfront_geo if answers.vpc_mode == "new": values["VPC_NAME"] = "" values["NEW_VPC_CIDR"] = answers.new_vpc_cidr values["NEW_VPC_DEFAULT_NAME"] = f"{answers.cdk_prefix}vpc" values["PUBLIC_SUBNETS_TO_USE"] = "" values["PRIVATE_SUBNETS_TO_USE"] = "" values["PUBLIC_SUBNET_CIDR_BLOCKS"] = "" values["PRIVATE_SUBNET_CIDR_BLOCKS"] = "" values["PUBLIC_SUBNET_AVAILABILITY_ZONES"] = "" values["PRIVATE_SUBNET_AVAILABILITY_ZONES"] = "" else: values["VPC_NAME"] = answers.vpc_name values["NEW_VPC_CIDR"] = "" apply_subnet_tier_env( values, answers, tier="public", mode=answers.public_subnet_mode ) if answers_use_public_subnets_only(answers): values["ECS_EXPRESS_USE_PUBLIC_SUBNETS"] = "True" values["PRIVATE_SUBNETS_TO_USE"] = "" values["PRIVATE_SUBNET_CIDR_BLOCKS"] = "" values["PRIVATE_SUBNET_AVAILABILITY_ZONES"] = "" else: apply_subnet_tier_env( values, answers, tier="private", mode=answers.private_subnet_mode ) if values.get("ENABLE_APPREGISTRY") == "True": values["APPREGISTRY_STACK_NAME"] = ( f"{answers.cdk_prefix}{APPREGISTRY_STACK_SUFFIX}" ) return values def validate_env_values( values: Dict[str, str], *, allow_empty_agentcore_url: bool = False ) -> List[str]: errors: List[str] = [] express = values.get("USE_ECS_EXPRESS_MODE") == "True" acm = (values.get("ACM_SSL_CERTIFICATE_ARN") or "").strip() if express and acm: errors.append( "USE_ECS_EXPRESS_MODE=True cannot be used with ACM_SSL_CERTIFICATE_ARN." ) if values.get("ENABLE_ECS_SERVICE_CONNECT") == "True" and express: errors.append( "ENABLE_ECS_SERVICE_CONNECT requires legacy Fargate (not Express)." ) if values.get("ENABLE_S3_BATCH_ECS_TRIGGER") == "True" and express: errors.append( "ENABLE_S3_BATCH_ECS_TRIGGER requires legacy Fargate (not Express)." ) headless = values.get("ENABLE_HEADLESS_DEPLOYMENT") == "True" if headless: if values.get("ENABLE_S3_BATCH_ECS_TRIGGER") != "True": errors.append( "ENABLE_HEADLESS_DEPLOYMENT requires ENABLE_S3_BATCH_ECS_TRIGGER=True." ) if express: errors.append( "ENABLE_HEADLESS_DEPLOYMENT cannot be combined with USE_ECS_EXPRESS_MODE=True " "(use --profile headless, production --headless, or custom without Express)." ) if values.get("USE_CLOUDFRONT") == "True": errors.append( "ENABLE_HEADLESS_DEPLOYMENT is incompatible with USE_CLOUDFRONT=True." ) agentic_ecs = values.get("ENABLE_PI_AGENT_ECS_SERVICE") == "True" agentic_express = values.get("ENABLE_PI_AGENT_EXPRESS_SERVICE") == "True" if agentic_ecs and agentic_express: errors.append("Enable at most one agentic deployment mode (ECS or Express).") if agentic_express and not express: errors.append( "ENABLE_PI_AGENT_EXPRESS_SERVICE requires USE_ECS_EXPRESS_MODE=True." ) if agentic_ecs and express: errors.append( "ENABLE_PI_AGENT_ECS_SERVICE requires USE_ECS_EXPRESS_MODE=False." ) vpc_name = (values.get("VPC_NAME") or "").strip() new_cidr = (values.get("NEW_VPC_CIDR") or "").strip() if not vpc_name and not new_cidr: errors.append("Set VPC_NAME (existing VPC) or NEW_VPC_CIDR (new VPC).") elif new_cidr and not vpc_name: format_error = validate_new_vpc_cidr_format(new_cidr) if format_error: errors.append(format_error) if express: public_cidrs_raw = (values.get("PUBLIC_SUBNET_CIDR_BLOCKS") or "").strip() if public_cidrs_raw: try: import ast parsed = ast.literal_eval(public_cidrs_raw) public_cidrs = parsed if isinstance(parsed, list) else [str(parsed)] except (SyntaxError, ValueError): public_cidrs = [ part.strip().strip("'\"") for part in public_cidrs_raw.split(",") if part.strip() ] for cidr in public_cidrs: subnet_error = validate_public_subnet_cidr_for_express(str(cidr)) if subnet_error: errors.append(subnet_error) if values.get("USE_CLOUDFRONT") == "True" and not express: if not acm: errors.append( "Production CloudFront path requires ACM_SSL_CERTIFICATE_ARN." ) if not (values.get("SSL_CERTIFICATE_DOMAIN") or "").strip(): errors.append("Production CloudFront path requires SSL_CERTIFICATE_DOMAIN.") if not (values.get("CDK_PREFIX") or "").strip(): errors.append("CDK_PREFIX is required.") prefix_lower = (values.get("CDK_PREFIX") or "").strip().lower() for env_key, bare_suffix in ( ("S3_LOG_CONFIG_BUCKET_NAME", "s3-logs"), ("S3_OUTPUT_BUCKET_NAME", "s3-output"), ("S3_MALWARE_SCAN_BUCKET_NAME", "s3-malware-scan"), ): bucket_name = (values.get(env_key) or "").strip().lower() if prefix_lower and bucket_name == bare_suffix: errors.append( f"{env_key} must include CDK_PREFIX (got bare '{bare_suffix}'; " f"expected '{prefix_lower}{bare_suffix}'). Re-run cdk_install or set " f"{env_key}={prefix_lower}{bare_suffix} in config/cdk_config.env." ) if values.get("ENABLE_HEADLESS_DEPLOYMENT") != "True": cognito_prefix_error = validate_cognito_domain_prefix( values.get("COGNITO_USER_POOL_DOMAIN_PREFIX", "") ) if cognito_prefix_error: errors.append(cognito_prefix_error) if agentic_ecs and values.get("ENABLE_ECS_SERVICE_CONNECT") != "True": errors.append( "ENABLE_PI_AGENT_ECS_SERVICE=True requires ENABLE_ECS_SERVICE_CONNECT=True." ) orchestrator = normalize_agent_orchestrator(values.get("AGENT_ORCHESTRATOR", "pi")) if orchestrator not in AGENT_ORCHESTRATOR_CHOICES: errors.append( f"AGENT_ORCHESTRATOR must be one of {list(AGENT_ORCHESTRATOR_CHOICES)}; " f"got '{values.get('AGENT_ORCHESTRATOR')}'." ) agentcore_enabled = values.get("ENABLE_AGENTCORE_RUNTIME") == "True" if (agentic_ecs or agentic_express) and orchestrator == "agentcore": if not (values.get("AGENTCORE_RUNTIME_URL") or "").strip(): cdk_deploy = values.get("AGENTCORE_CDK_DEPLOY") == "True" if not allow_empty_agentcore_url and not cdk_deploy: errors.append( "AGENTCORE_RUNTIME_URL is required when AGENT_ORCHESTRATOR=agentcore " "unless AGENTCORE_CDK_DEPLOY=True (CDK-native ECR/runtime path)." ) if agentcore_enabled and orchestrator != "agentcore": errors.append( "ENABLE_AGENTCORE_RUNTIME=True requires AGENT_ORCHESTRATOR=agentcore." ) if headless and ( agentic_ecs or agentic_express or values.get("ENABLE_ECS_SERVICE_CONNECT") == "True" ): errors.append( "ENABLE_HEADLESS_DEPLOYMENT is incompatible with agentic redaction or Service Connect." ) agentic_routing = (values.get("AGENT_ALB_ROUTING") or "").strip().lower() if agentic_ecs: if agentic_routing not in AGENTIC_ALB_ROUTING_MODES: errors.append( f"AGENT_ALB_ROUTING must be one of {list(AGENTIC_ALB_ROUTING_MODES)}; got '{agentic_routing}'." ) elif ( agentic_routing in ("host", "both") and not (values.get("AGENT_ALB_HOST_HEADER") or "").strip() ): errors.append( "AGENT_ALB_HOST_HEADER is required when AGENT_ALB_ROUTING is 'host' or 'both'." ) return errors def build_app_config_env_values(values: Dict[str, str]) -> Dict[str, str]: """AWS deployment keys merged into config/app_config.env for ECS tasks.""" prefix_lower = (values.get("CDK_PREFIX") or "").lower() headless = values.get("ENABLE_HEADLESS_DEPLOYMENT") == "True" agentic_express = values.get("ENABLE_PI_AGENT_EXPRESS_SERVICE") == "True" def _name(env_key: str, suffix: str) -> str: return (values.get(env_key) or "").strip() or f"{prefix_lower}{suffix}" return { "COGNITO_AUTH": "False" if headless or agentic_express else "True", "RUN_AWS_FUNCTIONS": "True", "DISPLAY_FILE_NAMES_IN_LOGS": "False", "SESSION_OUTPUT_FOLDER": "True", "SAVE_LOGS_TO_DYNAMODB": "True", "SHOW_COSTS": "True", "SHOW_WHOLE_DOCUMENT_TEXTRACT_CALL_OPTIONS": "True", "LOAD_PREVIOUS_TEXTRACT_JOBS_S3": "True", "RUN_ALL_EXAMPLES_THROUGH_AWS": "True", "SHOW_EXAMPLES": "True", "CUSTOM_VLM_BACKEND": "bedrock_vlm", "INCLUDE_FACE_IDENTIFICATION_TEXTRACT_OPTION": "True", "SHOW_SUMMARISATION": "True", "DOCUMENT_REDACTION_BUCKET": _name("S3_LOG_CONFIG_BUCKET_NAME", "s3-logs"), "TEXTRACT_WHOLE_DOCUMENT_ANALYSIS_BUCKET": _name( "S3_OUTPUT_BUCKET_NAME", "s3-output" ), "SCAN_UPLOADS_FOR_MALWARE": "True", "MALWARE_SCAN_S3_BUCKET": _name( "S3_MALWARE_SCAN_BUCKET_NAME", "s3-malware-scan" ), "ACCESS_LOG_DYNAMODB_TABLE_NAME": _name( "ACCESS_LOG_DYNAMODB_TABLE_NAME", "dynamodb-access-logs" ), "FEEDBACK_LOG_DYNAMODB_TABLE_NAME": _name( "FEEDBACK_LOG_DYNAMODB_TABLE_NAME", "dynamodb-feedback-logs" ), "USAGE_LOG_DYNAMODB_TABLE_NAME": _name( "USAGE_LOG_DYNAMODB_TABLE_NAME", "dynamodb-usage-logs" ), "SAVE_OUTPUTS_TO_S3": "True" if headless else "False", "S3_OUTPUTS_BUCKET": ( _name("S3_OUTPUT_BUCKET_NAME", "s3-output") if headless else "" ), "S3_OUTPUTS_FOLDER": _name("S3_OUTPUTS_FOLDER", "") if headless else "", } def write_app_config_env_file( answers: InstallAnswers, values: Dict[str, str], *, overwrite: bool = False, ) -> Optional[Path]: if not answers.write_app_config_env: return None updates = build_app_config_env_values(values) APP_CONFIG_ENV_PATH.parent.mkdir(parents=True, exist_ok=True) if APP_CONFIG_ENV_PATH.is_file() and not overwrite: existing = read_env_file(APP_CONFIG_ENV_PATH) existing.update(updates) backup_file(APP_CONFIG_ENV_PATH) lines = [f"{k}={v}" for k, v in existing.items()] APP_CONFIG_ENV_PATH.write_text("\n".join(lines) + "\n", encoding="utf-8") print(f"Updated {APP_CONFIG_ENV_PATH} (AWS deployment keys merged)") return APP_CONFIG_ENV_PATH if APP_CONFIG_ENV_EXAMPLE.is_file() and not overwrite: if APP_CONFIG_ENV_PATH.is_file(): backup_file(APP_CONFIG_ENV_PATH) shutil.copy2(APP_CONFIG_ENV_EXAMPLE, APP_CONFIG_ENV_PATH) existing = read_env_file(APP_CONFIG_ENV_PATH) existing.update(updates) lines = [f"{k}={v}" for k, v in existing.items()] APP_CONFIG_ENV_PATH.write_text("\n".join(lines) + "\n", encoding="utf-8") print(f"Created {APP_CONFIG_ENV_PATH} from example + AWS defaults") return APP_CONFIG_ENV_PATH if APP_CONFIG_ENV_PATH.is_file(): backup_file(APP_CONFIG_ENV_PATH) lines = [ "# Generated by cdk_install.py — app runtime config for AWS ECS", *[f"{k}={v}" for k, v in updates.items()], ] APP_CONFIG_ENV_PATH.write_text("\n".join(lines) + "\n", encoding="utf-8") print(f"Wrote {APP_CONFIG_ENV_PATH}") return APP_CONFIG_ENV_PATH def resolve_doc_redaction_gradio_url(answers: InstallAnswers) -> str: """Backend URL for Pi / AgentCore runtime_config (Service Connect or main Express HTTPS).""" sc_url = f"http://{answers.sc_discovery_name}:7860" orchestrator = normalize_agent_orchestrator(answers.agent_orchestrator) if orchestrator != "agentcore": return sc_url endpoint = fetch_stack_output( REGIONAL_STACK, "ExpressServiceEndpoint", answers.aws_region ) if endpoint: from cdk_config import normalize_https_redirect_url return normalize_https_redirect_url(endpoint) return sc_url def build_pi_agent_env_values(answers: InstallAnswers) -> Dict[str, str]: """Runtime settings for the Agent Gradio app (uploaded to S3 as agent.env).""" values = { "AGENT_DEPLOYMENT_PROFILE": "aws-ecs", "AGENT_DEFAULT_PROVIDER": answers.pi_default_provider, "DOC_REDACTION_GRADIO_URL": resolve_doc_redaction_gradio_url(answers), "RUN_AWS_FUNCTIONS": "True", "AWS_REGION": answers.aws_region, "AGENT_GRADIO_PORT": answers.agentic_gradio_port, "AGENT_DEFAULT_OCR_METHOD": "AWS Textract service - all PDF types", "AGENT_DEFAULT_PII_METHOD": "AWS Comprehend", } if answers.enable_agentic_express: values["RUN_FASTAPI"] = "True" orchestrator = normalize_agent_orchestrator(answers.agent_orchestrator) values["AGENT_ORCHESTRATOR"] = orchestrator if orchestrator == "agentcore": # The AgentCore runtime uses Bedrock; pin the model so the container's # AGENT_DEFAULT_MODEL matches what the runtime was created with. values["AGENT_DEFAULT_PROVIDER"] = "amazon-bedrock" values["AGENT_DEFAULT_MODEL"] = normalize_agentcore_bedrock_model( answers.agentcore_bedrock_model ) if answers.agentcore_runtime_url.strip(): values["AGENTCORE_RUNTIME_URL"] = normalize_agentcore_runtime_url( answers.agentcore_runtime_url ) if answers.agentcore_api_key.strip(): values["AGENTCORE_API_KEY"] = answers.agentcore_api_key.strip() if answers.enable_agentic_express: return values path_prefix = normalize_agentic_path_prefix(answers.agentic_alb_path_prefix) if answers.agentic_alb_routing.strip().lower() in ("path", "both"): # The agent app only consumes AGENT_ROOT_PATH; ROOT_PATH / FASTAPI_ROOT_PATH # are the main redaction app's variables and must not be set here. values["AGENT_ROOT_PATH"] = path_prefix return values def write_pi_agent_env_file( answers: InstallAnswers, *, overwrite: bool = False, ) -> Optional[Path]: if not answers.agentic_enabled or not answers.write_pi_agent_env: return None updates = build_pi_agent_env_values(answers) PI_AGENT_ENV_PATH.parent.mkdir(parents=True, exist_ok=True) # Prefer agent.env; migrate an existing legacy pi_agent.env when agent.env is absent. existing_source = None if PI_AGENT_ENV_PATH.is_file(): existing_source = PI_AGENT_ENV_PATH elif PI_AGENT_ENV_LEGACY_PATH.is_file(): existing_source = PI_AGENT_ENV_LEGACY_PATH if existing_source is not None and not overwrite: existing = read_env_file(existing_source) existing.update(updates) backup_file(PI_AGENT_ENV_PATH) if PI_AGENT_ENV_PATH.is_file() else None lines = [f"{k}={v}" for k, v in existing.items()] PI_AGENT_ENV_PATH.write_text("\n".join(lines) + "\n", encoding="utf-8") note = ( " (migrated from pi_agent.env)" if existing_source is PI_AGENT_ENV_LEGACY_PATH else "" ) print(f"Updated {PI_AGENT_ENV_PATH} (AWS agentic runtime keys merged){note}") return PI_AGENT_ENV_PATH if PI_AGENT_ENV_EXAMPLE.is_file() and not overwrite: backup_file(PI_AGENT_ENV_PATH) if PI_AGENT_ENV_PATH.is_file() else None shutil.copy2(PI_AGENT_ENV_EXAMPLE, PI_AGENT_ENV_PATH) existing = read_env_file(PI_AGENT_ENV_PATH) existing.update(updates) lines = [f"{k}={v}" for k, v in existing.items()] PI_AGENT_ENV_PATH.write_text("\n".join(lines) + "\n", encoding="utf-8") print(f"Created {PI_AGENT_ENV_PATH} from example + AWS defaults") return PI_AGENT_ENV_PATH backup_file(PI_AGENT_ENV_PATH) if PI_AGENT_ENV_PATH.is_file() else None lines = [ "# Generated by cdk_install.py — agentic runtime config for AWS ECS (agent.env)", *[f"{k}={v}" for k, v in updates.items()], ] PI_AGENT_ENV_PATH.write_text("\n".join(lines) + "\n", encoding="utf-8") print(f"Wrote {PI_AGENT_ENV_PATH}") return PI_AGENT_ENV_PATH def write_env_file(path: Path, values: Dict[str, str]) -> Path: backup_file(path) lines = [f"{key}={val}" for key, val in values.items()] path.parent.mkdir(parents=True, exist_ok=True) path.write_text("\n".join(lines) + "\n", encoding="utf-8") return path def read_env_file(path: Path) -> Dict[str, str]: values: Dict[str, str] = {} if not path.is_file(): return values for line in path.read_text(encoding="utf-8").splitlines(): line = line.strip() if not line or line.startswith("#"): continue if "=" in line: key, _, val = line.partition("=") values[key.strip()] = val.strip() return values def patch_env_file(path: Path, updates: Dict[str, str]) -> None: values = read_env_file(path) values.update(updates) write_env_file(path, values) def print_summary(values: Dict[str, str], python_exe: Optional[Path] = None) -> None: keys = [ "CDK_PREFIX", "AWS_REGION", "AWS_ACCOUNT_ID", "USE_ECS_EXPRESS_MODE", "USE_CLOUDFRONT", "ENABLE_HEADLESS_DEPLOYMENT", "ENABLE_S3_BATCH_ECS_TRIGGER", "ENABLE_RESOURCE_DELETE_PROTECTION", "ENABLE_APPREGISTRY", "VPC_NAME", "NEW_VPC_CIDR", "ACM_SSL_CERTIFICATE_ARN", "SSL_CERTIFICATE_DOMAIN", "ENABLE_PI_AGENT_EXPRESS_SERVICE", "ENABLE_PI_AGENT_ECS_SERVICE", "AGENT_ALB_ROUTING", "AGENT_ALB_PATH_PREFIX", "AGENT_ALB_HOST_HEADER", ] print("\n--- Configuration summary ---") for key in keys: if key in values: print(f" {key}={values[key]}") if python_exe: print(f" cdk.json app={format_cdk_app_command(python_exe)}") print("----------------------------\n") # --------------------------------------------------------------------------- # Deploy runner # --------------------------------------------------------------------------- def _deploy_env() -> Dict[str, str]: return build_cdk_subprocess_env() def run_cdk_command( args: List[str], *, check: bool = True ) -> subprocess.CompletedProcess: cdk_exe = resolve_cdk_executable() cmd = [cdk_exe, *args] print(f"Running: {' '.join(cmd)}") return subprocess.run( cmd, cwd=str(CDK_DIR), env=_deploy_env(), check=check, ) @dataclass(frozen=True) class ExistingStack: name: str region: str status: str termination_protection: bool = False def _should_check_cloudfront_stack( config_values: Optional[Dict[str, str]] = None, ) -> bool: """Skip legacy us-east-1 CloudFront stack lookup (CloudFront is in RedactionStack).""" if not config_values: return False run_useast = config_values.get("RUN_USEAST_STACK") return run_useast == "True" def _stack_check_skippable_error(exc: Exception) -> bool: """Permission / SCP errors in one region must not block checks in others.""" from botocore.exceptions import ClientError if not isinstance(exc, ClientError): return False code = exc.response.get("Error", {}).get("Code", "") return code in ( "AccessDenied", "UnauthorizedOperation", "AuthorizationError", "AccessDeniedException", ) def derived_appregistry_stack_name( config_values: Optional[Dict[str, str]] = None, ) -> Optional[str]: """Resolve AppRegistry stack name from config (explicit or CDK_PREFIX default).""" if not config_values: return None explicit = (config_values.get("APPREGISTRY_STACK_NAME") or "").strip() if explicit: return explicit prefix = (config_values.get("CDK_PREFIX") or "").strip() if prefix: return f"{prefix}{APPREGISTRY_STACK_SUFFIX}" return None def list_regional_appregistry_stack_names(region: str) -> List[str]: """List active AppRegistry stacks in the regional account (suffix match).""" import boto3 from botocore.exceptions import ClientError cfn = boto3.client("cloudformation", region_name=region) skip_statuses = {"DELETE_COMPLETE", "DELETE_IN_PROGRESS"} names: List[str] = [] try: paginator = cfn.get_paginator("list_stacks") for page in paginator.paginate(): for summary in page.get("StackSummaries", []): name = summary.get("StackName", "") status = summary.get("StackStatus", "") if not name.endswith(APPREGISTRY_STACK_SUFFIX): continue if status in skip_statuses: continue names.append(name) except ClientError as exc: if _stack_check_skippable_error(exc): code = exc.response.get("Error", {}).get("Code", "ClientError") print( f"Warning: could not list AppRegistry stacks in {region} " f"({code}). Continuing with configured stack names only." ) return [] raise return sorted(dict.fromkeys(names)) def appregistry_stack_names_to_check( config_values: Optional[Dict[str, str]] = None, *, discovered_names: Optional[Sequence[str]] = None, ) -> List[str]: """Merge configured and discovered AppRegistry stack names (deduped).""" names: List[str] = [] derived = derived_appregistry_stack_name(config_values) if derived: names.append(derived) for name in discovered_names or (): if name not in names: names.append(name) return names def stacks_to_check( regional_region: str, config_values: Optional[Dict[str, str]] = None, *, discovered_appregistry_names: Optional[Sequence[str]] = None, ) -> List[Tuple[str, str]]: """Return (stack_name, region) pairs in safe deletion order.""" checks: List[Tuple[str, str]] = [] if _should_check_cloudfront_stack(config_values): checks.append((CLOUDFRONT_STACK, CLOUDFRONT_STACK_REGION)) for appregistry_name in appregistry_stack_names_to_check( config_values, discovered_names=discovered_appregistry_names, ): checks.append((appregistry_name, regional_region)) checks.append((REGIONAL_STACK, regional_region)) return checks def describe_existing_stack(stack_name: str, region: str) -> Optional[ExistingStack]: import boto3 from botocore.exceptions import ClientError cfn = boto3.client("cloudformation", region_name=region) try: response = cfn.describe_stacks(StackName=stack_name) except ClientError as exc: code = exc.response.get("Error", {}).get("Code", "") if code in ("ValidationError", "ResourceNotFoundException") or ( "does not exist" in str(exc) ): return None raise stack = response["Stacks"][0] status = stack.get("StackStatus", "") if status == "DELETE_COMPLETE": return None return ExistingStack( name=stack_name, region=region, status=status, termination_protection=bool(stack.get("EnableTerminationProtection", False)), ) def discover_existing_doc_redaction_stacks( regional_region: str, config_values: Optional[Dict[str, str]] = None, ) -> List[ExistingStack]: """Find deployed doc_redaction CloudFormation stacks in the target account.""" from botocore.exceptions import ClientError discovered_appregistry = list_regional_appregistry_stack_names(regional_region) checks = stacks_to_check( regional_region, config_values, discovered_appregistry_names=discovered_appregistry, ) ordered: List[ExistingStack] = [] for stack_name, region in checks: try: info = describe_existing_stack(stack_name, region) except ClientError as exc: if _stack_check_skippable_error(exc): code = exc.response.get("Error", {}).get("Code", "ClientError") print( f"Warning: could not check stack {stack_name!r} in {region} " f"({code}). Continuing with other regions." ) continue raise if info: ordered.append(info) return ordered def force_delete_cloudformation_stacks( stacks: Sequence[ExistingStack], *, wait: bool = True, ) -> None: """Delete stacks via CloudFormation (disables termination protection first).""" import boto3 from botocore.exceptions import ClientError clients: Dict[str, Any] = {} def cfn_for(region: str): if region not in clients: clients[region] = boto3.client("cloudformation", region_name=region) return clients[region] for stack in stacks: cfn = cfn_for(stack.region) if stack.termination_protection: print(f"Disabling termination protection on {stack.name} ({stack.region})") cfn.update_termination_protection( EnableTerminationProtection=False, StackName=stack.name, ) if stack.status == "DELETE_IN_PROGRESS": print(f"{stack.name} ({stack.region}) is already deleting.") else: print(f"Deleting stack {stack.name} ({stack.region}) ...") try: cfn.delete_stack(StackName=stack.name) except ClientError as exc: code = exc.response.get("Error", {}).get("Code", "") if code == "ValidationError" and "DELETE_IN_PROGRESS" in str(exc): print(f"{stack.name} is already deleting.") else: raise if not wait: return for stack in stacks: cfn = cfn_for(stack.region) print(f"Waiting for {stack.name} ({stack.region}) to finish deleting ...") try: cfn.get_waiter("stack_delete_complete").wait( StackName=stack.name, WaiterConfig={"Delay": 15, "MaxAttempts": 120}, ) except ClientError: detail = describe_existing_stack(stack.name, stack.region) status = detail.status if detail else "unknown" raise SystemExit( f"Stack {stack.name} ({stack.region}) did not delete cleanly " f"(status={status}). Resolve in the CloudFormation console and retry." ) from None def handle_existing_stacks_at_start( args: argparse.Namespace, regional_region: str, *, config_values: Optional[Dict[str, str]] = None, ) -> None: """At wizard start: report existing stacks and optionally force-delete them.""" if getattr(args, "skip_stack_check", False) or args.config_only or args.synth_only: return if config_values is None and ENV_PATH.is_file(): config_values = read_env_file(ENV_PATH) try: existing = discover_existing_doc_redaction_stacks( regional_region, config_values ) except Exception as exc: print(f"Existing stack check skipped: {exc}") return if not existing: return print("\n--- Existing doc_redaction CloudFormation stacks ---") for stack in existing: line = f" {stack.name} ({stack.region}): {stack.status}" if stack.termination_protection: line += " [termination protection ON]" print(line) # cdk_install performs INITIAL deploys only. It never updates an existing # stack in place (repeated in-place updates caused imported-resource drift, # e.g. deleted CodeBuild roles/projects) and never interactively offers to # delete a stack. If the caller explicitly passes --force-delete-stacks we # honour it; otherwise abort with guidance so the operator can tear the # stack(s) down themselves and re-run for a clean initial deploy. if args.force_delete_stacks: force_delete_cloudformation_stacks(existing) print("Existing stacks deleted.\n") return raise SystemExit( "\ncdk_install only performs initial deploys; it will not update the " "existing stack(s) above in place. To deploy cleanly, remove the " "stack(s) yourself (CloudFormation console or your own tooling) and " "re-run cdk_install.py. For post-deploy tasks that don't require a " "stack change (CodeBuild images, ECS scaling, Cognito callback and " "Service Connect fixups), run post_cdk_build_quickstart.py instead." ) def fetch_stack_output( stack_name: str, output_key: str, region: str, ) -> Optional[str]: import boto3 from botocore.exceptions import ClientError cfn = boto3.client("cloudformation", region_name=region) try: response = cfn.describe_stacks(StackName=stack_name) except ClientError: return None for stack in response.get("Stacks", []): for output in stack.get("Outputs", []): if output.get("OutputKey") == output_key: return output.get("OutputValue") return None def apply_post_deploy_fixup(values: Dict[str, str], assume_yes: bool) -> bool: """Return True if post-deploy fixup changed env and/or Cognito callbacks.""" from cdk_post_deploy import ( apply_cognito_alb_callback_fixup, cognito_alb_callbacks_need_update, ) if values.get("ENABLE_HEADLESS_DEPLOYMENT") == "True": return False region = values.get("AWS_REGION", "") express = values.get("USE_ECS_EXPRESS_MODE") == "True" cloudfront = values.get("USE_CLOUDFRONT") == "True" fixup_applied = False pool_id = fetch_stack_output(REGIONAL_STACK, "CognitoPoolId", region) client_id = fetch_stack_output(REGIONAL_STACK, "CognitoAppClientId", region) if not pool_id or not client_id: print( "CognitoPoolId or CognitoAppClientId stack output missing; " "skipping Cognito callback fixup." ) return False if express and not cloudfront: from cdk_config import normalize_https_redirect_url endpoint = fetch_stack_output(REGIONAL_STACK, "ExpressServiceEndpoint", region) if not endpoint: print("No ExpressServiceEndpoint output found; skipping Cognito URL fixup.") return False endpoint = normalize_https_redirect_url(endpoint) current = (values.get("ECS_EXPRESS_COGNITO_REDIRECT_BASE") or "").strip() env_needs_patch = current != endpoint cognito_needs_update = cognito_alb_callbacks_need_update( pool_id, client_id, endpoint, aws_region=region ) if not env_needs_patch and not cognito_needs_update: print("Express Cognito redirect base and callback URLs already set.") else: if env_needs_patch: print(f"Setting ECS_EXPRESS_COGNITO_REDIRECT_BASE={endpoint}") patch_env_file( ENV_PATH, { "ECS_EXPRESS_COGNITO_REDIRECT_BASE": endpoint, "COGNITO_REDIRECTION_URL": endpoint, }, ) fixup_applied = True if cognito_needs_update and ( assume_yes or ask_yes_no( "Update Cognito app client callback URLs for the Express endpoint " "(no CDK redeploy)?", True, ) ): if apply_cognito_alb_callback_fixup( user_pool_id=pool_id, client_id=client_id, redirect_base=endpoint, aws_region=region, ): fixup_applied = True elif env_needs_patch and cognito_needs_update: print( "Env updated with Express URL; Cognito callbacks unchanged. " "ALB login will fail until callback URLs are updated." ) elif cloudfront: cf_domain = fetch_stack_output( REGIONAL_STACK, "CloudFrontDistributionURL", region ) if not cf_domain: print( "No CloudFrontDistributionURL output found; skipping CloudFront fixup." ) return fixup_applied redirect_base = f"https://{cf_domain.strip()}" current = (values.get("CLOUDFRONT_DOMAIN") or "").strip() env_needs_patch = current != cf_domain.strip() cognito_needs_update = cognito_alb_callbacks_need_update( pool_id, client_id, redirect_base, aws_region=region ) if not env_needs_patch and not cognito_needs_update: print("CloudFront domain and Cognito callback URLs already set.") return fixup_applied if env_needs_patch: print(f"Setting CLOUDFRONT_DOMAIN={cf_domain}") patch_env_file( ENV_PATH, { "CLOUDFRONT_DOMAIN": cf_domain, "COGNITO_REDIRECTION_URL": redirect_base, }, ) fixup_applied = True if cognito_needs_update and ( assume_yes or ask_yes_no( "Update Cognito app client callback URLs for the CloudFront domain " "(no CDK redeploy)?", True, ) ): if apply_cognito_alb_callback_fixup( user_pool_id=pool_id, client_id=client_id, redirect_base=redirect_base, aws_region=region, ): fixup_applied = True elif env_needs_patch and cognito_needs_update: print( "Env updated with CloudFront domain; Cognito callbacks unchanged. " "ALB login will fail until callback URLs are updated." ) if express: from cdk_post_deploy import apply_cognito_secret_fixup_from_stack resolved = resolve_fixup_env_values(values) cluster_name = resolved["CLUSTER_NAME"] main_service = resolved["ECS_EXPRESS_SERVICE_NAME"] secret_name = resolved.get("COGNITO_USER_POOL_CLIENT_SECRET_NAME", "") if not main_service: print( "ECS express service name could not be resolved; " "skipping Express Cognito secret sync." ) return fixup_applied try: if pool_id and client_id: if apply_cognito_secret_fixup_from_stack( stack_name=REGIONAL_STACK, secret_name=secret_name, cluster_name=cluster_name, main_service_name=main_service, aws_region=region, recycle_tasks=False, ): fixup_applied = True except Exception as exc: print(f"Warning: could not sync Express Cognito secret: {exc}") return fixup_applied def run_quickstart(python_exe: Path) -> None: if not QUICKSTART_SCRIPT.is_file(): raise SystemExit(f"Quickstart script not found: {QUICKSTART_SCRIPT}") cmd = [str(python_exe), str(QUICKSTART_SCRIPT)] print(f"Running: {' '.join(cmd)}") subprocess.run(cmd, cwd=str(CDK_DIR), env=_deploy_env(), check=True) def maybe_complete_agentcore_cdk_deploy( values: Dict[str, str], args: argparse.Namespace, *, assume_yes: bool, ) -> None: """Phase 2: create the AgentCore runtime once its ECR image exists, then wire URL. Uses the bedrock-agentcore-control API (boto3) rather than a second ``cdk deploy``. A second deploy of RedactionStack re-runs the precheck and flips the stack's own managed roles/buckets/pool to imports, deleting them (removal policy DESTROY on the demo profile) — so the runtime is created out-of-band to avoid that footgun. """ if values.get("AGENTCORE_CDK_DEPLOY") != "True": return if (values.get("AGENTCORE_RUNTIME_URL") or "").strip(): # Already wired in a previous run; nothing to do. return print( "\n--- AgentCore phase 2 (create runtime from ECR image, no stack update) ---" ) try: from cdk_config import ( CODEBUILD_AGENTCORE_PROJECT_NAME, ECR_AGENTCORE_REPO_NAME, ) from cdk_post_deploy import ( create_agentcore_runtime_from_ecr, wait_for_agentcore_ecr_image, ) except ImportError as exc: print(f"Skipping AgentCore phase 2: {exc}") return build_id = (values.get("AGENTCORE_LAST_CODEBUILD_ID") or "").strip() or None image_ready = wait_for_agentcore_ecr_image( repository_name=ECR_AGENTCORE_REPO_NAME, codebuild_project=CODEBUILD_AGENTCORE_PROJECT_NAME, build_id=build_id, ) if not image_ready: print( "\nAgentCore ECR image is not ready yet. Once the image is in ECR " "(CodeBuild succeeds or you push it manually), complete phase 2 with:\n" " python cdk_install.py --complete-agentcore" ) return if not assume_yes and values.get("USE_ECS_EXPRESS_MODE") != "True": proceed = ask_yes_no( "Create the Bedrock AgentCore Runtime from the ECR image now?", default=True, ) if not proceed: print( "Skipped AgentCore runtime creation. When the image is in ECR, " "complete phase 2 with:\n" " python cdk_install.py --complete-agentcore" ) return if args.config_only: print( "Config-only run: AgentCore runtime not created. Complete phase 2 with:\n" " python cdk_install.py --complete-agentcore" ) return create_agentcore_runtime_from_ecr(stack_name=REGIONAL_STACK) # --------------------------------------------------------------------------- # Agentic redaction configuration # --------------------------------------------------------------------------- def apply_agent_orchestrator_cli_flags( args: argparse.Namespace, answers: InstallAnswers ) -> None: if getattr(args, "agent_orchestrator", None): answers.agent_orchestrator = normalize_agent_orchestrator( args.agent_orchestrator ) if getattr(args, "enable_agentcore_runtime", False): answers.enable_agentcore_runtime = True answers.agent_orchestrator = "agentcore" if getattr(args, "enable_agentcore_cdk_deploy", False): answers.enable_agentcore_cdk_deploy = True answers.enable_agentcore_runtime = True answers.agent_orchestrator = "agentcore" answers.allow_empty_agentcore_url = True if getattr(args, "enable_agentcore_cdk_runtime", False): answers.enable_agentcore_cdk_runtime = True answers.enable_agentcore_cdk_deploy = True answers.enable_agentcore_runtime = True answers.agent_orchestrator = "agentcore" answers.allow_empty_agentcore_url = True if getattr(args, "agentcore_runtime_url", None): answers.agentcore_runtime_url = normalize_agentcore_runtime_url( str(args.agentcore_runtime_url) ) if getattr(args, "agentcore_api_key", None): answers.agentcore_api_key = str(args.agentcore_api_key).strip() if getattr(args, "agentcore_model", None): answers.agentcore_bedrock_model = normalize_agentcore_bedrock_model( str(args.agentcore_model) ) def configure_agent_orchestrator_options( answers: InstallAnswers, args: argparse.Namespace, *, interactive: bool, assume_yes: bool, ) -> None: """Prompt for Pi vs LangGraph vs AgentCore orchestration when agentic redaction is enabled.""" if not answers.agentic_enabled: return apply_agent_orchestrator_cli_flags(args, answers) apply_demo_agentcore_orchestrator_defaults(answers, args) if not interactive or assume_yes: if answers.enable_agentcore_runtime: answers.agent_orchestrator = "agentcore" elif ( answers.profile == "demo" and answers.enable_agentic_express and not getattr(args, "agent_orchestrator", None) ): answers.agent_orchestrator = "agentcore" answers.enable_agentcore_runtime = True if ( answers.agent_orchestrator == "agentcore" and not (answers.agentcore_runtime_url or "").strip() and ( answers.enable_agentcore_cdk_deploy or ( answers.profile == "demo" and not getattr(args, "agentcore_runtime_url", None) ) ) ): answers.enable_agentcore_cdk_deploy = True answers.allow_empty_agentcore_url = True if answers.agent_orchestrator == "agentcore": finalize_agentcore_bedrock_model( answers, args, interactive=False, assume_yes=assume_yes ) return if args.agent_orchestrator: if answers.agent_orchestrator == "agentcore": finalize_agentcore_bedrock_model( answers, args, interactive=interactive, assume_yes=assume_yes ) return demo_default_idx = 2 if answers.profile == "demo" else 2 print("\n--- Agent orchestration backend ---") idx = ask_choice( "Which backend should power the agentic Gradio UI?", [ "Pi coding agent (bash + skills; most open with tools and file access in container)", "LangGraph (curated Python tools only; no shell; less agent freedom but more secure)", "Bedrock AgentCore (demo default — Gradio proxies to AgentCore runtime URL; most secure)", ], default_index=demo_default_idx, ) answers.agent_orchestrator = AGENT_ORCHESTRATOR_CHOICES[idx] if answers.agent_orchestrator == "agentcore": answers.enable_agentcore_runtime = True finalize_agentcore_bedrock_model( answers, args, interactive=True, assume_yes=assume_yes ) use_cdk_native = False if getattr(args, "enable_agentcore_cdk_deploy", False) or getattr( args, "enable_agentcore_cdk_runtime", False ): use_cdk_native = True elif not getattr(args, "agentcore_runtime_url", None): if answers.profile == "demo": use_cdk_native = True print("\n--- AgentCore runtime (demo) ---") print(AGENTCORE_CDK_DEPLOY_SUMMARY) else: print("\n--- AgentCore runtime deployment ---") use_cdk_native = ask_yes_no( "Deploy the AgentCore runtime via CDK (CodeBuild -> ECR -> Runtime)? " "(recommended; no local agentcore CLI required)", default=False, ) if use_cdk_native: answers.enable_agentcore_cdk_deploy = True answers.allow_empty_agentcore_url = True if getattr(args, "enable_agentcore_cdk_runtime", False): answers.enable_agentcore_cdk_runtime = True else: answers.agentcore_runtime_url = normalize_agentcore_runtime_url( ask( "AgentCore runtime URL (from `agentcore status` invocationUrl — " "base URL only; do not include /invocations or a trailing slash)", answers.agentcore_runtime_url, ) ) if not answers.agentcore_runtime_url: print( "\nNo runtime URL yet — CDK can proceed, but the agent UI will not work " "until you deploy the runtime and set AGENTCORE_RUNTIME_URL." ) print(AGENTCORE_MANUAL_DEPLOY_CHECKLIST) if ask_yes_no( "Continue without AGENTCORE_RUNTIME_URL for now?", default=answers.profile == "demo", ): answers.allow_empty_agentcore_url = True print( "On AWS ECS the task role uses IAM SigV4 for AgentCore — " "AGENTCORE_API_KEY is usually not needed unless the runtime uses " "CUSTOM_JWT bearer auth." ) if ask_yes_no( "Set AGENTCORE_API_KEY bearer token in config?", default=bool(answers.agentcore_api_key), ): answers.agentcore_api_key = ask( "AgentCore API key (paste token; not auto-generated — leave blank to skip)", answers.agentcore_api_key, ).strip() elif answers.agent_orchestrator == "langgraph": print( "LangGraph: orchestration runs inside the agent container using curated tools " "(AGENT_DEFAULT_PROVIDER / Bedrock still applies for the LLM)." ) def apply_agentic_cli_flags(args: argparse.Namespace, answers: InstallAnswers) -> None: apply_agent_orchestrator_cli_flags(args, answers) if getattr(args, "enable_agentic", False): preset = merge_preset(answers.profile, answers.custom_overrides) if preset.get("USE_ECS_EXPRESS_MODE") == "True": answers.enable_agentic_express = True else: answers.enable_agentic_legacy = True answers.enable_service_connect = True if getattr(args, "enable_agentic_express", False): answers.enable_agentic_express = True if getattr(args, "enable_agentic_legacy", False): answers.enable_agentic_legacy = True answers.enable_service_connect = True apply_demo_agentcore_orchestrator_defaults(answers, args) if getattr(args, "agentic_alb_routing", None): answers.agentic_alb_routing = args.agentic_alb_routing if getattr(args, "agentic_path_prefix", None): answers.agentic_alb_path_prefix = args.agentic_path_prefix if getattr(args, "agentic_host_header", None): answers.agentic_alb_host_header = args.agentic_host_header if getattr(args, "agentic_listener_priority", None): answers.agentic_alb_listener_rule_priority = args.agentic_listener_priority if getattr(args, "agentic_gradio_port", None): answers.agentic_gradio_port = args.agentic_gradio_port if getattr(args, "sc_discovery_name", None): answers.sc_discovery_name = args.sc_discovery_name if getattr(args, "pi_provider", None): answers.pi_default_provider = args.pi_provider if getattr(args, "skip_pi_agent_env", False): answers.write_pi_agent_env = False def configure_app_config_options( answers: InstallAnswers, args: argparse.Namespace, *, interactive: bool, ) -> None: if getattr(args, "skip_app_config_env", False): answers.write_app_config_env = False return if getattr(args, "overwrite_app_config_env", False): answers.write_app_config_env = True answers.overwrite_app_config_env = True return if not interactive: answers.write_app_config_env = True return example_label = ( APP_CONFIG_ENV_EXAMPLE.name if APP_CONFIG_ENV_EXAMPLE.is_file() else "defaults" ) if ask_yes_no( f"Write/update {APP_CONFIG_ENV_PATH.name} for the deployed app " f"(from {example_label} + AWS resource names)?", default=True, ): answers.write_app_config_env = True if APP_CONFIG_ENV_PATH.is_file(): answers.overwrite_app_config_env = ask_yes_no( f"{APP_CONFIG_ENV_PATH.name} exists — replace file from example template?", default=False, ) else: answers.write_app_config_env = False def configure_agentic_options( answers: InstallAnswers, args: argparse.Namespace, *, interactive: bool, assume_yes: bool, ) -> None: preset = merge_preset(answers.profile, answers.custom_overrides) use_express = preset.get("USE_ECS_EXPRESS_MODE") == "True" use_cloudfront = preset.get("USE_CLOUDFRONT") == "True" apply_agentic_cli_flags(args, answers) if not answers.agentic_enabled and interactive: if use_express: label = "Deploy agentic redaction (second Gradio app on Express, dedicated HTTPS URL)?" answers.enable_agentic_express = ask_yes_no(label, default=False) else: label = "Deploy agentic redaction (second Gradio app on legacy Fargate + Service Connect)?" if ask_yes_no(label, default=False): answers.enable_agentic_legacy = True answers.enable_service_connect = True if not answers.agentic_enabled: return configure_agent_orchestrator_options( answers, args, interactive=interactive, assume_yes=assume_yes ) if ( not answers.agentic_alb_listener_rule_priority and not answers.enable_agentic_express ): answers.agentic_alb_listener_rule_priority = default_agentic_listener_priority( use_cloudfront ) if answers.enable_agentic_express: if interactive and not assume_yes: print("\n--- Agentic redaction (ECS Express) ---") answers.sc_discovery_name = ask( "Service Connect discovery name for main app", answers.sc_discovery_name, ) answers.agentic_gradio_port = ask( "Agent Gradio port", answers.agentic_gradio_port ) if ask_yes_no( f"Write/update {PI_AGENT_ENV_PATH.name} for AWS ECS (DOC_REDACTION_GRADIO_URL, etc.)?", default=True, ): answers.write_pi_agent_env = True if PI_AGENT_ENV_PATH.is_file(): answers.overwrite_pi_agent_env = ask_yes_no( "agent.env exists — replace file from example template?", default=False, ) else: answers.write_pi_agent_env = False print( f"Agentic redaction: Express (dedicated HTTPS endpoint per service); " f"Service Connect discovery={answers.sc_discovery_name}; " f"orchestrator={normalize_agent_orchestrator(answers.agent_orchestrator)}" ) return if interactive and not ( args.agentic_alb_routing or args.agentic_path_prefix or args.agentic_host_header or assume_yes ): print("\n--- Agentic redaction ALB routing ---") ridx = ask_choice( "How should the shared ALB route traffic to the Agent UI?", [ "Path prefix (default /agent/ — e.g. https://host/agent/)", "Dedicated hostname (AGENT_ALB_HOST_HEADER)", "Both path prefix and hostname", ], default_index=0, ) answers.agentic_alb_routing = AGENTIC_ALB_ROUTING_MODES[ridx] if answers.agentic_alb_routing in ("path", "both"): answers.agentic_alb_path_prefix = ask( "Agent path prefix", answers.agentic_alb_path_prefix, ) if answers.agentic_alb_routing in ("host", "both"): default_host = "" if answers.ssl_domain: default_host = f"agent.{answers.ssl_domain}" answers.agentic_alb_host_header = ask( "Agent ALB host header (DNS CNAME to CloudFront/ALB)", default_host, ) if use_cloudfront: default_pri = default_agentic_listener_priority(True) answers.agentic_alb_listener_rule_priority = ask( "Agent ALB listener rule priority (default 3; priorities 1–2 reserved)", default_pri, ) answers.sc_discovery_name = ask( "Service Connect discovery name for main app", answers.sc_discovery_name, ) answers.agentic_gradio_port = ask( "Agent Gradio port", answers.agentic_gradio_port ) if ask_yes_no( f"Write/update {PI_AGENT_ENV_PATH.name} for AWS ECS (DOC_REDACTION_GRADIO_URL, etc.)?", default=True, ): answers.write_pi_agent_env = True if PI_AGENT_ENV_PATH.is_file(): answers.overwrite_pi_agent_env = ask_yes_no( "agent.env exists — replace file from example template?", default=False, ) else: answers.write_pi_agent_env = False print( f"Agentic redaction: " f"{'Express' if answers.enable_agentic_express else 'legacy Fargate'}, " f"routing={answers.agentic_alb_routing}, " f"prefix={normalize_agentic_path_prefix(answers.agentic_alb_path_prefix)}, " f"orchestrator={normalize_agent_orchestrator(answers.agent_orchestrator)}" ) # --------------------------------------------------------------------------- # Interactive wizard # --------------------------------------------------------------------------- def run_wizard(args: argparse.Namespace) -> InstallAnswers: answers = InstallAnswers() interactive = not args.yes assume_yes = args.yes if args.profile: answers.profile = args.profile elif interactive: idx = ask_choice( "Deployment profile", [ "Demonstration (Express, no CloudFront, no delete protection)", "Production (ACM cert, CloudFront, delete protection)", "Headless batch only (demo-style networking, no Express web UI)", "Custom (configure individual toggles)", ], ) answers.profile = ("demo", "production", "headless", "custom")[idx] else: answers.profile = "demo" if answers.profile == "headless": answers.enable_headless = True elif getattr(args, "headless", False): if answers.profile == "demo": raise SystemExit( "Headless batch mode is not available with --profile demo (Express). " "Use --profile headless, --profile production --headless, " "or --profile custom without ECS Express." ) answers.enable_headless = True elif interactive and answers.profile == "production": answers.enable_headless = ask_yes_no( "Enable headless batch-only deployment (S3 → Lambda → one-shot ECS, " "no always-on web UI)?", default=False, ) if answers.profile == "custom" and interactive: answers.custom_overrides["USE_ECS_EXPRESS_MODE"] = ( "True" if ask_yes_no("Use ECS Express Mode?", False) else "False" ) answers.custom_overrides["USE_CLOUDFRONT"] = ( "True" if ask_yes_no("Use CloudFront?", True) else "False" ) answers.custom_overrides["RUN_USEAST_STACK"] = "False" answers.custom_overrides["ENABLE_RESOURCE_DELETE_PROTECTION"] = ( "True" if ask_yes_no("Enable delete protection?", True) else "False" ) answers.custom_overrides["ENABLE_APPREGISTRY"] = ( "True" if ask_yes_no( "Enable AppRegistry (AWS Console myApplications)? AWS is retiring " "creation of new AppRegistry Applications, so this may fail on new " "accounts", default=False, ) else "False" ) if getattr(args, "headless", False): if answers.custom_overrides.get("USE_ECS_EXPRESS_MODE") == "True": raise SystemExit( "Headless batch mode requires USE_ECS_EXPRESS_MODE=False in " "the custom profile." ) answers.enable_headless = True elif profile_allows_headless_add_on(answers): answers.enable_headless = ask_yes_no( "Enable headless batch-only deployment (S3 → Lambda → one-shot ECS, " "no always-on web UI)?", default=False, ) # Delete/termination protection. The production preset defaults this ON, but # let the user opt out interactively (custom already prompts above). This # single flag drives stack termination protection and every resource-level # deletion protection / RETAIN removal policy via # ENABLE_RESOURCE_DELETE_PROTECTION. if interactive and answers.profile == "production": answers.custom_overrides["ENABLE_RESOURCE_DELETE_PROTECTION"] = ( "True" if ask_yes_no( "Enable delete/termination protection for the stack and its " "resources (recommended for production)?", default=True, ) else "False" ) # A --delete-protection flag overrides the profile default (and any prompt), # so non-interactive runs can select the behaviour explicitly. delete_protection_cli = getattr(args, "delete_protection", None) if delete_protection_cli in ("on", "off"): answers.custom_overrides["ENABLE_RESOURCE_DELETE_PROTECTION"] = ( "True" if delete_protection_cli == "on" else "False" ) # AppRegistry (AWS Console myApplications). Default OFF because AWS is retiring # creation of new AppRegistry Applications; offer to opt in interactively for the # demo/production profiles (custom already prompts above). if interactive and answers.profile in ("demo", "production"): answers.custom_overrides["ENABLE_APPREGISTRY"] = ( "True" if ask_yes_no( "Enable AppRegistry (AWS Console myApplications)? AWS is retiring " "creation of new AppRegistry Applications, so leave this off unless " "your account can still create them", default=False, ) else "False" ) # A --appregistry flag overrides the profile default (and any prompt), so # non-interactive runs can select the behaviour explicitly. appregistry_cli = getattr(args, "appregistry", None) if appregistry_cli in ("on", "off"): answers.custom_overrides["ENABLE_APPREGISTRY"] = ( "True" if appregistry_cli == "on" else "False" ) headless_err = headless_profile_error(answers) if headless_err: raise SystemExit(headless_err) try: account, region = get_aws_identity(args.region) except Exception as exc: raise SystemExit(f"AWS credentials error: {exc}") from exc answers.aws_account_id = args.account or account answers.aws_region = args.region or region if interactive and not args.account: answers.aws_account_id = ask("AWS account ID", answers.aws_account_id) if interactive and not args.region: answers.aws_region = ask("AWS region", answers.aws_region) default_prefix = f"{answers.profile.capitalize()}-Redaction-" answers.cdk_prefix = args.cdk_prefix or ( ask("CDK resource prefix (e.g. MyOrg-Redaction-)", default_prefix) if interactive else default_prefix ) if not answers.cdk_prefix.endswith("-"): answers.cdk_prefix += "-" if answers_use_headless(answers): answers.cognito_domain_prefix = "" print("Headless batch mode: skipping Cognito hosted UI domain (no web login).") resolve_globally_unique_install_names( answers, interactive=interactive, assume_yes=assume_yes, args=args, ) # VPC if args.new_vpc_cidr: answers.vpc_mode = "new" answers.new_vpc_cidr = args.new_vpc_cidr elif args.vpc_name: answers.vpc_mode = "existing" answers.vpc_name = args.vpc_name elif interactive: vpc_idx = ask_choice( "VPC", ["Create new VPC", "Use existing VPC"], ) answers.vpc_mode = "new" if vpc_idx == 0 else "existing" else: answers.vpc_mode = "existing" if answers.vpc_mode == "new": prompt_new_vpc_cidr(answers, interactive=interactive) else: if not answers.vpc_name: vpcs = list_vpcs(answers.aws_region) if not vpcs: raise SystemExit("No VPCs found in region.") if interactive: labels = [f"{v['name']} ({v['cidr']})" for v in vpcs] vidx = ask_choice("Select VPC", labels) answers.vpc_name = vpcs[vidx]["name"] else: raise SystemExit( "--vpc-name required for existing VPC in non-interactive mode." ) vpc_list = list_vpcs(answers.aws_region) vpc_id = next( (v["id"] for v in vpc_list if v["name"] == answers.vpc_name), None, ) # Subnets (public only for Express or demo-style headless) apply_subnet_cli_flags(args, answers) public_subnets_only = answers_use_public_subnets_only(answers) if public_subnets_only: if interactive: answers.public_subnet_mode = ask_subnet_tier_mode("Public") else: answers.public_subnet_mode, _ = resolve_subnet_tier_modes(args) answers.private_subnet_mode = "auto" answers.private_subnet_names = [] answers.private_subnet_cidrs = [] answers.private_subnet_azs = [] elif interactive: layout_idx = ask_choice( "Subnets in existing VPC", [ "Auto-discover public and private subnets", "Use existing named subnets (both tiers)", "Create new stack-specific subnets (both tiers)", "Configure public and private separately", ], default_index=0, ) if layout_idx < 3: shared_mode = SUBNET_TIER_MODES[layout_idx] answers.public_subnet_mode = shared_mode answers.private_subnet_mode = shared_mode else: answers.public_subnet_mode = ask_subnet_tier_mode("Public") answers.private_subnet_mode = ask_subnet_tier_mode("Private") else: answers.public_subnet_mode, answers.private_subnet_mode = ( resolve_subnet_tier_modes(args) ) azs = list_availability_zones(answers.aws_region)[:3] vpc_cidr = next( (v["cidr"] for v in vpc_list if v["name"] == answers.vpc_name), "", ) if vpc_id: subnets = list_subnets_in_vpc(vpc_id, answers.aws_region) configure_subnet_tier( answers, "public", answers.public_subnet_mode, subnets, azs, interactive=interactive, vpc_cidr=vpc_cidr, ) if not public_subnets_only: reserved_public = ( list(answers.public_subnet_cidrs) if answers.public_subnet_mode == "create" else [] ) configure_subnet_tier( answers, "private", answers.private_subnet_mode, subnets, azs, interactive=interactive, vpc_cidr=vpc_cidr, reserved_subnet_cidrs=reserved_public, ) # Optional infra reuse if ( vpc_id and interactive and ask_yes_no("Configure optional existing infra (IGW/ALB)?", False) ): igws = list_igws_for_vpc(vpc_id, answers.aws_region) if igws: # igws_ids = [g["id"] for g in igws] print("Internet gateways:", ", ".join(g["id"] for g in igws)) answers.existing_igw_id = ask("EXISTING_IGW_ID (blank=new)", "") albs = list_albs_in_vpc(vpc_id, answers.aws_region) if albs: labels = [f"{a['name']} ({a['dns']})" for a in albs] if ask_yes_no("Reuse an existing ALB?", False): aidx = ask_choice("Select ALB", labels) answers.existing_alb_arn = albs[aidx]["arn"] answers.existing_alb_dns = albs[aidx]["dns"] # Production TLS preset = merge_preset(answers_preset_profile(answers), answers.custom_overrides) use_express = preset.get("USE_ECS_EXPRESS_MODE") == "True" if ( preset.get("USE_CLOUDFRONT") == "True" and preset.get("USE_ECS_EXPRESS_MODE") != "True" and not answers_use_headless(answers) ): if args.cert_arn: answers.acm_cert_arn = args.cert_arn answers.ssl_domain = args.domain or "" elif interactive: certs = list_acm_certificates(answers.aws_region) if not certs: raise SystemExit(f"No ISSUED ACM certificates in {answers.aws_region}.") labels = [f"{c['label']} — {c['arn']}" for c in certs] cidx = ask_choice("Select ACM certificate", labels) answers.acm_cert_arn = certs[cidx]["arn"] answers.ssl_domain = ask( "SSL certificate domain (Cognito/CloudFront URL)", certs[cidx]["domain"], ) else: raise SystemExit( "--cert-arn and --domain required for production in non-interactive mode." ) if interactive: answers.cloudfront_geo = ask( "CloudFront geo restriction (e.g. GB, blank=none)", "" ) if not answers_use_headless(answers): configure_agentic_options( answers, args, interactive=interactive, assume_yes=assume_yes, ) else: answers.enable_s3_batch = True # Advanced add-ons (without agentic redaction) is_express = use_express and not answers_use_headless(answers) if answers_use_headless(answers) and interactive: cpu = ask("ECS task CPU (units; 1024 = 1 vCPU)", answers.ecs_cpu) if cpu: answers.ecs_cpu = cpu mem = ask("ECS task memory (MB)", answers.ecs_memory) if mem: answers.ecs_memory = mem elif interactive and ask_yes_no("Configure other optional add-ons?", False): if not is_express: if not answers.enable_service_connect: answers.enable_service_connect = ask_yes_no( "Enable ECS Service Connect (without agentic redaction)?", False ) answers.enable_s3_batch = ask_yes_no("Enable S3 batch ECS trigger?", False) cpu = ask("ECS task CPU (units; 1024 = 1 vCPU)", answers.ecs_cpu) if cpu: answers.ecs_cpu = cpu mem = ask("ECS task memory (MB)", answers.ecs_memory) if mem: answers.ecs_memory = mem configure_app_config_options(answers, args, interactive=interactive) answers.python_path = args.python return answers # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- def build_arg_parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser( description="Interactive CDK installer for doc_redaction", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=__doc__, ) p.add_argument( "--profile", choices=("demo", "production", "headless", "custom"), help="Base profile; use --profile headless or production --headless for batch-only", ) p.add_argument( "--yes", action="store_true", help="Accept defaults / skip confirmations" ) p.add_argument("--config-only", action="store_true", help="Write config files only") p.add_argument( "--deploy-only", action="store_true", help="Skip wizard; use existing cdk_config.env", ) p.add_argument( "--complete-agentcore", action="store_true", help="AgentCore two-phase deploy, phase 2 only: after the AgentCore runtime " "image is in ECR, create the Bedrock runtime via the bedrock-agentcore " "API (boto3) and sync the runtime URL. No cdk deploy / no stack update. " "Uses existing cdk_config.env; skips the wizard and existing-stack guard.", ) p.add_argument( "--synth-only", action="store_true", help="Run cdk synth only (no deploy)" ) p.add_argument("--skip-deploy", action="store_true", help="Skip cdk deploy") p.add_argument( "--skip-quickstart", action="store_true", help="Skip post_cdk_build_quickstart.py", ) p.add_argument( "--skip-stack-check", action="store_true", help="Do not check for existing CloudFormation stacks at startup", ) p.add_argument( "--force-delete-stacks", action="store_true", help="Explicit opt-in: if doc_redaction stacks already exist, delete them " "before a clean initial deploy (disables termination protection). Without " "this flag cdk_install aborts when a stack exists (it never updates in place).", ) p.add_argument( "--skip-cdk-json", action="store_true", help="Do not update cdk.json" ) p.add_argument( "--refresh-cdk-json", action="store_true", help="Force rewrite cdk.json app key" ) p.add_argument("--python", help="Python executable for cdk.json") p.add_argument("--region", help="AWS region") p.add_argument("--account", help="AWS account ID") p.add_argument("--cdk-prefix", help="CDK resource prefix") p.add_argument("--cognito-prefix", help="Cognito user pool domain prefix") p.add_argument( "--s3-log-bucket", help="S3 log/config bucket name (globally unique; skips wizard suggestion)", ) p.add_argument( "--s3-output-bucket", help="S3 output bucket name (globally unique; skips wizard suggestion)", ) p.add_argument( "--s3-malware-scan-bucket", help="S3 malware scan staging bucket name (globally unique)", ) p.add_argument("--vpc-name", help="Existing VPC Name tag") p.add_argument("--new-vpc-cidr", help="CIDR for new VPC") p.add_argument( "--subnet-mode", choices=SUBNET_TIER_MODES, help="Subnet mode for both public and private tiers (overridden by per-tier flags)", ) p.add_argument( "--public-subnet-mode", choices=SUBNET_TIER_MODES, help="Public subnet tier: auto, existing, or create", ) p.add_argument( "--private-subnet-mode", choices=SUBNET_TIER_MODES, help="Private subnet tier: auto, existing, or create", ) p.add_argument( "--public-subnet-names", help="Comma-separated existing public subnet names (with --public-subnet-mode existing)", ) p.add_argument( "--private-subnet-names", help="Comma-separated existing private subnet names (with --private-subnet-mode existing)", ) p.add_argument( "--headless", action="store_true", help="Batch-only add-on (production/custom without Express only; not valid with demo)", ) p.add_argument("--cert-arn", help="ACM certificate ARN (production)") p.add_argument("--domain", help="SSL certificate domain (production)") p.add_argument( "--delete-protection", choices=("on", "off"), help="Override stack termination + resource deletion protection " "(default: on for production, off for demo/headless). Applies to all " "resources governed by ENABLE_RESOURCE_DELETE_PROTECTION.", ) p.add_argument( "--appregistry", choices=("on", "off"), help="Override AWS Console myApplications (AppRegistry) creation " "(default: off). AWS is retiring creation of new AppRegistry " "Applications; sets ENABLE_APPREGISTRY.", ) agentic = p.add_argument_group("Agentic redaction (second Gradio app)") agentic.add_argument( "--enable-agentic", "--enable-pi", dest="enable_agentic", action="store_true", help="Enable agentic redaction for current profile (Express on demo, legacy on production)", ) agentic.add_argument( "--enable-agentic-express", "--enable-pi-express", dest="enable_agentic_express", action="store_true", help="Enable agentic redaction on ECS Express Mode (demo)", ) agentic.add_argument( "--enable-agentic-legacy", "--enable-pi-legacy", dest="enable_agentic_legacy", action="store_true", help="Enable agentic redaction on legacy Fargate (implies Service Connect)", ) agentic.add_argument( "--agentic-alb-routing", "--pi-alb-routing", dest="agentic_alb_routing", choices=AGENTIC_ALB_ROUTING_MODES, help="ALB routing mode for agentic UI (default: path)", ) agentic.add_argument( "--agentic-path-prefix", "--pi-path-prefix", dest="agentic_path_prefix", default="", help="Agentic UI path prefix (default: /agent)", ) agentic.add_argument( "--agentic-host-header", "--pi-host-header", dest="agentic_host_header", default="", help="Dedicated hostname for agentic mode when routing is host or both", ) agentic.add_argument( "--agentic-listener-priority", "--pi-listener-priority", dest="agentic_listener_priority", default="", help="ALB listener rule priority for agentic mode (default 3; priorities 1–2 reserved)", ) agentic.add_argument( "--agentic-gradio-port", "--pi-gradio-port", dest="agentic_gradio_port", default="", help="Agentic Gradio listen port (default 7862)", ) agentic.add_argument( "--sc-discovery-name", default="", help="Service Connect name for main app (default redaction)", ) agentic.add_argument( "--pi-provider", default="", help="AGENT_DEFAULT_PROVIDER for agent.env (default amazon-bedrock)", ) agentic.add_argument( "--skip-pi-agent-env", action="store_true", help="Do not write config/agent.env", ) agentic.add_argument( "--agent-orchestrator", choices=AGENT_ORCHESTRATOR_CHOICES, help="Agent orchestration backend (demo+--enable-agentic default: agentcore)", ) agentic.add_argument( "--enable-agentcore-cdk-deploy", action="store_true", help=( "CDK-native AgentCore phase 1 only (ARM64 CodeBuild + ECR). " "Demo + --enable-agentic sets this automatically; phase 2 runs after " "the image build without this flag." ), ) agentic.add_argument( "--enable-agentcore-cdk-runtime", action="store_true", help=( "Force AgentCore phase 2 immediately (Bedrock runtime from ECR). " "Usually unnecessary — re-run cdk_install.py after the image build instead." ), ) agentic.add_argument( "--enable-agentcore-runtime", action="store_true", help="Use Bedrock AgentCore (sets AGENT_ORCHESTRATOR=agentcore)", ) agentic.add_argument( "--agentcore-runtime-url", default="", help=( "AgentCore runtime base URL from `agentcore status` invocationUrl " "(no /invocations suffix, no trailing slash)" ), ) agentic.add_argument( "--agentcore-api-key", default="", help=( "Optional bearer token for CUSTOM_JWT AgentCore auth (not auto-generated; " "usually omitted on AWS ECS where IAM SigV4 is used)" ), ) agentic.add_argument( "--agentcore-model", default="", help=( "Bedrock model id for the AgentCore runtime engine (fixed at runtime " f"creation). Default: {AGENTCORE_DEFAULT_BEDROCK_MODEL}. Common choices: " + ", ".join(m for m, _ in AGENTCORE_BEDROCK_MODEL_CHOICES) ), ) agentic.add_argument( "--skip-bedrock-model-check", action="store_true", help=( "Skip validating that the calling AWS identity has access to the chosen " "AgentCore Bedrock model." ), ) p.add_argument( "--skip-app-config-env", action="store_true", help="Do not write cdk/config/app_config.env from app_config.env.example", ) p.add_argument( "--overwrite-app-config-env", action="store_true", help="Replace existing config/app_config.env from app_config.env.example (non-interactive)", ) return p def _run_complete_agentcore(args: argparse.Namespace) -> int: """Phase 2 of the AgentCore two-phase deploy — WITHOUT a stack update. Assumes the AgentCore runtime image is already in ECR (built by CodeBuild or pushed manually) and that phase 1 created the execution role. Creates the Bedrock AgentCore runtime via the bedrock-agentcore-control API (boto3), then derives ``AGENTCORE_RUNTIME_URL``, patches config, re-uploads ``agent.env`` and recycles the agent Express service. This deliberately does NOT run ``cdk deploy``. A second ``cdk deploy`` of RedactionStack would re-run the precheck and flip the stack's own managed roles/buckets/pool to imports, deleting them (removal policy DESTROY on the demo profile). Creating the runtime out-of-band avoids that footgun. """ if not ENV_PATH.is_file(): raise SystemExit( f"No config at {ENV_PATH}. Run the initial deploy first, then re-run " "with --complete-agentcore once the AgentCore image is in ECR." ) values = read_env_file(ENV_PATH) if values.get("AGENTCORE_CDK_DEPLOY") != "True": raise SystemExit( "AGENTCORE_CDK_DEPLOY is not True in cdk_config.env; there is no " "AgentCore CDK phase 1 to complete. (Manual/CLI AgentCore deploys do " "not use --complete-agentcore — see agent-redact/agentcore/README.md.)" ) from cdk_post_deploy import create_agentcore_runtime_from_ecr url = create_agentcore_runtime_from_ecr(stack_name=REGIONAL_STACK) return 0 if url else 1 def main(argv: Optional[Sequence[str]] = None) -> int: args = build_arg_parser().parse_args(argv) CONFIG_DIR.mkdir(parents=True, exist_ok=True) print("doc_redaction CDK installer\n") # Prerequisites try: cdk_version = check_cdk_cli() print(f"CDK CLI: {cdk_version}") except SystemExit: if not args.config_only: raise print("Warning: CDK CLI not found; config-only mode continuing.") # AgentCore two-phase deploy, phase 2 only. This is the supported way to # complete the runtime after its ECR image exists — it performs a targeted # 'cdk deploy RedactionStack' (the one place an existing stack is updated on # purpose) without the wizard or the existing-stack guard. if args.complete_agentcore: return _run_complete_agentcore(args) if not args.config_only and not args.synth_only: try: _stack_account, stack_region = get_aws_identity(args.region) handle_existing_stacks_at_start(args, stack_region) except SystemExit: raise except Exception as exc: print(f"Existing stack check skipped: {exc}") python_exe: Optional[Path] = None if not args.skip_cdk_json: python_exe = resolve_python_executable( override=args.python, interactive=not args.yes and not args.deploy_only, assume_yes=args.yes or args.deploy_only, ) write_cdk_json( python_exe, force=args.refresh_cdk_json or not CDK_JSON_PATH.is_file(), skip=args.skip_cdk_json, ) elif CDK_JSON_PATH.is_file(): # Best-effort parse python from existing cdk.json try: data = json.loads(CDK_JSON_PATH.read_text(encoding="utf-8")) app_cmd = data.get("app", "") if app_cmd: python_exe = Path(app_cmd.split()[0]) except (json.JSONDecodeError, IndexError): pass values: Dict[str, str] if args.deploy_only: if not ENV_PATH.is_file(): raise SystemExit( f"No config at {ENV_PATH}. Run without --deploy-only first." ) values = read_env_file(ENV_PATH) pre_deploy_errors = validate_globally_unique_env_values( values ) + validate_new_vpc_cidr_env_values(values) if pre_deploy_errors: print("Pre-deploy configuration conflicts:") for err in pre_deploy_errors: print(f" - {err}") return 1 if not python_exe or not python_exe.is_file(): python_exe = resolve_python_executable( override=args.python, interactive=False, assume_yes=True ) else: answers = run_wizard(args) errors = validate_install_answers(answers) errors.extend(validate_subnet_answers(answers)) errors.extend(enrich_existing_subnet_details_from_aws(answers)) values = build_env_values(answers) errors.extend( validate_env_values( values, allow_empty_agentcore_url=answers.allow_empty_agentcore_url, ) ) if errors: print("Configuration errors:") for err in errors: print(f" - {err}") return 1 if not python_exe: python_exe = resolve_python_executable( override=answers.python_path or args.python, interactive=not args.yes, assume_yes=args.yes, ) if not args.skip_cdk_json: write_cdk_json(python_exe, force=args.refresh_cdk_json) print_summary(values, python_exe) if not args.yes and not ask_yes_no("Write config/cdk_config.env?", True): print("Aborted.") return 0 write_env_file(ENV_PATH, values) if answers.write_app_config_env: write_app_config_env_file( answers, values, overwrite=answers.overwrite_app_config_env, ) if answers.agentic_enabled: write_pi_agent_env_file( answers, overwrite=answers.overwrite_pi_agent_env, ) apply_cdk_runtime_env(values) if args.config_only: print("Config written. Exiting (--config-only).") return 0 run_smoke_test_if_needed(python_exe, args) # Bootstrap prompt account = values.get("AWS_ACCOUNT_ID", "") region = values.get("AWS_REGION", "") if account and region: try: if cdk_bootstrap_needed(account, region): print(f"CDK bootstrap not found in {region}.") if args.yes or ask_yes_no( f"Run cdk bootstrap aws://{account}/{region}?", True ): run_cdk_command( ["bootstrap", f"aws://{account}/{region}"], check=True, ) except Exception as exc: print(f"Bootstrap check skipped: {exc}") verify_node_for_jsii() run_cdk_command(["synth"], check=True) if args.synth_only or args.skip_deploy: print("Synth complete. Skipping deploy.") return 0 if not args.yes and not ask_yes_no("Run cdk deploy --all?", True): print("Deploy skipped.") return 0 run_cdk_command(["deploy", "--all", "--require-approval", "broadening"], check=True) values = read_env_file(ENV_PATH) cloudfront_enabled = values.get("USE_CLOUDFRONT") == "True" cf_domain_before = (values.get("CLOUDFRONT_DOMAIN") or "").strip().lower() apply_post_deploy_fixup(values, assume_yes=args.yes) # The initial deploy bakes the placeholder domain into the CloudFront # response-headers policy (CSP/CORS) because the real *.cloudfront.net domain # is only known after the distribution exists (a self-reference would be a # circular dependency). The boto3 fixup above already wrote the real domain # into the env file and updated the Cognito callback URLs (no stack change). # # cdk_install intentionally does NOT re-deploy the stack a second time to # push the resolved domain into the response-headers policy: in-place stack # updates have repeatedly caused imported-resource drift, so this installer # performs a single initial deploy only. If the CSP/response-headers policy # must reflect the real domain, delete and redeploy the stack fresh (with # CLOUDFRONT_DOMAIN already set) rather than updating in place. values = read_env_file(ENV_PATH) cf_domain_after = (values.get("CLOUDFRONT_DOMAIN") or "").strip().lower() cloudfront_domain_resolved = ( cloudfront_enabled and bool(cf_domain_after) and "placeholder" not in cf_domain_after and cf_domain_after != cf_domain_before ) if cloudfront_domain_resolved: print( f"CloudFront domain resolved to {cf_domain_after}. Cognito callback " "URLs were updated via the post-deploy fixup. The stack's " "response-headers policy (CSP/CORS) keeps the value baked in at the " "initial deploy — cdk_install does not update the stack in place. " "Redeploy the stack fresh if the CSP/CORS domain must change." ) run_qs = False if args.skip_quickstart: print("Skipping post-deploy quickstart.") else: is_headless = values.get("ENABLE_HEADLESS_DEPLOYMENT") == "True" is_demo = values.get("USE_ECS_EXPRESS_MODE") == "True" if args.yes: run_qs = True elif is_headless: run_qs = ask_yes_no( "Run post_cdk_build_quickstart.py (CodeBuild image only; no ECS service)?", default=True, ) else: run_qs = ask_yes_no( "Run post_cdk_build_quickstart.py (CodeBuild + scale ECS)?", default=is_demo, ) if run_qs: if not python_exe: python_exe = resolve_python_executable( assume_yes=True, interactive=False ) run_quickstart(python_exe) values = read_env_file(ENV_PATH) maybe_complete_agentcore_cdk_deploy(values, args, assume_yes=args.yes) values = read_env_file(ENV_PATH) is_headless = values.get("ENABLE_HEADLESS_DEPLOYMENT") == "True" if values.get("USE_ECS_EXPRESS_MODE") == "True" and not is_headless: from cdk_post_deploy import print_express_mode_next_steps if args.skip_quickstart or not run_qs: print_express_mode_next_steps(values) return 0 if is_headless: from cdk_post_deploy import print_headless_deployment_next_steps if args.skip_quickstart or not run_qs: print_headless_deployment_next_steps(values) return 0 print("\nDone. Next steps:") if ( values.get("ENABLE_PI_AGENT_EXPRESS_SERVICE") == "True" or values.get("ENABLE_PI_AGENT_ECS_SERVICE") == "True" ): if values.get("ENABLE_PI_AGENT_EXPRESS_SERVICE") == "True": print( " - Agent UI: AgenticExpressEndpoint stack output (dedicated Express HTTPS URL)" ) else: routing = values.get("AGENT_ALB_ROUTING", "path") if routing in ("path", "both"): prefix = values.get("AGENT_ALB_PATH_PREFIX", "/agent") print(f" - Agent UI path: {prefix}/ on your app URL") if routing in ("host", "both") and values.get("AGENT_ALB_HOST_HEADER"): print( f" - Agent DNS: CNAME {values['AGENT_ALB_HOST_HEADER']} -> " f"{values.get('CLOUDFRONT_DOMAIN') or 'ALB/Express endpoint'}" ) if PI_AGENT_ENV_PATH.is_file(): print( f" - Agent runtime config: {PI_AGENT_ENV_PATH} (uploaded by quickstart)" ) if values.get("USE_CLOUDFRONT") == "True": domain = values.get("SSL_CERTIFICATE_DOMAIN", "") cf = values.get("CLOUDFRONT_DOMAIN", "") if domain and cf and cf != "cloudfront_placeholder.net": print(f" - Point DNS CNAME {domain} -> {cf}") if values.get("CLOUDFRONT_AUTH_MODE") == "magic-link": print( " - After deploy: open RedactionLoginUrl from stack outputs " "(?key= unlock), then browse via RedactionUrl / CloudFrontDistributionURL" ) elif values.get("USE_ECS_EXPRESS_MODE") == "True": ep = values.get("ECS_EXPRESS_COGNITO_REDIRECT_BASE", "") if ep: from cdk_config import normalize_https_redirect_url print(f" - Express endpoint: {normalize_https_redirect_url(ep)}") if APP_CONFIG_ENV_PATH.is_file(): print(f" - App runtime config: {APP_CONFIG_ENV_PATH} (uploaded by quickstart)") print(f" - Config: {ENV_PATH}") return 0 if __name__ == "__main__": raise SystemExit(main())