| 1 | #!/usr/bin/env bash |
| 2 | # EXPERIMENTAL — DigitalOcean smoke-lab provisioning for the CodeWhale |
| 3 | # remote workbench (issue #1990 "clearly documented better alternative" |
| 4 | # clause). Creates ONE Ubuntu 24.04 droplet plus a cloud firewall that |
| 5 | # allows inbound SSH only. Prints the monthly price from the DO API and |
| 6 | # requires a typed "yes" before creating anything billable. |
| 7 | # |
| 8 | # Auth: doctl must be authenticated. Either |
| 9 | # doctl auth init # paste token interactively, or |
| 10 | # export DIGITALOCEAN_ACCESS_TOKEN=... # doctl reads this env var |
| 11 | # |
| 12 | # Usage: |
| 13 | # bash scripts/remote-smoke/digitalocean/provision.sh |
| 14 | # |
| 15 | # Tunables (env): |
| 16 | # DROPLET_NAME default codewhale-smoke |
| 17 | # DO_REGION default sfo3 (San Francisco) |
| 18 | # DROPLET_SIZE default s-1vcpu-2gb (~$12/mo; prebuilt binaries mean no |
| 19 | # Rust build, so 1 vCPU / 2 GB is enough for the smoke. |
| 20 | # Use s-2vcpu-2gb/s-2vcpu-4gb for a longer-lived host.) |
| 21 | # DROPLET_IMAGE default ubuntu-24-04-x64 |
| 22 | # SSH_PUBKEY default ~/.ssh/id_ed25519.pub (imported if not present) |
| 23 | # RESTRICT_SSH_TO_MY_IP default true (firewall source = caller IP /32) |
| 24 | set -euo pipefail |
| 25 | |
| 26 | DROPLET_NAME="${DROPLET_NAME:-codewhale-smoke}" |
| 27 | DO_REGION="${DO_REGION:-sfo3}" |
| 28 | DROPLET_SIZE="${DROPLET_SIZE:-s-1vcpu-2gb}" |
| 29 | DROPLET_IMAGE="${DROPLET_IMAGE:-ubuntu-24-04-x64}" |
| 30 | SSH_PUBKEY="${SSH_PUBKEY:-$HOME/.ssh/id_ed25519.pub}" |
| 31 | SSH_KEY_NAME="${SSH_KEY_NAME:-${DROPLET_NAME}-key}" |
| 32 | FIREWALL_NAME="${FIREWALL_NAME:-${DROPLET_NAME}-ssh-only}" |
| 33 | RESTRICT_SSH_TO_MY_IP="${RESTRICT_SSH_TO_MY_IP:-true}" |
| 34 | |
| 35 | command -v doctl >/dev/null || { echo "doctl is required (brew install doctl)" >&2; exit 1; } |
| 36 | doctl account get >/dev/null || { echo "doctl is not authenticated; run 'doctl auth init' or set DIGITALOCEAN_ACCESS_TOKEN" >&2; exit 1; } |
| 37 | [[ -f "$SSH_PUBKEY" ]] || { echo "SSH public key not found: $SSH_PUBKEY" >&2; exit 1; } |
| 38 | |
| 39 | echo "== Preflight ==" |
| 40 | [[ "$(doctl compute region list --format Slug,Available --no-header | awk -v r="$DO_REGION" '$1 == r {print $2}')" == "true" ]] \ |
| 41 | || { echo "Region ${DO_REGION} not available" >&2; exit 1; } |
| 42 | |
| 43 | read -r PRICE VCPUS MEM DISK < <(doctl compute size list \ |
| 44 | --format Slug,PriceMonthly,VCPUs,Memory,Disk --no-header \ |
| 45 | | awk -v s="$DROPLET_SIZE" '$1 == s {print $2, $3, $4, $5}') |
| 46 | [[ -n "${PRICE:-}" ]] || { echo "Size ${DROPLET_SIZE} not found" >&2; exit 1; } |
| 47 | |
| 48 | echo "Region: $DO_REGION" |
| 49 | echo "Droplet: $DROPLET_NAME" |
| 50 | echo "Image: $DROPLET_IMAGE" |
| 51 | echo "Size: $DROPLET_SIZE (${VCPUS} vCPU / ${MEM} MB RAM / ${DISK} GB disk)" |
| 52 | echo "Monthly price: \$$PRICE USD (billed hourly until destroyed)" |
| 53 | echo "SSH key: $SSH_PUBKEY -> '$SSH_KEY_NAME'" |
| 54 | echo "Firewall: $FIREWALL_NAME (inbound 22/tcp only)" |
| 55 | echo |
| 56 | read -r -p "Create this droplet and start billing? Type 'yes' to proceed: " CONFIRM |
| 57 | [[ "$CONFIRM" == "yes" ]] || { echo "Aborted; nothing created."; exit 1; } |
| 58 | |
| 59 | echo "== Import SSH key ==" |
| 60 | KEY_ID=$(doctl compute ssh-key list --format ID,Name --no-header | awk -v n="$SSH_KEY_NAME" '$2 == n {print $1; exit}') |
| 61 | if [[ -z "$KEY_ID" ]]; then |
| 62 | KEY_ID=$(doctl compute ssh-key import "$SSH_KEY_NAME" --public-key-file "$SSH_PUBKEY" --format ID --no-header) |
| 63 | echo "imported $SSH_KEY_NAME (id $KEY_ID)" |
| 64 | else |
| 65 | echo "key $SSH_KEY_NAME already exists (id $KEY_ID); reusing" |
| 66 | fi |
| 67 | |
| 68 | echo "== Create droplet ==" |
| 69 | doctl compute droplet create "$DROPLET_NAME" \ |
| 70 | --region "$DO_REGION" \ |
| 71 | --image "$DROPLET_IMAGE" \ |
| 72 | --size "$DROPLET_SIZE" \ |
| 73 | --ssh-keys "$KEY_ID" \ |
| 74 | --tag-name codewhale-smoke \ |
| 75 | --wait >/dev/null |
| 76 | DROPLET_ID=$(doctl compute droplet list --format ID,Name --no-header | awk -v n="$DROPLET_NAME" '$2 == n {print $1; exit}') |
| 77 | IP=$(doctl compute droplet get "$DROPLET_ID" --format PublicIPv4 --no-header) |
| 78 | echo "created $DROPLET_NAME (id $DROPLET_ID, $IP)" |
| 79 | |
| 80 | echo "== Cloud firewall: SSH only ==" |
| 81 | SRC="0.0.0.0/0,address:::/0" |
| 82 | if [[ "$RESTRICT_SSH_TO_MY_IP" == "true" ]]; then |
| 83 | MYIP=$(curl -fsS https://api.ipify.org) |
| 84 | SRC="${MYIP}/32" |
| 85 | fi |
| 86 | if ! doctl compute firewall list --format Name --no-header | grep -qx "$FIREWALL_NAME"; then |
| 87 | doctl compute firewall create \ |
| 88 | --name "$FIREWALL_NAME" \ |
| 89 | --inbound-rules "protocol:tcp,ports:22,address:${SRC}" \ |
| 90 | --outbound-rules "protocol:tcp,ports:all,address:0.0.0.0/0,address:::/0 protocol:udp,ports:all,address:0.0.0.0/0,address:::/0 protocol:icmp,address:0.0.0.0/0,address:::/0" \ |
| 91 | --droplet-ids "$DROPLET_ID" >/dev/null |
| 92 | echo "firewall $FIREWALL_NAME created: inbound 22/tcp from ${SRC}, all else blocked" |
| 93 | else |
| 94 | FW_ID=$(doctl compute firewall list --format ID,Name --no-header | awk -v n="$FIREWALL_NAME" '$2 == n {print $1; exit}') |
| 95 | doctl compute firewall add-droplets "$FW_ID" --droplet-ids "$DROPLET_ID" |
| 96 | echo "existing firewall $FIREWALL_NAME attached" |
| 97 | fi |
| 98 | |
| 99 | echo |
| 100 | echo "== Done ==" |
| 101 | echo "Droplet: $DROPLET_NAME ($DO_REGION)" |
| 102 | echo "Public IP: $IP" |
| 103 | echo "SSH: ssh -i ${SSH_PUBKEY%.pub} root@${IP}" |
| 104 | echo " (DO Ubuntu images log in as root, not ubuntu)" |
| 105 | echo |
| 106 | echo "Teardown when finished (stops billing):" |
| 107 | echo " bash scripts/remote-smoke/digitalocean/teardown.sh" |
| 108 |