#!/usr/bin/env bash # SPDX-License-Identifier: AGPL-3.0-or-later # # UI-free provision script for the four-droplet + three-Spaces + # one-volume topology described in deploy/cutover/SETUP-GUIDE.md # Phase B. Uses `doctl`, DO's CLI — its commands are stable across # UI redesigns and the source of truth for what Phase B should # produce. # # Why prefer this over the dashboard: # - Reproducible: same flags → same resources, every time. # - No UI drift: `doctl` is versioned and changelogged. # - Cheaper to recover from a botched run: destroy + re-run. # - The dashboard remains a fine choice; this is the alternative. # # Prereqs (do these once, then re-run is idempotent-friendly): # - `doctl` installed: brew install doctl / apt-get install doctl # - Authenticated: doctl auth init (paste a Personal Access Token # from DO → API → Tokens with read+write scope) # - Your laptop's ~/.ssh/id_ed25519.pub uploaded to your account # (doctl compute ssh-key import or via the dashboard). # # Usage: # PRIMARY_REGION=sfo3 DR_REGION=ams3 \ # PROJECT_NAME=shithub-prod \ # SSH_KEY_NAME=macbook-pro \ # ./deploy/cutover/provision-do.sh # # What it creates: # - 1 project (if not present) # - 4 droplets (app, db, backup, monitoring) — all in PRIMARY_REGION # - 1 100 GB volume attached to shithub-app # - 3 Spaces buckets (shithub-backups in PRIMARY_REGION, # shithub-backups-dr in DR_REGION, shithub-docs in PRIMARY_REGION) # # What it does NOT do: # - Generate Spaces access keys (do that via API → Spaces Keys in # the UI; doctl can't surface the secret either). # - Configure CDN custom domain on shithub-docs. # - Set DNS records on Namecheap. # # Safe to re-run: it skips resources that already exist by name. set -euo pipefail PRIMARY_REGION="${PRIMARY_REGION:-sfo3}" DR_REGION="${DR_REGION:-ams3}" PROJECT_NAME="${PROJECT_NAME:-shithub-prod}" SSH_KEY_NAME="${SSH_KEY_NAME:?set SSH_KEY_NAME to the name of the SSH key in your DO account}" if ! command -v doctl >/dev/null 2>&1; then echo "fatal: doctl not on PATH; install from https://docs.digitalocean.com/reference/doctl/" >&2 exit 2 fi if ! doctl account get >/dev/null 2>&1; then echo "fatal: doctl not authenticated; run 'doctl auth init'" >&2 exit 2 fi # Resolve the SSH key id by name. SSH_KEY_ID="$(doctl compute ssh-key list --no-header --format ID,Name | awk -v n="$SSH_KEY_NAME" '$2==n {print $1; exit}')" if [[ -z "$SSH_KEY_ID" ]]; then echo "fatal: no SSH key named $SSH_KEY_NAME in your DO account" >&2 echo "(list with: doctl compute ssh-key list)" >&2 exit 2 fi echo "using SSH key: $SSH_KEY_NAME (id $SSH_KEY_ID)" # --- 1. Project (idempotent: skip if name already exists) --- PROJECT_ID="$(doctl projects list --no-header --format ID,Name | awk -v n="$PROJECT_NAME" '$2==n {print $1; exit}')" if [[ -z "$PROJECT_ID" ]]; then echo "creating project $PROJECT_NAME..." PROJECT_ID="$(doctl projects create \ --name "$PROJECT_NAME" \ --purpose "Service or API" \ --environment Production \ --description "shithub.sh production environment" \ --no-header --format ID)" fi echo "project: $PROJECT_NAME (id $PROJECT_ID)" >&2 # --- 2. Droplets --- # Status messages must go to stderr so they don't pollute the captured # stdout (which the caller assigns to APP_ID etc.). Only the bare ID # goes to stdout. create_or_skip_droplet() { local name="$1" size="$2" tag="$3" local existing existing="$(doctl compute droplet list --no-header --format ID,Name | awk -v n="$name" '$2==n {print $1; exit}')" if [[ -n "$existing" ]]; then echo "droplet $name already exists (id $existing); skipping" >&2 echo "$existing" return fi echo "creating droplet $name (size $size)..." >&2 local id id="$(doctl compute droplet create "$name" \ --image ubuntu-24-04-x64 \ --region "$PRIMARY_REGION" \ --size "$size" \ --ssh-keys "$SSH_KEY_ID" \ --enable-monitoring \ --tag-names "shithub,$tag" \ --wait \ --no-header --format ID)" echo "$id" } APP_ID="$(create_or_skip_droplet shithub-app s-2vcpu-4gb shithub-app)" DB_ID="$(create_or_skip_droplet shithub-db s-2vcpu-4gb shithub-db)" BAK_ID="$(create_or_skip_droplet shithub-backup s-1vcpu-2gb shithub-backup)" MON_ID="$(create_or_skip_droplet shithub-monitoring s-2vcpu-4gb shithub-monitoring)" # --- 3. Block volume + attach to shithub-app --- # Volume creation and attach are independent steps; on a re-run we may # find the volume created but never attached (if a prior run died here). # Handle both checks separately. VOL_NAME="shithub-data" VOL_ID="$(doctl compute volume list --no-header --format ID,Name | awk -v n="$VOL_NAME" '$2==n {print $1; exit}')" if [[ -z "$VOL_ID" ]]; then echo "creating 100 GB volume $VOL_NAME..." >&2 VOL_ID="$(doctl compute volume create "$VOL_NAME" \ --region "$PRIMARY_REGION" \ --size 100GiB \ --fs-type ext4 \ --no-header --format ID)" else echo "volume $VOL_NAME already exists (id $VOL_ID); skipping create" >&2 fi # Check whether the volume is already attached. doctl prints DropletIDs # as a JSON-style array like [123456789] or []. ATTACHED_TO="$(doctl compute volume get "$VOL_ID" --no-header --format DropletIDs | tr -d '[]' | xargs)" if [[ -z "$ATTACHED_TO" ]]; then echo "attaching $VOL_NAME to shithub-app (id $APP_ID)..." >&2 doctl compute volume-action attach "$VOL_ID" "$APP_ID" --wait >&2 else echo "volume $VOL_NAME already attached to droplet(s) $ATTACHED_TO; skipping" >&2 fi # --- 4. Spaces buckets --- # doctl's spaces support varies by version. We attempt the native commands; # if they're not present, we print a fallback and continue. create_or_skip_space() { local name="$1" region="$2" if doctl spaces buckets list --no-header --format Name 2>/dev/null | grep -qx "$name"; then echo "Spaces bucket $name already exists; skipping" >&2 return fi echo "creating Spaces bucket $name in $region..." >&2 if doctl spaces buckets create "$name" --region "$region" >/dev/null 2>&1; then echo " ok" >&2 else echo " doctl can't create the bucket (CLI version may not support it);" >&2 echo " create '$name' in region '$region' via the dashboard." >&2 fi } create_or_skip_space "shithub-backups" "$PRIMARY_REGION" create_or_skip_space "shithub-backups-dr" "$DR_REGION" create_or_skip_space "shithub-docs" "$PRIMARY_REGION" # --- 5. Move resources into the project --- echo "assigning resources to project $PROJECT_NAME..." doctl projects resources assign "$PROJECT_ID" \ --resource "do:droplet:$APP_ID" \ --resource "do:droplet:$DB_ID" \ --resource "do:droplet:$BAK_ID" \ --resource "do:droplet:$MON_ID" \ --resource "do:volume:$VOL_ID" >/dev/null # --- 6. Print the summary the operator needs for the inventory --- cat < A www CNAME docs 4. Continue with SETUP-GUIDE.md Phase B5 (SSH-bootstrap). ============================================================== SUMMARY