#!/usr/bin/env bash # PLAT-86: dispose former CDK CloudFormation stacks after HCP Terraform import. # # Import-in-place only. Sets DeletionPolicy=Retain on every resource so stack # delete orphans CFN ownership without destroying live TF-managed resources. # Custom::S3BucketNotifications must be retained — its Delete handler would # empty PutBucketNotificationConfiguration and wipe TF-owned inbound triggers. # # Usage: # AWS_PROFILE=seahaven-prod ./scripts/plat86-cfn-dispose.sh --dry-run # AWS_PROFILE=seahaven-prod ./scripts/plat86-cfn-dispose.sh --execute # # Never pairs with seahaven-org-baseline cfn-stack-decommission.sh --execute # (that script deletes RETAIN orphans after stack delete). set -euo pipefail REGION="${AWS_REGION:-us-east-1}" PROFILE="${AWS_PROFILE:-seahaven-prod}" STACKS=(po-ingest WorkorderIngestStack procurement-api) PO_BUCKET="po-ingest-emails-011934824531" WO_BUCKET="workorder-ingest-emails-011934824531" # Staging for retain-all templates (inline --template-body is capped at 51KB). TEMPLATE_BUCKET="${PLAT86_TEMPLATE_BUCKET:-procurement-ingest-artifacts-011934824531}" TEMPLATE_PREFIX="plat86-cfn-dispose" MODE="" aws_cmd() { aws --profile "${PROFILE}" --region "${REGION}" "$@" } usage() { echo "Usage: $0 --dry-run | --execute" >&2 exit 2 } [[ $# -eq 1 ]] || usage case "$1" in --dry-run) MODE=dry-run ;; --execute) MODE=execute ;; *) usage ;; esac work_dir="$(mktemp -d)" trap 'rm -rf "${work_dir}"' EXIT echo "==> mode=${MODE} profile=${PROFILE} region=${REGION}" verify_notifications() { local bucket="$1" expect_id="$2" expect_fn="$3" local id fn id="$(aws_cmd s3api get-bucket-notification-configuration --bucket "${bucket}" \ --query 'LambdaFunctionConfigurations[0].Id' --output text)" fn="$(aws_cmd s3api get-bucket-notification-configuration --bucket "${bucket}" \ --query 'LambdaFunctionConfigurations[0].LambdaFunctionArn' --output text)" if [[ "${id}" != "${expect_id}" ]] || [[ "${fn}" != *":function:${expect_fn}" ]]; then echo "FAIL: ${bucket} notification mismatch id=${id} fn=${fn}" >&2 return 1 fi echo "OK: ${bucket} -> ${id} (${expect_fn})" } verify_core() { local fn for fn in po-email-processor workorder-email-processor procurement-api \ po-ingest-site-extractor workorder-shoc-emitter; do aws_cmd lambda get-function --function-name "${fn}" --query 'Configuration.FunctionName' --output text >/dev/null echo "OK: lambda ${fn}" done for table in purchase-orders verified-sites pending-site-review WorkOrders WorkOrderComments; do aws_cmd dynamodb describe-table --table-name "${table}" --query 'Table.TableName' --output text >/dev/null echo "OK: table ${table}" done aws_cmd s3api head-bucket --bucket "${PO_BUCKET}" >/dev/null aws_cmd s3api head-bucket --bucket "${WO_BUCKET}" >/dev/null echo "OK: email buckets" verify_notifications "${PO_BUCKET}" "po-email-processor-inbound" "po-email-processor" verify_notifications "${WO_BUCKET}" "wo-email-processor-inbound" "workorder-email-processor" } echo "==> pre-checks" verify_core retain_template() { local stack="$1" local raw="${work_dir}/${stack}.raw.json" local out="${work_dir}/${stack}.retain.json" aws_cmd cloudformation get-template --stack-name "${stack}" --template-stage Original \ --query TemplateBody --output json >"${raw}" python3 - "${raw}" "${out}" <<'PY' import json, sys raw_path, out_path = sys.argv[1], sys.argv[2] body = json.load(open(raw_path)) # get-template may return already-parsed dict or a JSON string if isinstance(body, str): body = json.loads(body) resources = body.get("Resources") or {} changed = 0 for name, res in resources.items(): if not isinstance(res, dict): continue before = (res.get("DeletionPolicy"), res.get("UpdateReplacePolicy")) res["DeletionPolicy"] = "Retain" res["UpdateReplacePolicy"] = "Retain" if before != ("Retain", "Retain"): changed += 1 print(f"{len(resources)} resources; {changed} policy fields updated") json.dump(body, open(out_path, "w")) PY } wait_stack() { local stack="$1" want="$2" aws_cmd cloudformation wait "stack-${want}" --stack-name "${stack}" local status status="$(aws_cmd cloudformation describe-stacks --stack-name "${stack}" \ --query 'Stacks[0].StackStatus' --output text 2>/dev/null || echo DELETE_COMPLETE)" echo "stack ${stack} -> ${status}" case "${status}" in *COMPLETE) ;; *) echo "FAIL: unexpected status ${status}" >&2; exit 1 ;; esac } for stack in "${STACKS[@]}"; do echo "==> retain-all template for ${stack}" retain_template "${stack}" if [[ "${MODE}" == "dry-run" ]]; then echo "dry-run: would update-stack ${stack} then delete-stack" continue fi echo "==> update-stack ${stack} (retain-all via s3://${TEMPLATE_BUCKET})" key="${TEMPLATE_PREFIX}/${stack}-retain-$(date -u +%Y%m%dT%H%M%SZ).json" aws_cmd s3 cp "${work_dir}/${stack}.retain.json" "s3://${TEMPLATE_BUCKET}/${key}" >/dev/null # us-east-1 regional URL form required by CloudFormation. template_url="https://${TEMPLATE_BUCKET}.s3.${REGION}.amazonaws.com/${key}" aws_cmd cloudformation update-stack \ --stack-name "${stack}" \ --template-url "${template_url}" \ --capabilities CAPABILITY_NAMED_IAM \ >/dev/null wait_stack "${stack}" "update-complete" echo "==> delete-stack ${stack}" aws_cmd cloudformation delete-stack --stack-name "${stack}" wait_stack "${stack}" "delete-complete" echo "==> post-delete verify after ${stack}" verify_core done if [[ "${MODE}" == "dry-run" ]]; then echo "dry-run complete; no stacks modified" exit 0 fi echo "==> sweep BucketNotificationsHandler Lambdas (CDK helpers only)" mapfile -t handlers < <(aws_cmd lambda list-functions \ --query "Functions[?contains(FunctionName, 'BucketNotificationsHandler')].FunctionName" \ --output text | tr '\t' '\n' | grep -E 'po-ingest-|WorkorderIngestStack-' || true) for h in "${handlers[@]:-}"; do [[ -n "${h}" ]] || continue echo "deleting helper lambda ${h}" aws_cmd lambda delete-function --function-name "${h}" done echo "==> sweep leftover BucketNotificationsHandler IAM roles" mapfile -t roles < <(aws_cmd iam list-roles \ --query "Roles[?contains(RoleName, 'BucketNotificationsHandler')].RoleName" \ --output text | tr '\t' '\n' | grep -E 'po-ingest-|WorkorderIngestStack-' || true) for role in "${roles[@]:-}"; do [[ -n "${role}" ]] || continue echo "deleting helper role ${role}" # Detach inline + managed then delete mapfile -t inlines < <(aws_cmd iam list-role-policies --role-name "${role}" --query 'PolicyNames[]' --output text | tr '\t' '\n') for p in "${inlines[@]:-}"; do [[ -n "${p}" ]] || continue aws_cmd iam delete-role-policy --role-name "${role}" --policy-name "${p}" done mapfile -t attached < <(aws_cmd iam list-attached-role-policies --role-name "${role}" --query 'AttachedPolicies[].PolicyArn' --output text | tr '\t' '\n') for a in "${attached[@]:-}"; do [[ -n "${a}" ]] || continue aws_cmd iam detach-role-policy --role-name "${role}" --policy-arn "${a}" done aws_cmd iam delete-role --role-name "${role}" done echo "==> final verify + smoke" verify_core ROOT="$(cd "$(dirname "$0")/.." && pwd)" AWS_PROFILE="${PROFILE}" AWS_REGION="${REGION}" bash "${ROOT}/scripts/post-deploy-smoke.sh" echo "PLAT-86 CFN dispose complete"