Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions scripts/aws/first-deploy.sh
Original file line number Diff line number Diff line change
@@ -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 ..."
242 changes: 242 additions & 0 deletions scripts/aws/one-time-aws-admin.sh
Original file line number Diff line number Diff line change
@@ -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
Loading