From 3ae867a907a1d12e8ceb7339b1087c87f2920203 Mon Sep 17 00:00:00 2001 From: Xiro The Dev Date: Mon, 7 Sep 2026 00:32:56 +0700 Subject: [PATCH] scripts: one-time AWS access wizard + first-deploy bootstrap (OIDC, cert, WAF, SSM) --- scripts/aws/first-deploy.sh | 80 ++++++++++ scripts/aws/one-time-aws-admin.sh | 242 ++++++++++++++++++++++++++++++ 2 files changed, 322 insertions(+) create mode 100755 scripts/aws/first-deploy.sh create mode 100755 scripts/aws/one-time-aws-admin.sh diff --git a/scripts/aws/first-deploy.sh b/scripts/aws/first-deploy.sh new file mode 100755 index 0000000..dd4ba17 --- /dev/null +++ b/scripts/aws/first-deploy.sh @@ -0,0 +1,80 @@ +#!/bin/bash +# One-time real-AWS bootstrap after scripts/aws/one-time-aws-admin.sh provided +# the [profile bootstrap] keys. Idempotent; safe to re-run after failures. +# Agent drives this; it STOPS before cutting a release tag (human call). +set -euo pipefail +cd "$(dirname "$0")/../../infra" + +ACCOUNT=772889137569 +DOMAIN=webdevstudio.resonance.io.vn +export AWS_PROFILE=bootstrap +MAIN_REGION=ap-southeast-1 +CF_REGION=us-east-1 + +echo "==> 0. identity" +WHOAMI=$(aws sts get-caller-identity --query Account --output text) +[[ "$WHOAMI" == "$ACCOUNT" ]] || { echo "wrong account: $WHOAMI (expected $ACCOUNT)"; exit 1; } + +cdk_cmd() { bunx --bun cdk "$@"; } + +echo "==> 1. bootstrap CDK toolchains (both regions)" +cdk_cmd bootstrap "aws://$ACCOUNT/$MAIN_REGION" --tags owner=webdev +cdk_cmd bootstrap "aws://$ACCOUNT/$CF_REGION" --tags owner=webdev + +echo "==> 2. network + GitHub OIDC roles" +cdk_cmd deploy webdev-network --require-approval never +cdk_cmd deploy webdev-oidc --require-approval never + +echo "==> 3. ACM certificate (CloudFront needs it in $CF_REGION)" +CERT_ARN=$(aws acm list-certificates --region "$CF_REGION" \ + --certificate-statuses ISSUED \ + --query "CertificateSummaryList[?DomainName=='$DOMAIN'].CertificateArn | [0]" --output text) +if [[ -z "$CERT_ARN" || "$CERT_ARN" == "None" ]]; then + PENDING=$(aws acm list-certificates --region "$CF_REGION" \ + --certificate-statuses PENDING_VALIDATION \ + --query "CertificateSummaryList[?DomainName=='$DOMAIN'].CertificateArn | [0]" --output text) + if [[ -z "$PENDING" || "$PENDING" == "None" ]]; then + CERT_ARN=$(aws acm request-certificate --region "$CF_REGION" \ + --domain-name "$DOMAIN" --validation-method DNS --key-algorithm rsa-2048 \ + --query CertificateArn --output text) + else + CERT_ARN="$PENDING" + fi + ZONE_ID=$(aws route53 list-hosted-zones --query "HostedZones[?Name=='$DOMAIN.'].Id | [0]" --output text | sed 's|/hostedzone/||') + [[ "$ZONE_ID" != "None" ]] || { echo "no Route53 zone for $DOMAIN — did webdev-network create it?"; exit 1; } + RES_NAME=$(aws acm describe-certificate --region "$CF_REGION" --certificate-arn "$CERT_ARN" \ + --query 'DomainValidationOptions[0].ResourceRecord.Name' --output text) + RES_TYPE=$(aws acm describe-certificate --region "$CF_REGION" --certificate-arn "$CERT_ARN" \ + --query 'DomainValidationOptions[0].ResourceRecord.Type' --output text) + RES_VAL=$(aws acm describe-certificate --region "$CF_REGION" --certificate-arn "$CERT_ARN" \ + --query 'DomainValidationOptions[0].ResourceRecord.Value' --output text) + aws route53 change-batch --hosted-zone-id "$ZONE_ID" --wait \ + --change-batch "{\"Changes\":[{\"Action\":\"UPSERT\",\"ResourceRecordSet\":{\"Name\":\"$RES_NAME\",\"Type\":\"$RES_TYPE\",\"TTL\":60,\"ResourceRecords\":[{\"Value\":\"$RES_VAL\"}]}}]}" >/dev/null + echo " waiting for DNS validation…" + for i in $(seq 1 30); do + ST=$(aws acm describe-certificate --region "$CF_REGION" --certificate-arn "$CERT_ARN" --query Certificate.Status --output text) + [[ "$ST" == "ISSUED" ]] && break + sleep 10 + done + [[ "$ST" == "ISSUED" ]] || { echo "cert still $ST — re-run later"; exit 1; } +fi +echo " cert: $CERT_ARN" + +echo "==> 4. WAF (CloudFront scope) + SSM pointers the prod stack reads" +cdk_cmd deploy webdev-waf --require-approval never +WAF_ARN=$(aws wafv2 list-web-acls --region "$CF_REGION" --scope CLOUDFRONT \ + --query "WebACLs[?starts_with(Name, 'webdev')].ARN | [0]" --output text) +[[ "$WAF_ARN" != "None" ]] || { echo "webdev WAF ACL not found"; exit 1; } + +echo "==> 5. SSM parameters (prod stack inputs)" +for pair in "/webdev/cloudfront/cert-arn=$CERT_ARN" "/webdev/cloudfront/waf-arn=$WAF_ARN"; do + aws ssm put-parameter --region "$MAIN_REGION" --name "${pair%%=*}" --value "${pair#*=}" --type String --overwrite >/dev/null + echo " ${pair%%=*} = ${pair#*=}" +done + +echo "==> 6. probe sanity (from CI side):" +echo " OIDC provider: $(aws iam list-open-id-connect-providers --query 'OpenIDConnectProviderList[?contains(Arn,`github`)].Arn | [0]' --output text)" +echo +echo "Bootstrap complete. Next (human decision — real money):" +echo " gh workflow run aws-probe.yml # should go green now" +echo " git tag v0.1.1 && git push origin v0.1.1 && gh release create v0.1.1 ..." diff --git a/scripts/aws/one-time-aws-admin.sh b/scripts/aws/one-time-aws-admin.sh new file mode 100755 index 0000000..f4ef83a --- /dev/null +++ b/scripts/aws/one-time-aws-admin.sh @@ -0,0 +1,242 @@ +#!/usr/bin/env bash +# +# A wizard walks a human through a manual procedure, step by step. +# Generated by the /wizard skill. +# +# Everything above the "STAGES" marker is the wizard library: do not hand-edit +# it. Author the per-step stages below the marker. + +set -euo pipefail + +# ────────────────────────────────────────────────────────────────────────── +# Wizard library: delightful, consistent UX, identical across every wizard. +# ────────────────────────────────────────────────────────────────────────── + +if [[ -t 1 ]] && command -v tput >/dev/null 2>&1 && [[ "$(tput colors 2>/dev/null || echo 0)" -ge 8 ]]; then + BOLD=$(tput bold); DIM=$(tput dim); RESET=$(tput sgr0) + BLUE=$(tput setaf 4); GREEN=$(tput setaf 2); YELLOW=$(tput setaf 3); RED=$(tput setaf 1) +else + BOLD=""; DIM=""; RESET=""; BLUE=""; GREEN=""; YELLOW=""; RED="" +fi + +# Author sets this at the top of the stages section. +TOTAL_STAGES=0 + +_STAGE_INDEX=0 +ENV_FILE="${ENV_FILE:-.env}" +WRITTEN_ENV=() # KEYs written to ENV_FILE this run +WRITTEN_SECRET=() # secret NAMEs set this run +SKIPPED=() # things we couldn't do (e.g. gh missing) + +# _clear wipes the terminal so only the current step is on screen. No-op when +# output isn't a terminal, so piped logs stay readable. +_clear() { + [[ -t 1 ]] || return 0 + if command -v tput >/dev/null 2>&1; then tput clear; else printf '\033[2J\033[3J\033[H'; fi +} + +# banner "Title" shows the opening frame: what this wizard does. +banner() { + _clear + printf '\n%s%s %s%s\n' "$BOLD" "$BLUE" "$1" "$RESET" + printf '%s %s stages%s\n\n' "$DIM" "$TOTAL_STAGES" "$RESET" + printf '%s You drive the browser; this wizard tells you exactly what to do and\n' "$DIM" + printf ' captures the values you copy back. Stop any time with Ctrl-C and re-run\n' + printf ' later, since it remembers values already saved.%s\n' "$RESET" + pause "Ready to start?" +} + +# stage "Name" clears the screen, then announces a stage and shows progress. +# Clearing keeps only the current step on screen. +stage() { + _clear + _STAGE_INDEX=$((_STAGE_INDEX + 1)) + printf '\n%s%s▸ Stage %s/%s · %s%s\n' \ + "$BOLD" "$BLUE" "$_STAGE_INDEX" "$TOTAL_STAGES" "$1" "$RESET" +} + +# say "..." prints a plain instruction line. +say() { printf ' %s\n' "$1"; } +# step "..." is a numbered-feeling action the human takes in the browser. +step() { printf ' %s•%s %s\n' "$BLUE" "$RESET" "$1"; } +note() { printf ' %s%s%s\n' "$DIM" "$1" "$RESET"; } +warn() { printf ' %s⚠ %s%s\n' "$YELLOW" "$1" "$RESET"; } + +# open_url URL opens it in the human's browser, cross-platform incl. WSL. +open_url() { + local url="$1" + printf ' %s↗ opening%s %s\n' "$GREEN" "$RESET" "$url" + { if command -v wslview >/dev/null 2>&1; then wslview "$url" + elif command -v explorer.exe >/dev/null 2>&1; then explorer.exe "$url" + elif command -v xdg-open >/dev/null 2>&1; then xdg-open "$url" + elif command -v open >/dev/null 2>&1; then open "$url" + else warn "couldn't open a browser; visit it manually: $url"; fi + } >/dev/null 2>&1 || warn "couldn't open a browser, so visit it manually: $url" +} + +# pause "msg" waits for the human to confirm they've done the manual part. +pause() { + printf ' %s%s%s ' "$DIM" "${1:-Press Enter to continue}" "$RESET" + read -r _ || true +} + +# confirm "question" is a y/N gate; returns success on yes. +confirm() { + local reply="" + printf ' %s? %s [y/N] ' "$YELLOW" "$1" + read -r reply || true + [[ "$reply" =~ ^[Yy] ]] +} + +# _existing KEY: current value of KEY in ENV_FILE, if any. +_existing() { + [[ -f "$ENV_FILE" ]] || return 1 + local line; line=$(grep -E "^${1}=" "$ENV_FILE" | tail -n1) || return 1 + printf '%s' "${line#*=}" +} + +# ask KEY "Prompt" reads a value into $KEY. Offers the existing .env value as +# a default on re-runs (Enter keeps it). Visible input (non-secret). +ask() { + local key="$1" prompt="$2" current input + current=$(_existing "$key" || true) + if [[ -n "$current" ]]; then + printf ' %s%s%s %s[Enter keeps current]%s ' "$BOLD" "$prompt" "$RESET" "$DIM" "$RESET" + else + printf ' %s%s%s ' "$BOLD" "$prompt" "$RESET" + fi + read -r input || true + [[ -z "$input" && -n "$current" ]] && input="$current" + printf -v "$key" '%s' "$input" +} + +# ask_secret KEY "Prompt" is like ask, but input is hidden. +ask_secret() { + local key="$1" prompt="$2" current input + current=$(_existing "$key" || true) + if [[ -n "$current" ]]; then + printf ' %s%s%s %s[Enter keeps current]%s ' "$BOLD" "$prompt" "$RESET" "$DIM" "$RESET" + else + printf ' %s%s%s ' "$BOLD" "$prompt" "$RESET" + fi + read -rs input || true + printf '\n' + [[ -z "$input" && -n "$current" ]] && input="$current" + printf -v "$key" '%s' "$input" +} + +# write_env KEY VALUE upserts KEY=VALUE into ENV_FILE (creates it; replaces +# any existing line). Idempotent. +write_env() { + local key="$1" value="$2" tmp + touch "$ENV_FILE" + tmp=$(mktemp) + grep -vE "^${key}=" "$ENV_FILE" > "$tmp" || true + printf '%s=%s\n' "$key" "$value" >> "$tmp" + mv "$tmp" "$ENV_FILE" + WRITTEN_ENV+=("$key") + printf ' %s✓ wrote%s %s → %s\n' "$GREEN" "$RESET" "$key" "$ENV_FILE" +} + +# set_secret NAME VALUE sets a GitHub Actions repo secret via gh. Falls back +# to a warning (and records it) if gh is unavailable or unauthenticated. +set_secret() { + local name="$1" value="$2" + if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then + if printf '%s' "$value" | gh secret set "$name" >/dev/null 2>&1; then + WRITTEN_SECRET+=("$name") + printf ' %s✓ set%s GitHub secret %s\n' "$GREEN" "$RESET" "$name" + return + fi + fi + SKIPPED+=("GitHub secret $name (set it manually: gh secret set $name)") + warn "skipped GitHub secret $name: gh not ready; set it later" +} + +# set_var NAME VALUE sets a GitHub Actions repo variable (non-secret). +set_var() { + local name="$1" value="$2" + if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then + if gh variable set "$name" --body "$value" >/dev/null 2>&1; then + printf ' %s✓ set%s GitHub variable %s\n' "$GREEN" "$RESET" "$name" + return + fi + fi + SKIPPED+=("GitHub variable $name") + warn "skipped GitHub variable $name, gh not ready; set it later" +} + +# finish clears, then shows a closing summary of everything configured. +finish() { + _clear + printf '\n%s%s ✓ Setup complete%s\n' "$BOLD" "$GREEN" "$RESET" + (( ${#WRITTEN_ENV[@]} )) && note "wrote ${#WRITTEN_ENV[@]} value(s) to $ENV_FILE: ${WRITTEN_ENV[*]}" + (( ${#WRITTEN_SECRET[@]} )) && note "set ${#WRITTEN_SECRET[@]} GitHub secret(s): ${WRITTEN_SECRET[*]}" + if (( ${#SKIPPED[@]} )); then + printf '\n'; warn "still to do by hand:" + for s in "${SKIPPED[@]}"; do note " - $s"; done + fi + printf '\n' +} + +# ────────────────────────────────────────────────────────────────────────── +# STAGES: author this section. One stage() per step the human takes. +# Replace the example below. Set TOTAL_STAGES to match the stages you write. +# ────────────────────────────────────────────────────────────────────────── + +TOTAL_STAGES=4 + +banner "One-time AWS bootstrap access" + +EXPECTED_ACCOUNT=772889137569 + +# ── Stage 1: create the throwaway admin user ────────────────────────────── +stage "AWS Console: throwaway admin user" +say "We create a TEMPORARY admin user, capture its keys, and delete the user" +say "once the GitHub OIDC roles exist. You will never need it again." +open_url "https://console.aws.amazon.com/iam/v2/users" +step "Sign in to the AWS Console as an administrator." +step "Users → Create user → name: temp-bootstrap" +step "Permissions: 'Attach policies directly' → check AdministratorAccess → Next → Create user" +step "Open the new user → Credentials tab (or 'AWS security credentials') → Create access key" +step "Use case: CLI / 'Command Line Interface' → create, then copy both values." +ask AWS_ACCESS_KEY_ID "Paste the Access key ID (starts AKIA):" +ask_secret AWS_SECRET_ACCESS_KEY "Paste the Secret access key:" +# ── Stage 2: save as aws profile + verify identity ──────────────────────── +stage "Save profile 'bootstrap' and verify the account" +CRED_FILE="$HOME/.aws/credentials" +mkdir -p "$HOME/.aws" +touch "$CRED_FILE" +if grep -q '^\[profile bootstrap\]' "$CRED_FILE"; then + sed -i.bak '/^\[profile bootstrap\]/,+3d' "$CRED_FILE" +fi +printf '[profile bootstrap]\naws_access_key_id = %s\naws_secret_access_key = %s\nregion = ap-southeast-1\n' \ + "$AWS_ACCESS_KEY_ID" "$AWS_SECRET_ACCESS_KEY" >> "$CRED_FILE" +chmod 600 "$CRED_FILE" +printf ' ✓ wrote %s[profile bootstrap]%s → %s\n' "$GREEN" "$RESET" "$CRED_FILE" +step "Verifying identity against account $EXPECTED_ACCOUNT ..." +WHOAMI=$(AWS_PROFILE=bootstrap aws sts get-caller-identity --query Account --output text 2>&1) || true +if [[ "$WHOAMI" == "$EXPECTED_ACCOUNT" ]]; then + printf ' %s✓%s keys valid for account %s (%s)\n' "$GREEN" "$RESET" "$EXPECTED_ACCOUNT" "$(AWS_PROFILE=bootstrap aws sts get-caller-identity --query Arn --output text)" +else + warn "identity check failed or wrong account: $WHOAMI" + warn "the keys must belong to AWS account $EXPECTED_ACCOUNT" +fi +# ── Stage 3: budget alarm email (cd.yml: secrets.ALERT_EMAIL) ───────────── +stage "Budget alarm email" +say "The prod CD wires an AWS budget alarm to this address (CDK only creates" +say "the alarm when the email is provided)." +ask ALERT_EMAIL "Alarm email (e.g. aws-billing@yourdomain):" +set_secret ALERT_EMAIL "$ALERT_EMAIL" +# ── Stage 4: cleanup promise ────────────────────────────────────────────── +stage "Cleanup reminder" +say "After the bootstrap finishes (OIDC provider + webdev-deploy-* roles exist" +say "and the first real release deploys), DELETE the temp user:" +say " Console → IAM → Users → temp-bootstrap → Delete user (keys first)." +say "Ask the agent to verify no webdev-deploy role trusts temp-bootstrap before" +say "deleting. Nothing else uses these keys." +if confirm "Type yes to acknowledge (deletion happens later, not now):"; then + say "Acknowledged. See stage 3 notes when you're done." +fi + +finish