| 1 | #!/usr/bin/env bash |
| 2 | # SPDX-License-Identifier: AGPL-3.0-or-later |
| 3 | # |
| 4 | # UI-free provision script for the four-droplet + three-Spaces + |
| 5 | # one-volume topology described in deploy/cutover/SETUP-GUIDE.md |
| 6 | # Phase B. Uses `doctl`, DO's CLI — its commands are stable across |
| 7 | # UI redesigns and the source of truth for what Phase B should |
| 8 | # produce. |
| 9 | # |
| 10 | # Why prefer this over the dashboard: |
| 11 | # - Reproducible: same flags → same resources, every time. |
| 12 | # - No UI drift: `doctl` is versioned and changelogged. |
| 13 | # - Cheaper to recover from a botched run: destroy + re-run. |
| 14 | # - The dashboard remains a fine choice; this is the alternative. |
| 15 | # |
| 16 | # Prereqs (do these once, then re-run is idempotent-friendly): |
| 17 | # - `doctl` installed: brew install doctl / apt-get install doctl |
| 18 | # - Authenticated: doctl auth init (paste a Personal Access Token |
| 19 | # from DO → API → Tokens with read+write scope) |
| 20 | # - Your laptop's ~/.ssh/id_ed25519.pub uploaded to your account |
| 21 | # (doctl compute ssh-key import or via the dashboard). |
| 22 | # |
| 23 | # Usage: |
| 24 | # PRIMARY_REGION=sfo3 DR_REGION=ams3 \ |
| 25 | # PROJECT_NAME=shithub-prod \ |
| 26 | # SSH_KEY_NAME=macbook-pro \ |
| 27 | # ./deploy/cutover/provision-do.sh |
| 28 | # |
| 29 | # What it creates: |
| 30 | # - 1 project (if not present) |
| 31 | # - 4 droplets (app, db, backup, monitoring) — all in PRIMARY_REGION |
| 32 | # - 1 100 GB volume attached to shithub-app |
| 33 | # - 3 Spaces buckets (shithub-backups in PRIMARY_REGION, |
| 34 | # shithub-backups-dr in DR_REGION, shithub-docs in PRIMARY_REGION) |
| 35 | # |
| 36 | # What it does NOT do: |
| 37 | # - Generate Spaces access keys (do that via API → Spaces Keys in |
| 38 | # the UI; doctl can't surface the secret either). |
| 39 | # - Configure CDN custom domain on shithub-docs. |
| 40 | # - Set DNS records on Namecheap. |
| 41 | # |
| 42 | # Safe to re-run: it skips resources that already exist by name. |
| 43 | |
| 44 | set -euo pipefail |
| 45 | |
| 46 | PRIMARY_REGION="${PRIMARY_REGION:-sfo3}" |
| 47 | DR_REGION="${DR_REGION:-ams3}" |
| 48 | PROJECT_NAME="${PROJECT_NAME:-shithub-prod}" |
| 49 | SSH_KEY_NAME="${SSH_KEY_NAME:?set SSH_KEY_NAME to the name of the SSH key in your DO account}" |
| 50 | |
| 51 | if ! command -v doctl >/dev/null 2>&1; then |
| 52 | echo "fatal: doctl not on PATH; install from https://docs.digitalocean.com/reference/doctl/" >&2 |
| 53 | exit 2 |
| 54 | fi |
| 55 | |
| 56 | if ! doctl account get >/dev/null 2>&1; then |
| 57 | echo "fatal: doctl not authenticated; run 'doctl auth init'" >&2 |
| 58 | exit 2 |
| 59 | fi |
| 60 | |
| 61 | # Resolve the SSH key id by name. |
| 62 | SSH_KEY_ID="$(doctl compute ssh-key list --no-header --format ID,Name | awk -v n="$SSH_KEY_NAME" '$2==n {print $1; exit}')" |
| 63 | if [[ -z "$SSH_KEY_ID" ]]; then |
| 64 | echo "fatal: no SSH key named $SSH_KEY_NAME in your DO account" >&2 |
| 65 | echo "(list with: doctl compute ssh-key list)" >&2 |
| 66 | exit 2 |
| 67 | fi |
| 68 | echo "using SSH key: $SSH_KEY_NAME (id $SSH_KEY_ID)" |
| 69 | |
| 70 | # --- 1. Project (idempotent: skip if name already exists) --- |
| 71 | PROJECT_ID="$(doctl projects list --no-header --format ID,Name | awk -v n="$PROJECT_NAME" '$2==n {print $1; exit}')" |
| 72 | if [[ -z "$PROJECT_ID" ]]; then |
| 73 | echo "creating project $PROJECT_NAME..." |
| 74 | PROJECT_ID="$(doctl projects create \ |
| 75 | --name "$PROJECT_NAME" \ |
| 76 | --purpose "Service or API" \ |
| 77 | --environment Production \ |
| 78 | --description "shithub.sh production environment" \ |
| 79 | --no-header --format ID)" |
| 80 | fi |
| 81 | echo "project: $PROJECT_NAME (id $PROJECT_ID)" >&2 |
| 82 | |
| 83 | # --- 2. Droplets --- |
| 84 | # Status messages must go to stderr so they don't pollute the captured |
| 85 | # stdout (which the caller assigns to APP_ID etc.). Only the bare ID |
| 86 | # goes to stdout. |
| 87 | create_or_skip_droplet() { |
| 88 | local name="$1" size="$2" tag="$3" |
| 89 | local existing |
| 90 | existing="$(doctl compute droplet list --no-header --format ID,Name | awk -v n="$name" '$2==n {print $1; exit}')" |
| 91 | if [[ -n "$existing" ]]; then |
| 92 | echo "droplet $name already exists (id $existing); skipping" >&2 |
| 93 | echo "$existing" |
| 94 | return |
| 95 | fi |
| 96 | echo "creating droplet $name (size $size)..." >&2 |
| 97 | local id |
| 98 | id="$(doctl compute droplet create "$name" \ |
| 99 | --image ubuntu-24-04-x64 \ |
| 100 | --region "$PRIMARY_REGION" \ |
| 101 | --size "$size" \ |
| 102 | --ssh-keys "$SSH_KEY_ID" \ |
| 103 | --enable-monitoring \ |
| 104 | --tag-names "shithub,$tag" \ |
| 105 | --wait \ |
| 106 | --no-header --format ID)" |
| 107 | echo "$id" |
| 108 | } |
| 109 | |
| 110 | APP_ID="$(create_or_skip_droplet shithub-app s-2vcpu-4gb shithub-app)" |
| 111 | DB_ID="$(create_or_skip_droplet shithub-db s-2vcpu-4gb shithub-db)" |
| 112 | BAK_ID="$(create_or_skip_droplet shithub-backup s-1vcpu-2gb shithub-backup)" |
| 113 | MON_ID="$(create_or_skip_droplet shithub-monitoring s-2vcpu-4gb shithub-monitoring)" |
| 114 | |
| 115 | # --- 3. Block volume + attach to shithub-app --- |
| 116 | # Volume creation and attach are independent steps; on a re-run we may |
| 117 | # find the volume created but never attached (if a prior run died here). |
| 118 | # Handle both checks separately. |
| 119 | VOL_NAME="shithub-data" |
| 120 | VOL_ID="$(doctl compute volume list --no-header --format ID,Name | awk -v n="$VOL_NAME" '$2==n {print $1; exit}')" |
| 121 | if [[ -z "$VOL_ID" ]]; then |
| 122 | echo "creating 100 GB volume $VOL_NAME..." >&2 |
| 123 | VOL_ID="$(doctl compute volume create "$VOL_NAME" \ |
| 124 | --region "$PRIMARY_REGION" \ |
| 125 | --size 100GiB \ |
| 126 | --fs-type ext4 \ |
| 127 | --no-header --format ID)" |
| 128 | else |
| 129 | echo "volume $VOL_NAME already exists (id $VOL_ID); skipping create" >&2 |
| 130 | fi |
| 131 | |
| 132 | # Check whether the volume is already attached. doctl prints DropletIDs |
| 133 | # as a JSON-style array like [123456789] or []. |
| 134 | ATTACHED_TO="$(doctl compute volume get "$VOL_ID" --no-header --format DropletIDs | tr -d '[]' | xargs)" |
| 135 | if [[ -z "$ATTACHED_TO" ]]; then |
| 136 | echo "attaching $VOL_NAME to shithub-app (id $APP_ID)..." >&2 |
| 137 | doctl compute volume-action attach "$VOL_ID" "$APP_ID" --wait >&2 |
| 138 | else |
| 139 | echo "volume $VOL_NAME already attached to droplet(s) $ATTACHED_TO; skipping" >&2 |
| 140 | fi |
| 141 | |
| 142 | # --- 4. Spaces buckets --- |
| 143 | # doctl's spaces support varies by version. We attempt the native commands; |
| 144 | # if they're not present, we print a fallback and continue. |
| 145 | create_or_skip_space() { |
| 146 | local name="$1" region="$2" |
| 147 | if doctl spaces buckets list --no-header --format Name 2>/dev/null | grep -qx "$name"; then |
| 148 | echo "Spaces bucket $name already exists; skipping" >&2 |
| 149 | return |
| 150 | fi |
| 151 | echo "creating Spaces bucket $name in $region..." >&2 |
| 152 | if doctl spaces buckets create "$name" --region "$region" >/dev/null 2>&1; then |
| 153 | echo " ok" >&2 |
| 154 | else |
| 155 | echo " doctl can't create the bucket (CLI version may not support it);" >&2 |
| 156 | echo " create '$name' in region '$region' via the dashboard." >&2 |
| 157 | fi |
| 158 | } |
| 159 | |
| 160 | create_or_skip_space "shithub-backups" "$PRIMARY_REGION" |
| 161 | create_or_skip_space "shithub-backups-dr" "$DR_REGION" |
| 162 | create_or_skip_space "shithub-docs" "$PRIMARY_REGION" |
| 163 | |
| 164 | # --- 5. Move resources into the project --- |
| 165 | echo "assigning resources to project $PROJECT_NAME..." |
| 166 | doctl projects resources assign "$PROJECT_ID" \ |
| 167 | --resource "do:droplet:$APP_ID" \ |
| 168 | --resource "do:droplet:$DB_ID" \ |
| 169 | --resource "do:droplet:$BAK_ID" \ |
| 170 | --resource "do:droplet:$MON_ID" \ |
| 171 | --resource "do:volume:$VOL_ID" >/dev/null |
| 172 | |
| 173 | # --- 6. Print the summary the operator needs for the inventory --- |
| 174 | cat <<SUMMARY |
| 175 | |
| 176 | ============================================================== |
| 177 | provisioned. summary for inventory: |
| 178 | |
| 179 | Region (primary): $PRIMARY_REGION |
| 180 | Region (DR): $DR_REGION |
| 181 | Project: $PROJECT_NAME (id $PROJECT_ID) |
| 182 | |
| 183 | Droplets (public IPv4 → private IPv4): |
| 184 | SUMMARY |
| 185 | |
| 186 | doctl compute droplet list --no-header --tag-name shithub \ |
| 187 | --format Name,PublicIPv4,PrivateIPv4 \ |
| 188 | | column -t |
| 189 | |
| 190 | cat <<SUMMARY |
| 191 | |
| 192 | Volume: $VOL_NAME (id $VOL_ID; attached to shithub-app) |
| 193 | |
| 194 | Spaces buckets: |
| 195 | shithub-backups ($PRIMARY_REGION) — endpoint: $PRIMARY_REGION.digitaloceanspaces.com |
| 196 | shithub-backups-dr ($DR_REGION) — endpoint: $DR_REGION.digitaloceanspaces.com |
| 197 | shithub-docs ($PRIMARY_REGION, CDN target) |
| 198 | |
| 199 | NEXT STEPS (manual, no doctl path): |
| 200 | 1. Generate Spaces access keys via dashboard: |
| 201 | API → Spaces Keys → Generate New Key |
| 202 | Name: shithub-prod-app |
| 203 | Copy the secret immediately (shown once). |
| 204 | 2. Enable CDN on the shithub-docs bucket and set custom domain |
| 205 | to docs.shithub.sh (DO will print a CNAME target). |
| 206 | 3. Set Namecheap DNS records: |
| 207 | A @ <shithub-app public IPv4> |
| 208 | A www <shithub-app public IPv4> |
| 209 | CNAME docs <CDN target from step 2> |
| 210 | 4. Continue with SETUP-GUIDE.md Phase B5 (SSH-bootstrap). |
| 211 | ============================================================== |
| 212 | SUMMARY |