Spaces:
Sleeping
Sleeping
| #!/usr/bin/env python3 | |
| """ | |
| Interactive CDK installer for llm_topic_modeller. | |
| 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, | |
| no second deploy), and post_cdk_build_quickstart.py. | |
| 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 llm-topic.example.com --yes | |
| # Redeploy using existing config, skip quickstart | |
| python cdk_install.py --deploy-only --skip-quickstart | |
| # Remove existing stacks before a clean install (non-interactive) | |
| python cdk_install.py --profile headless --vpc-name my-vpc \\ | |
| --force-delete-stacks --yes | |
| # 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 | |
| # Optional scheduled DynamoDB usage log export to S3 | |
| python cdk_install.py --profile demo --vpc-name my-vpc --yes \\ | |
| --dynamo-usage-log-export --dynamo-export-schedule-time 06:00 \\ | |
| --dynamo-export-schedule-days weekdays | |
| # Headless with S3 output email alerts + IAM reader user | |
| python cdk_install.py --profile headless --vpc-name my-vpc --yes \\ | |
| --headless-output-notifications \\ | |
| --headless-notify-email analyst@example.com | |
| # Enable Bedrock model invocation logging (S3 log bucket + CloudWatch) | |
| python cdk_install.py --profile headless --vpc-name my-vpc --yes \\ | |
| --bedrock-model-invocation-logging | |
| # Prefer Resource Groups + tags; leave AppRegistry off (default) | |
| python cdk_install.py --profile production --vpc-name my-vpc --yes \\ | |
| --application-resource-group --application-name my-prefix-llm-topic-modeller | |
| # Opt in to legacy AppRegistry / myApplications (existing customers only) | |
| python cdk_install.py --profile demo --vpc-name my-vpc --yes --appregistry | |
| """ | |
| 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" | |
| PI_AGENT_ENV_PATH = CONFIG_DIR / "pi_agent.env" | |
| PI_AGENT_ENV_EXAMPLE = REPO_ROOT / "config" / "pi_agent.env.example" | |
| PI_ALB_ROUTING_MODES = ("path", "host", "both") | |
| 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 = "SummarisationStack" | |
| CLOUDFRONT_STACK = "SummarisationStackCloudfront" | |
| 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": "False", | |
| "RUN_USEAST_STACK": "False", | |
| "ENABLE_RESOURCE_DELETE_PROTECTION": "False", | |
| "ENABLE_APPREGISTRY": "False", | |
| "ENABLE_APPLICATION_RESOURCE_GROUP": "True", | |
| "ACM_SSL_CERTIFICATE_ARN": "", | |
| "SSL_CERTIFICATE_DOMAIN": "", | |
| } | |
| PRODUCTION_PRESET: Dict[str, str] = { | |
| "USE_ECS_EXPRESS_MODE": "False", | |
| "USE_CLOUDFRONT": "True", | |
| "RUN_USEAST_STACK": "True", | |
| "ENABLE_RESOURCE_DELETE_PROTECTION": "True", | |
| "ENABLE_APPREGISTRY": "False", | |
| "ENABLE_APPLICATION_RESOURCE_GROUP": "True", | |
| } | |
| 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", | |
| "ENABLE_APPREGISTRY": "False", | |
| "ENABLE_APPLICATION_RESOURCE_GROUP": "True", | |
| "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)]) | |
| 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 = "" | |
| 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_pi_express: bool = False | |
| enable_pi_legacy: bool = False | |
| enable_service_connect: bool = False | |
| enable_s3_batch: bool = False | |
| enable_headless: bool = False | |
| enable_headless_output_notifications: bool = False | |
| headless_output_notify_email: str = "" | |
| headless_output_iam_user_name: str = "" | |
| enable_dynamo_usage_log_export: bool = False | |
| dynamo_export_schedule_time: str = "06:00" | |
| dynamo_export_schedule_days: str = "daily" | |
| dynamo_export_s3_key: str = "" | |
| dynamo_export_date_attribute: str = "timestamp" | |
| enable_bedrock_model_invocation_logging: bool = False | |
| bedrock_invocation_s3_prefix: str = "bedrock-logs" | |
| bedrock_invocation_log_group_name: str = "" | |
| bedrock_invocation_log_retention_days: str = "90" | |
| enable_appregistry: bool = False | |
| enable_application_resource_group: bool = True | |
| application_name: str = "" | |
| application_tag_key: str = "Application" | |
| ecs_memory: str = "8192" | |
| pi_alb_routing: str = "path" | |
| pi_alb_path_prefix: str = "/agent" | |
| pi_alb_host_header: str = "" | |
| pi_alb_listener_rule_priority: str = "" | |
| pi_gradio_port: str = "7862" | |
| sc_discovery_name: str = "llm-topic" | |
| pi_default_provider: str = "amazon-bedrock" | |
| 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 | |
| def pi_enabled(self) -> bool: | |
| return self.enable_pi_express or self.enable_pi_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 "llm-topic-app" | |
| prefix = prefix.rstrip("-") | |
| if not prefix: | |
| return "llm-topic-app" | |
| for reserved in COGNITO_DOMAIN_RESERVED_SUBSTRINGS: | |
| if reserved in prefix: | |
| return "llm-topic-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_pi_path_prefix(raw: str) -> str: | |
| segment = (raw or "agent").strip().strip("/") | |
| return f"/{segment}" if segment else "/agent" | |
| def default_pi_listener_priority(use_cloudfront: bool = False) -> str: | |
| """Default Pi 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}PiExpressService", | |
| "ECS_PI_SERVICE_NAME": f"{prefix}PiAgentService", | |
| "COGNITO_USER_POOL_CLIENT_SECRET_NAME": f"{prefix}ParamCognitoSecret", | |
| "CODEBUILD_PROJECT_NAME": f"{prefix}CodeBuildProject", | |
| "CODEBUILD_PI_PROJECT_NAME": f"{prefix}CodeBuildPiProject", | |
| } | |
| 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_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, | |
| ) | |
| 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"): | |
| 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 validate_notify_email(email: str) -> Optional[str]: | |
| """Return an error message when email is not a plausible notification address.""" | |
| cleaned = (email or "").strip() | |
| if not cleaned: | |
| return "Notification email address is required." | |
| if not re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", cleaned): | |
| return f"Invalid notification email address: {cleaned!r}" | |
| return None | |
| DYNAMO_EXPORT_SCHEDULE_DAY_PRESETS = { | |
| "daily": "*", | |
| "weekdays": "MON-FRI", | |
| "weekends": "SAT-SUN", | |
| } | |
| def default_dynamo_export_s3_key() -> str: | |
| return "reports/dynamodb-usage/dynamodb_logs_export.csv" | |
| def parse_schedule_time_hhmm(raw: str) -> Tuple[int, int]: | |
| match = re.match(r"^(\d{1,2}):(\d{2})$", (raw or "").strip()) | |
| if not match: | |
| raise ValueError( | |
| "Schedule time must use HH:MM in 24-hour UTC format, e.g. 06:00." | |
| ) | |
| hour, minute = int(match.group(1)), int(match.group(2)) | |
| if hour > 23 or minute > 59: | |
| raise ValueError("Schedule time hour must be 0-23 and minute 0-59.") | |
| return hour, minute | |
| def validate_schedule_time_hhmm(raw: str) -> Optional[str]: | |
| try: | |
| parse_schedule_time_hhmm(raw) | |
| return None | |
| except ValueError as exc: | |
| return str(exc) | |
| def build_dynamo_export_cron_expression( | |
| hour: int, minute: int, days_preset: str | |
| ) -> str: | |
| """Build an EventBridge cron expression (UTC).""" | |
| dow = DYNAMO_EXPORT_SCHEDULE_DAY_PRESETS.get( | |
| (days_preset or "").strip().lower(), (days_preset or "*").strip().upper() | |
| ) | |
| return f"cron({minute} {hour} ? * {dow} *)" | |
| def prompt_dynamo_usage_log_export_options( | |
| answers: "InstallAnswers", | |
| args: argparse.Namespace, | |
| *, | |
| interactive: bool, | |
| ) -> None: | |
| if getattr(args, "dynamo_usage_log_export", False): | |
| answers.enable_dynamo_usage_log_export = True | |
| elif getattr(args, "no_dynamo_usage_log_export", False): | |
| answers.enable_dynamo_usage_log_export = False | |
| elif interactive: | |
| answers.enable_dynamo_usage_log_export = ask_yes_no( | |
| "Enable scheduled DynamoDB usage log export to S3 (EventBridge)?", | |
| default=False, | |
| ) | |
| else: | |
| return | |
| if not answers.enable_dynamo_usage_log_export: | |
| return | |
| if getattr(args, "dynamo_export_schedule_time", "").strip(): | |
| answers.dynamo_export_schedule_time = args.dynamo_export_schedule_time.strip() | |
| elif interactive: | |
| while True: | |
| raw = ask( | |
| "Export schedule time (UTC, HH:MM)", | |
| answers.dynamo_export_schedule_time, | |
| ) | |
| err = validate_schedule_time_hhmm(raw) | |
| if not err: | |
| answers.dynamo_export_schedule_time = raw | |
| break | |
| print(err) | |
| if getattr(args, "dynamo_export_schedule_days", "").strip(): | |
| answers.dynamo_export_schedule_days = ( | |
| args.dynamo_export_schedule_days.strip().lower() | |
| ) | |
| elif interactive: | |
| idx = ask_choice( | |
| "Run export on which days?", | |
| ["Every day", "Weekdays (Mon-Fri)", "Weekends (Sat-Sun)"], | |
| default_index=0, | |
| ) | |
| answers.dynamo_export_schedule_days = ["daily", "weekdays", "weekends"][idx] | |
| default_key = answers.dynamo_export_s3_key or default_dynamo_export_s3_key() | |
| if getattr(args, "dynamo_export_s3_key", "").strip(): | |
| answers.dynamo_export_s3_key = args.dynamo_export_s3_key.strip() | |
| elif interactive: | |
| answers.dynamo_export_s3_key = ( | |
| ask("S3 object key for the CSV export", default_key).strip() or default_key | |
| ) | |
| if getattr(args, "dynamo_export_date_attribute", "").strip(): | |
| answers.dynamo_export_date_attribute = args.dynamo_export_date_attribute.strip() | |
| elif interactive: | |
| raw = ask( | |
| "DynamoDB date attribute for filtering", | |
| answers.dynamo_export_date_attribute, | |
| ) | |
| if raw.strip(): | |
| answers.dynamo_export_date_attribute = raw.strip() | |
| def default_bedrock_invocation_log_group_name(cdk_prefix: str) -> str: | |
| """Default CloudWatch log group for Bedrock model invocation logging.""" | |
| prefix = (cdk_prefix or "").strip() | |
| return f"/aws/bedrock/{prefix}model-invocations".rstrip("/") | |
| def prompt_bedrock_model_invocation_logging_options( | |
| answers: "InstallAnswers", | |
| args: argparse.Namespace, | |
| *, | |
| interactive: bool, | |
| ) -> None: | |
| """ | |
| Configure account/Region Bedrock model invocation logging to S3 + CloudWatch. | |
| Writes ENABLE_BEDROCK_MODEL_INVOCATION_LOGGING and related keys into cdk_config.env. | |
| """ | |
| if getattr(args, "bedrock_model_invocation_logging", False): | |
| answers.enable_bedrock_model_invocation_logging = True | |
| elif getattr(args, "no_bedrock_model_invocation_logging", False): | |
| answers.enable_bedrock_model_invocation_logging = False | |
| elif interactive: | |
| default_on = answers_use_headless(answers) | |
| answers.enable_bedrock_model_invocation_logging = ask_yes_no( | |
| "Enable Amazon Bedrock model invocation logging " | |
| "(S3 log bucket + CloudWatch; account/Region-level setting)?", | |
| default=default_on, | |
| ) | |
| else: | |
| return | |
| if not answers.enable_bedrock_model_invocation_logging: | |
| return | |
| default_prefix = answers.bedrock_invocation_s3_prefix or "bedrock-logs" | |
| if getattr(args, "bedrock_invocation_s3_prefix", "").strip(): | |
| answers.bedrock_invocation_s3_prefix = ( | |
| args.bedrock_invocation_s3_prefix.strip().strip("/") | |
| ) | |
| elif interactive: | |
| answers.bedrock_invocation_s3_prefix = ( | |
| ask( | |
| "S3 key prefix for Bedrock invocation logs (on the log/config bucket)", | |
| default_prefix, | |
| ) | |
| .strip() | |
| .strip("/") | |
| or default_prefix | |
| ) | |
| else: | |
| answers.bedrock_invocation_s3_prefix = default_prefix | |
| default_log_group = ( | |
| answers.bedrock_invocation_log_group_name.strip() | |
| or default_bedrock_invocation_log_group_name(answers.cdk_prefix) | |
| ) | |
| if getattr(args, "bedrock_invocation_log_group", "").strip(): | |
| answers.bedrock_invocation_log_group_name = ( | |
| args.bedrock_invocation_log_group.strip() | |
| ) | |
| elif interactive: | |
| answers.bedrock_invocation_log_group_name = ( | |
| ask( | |
| "CloudWatch log group for Bedrock invocation logs", default_log_group | |
| ).strip() | |
| or default_log_group | |
| ) | |
| else: | |
| answers.bedrock_invocation_log_group_name = default_log_group | |
| default_retention = answers.bedrock_invocation_log_retention_days or "90" | |
| if getattr(args, "bedrock_invocation_log_retention_days", None) is not None: | |
| answers.bedrock_invocation_log_retention_days = str( | |
| args.bedrock_invocation_log_retention_days | |
| ).strip() | |
| elif interactive: | |
| while True: | |
| raw = ask( | |
| "CloudWatch log retention (days)", | |
| default_retention, | |
| ).strip() | |
| try: | |
| days = int(raw) | |
| if days <= 0: | |
| raise ValueError | |
| answers.bedrock_invocation_log_retention_days = str(days) | |
| break | |
| except ValueError: | |
| print("Enter a positive whole number of days (e.g. 90).") | |
| else: | |
| answers.bedrock_invocation_log_retention_days = default_retention | |
| def default_application_name(cdk_prefix: str) -> str: | |
| """Default Application tag value / Resource Group identity from CDK_PREFIX.""" | |
| return f"{(cdk_prefix or '').strip()}llm-topic-modeller" | |
| def prompt_application_tagging_and_appregistry_options( | |
| answers: "InstallAnswers", | |
| args: argparse.Namespace, | |
| *, | |
| interactive: bool, | |
| ) -> None: | |
| """ | |
| Configure application tags / Resource Groups (preferred) and legacy AppRegistry. | |
| AppRegistry / myApplications closes to new customers after 30 Jul 2026. | |
| """ | |
| if getattr(args, "appregistry", False): | |
| answers.enable_appregistry = True | |
| elif getattr(args, "no_appregistry", False): | |
| answers.enable_appregistry = False | |
| elif interactive: | |
| answers.enable_appregistry = ask_yes_no( | |
| "Enable legacy AppRegistry / myApplications stack " | |
| "(opt-in; new customers cut off after 30 Jul 2026)?", | |
| default=False, | |
| ) | |
| # else: keep InstallAnswers default (False) | |
| if getattr(args, "application_resource_group", False): | |
| answers.enable_application_resource_group = True | |
| elif getattr(args, "no_application_resource_group", False): | |
| answers.enable_application_resource_group = False | |
| elif interactive: | |
| answers.enable_application_resource_group = ask_yes_no( | |
| "Tag all CDK-managed resources and create a Resource Group for this app " | |
| "(recommended for monitoring without AppRegistry)?", | |
| default=True, | |
| ) | |
| # else: keep default True | |
| if not answers.enable_application_resource_group: | |
| return | |
| default_tag_key = (answers.application_tag_key or "Application").strip() or ( | |
| "Application" | |
| ) | |
| if getattr(args, "application_tag_key", "").strip(): | |
| answers.application_tag_key = args.application_tag_key.strip() | |
| elif interactive: | |
| answers.application_tag_key = ( | |
| ask("Application tag key", default_tag_key).strip() or default_tag_key | |
| ) | |
| else: | |
| answers.application_tag_key = default_tag_key | |
| default_name = answers.application_name.strip() or default_application_name( | |
| answers.cdk_prefix | |
| ) | |
| if getattr(args, "application_name", "").strip(): | |
| answers.application_name = args.application_name.strip() | |
| elif interactive: | |
| answers.application_name = ( | |
| ask( | |
| "Application name (tag value / Resource Group filter)", default_name | |
| ).strip() | |
| or default_name | |
| ) | |
| else: | |
| answers.application_name = default_name | |
| def validate_install_answers(answers: "InstallAnswers") -> List[str]: | |
| errors: List[str] = [] | |
| msg = headless_profile_error(answers) | |
| if msg: | |
| errors.append(msg) | |
| if answers.enable_headless_output_notifications: | |
| if not answers_use_headless(answers): | |
| errors.append( | |
| "Headless output notifications require headless batch deployment." | |
| ) | |
| email_error = validate_notify_email(answers.headless_output_notify_email) | |
| if email_error: | |
| errors.append(email_error) | |
| if answers.enable_dynamo_usage_log_export: | |
| time_error = validate_schedule_time_hhmm(answers.dynamo_export_schedule_time) | |
| if time_error: | |
| errors.append(time_error) | |
| days = (answers.dynamo_export_schedule_days or "").strip().lower() | |
| if days not in DYNAMO_EXPORT_SCHEDULE_DAY_PRESETS: | |
| errors.append( | |
| "DynamoDB export schedule days must be one of: " | |
| + ", ".join(DYNAMO_EXPORT_SCHEDULE_DAY_PRESETS) | |
| ) | |
| if not (answers.dynamo_export_s3_key.strip() or default_dynamo_export_s3_key()): | |
| errors.append("DynamoDB export S3 object key is required.") | |
| if answers.enable_bedrock_model_invocation_logging: | |
| if not (answers.bedrock_invocation_s3_prefix or "").strip(): | |
| errors.append( | |
| "Bedrock model invocation logging requires an S3 key prefix " | |
| "(bedrock_invocation_s3_prefix)." | |
| ) | |
| log_group = (answers.bedrock_invocation_log_group_name or "").strip() | |
| if not log_group: | |
| errors.append( | |
| "Bedrock model invocation logging requires a CloudWatch log group name." | |
| ) | |
| try: | |
| retention = int( | |
| (answers.bedrock_invocation_log_retention_days or "90").strip() | |
| ) | |
| if retention <= 0: | |
| raise ValueError | |
| except ValueError: | |
| errors.append( | |
| "Bedrock invocation log retention days must be a positive integer." | |
| ) | |
| if answers.enable_application_resource_group: | |
| app_name = answers.application_name.strip() or default_application_name( | |
| answers.cdk_prefix | |
| ) | |
| if not app_name: | |
| errors.append( | |
| "Application Resource Group requires an application name " | |
| "(APPLICATION_NAME)." | |
| ) | |
| if not (answers.application_tag_key or "").strip(): | |
| errors.append("Application Resource Group requires an application tag key.") | |
| 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 | |
| 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_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_pi_express else "False" | |
| ), | |
| "ENABLE_PI_AGENT_ECS_SERVICE": ( | |
| "True" if answers.enable_pi_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", | |
| } | |
| ) | |
| if answers.enable_headless_output_notifications: | |
| iam_user = ( | |
| answers.headless_output_iam_user_name.strip() | |
| or f"{answers.cdk_prefix}s3-output-reader" | |
| ) | |
| values.update( | |
| { | |
| "ENABLE_HEADLESS_OUTPUT_NOTIFICATIONS": "True", | |
| "HEADLESS_OUTPUT_NOTIFY_EMAIL": ( | |
| answers.headless_output_notify_email.strip() | |
| ), | |
| "HEADLESS_OUTPUT_IAM_USER_NAME": iam_user, | |
| } | |
| ) | |
| if answers.enable_dynamo_usage_log_export: | |
| hour, minute = parse_schedule_time_hhmm(answers.dynamo_export_schedule_time) | |
| s3_key = answers.dynamo_export_s3_key.strip() or default_dynamo_export_s3_key() | |
| values.update( | |
| { | |
| "ENABLE_DYNAMODB_USAGE_LOG_EXPORT": "True", | |
| "DYNAMODB_USAGE_LOG_EXPORT_SCHEDULE": build_dynamo_export_cron_expression( | |
| hour, minute, answers.dynamo_export_schedule_days | |
| ), | |
| "DYNAMODB_USAGE_LOG_EXPORT_S3_KEY": s3_key, | |
| "DYNAMODB_USAGE_LOG_EXPORT_DATE_ATTRIBUTE": ( | |
| answers.dynamo_export_date_attribute.strip() or "timestamp" | |
| ), | |
| } | |
| ) | |
| if answers.enable_bedrock_model_invocation_logging: | |
| values.update( | |
| { | |
| "ENABLE_BEDROCK_MODEL_INVOCATION_LOGGING": "True", | |
| "BEDROCK_MODEL_INVOCATION_S3_PREFIX": ( | |
| answers.bedrock_invocation_s3_prefix.strip().strip("/") | |
| or "bedrock-logs" | |
| ), | |
| "BEDROCK_MODEL_INVOCATION_LOG_GROUP_NAME": ( | |
| answers.bedrock_invocation_log_group_name.strip() | |
| or default_bedrock_invocation_log_group_name(answers.cdk_prefix) | |
| ), | |
| "BEDROCK_MODEL_INVOCATION_LOG_RETENTION_DAYS": ( | |
| answers.bedrock_invocation_log_retention_days.strip() or "90" | |
| ), | |
| } | |
| ) | |
| # Application tags + Resource Groups (preferred); AppRegistry is legacy opt-in. | |
| # Always write tagging identity so Tags.of(app) has stable values even when | |
| # Resource Group creation is disabled. | |
| app_name = answers.application_name.strip() or default_application_name( | |
| answers.cdk_prefix | |
| ) | |
| tag_key = (answers.application_tag_key or "Application").strip() or "Application" | |
| values["APPLICATION_NAME"] = app_name | |
| values["APPLICATION_TAG_KEY"] = tag_key | |
| values["APPLICATION_RESOURCE_GROUP_NAME"] = f"{app_name}-resources" | |
| values["ENABLE_APPLICATION_RESOURCE_GROUP"] = ( | |
| "True" if answers.enable_application_resource_group else "False" | |
| ) | |
| values["ENABLE_APPREGISTRY"] = "True" if answers.enable_appregistry else "False" | |
| if answers.enable_appregistry: | |
| values["APPREGISTRY_STACK_NAME"] = ( | |
| f"{answers.cdk_prefix}{APPREGISTRY_STACK_SUFFIX}" | |
| ) | |
| values["APPREGISTRY_APPLICATION_NAME"] = app_name | |
| use_cloudfront = values.get("USE_CLOUDFRONT") == "True" | |
| if answers.pi_enabled: | |
| values.update( | |
| { | |
| "PI_GRADIO_PORT": answers.pi_gradio_port, | |
| "ECS_SERVICE_CONNECT_DISCOVERY_NAME": answers.sc_discovery_name, | |
| } | |
| ) | |
| if answers.enable_pi_express: | |
| values["ECS_EXPRESS_SC_PORT_NAME"] = "port-7860" | |
| values["ECS_PI_EXPRESS_SC_PORT_NAME"] = f"port-{answers.pi_gradio_port}" | |
| else: | |
| routing = answers.pi_alb_routing.strip().lower() | |
| path_prefix = normalize_pi_path_prefix(answers.pi_alb_path_prefix) | |
| priority = ( | |
| answers.pi_alb_listener_rule_priority.strip() | |
| or default_pi_listener_priority(use_cloudfront) | |
| ) | |
| values.update( | |
| { | |
| "PI_ALB_ROUTING": routing, | |
| "PI_ALB_PATH_PREFIX": path_prefix, | |
| "PI_ALB_LISTENER_RULE_PRIORITY": priority, | |
| } | |
| ) | |
| if routing in ("host", "both"): | |
| values["PI_ALB_HOST_HEADER"] = answers.pi_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 | |
| ) | |
| # Pi agent is not supported in llm_topic_modeller yet (stack code retained for future). | |
| values["ENABLE_PI_AGENT_EXPRESS_SERVICE"] = "False" | |
| values["ENABLE_PI_AGENT_ECS_SERVICE"] = "False" | |
| return values | |
| def validate_env_values(values: Dict[str, str]) -> 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." | |
| ) | |
| pi_ecs = values.get("ENABLE_PI_AGENT_ECS_SERVICE") == "True" | |
| pi_express = values.get("ENABLE_PI_AGENT_EXPRESS_SERVICE") == "True" | |
| if pi_ecs and pi_express: | |
| errors.append("Enable at most one agent mode (ECS or Express).") | |
| if pi_express and not express: | |
| errors.append( | |
| "ENABLE_PI_AGENT_EXPRESS_SERVICE requires USE_ECS_EXPRESS_MODE=True." | |
| ) | |
| if pi_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"), | |
| ): | |
| 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 pi_ecs and values.get("ENABLE_ECS_SERVICE_CONNECT") != "True": | |
| errors.append( | |
| "ENABLE_PI_AGENT_ECS_SERVICE=True requires ENABLE_ECS_SERVICE_CONNECT=True." | |
| ) | |
| if headless and ( | |
| pi_ecs or pi_express or values.get("ENABLE_ECS_SERVICE_CONNECT") == "True" | |
| ): | |
| errors.append( | |
| "ENABLE_HEADLESS_DEPLOYMENT is incompatible with agent mode or Service Connect." | |
| ) | |
| if values.get("ENABLE_HEADLESS_OUTPUT_NOTIFICATIONS") == "True": | |
| if not headless: | |
| errors.append( | |
| "ENABLE_HEADLESS_OUTPUT_NOTIFICATIONS requires " | |
| "ENABLE_HEADLESS_DEPLOYMENT=True." | |
| ) | |
| email_error = validate_notify_email( | |
| values.get("HEADLESS_OUTPUT_NOTIFY_EMAIL", "") | |
| ) | |
| if email_error: | |
| errors.append(email_error) | |
| if values.get("ENABLE_DYNAMODB_USAGE_LOG_EXPORT") == "True": | |
| if values.get("SAVE_LOGS_TO_DYNAMODB") != "True": | |
| errors.append( | |
| "ENABLE_DYNAMODB_USAGE_LOG_EXPORT requires SAVE_LOGS_TO_DYNAMODB=True." | |
| ) | |
| schedule = (values.get("DYNAMODB_USAGE_LOG_EXPORT_SCHEDULE") or "").strip() | |
| if not schedule.startswith("cron(") and not schedule.startswith("rate("): | |
| errors.append( | |
| "DYNAMODB_USAGE_LOG_EXPORT_SCHEDULE must be an EventBridge " | |
| f"cron(...) or rate(...) expression; got {schedule!r}." | |
| ) | |
| if not (values.get("DYNAMODB_USAGE_LOG_EXPORT_S3_KEY") or "").strip(): | |
| errors.append( | |
| "DYNAMODB_USAGE_LOG_EXPORT_S3_KEY is required when " | |
| "ENABLE_DYNAMODB_USAGE_LOG_EXPORT=True." | |
| ) | |
| pi_routing = (values.get("PI_ALB_ROUTING") or "").strip().lower() | |
| if pi_ecs: | |
| if pi_routing not in PI_ALB_ROUTING_MODES: | |
| errors.append( | |
| f"PI_ALB_ROUTING must be one of {list(PI_ALB_ROUTING_MODES)}; got '{pi_routing}'." | |
| ) | |
| elif ( | |
| pi_routing in ("host", "both") | |
| and not (values.get("PI_ALB_HOST_HEADER") or "").strip() | |
| ): | |
| errors.append( | |
| "PI_ALB_HOST_HEADER is required when PI_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" | |
| pi_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}" | |
| output_bucket = _name("S3_OUTPUT_BUCKET_NAME", "s3-output") | |
| return { | |
| "COGNITO_AUTH": "False" if headless or pi_express else "True", | |
| "RUN_AWS_FUNCTIONS": "True", | |
| "RUN_AWS_BEDROCK_MODELS": "True", | |
| "RUN_LOCAL_MODEL": "False", | |
| "RUN_GEMINI_MODELS": "False", | |
| "RUN_AZURE_MODELS": "False", | |
| "PRIORITISE_SSO_OVER_AWS_ENV_ACCESS_KEYS": "True", | |
| "DISPLAY_FILE_NAMES_IN_LOGS": "False", | |
| "SESSION_OUTPUT_FOLDER": "True", | |
| "SAVE_LOGS_TO_CSV": "True", | |
| "SAVE_LOGS_TO_DYNAMODB": "True", | |
| "SHOW_COSTS": "True", | |
| "S3_LOG_BUCKET": _name("S3_LOG_CONFIG_BUCKET_NAME", "s3-logs"), | |
| "S3_OUTPUTS_BUCKET": output_bucket if headless else "", | |
| "S3_OUTPUTS_FOLDER": "output/" if headless else "", | |
| "SAVE_OUTPUTS_TO_S3": "True" if headless else "False", | |
| "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" | |
| ), | |
| } | |
| 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 build_pi_agent_env_values(answers: InstallAnswers) -> Dict[str, str]: | |
| """Runtime settings for the Pi agent Gradio app (uploaded to S3 as pi_agent.env).""" | |
| values = { | |
| "PI_DEPLOYMENT_PROFILE": "aws-ecs", | |
| "PI_DEFAULT_PROVIDER": answers.pi_default_provider, | |
| "DOC_SUMMARISATION_GRADIO_URL": f"http://{answers.sc_discovery_name}:7860", | |
| "RUN_AWS_FUNCTIONS": "True", | |
| "AWS_REGION": answers.aws_region, | |
| "PI_GRADIO_PORT": answers.pi_gradio_port, | |
| "PI_DEFAULT_OCR_METHOD": "AWS Textract service - all PDF types", | |
| "PI_DEFAULT_PII_METHOD": "AWS Comprehend", | |
| } | |
| if answers.enable_pi_express: | |
| values["RUN_FASTAPI"] = "True" | |
| return values | |
| path_prefix = normalize_pi_path_prefix(answers.pi_alb_path_prefix) | |
| if answers.pi_alb_routing.strip().lower() in ("path", "both"): | |
| values["PI_ROOT_PATH"] = path_prefix | |
| values["ROOT_PATH"] = path_prefix | |
| values["FASTAPI_ROOT_PATH"] = path_prefix | |
| return values | |
| def write_pi_agent_env_file( | |
| answers: InstallAnswers, | |
| *, | |
| overwrite: bool = False, | |
| ) -> Optional[Path]: | |
| if not answers.pi_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) | |
| if PI_AGENT_ENV_PATH.is_file() and not overwrite: | |
| existing = read_env_file(PI_AGENT_ENV_PATH) | |
| existing.update(updates) | |
| backup_file(PI_AGENT_ENV_PATH) | |
| 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"Updated {PI_AGENT_ENV_PATH} (AWS/Pi agent keys merged)") | |
| 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 — Pi agent runtime config for AWS ECS", | |
| *[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") | |
| print(f"Wrote {path}") | |
| 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_BEDROCK_MODEL_INVOCATION_LOGGING", | |
| "ENABLE_APPREGISTRY", | |
| "ENABLE_APPLICATION_RESOURCE_GROUP", | |
| "APPLICATION_NAME", | |
| "ENABLE_RESOURCE_DELETE_PROTECTION", | |
| "VPC_NAME", | |
| "NEW_VPC_CIDR", | |
| "ACM_SSL_CERTIFICATE_ARN", | |
| "SSL_CERTIFICATE_DOMAIN", | |
| "ENABLE_PI_AGENT_EXPRESS_SERVICE", | |
| "ENABLE_PI_AGENT_ECS_SERVICE", | |
| ] | |
| 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, | |
| ) | |
| 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 us-east-1 CloudFront stack lookup when config disables that path.""" | |
| if not config_values: | |
| return True | |
| use_cloudfront = config_values.get("USE_CLOUDFRONT") | |
| run_useast = config_values.get("RUN_USEAST_STACK") | |
| if use_cloudfront is not None and use_cloudfront != "True": | |
| return False | |
| if run_useast is not None and run_useast != "True": | |
| return False | |
| return 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_llm_topic_stacks( | |
| regional_region: str, | |
| config_values: Optional[Dict[str, str]] = None, | |
| ) -> List[ExistingStack]: | |
| """Find deployed llm_topic_modeller 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 discover_existing_doc_summarisation_stacks( | |
| regional_region: str, | |
| config_values: Optional[Dict[str, str]] = None, | |
| ) -> List[ExistingStack]: | |
| """Deprecated alias for discover_existing_llm_topic_stacks.""" | |
| return discover_existing_llm_topic_stacks(regional_region, config_values) | |
| 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_llm_topic_stacks(regional_region, config_values) | |
| except Exception as exc: | |
| print(f"Existing stack check skipped: {exc}") | |
| return | |
| if not existing: | |
| return | |
| print("\n--- Existing llm_topic_modeller CloudFormation stacks ---") | |
| for stack in existing: | |
| line = f" {stack.name} ({stack.region}): {stack.status}" | |
| if stack.termination_protection: | |
| line += " [termination protection ON]" | |
| print(line) | |
| if args.force_delete_stacks: | |
| should_delete = True | |
| elif args.yes: | |
| print( | |
| "\nStacks already exist in this account/region. " | |
| "Pass --force-delete-stacks to remove them before deploy, " | |
| "or omit it to update in place." | |
| ) | |
| return | |
| else: | |
| should_delete = ask_yes_no( | |
| "Force-delete these stacks before continuing? " | |
| "(disables termination protection and deletes all stack resources)", | |
| default=False, | |
| ) | |
| if not should_delete: | |
| print("Keeping existing stacks (deploy will update them in place).\n") | |
| return | |
| if not args.force_delete_stacks and not args.yes: | |
| if not ask_yes_no( | |
| "This permanently deletes AWS resources in these stacks. Proceed?", | |
| default=False, | |
| ): | |
| print("Stack deletion cancelled.\n") | |
| return | |
| force_delete_cloudformation_stacks(existing) | |
| print("Existing stacks deleted.\n") | |
| 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( | |
| CLOUDFRONT_STACK, "CloudFrontDistributionURL", CLOUDFRONT_STACK_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) | |
| # --------------------------------------------------------------------------- | |
| # Pi agent configuration (disabled in installer; stack code retained for future) | |
| # --------------------------------------------------------------------------- | |
| _DEPRECATED_PI_CLI_FLAGS = ( | |
| "--enable-pi", | |
| "--enable-pi-express", | |
| "--enable-pi-legacy", | |
| "--pi-alb-routing", | |
| "--pi-path-prefix", | |
| "--pi-host-header", | |
| "--pi-listener-priority", | |
| "--pi-gradio-port", | |
| "--sc-discovery-name", | |
| "--pi-provider", | |
| "--skip-pi-agent-env", | |
| ) | |
| def warn_deprecated_pi_cli_flags(args: argparse.Namespace) -> None: | |
| """Ignore Pi agent CLI flags — not supported in llm_topic_modeller yet.""" | |
| argv = getattr(args, "_argv", ()) or () | |
| if any( | |
| token == flag or token.startswith(f"{flag}=") | |
| for flag in _DEPRECATED_PI_CLI_FLAGS | |
| for token in argv | |
| ): | |
| print( | |
| "Note: Pi agent is not supported in llm_topic_modeller yet; " | |
| "ignoring Pi-related flags." | |
| ) | |
| answers_pi = ( | |
| getattr(args, "enable_pi", False) | |
| or getattr(args, "enable_pi_express", False) | |
| or getattr(args, "enable_pi_legacy", False) | |
| ) | |
| if answers_pi: | |
| print( | |
| "Note: Pi agent is not supported in llm_topic_modeller yet; " | |
| "ignoring Pi-related flags." | |
| ) | |
| def apply_pi_cli_flags(args: argparse.Namespace, answers: InstallAnswers) -> None: | |
| if getattr(args, "enable_pi", False): | |
| preset = merge_preset(answers.profile, answers.custom_overrides) | |
| if preset.get("USE_ECS_EXPRESS_MODE") == "True": | |
| answers.enable_pi_express = True | |
| else: | |
| answers.enable_pi_legacy = True | |
| answers.enable_service_connect = True | |
| if getattr(args, "enable_pi_express", False): | |
| answers.enable_pi_express = True | |
| if getattr(args, "enable_pi_legacy", False): | |
| answers.enable_pi_legacy = True | |
| answers.enable_service_connect = True | |
| if args.pi_alb_routing: | |
| answers.pi_alb_routing = args.pi_alb_routing | |
| if args.pi_path_prefix: | |
| answers.pi_alb_path_prefix = args.pi_path_prefix | |
| if args.pi_host_header: | |
| answers.pi_alb_host_header = args.pi_host_header | |
| if args.pi_listener_priority: | |
| answers.pi_alb_listener_rule_priority = args.pi_listener_priority | |
| if args.pi_gradio_port: | |
| answers.pi_gradio_port = args.pi_gradio_port | |
| if args.sc_discovery_name: | |
| answers.sc_discovery_name = args.sc_discovery_name | |
| if args.pi_provider: | |
| answers.pi_default_provider = args.pi_provider | |
| if args.skip_pi_agent_env: | |
| 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_pi_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_pi_cli_flags(args, answers) | |
| if not answers.pi_enabled and interactive: | |
| if use_express: | |
| label = ( | |
| "Deploy agent mode (second Gradio app on Express, dedicated HTTPS URL)?" | |
| ) | |
| answers.enable_pi_express = ask_yes_no(label, default=False) | |
| else: | |
| label = "Deploy agent mode (second Gradio app on legacy Fargate + Service Connect)?" | |
| if ask_yes_no(label, default=False): | |
| answers.enable_pi_legacy = True | |
| answers.enable_service_connect = True | |
| if not answers.pi_enabled: | |
| return | |
| if not answers.pi_alb_listener_rule_priority and not answers.enable_pi_express: | |
| answers.pi_alb_listener_rule_priority = default_pi_listener_priority( | |
| use_cloudfront | |
| ) | |
| if answers.enable_pi_express: | |
| if interactive and not assume_yes: | |
| print("\n--- Agent mode (ECS Express) ---") | |
| answers.sc_discovery_name = ask( | |
| "Service Connect discovery name for main app", | |
| answers.sc_discovery_name, | |
| ) | |
| answers.pi_gradio_port = ask("Agent Gradio port", answers.pi_gradio_port) | |
| if ask_yes_no( | |
| f"Write/update {PI_AGENT_ENV_PATH.name} for AWS ECS (DOC_SUMMARISATION_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( | |
| "pi_agent.env exists — replace file from example template?", | |
| default=False, | |
| ) | |
| else: | |
| answers.write_pi_agent_env = False | |
| print( | |
| f"Agent mode: Express (dedicated HTTPS endpoint per service); " | |
| f"Service Connect discovery={answers.sc_discovery_name}" | |
| ) | |
| return | |
| if interactive and not ( | |
| args.pi_alb_routing or args.pi_path_prefix or args.pi_host_header or assume_yes | |
| ): | |
| print("\n--- Agent mode 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 (PI_ALB_HOST_HEADER)", | |
| "Both path prefix and hostname", | |
| ], | |
| default_index=0, | |
| ) | |
| answers.pi_alb_routing = PI_ALB_ROUTING_MODES[ridx] | |
| if answers.pi_alb_routing in ("path", "both"): | |
| answers.pi_alb_path_prefix = ask( | |
| "Agent path prefix", | |
| answers.pi_alb_path_prefix, | |
| ) | |
| if answers.pi_alb_routing in ("host", "both"): | |
| default_host = "" | |
| if answers.ssl_domain: | |
| default_host = f"agent.{answers.ssl_domain}" | |
| answers.pi_alb_host_header = ask( | |
| "Agent ALB host header (DNS CNAME to CloudFront/ALB)", | |
| default_host, | |
| ) | |
| if use_cloudfront: | |
| default_pri = default_pi_listener_priority(True) | |
| answers.pi_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.pi_gradio_port = ask("Agent Gradio port", answers.pi_gradio_port) | |
| if ask_yes_no( | |
| f"Write/update {PI_AGENT_ENV_PATH.name} for AWS ECS (DOC_SUMMARISATION_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( | |
| "pi_agent.env exists — replace file from example template?", | |
| default=False, | |
| ) | |
| else: | |
| answers.write_pi_agent_env = False | |
| print( | |
| f"Agent mode: " | |
| f"{'Express' if answers.enable_pi_express else 'legacy Fargate'}, " | |
| f"routing={answers.pi_alb_routing}, " | |
| f"prefix={normalize_pi_path_prefix(answers.pi_alb_path_prefix)}" | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # 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"] = ( | |
| "True" | |
| if answers.custom_overrides.get("USE_CLOUDFRONT") == "True" | |
| and ask_yes_no( | |
| "Deploy us-east-1 CloudFront stack (RUN_USEAST_STACK)?", True | |
| ) | |
| else "False" | |
| ) | |
| answers.custom_overrides["ENABLE_RESOURCE_DELETE_PROTECTION"] = ( | |
| "True" if ask_yes_no("Enable delete protection?", True) 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, | |
| ) | |
| 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()}-Summarisation-" | |
| answers.cdk_prefix = args.cdk_prefix or ( | |
| ask("CDK resource prefix (e.g. MyOrg-Summarisation-)", 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): | |
| warn_deprecated_pi_cli_flags(args) | |
| # Advanced add-ons (non-Pi agent) | |
| is_express = use_express and not answers_use_headless(answers) | |
| if answers_use_headless(answers) and interactive: | |
| mem = ask("ECS task memory (MB)", answers.ecs_memory) | |
| if mem: | |
| answers.ecs_memory = mem | |
| if getattr(args, "headless_output_notifications", False): | |
| answers.enable_headless_output_notifications = True | |
| elif getattr(args, "no_headless_output_notifications", False): | |
| answers.enable_headless_output_notifications = False | |
| elif ask_yes_no( | |
| "Enable email notifications when new analysis outputs are uploaded to S3?", | |
| default=True, | |
| ): | |
| answers.enable_headless_output_notifications = True | |
| if answers.enable_headless_output_notifications: | |
| default_email = getattr(args, "headless_notify_email", "") or "" | |
| while True: | |
| answers.headless_output_notify_email = ask( | |
| "Notification email address (SNS subscription; confirm via AWS email)", | |
| default_email, | |
| ).strip() | |
| email_error = validate_notify_email( | |
| answers.headless_output_notify_email | |
| ) | |
| if not email_error: | |
| break | |
| print(email_error) | |
| default_iam_user = ( | |
| getattr(args, "headless_output_iam_user", "").strip() | |
| or f"{answers.cdk_prefix}s3-output-reader" | |
| ) | |
| iam_user = ask( | |
| "IAM user name for programmatic S3 output access", | |
| default_iam_user, | |
| ).strip() | |
| answers.headless_output_iam_user_name = iam_user or default_iam_user | |
| elif answers_use_headless(answers): | |
| if getattr(args, "headless_output_notifications", False): | |
| answers.enable_headless_output_notifications = True | |
| answers.headless_output_notify_email = ( | |
| getattr(args, "headless_notify_email", "") or "" | |
| ).strip() | |
| answers.headless_output_iam_user_name = ( | |
| getattr(args, "headless_output_iam_user", "").strip() | |
| or f"{answers.cdk_prefix}s3-output-reader" | |
| ) | |
| email_error = validate_notify_email(answers.headless_output_notify_email) | |
| if email_error: | |
| raise SystemExit(email_error) | |
| 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 agent mode)?", False | |
| ) | |
| answers.enable_s3_batch = ask_yes_no("Enable S3 batch ECS trigger?", False) | |
| mem = ask("ECS task memory (MB)", answers.ecs_memory) | |
| if mem: | |
| answers.ecs_memory = mem | |
| prompt_dynamo_usage_log_export_options(answers, args, interactive=interactive) | |
| prompt_bedrock_model_invocation_logging_options( | |
| answers, args, interactive=interactive | |
| ) | |
| prompt_application_tagging_and_appregistry_options( | |
| answers, args, interactive=interactive | |
| ) | |
| 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 llm_topic_modeller", | |
| 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( | |
| "--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="If llm_topic_modeller stacks already exist, delete them before continuing " | |
| "(disables termination protection; implies consent in non-interactive mode)", | |
| ) | |
| 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("--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( | |
| "--headless-output-notifications", | |
| action="store_true", | |
| help="Enable S3 output PutRequests alarm, SNS email, and IAM reader user (headless)", | |
| ) | |
| p.add_argument( | |
| "--no-headless-output-notifications", | |
| action="store_true", | |
| help="Disable headless output notifications in interactive headless installs", | |
| ) | |
| p.add_argument( | |
| "--headless-notify-email", | |
| help="Email for SNS notifications when headless outputs are uploaded", | |
| ) | |
| p.add_argument( | |
| "--headless-output-iam-user", | |
| help="IAM user name for programmatic download of headless S3 outputs", | |
| ) | |
| p.add_argument( | |
| "--dynamo-usage-log-export", | |
| action="store_true", | |
| help="Enable scheduled DynamoDB usage log export Lambda (EventBridge)", | |
| ) | |
| p.add_argument( | |
| "--no-dynamo-usage-log-export", | |
| action="store_true", | |
| help="Disable DynamoDB usage log export in interactive installs", | |
| ) | |
| p.add_argument( | |
| "--dynamo-export-schedule-time", | |
| metavar="HH:MM", | |
| help="UTC schedule time for DynamoDB usage log export (default: 06:00)", | |
| ) | |
| p.add_argument( | |
| "--dynamo-export-schedule-days", | |
| choices=tuple(DYNAMO_EXPORT_SCHEDULE_DAY_PRESETS), | |
| help="Days to run DynamoDB usage log export (default: daily)", | |
| ) | |
| p.add_argument( | |
| "--dynamo-export-s3-key", | |
| help="S3 object key for the exported CSV (default: reports/dynamodb-usage/...)", | |
| ) | |
| p.add_argument( | |
| "--dynamo-export-date-attribute", | |
| help="DynamoDB attribute used for date filtering (default: timestamp)", | |
| ) | |
| p.add_argument( | |
| "--bedrock-model-invocation-logging", | |
| action="store_true", | |
| help=( | |
| "Enable Bedrock model invocation logging to the S3 log/config bucket " | |
| "and CloudWatch Logs (account/Region-level)" | |
| ), | |
| ) | |
| p.add_argument( | |
| "--no-bedrock-model-invocation-logging", | |
| action="store_true", | |
| help="Disable Bedrock model invocation logging in interactive installs", | |
| ) | |
| p.add_argument( | |
| "--bedrock-invocation-s3-prefix", | |
| default="", | |
| help="S3 key prefix for Bedrock invocation logs (default: bedrock-logs)", | |
| ) | |
| p.add_argument( | |
| "--bedrock-invocation-log-group", | |
| default="", | |
| help="CloudWatch log group for Bedrock invocation logs", | |
| ) | |
| p.add_argument( | |
| "--bedrock-invocation-log-retention-days", | |
| type=int, | |
| default=None, | |
| metavar="DAYS", | |
| help="CloudWatch retention for Bedrock invocation logs (default: 90)", | |
| ) | |
| p.add_argument( | |
| "--appregistry", | |
| action="store_true", | |
| help=( | |
| "Enable legacy AppRegistry / myApplications stack " | |
| "(opt-in; new customers cut off after 30 Jul 2026)" | |
| ), | |
| ) | |
| p.add_argument( | |
| "--no-appregistry", | |
| action="store_true", | |
| help="Disable AppRegistry (default)", | |
| ) | |
| p.add_argument( | |
| "--application-resource-group", | |
| action="store_true", | |
| help=( | |
| "Tag CDK-managed resources and create a tag-based AWS Resource Group " | |
| "(default on)" | |
| ), | |
| ) | |
| p.add_argument( | |
| "--no-application-resource-group", | |
| action="store_true", | |
| help="Disable application Resource Group creation", | |
| ) | |
| p.add_argument( | |
| "--application-name", | |
| default="", | |
| help="Application tag value / Resource Group filter (default: {CDK_PREFIX}llm-topic-modeller)", | |
| ) | |
| p.add_argument( | |
| "--application-tag-key", | |
| default="", | |
| help="Application tag key applied to CDK-managed resources (default: Application)", | |
| ) | |
| p.add_argument( | |
| "--create-headless-output-access-key", | |
| action="store_true", | |
| help="After deploy, create IAM access key for the headless output reader user", | |
| ) | |
| p.add_argument( | |
| "--skip-headless-output-access-key", | |
| action="store_true", | |
| help="Do not offer to create IAM access key after headless deploy", | |
| ) | |
| p.add_argument("--cert-arn", help="ACM certificate ARN (production)") | |
| p.add_argument("--domain", help="SSL certificate domain (production)") | |
| 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 main(argv: Optional[Sequence[str]] = None) -> int: | |
| argv = list(argv) if argv is not None else None | |
| args = build_arg_parser().parse_args(argv) | |
| args._argv = tuple(argv if argv is not None else sys.argv[1:]) | |
| CONFIG_DIR.mkdir(parents=True, exist_ok=True) | |
| print("llm_topic_modeller 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.") | |
| 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)) | |
| 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, | |
| ) | |
| 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) | |
| apply_post_deploy_fixup(values, assume_yes=args.yes) | |
| 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) | |
| 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, | |
| print_headless_output_notification_steps, | |
| provision_headless_output_reader_access_key, | |
| ) | |
| if args.skip_quickstart or not run_qs: | |
| print_headless_deployment_next_steps(values) | |
| if values.get("ENABLE_HEADLESS_OUTPUT_NOTIFICATIONS") == "True": | |
| print_headless_output_notification_steps(values) | |
| create_key = False | |
| if args.skip_headless_output_access_key: | |
| create_key = False | |
| elif args.create_headless_output_access_key: | |
| create_key = True | |
| elif not args.yes and not args.deploy_only: | |
| create_key = ask_yes_no( | |
| "Create IAM access key for the headless output reader user now?", | |
| default=True, | |
| ) | |
| elif args.yes: | |
| create_key = True | |
| if create_key: | |
| provision_headless_output_reader_access_key(values) | |
| return 0 | |
| print("\nDone. Next steps:") | |
| 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}") | |
| 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()) | |