| 1 | #!/bin/bash |
| 2 | # Grove cloud-init — bootstraps a Grove instance on any VPS. |
| 3 | # Sets up Mononoke + Grove Bridge + Grove API + Grove Web + Grove Collab + Caddy. |
| 4 | # |
| 5 | # Placeholders replaced by the deploy page before use: |
| 6 | # __INSTANCE_ID__ — hub instance record ID |
| 7 | # __HUB_DOMAIN__ — hub domain (e.g., grove.host) |
| 8 | # __INSTANCE_DOMAIN__ — instance domain (optional, empty for IP-only) |
| 9 | # __HUB_TOKEN__ — user's JWT for the readiness callback |
| 10 | |
| 11 | set -euo pipefail |
| 12 | |
| 13 | GROVE_DIR="/opt/grove" |
| 14 | DATA_DIR="/data/grove" |
| 15 | HUB_DOMAIN="__HUB_DOMAIN__" |
| 16 | INSTANCE_DOMAIN="__INSTANCE_DOMAIN__" |
| 17 | INSTANCE_ID="__INSTANCE_ID__" |
| 18 | HUB_TOKEN="__HUB_TOKEN__" |
| 19 | |
| 20 | # ── Install Docker if missing ──────────────────────────────────────── |
| 21 | |
| 22 | if ! command -v docker &>/dev/null; then |
| 23 | echo "Installing Docker..." |
| 24 | curl -fsSL https://get.docker.com | sh |
| 25 | fi |
| 26 | |
| 27 | # ── Discover public IP (provider-agnostic) ─────────────────────────── |
| 28 | |
| 29 | DROPLET_IP=$(curl -sf https://ifconfig.me || curl -sf https://api.ipify.org || hostname -I | awk '{print $1}') |
| 30 | echo "Public IP: ${DROPLET_IP}" |
| 31 | |
| 32 | # ── Generate secrets ───────────────────────────────────────────────── |
| 33 | |
| 34 | JWT_SECRET=$(openssl rand -hex 32) |
| 35 | |
| 36 | # ── Generate self-signed TLS certs for Mononoke SLAPI ─────────────── |
| 37 | |
| 38 | mkdir -p "${DATA_DIR}/tls" |
| 39 | |
| 40 | # CA |
| 41 | openssl genrsa -out "${DATA_DIR}/tls/ca.key" 4096 2>/dev/null |
| 42 | openssl req -new -x509 -key "${DATA_DIR}/tls/ca.key" \ |
| 43 | -out "${DATA_DIR}/tls/ca.crt" -days 3650 \ |
| 44 | -subj "/CN=Grove Instance CA" 2>/dev/null |
| 45 | |
| 46 | # Server cert |
| 47 | openssl genrsa -out "${DATA_DIR}/tls/server.key" 4096 2>/dev/null |
| 48 | openssl req -new -key "${DATA_DIR}/tls/server.key" \ |
| 49 | -out "${DATA_DIR}/tls/server.csr" \ |
| 50 | -subj "/CN=${INSTANCE_DOMAIN:-${DROPLET_IP}}" 2>/dev/null |
| 51 | |
| 52 | cat > "${DATA_DIR}/tls/ext.cnf" <<EOF |
| 53 | authorityKeyIdentifier=keyid,issuer |
| 54 | basicConstraints=CA:FALSE |
| 55 | keyUsage=digitalSignature,keyEncipherment |
| 56 | extendedKeyUsage=serverAuth,clientAuth |
| 57 | subjectAltName=@alt_names |
| 58 | |
| 59 | [alt_names] |
| 60 | IP.1 = ${DROPLET_IP} |
| 61 | DNS.1 = ${INSTANCE_DOMAIN:-localhost} |
| 62 | EOF |
| 63 | |
| 64 | openssl x509 -req -in "${DATA_DIR}/tls/server.csr" \ |
| 65 | -CA "${DATA_DIR}/tls/ca.crt" -CAkey "${DATA_DIR}/tls/ca.key" \ |
| 66 | -CAcreateserial -out "${DATA_DIR}/tls/server.crt" \ |
| 67 | -days 3650 -extfile "${DATA_DIR}/tls/ext.cnf" 2>/dev/null |
| 68 | |
| 69 | rm -f "${DATA_DIR}/tls/server.csr" "${DATA_DIR}/tls/ext.cnf" "${DATA_DIR}/tls/ca.srl" |
| 70 | |
| 71 | echo "TLS certificates generated." |
| 72 | |
| 73 | # ── Create directory structure ─────────────────────────────────────── |
| 74 | |
| 75 | mkdir -p "${DATA_DIR}/mononoke-config/common" |
| 76 | mkdir -p "${DATA_DIR}/mononoke-config/repo_definitions" |
| 77 | mkdir -p "${DATA_DIR}/mononoke-config/repos" |
| 78 | mkdir -p "${DATA_DIR}/configerator" |
| 79 | mkdir -p "${DATA_DIR}/mononoke" |
| 80 | mkdir -p "${DATA_DIR}/api" |
| 81 | mkdir -p "${GROVE_DIR}" |
| 82 | |
| 83 | # ── Write Mononoke config ─────────────────────────────────────────── |
| 84 | |
| 85 | cat > "${DATA_DIR}/mononoke-config/common/common.toml" <<'TOML' |
| 86 | enable_http_control_api = true |
| 87 | |
| 88 | [internal_identity] |
| 89 | identity_type = "SERVICE" |
| 90 | identity_data = "grove" |
| 91 | TOML |
| 92 | |
| 93 | cat > "${DATA_DIR}/mononoke-config/common/storage.toml" <<'TOML' |
| 94 | [default] |
| 95 | |
| 96 | [default.metadata] |
| 97 | [default.metadata.local] |
| 98 | local_db_path = "/data/grove/mononoke/metadata" |
| 99 | |
| 100 | [default.blobstore] |
| 101 | [default.blobstore.blob_files] |
| 102 | path = "/data/grove/mononoke/blobs" |
| 103 | |
| 104 | [default.mutable_blobstore] |
| 105 | [default.mutable_blobstore.blob_files] |
| 106 | path = "/data/grove/mononoke/mutable_blobs" |
| 107 | TOML |
| 108 | |
| 109 | # ── Write justknobs.json (required by Mononoke) ───────────────────── |
| 110 | |
| 111 | cat > "${DATA_DIR}/justknobs.json" <<'JSON' |
| 112 | {} |
| 113 | JSON |
| 114 | |
| 115 | # ── Write .env ─────────────────────────────────────────────────────── |
| 116 | |
| 117 | cat > "${GROVE_DIR}/.env" <<EOF |
| 118 | JWT_SECRET=${JWT_SECRET} |
| 119 | DOMAIN=${INSTANCE_DOMAIN:-${DROPLET_IP}} |
| 120 | HUB_DOMAIN=${HUB_DOMAIN} |
| 121 | EOF |
| 122 | |
| 123 | # ── Write Caddyfile ────────────────────────────────────────────────── |
| 124 | |
| 125 | if [ -n "${INSTANCE_DOMAIN}" ]; then |
| 126 | CADDY_ADDR="${INSTANCE_DOMAIN}" |
| 127 | else |
| 128 | CADDY_ADDR=":80" |
| 129 | fi |
| 130 | |
| 131 | cat > "${GROVE_DIR}/Caddyfile" <<CADDYEOF |
| 132 | ${CADDY_ADDR} { |
| 133 | handle /api/auth/* { |
| 134 | reverse_proxy https://${HUB_DOMAIN} { |
| 135 | header_up Host ${HUB_DOMAIN} |
| 136 | } |
| 137 | } |
| 138 | |
| 139 | handle /api/instances/* { |
| 140 | reverse_proxy https://${HUB_DOMAIN} { |
| 141 | header_up Host ${HUB_DOMAIN} |
| 142 | } |
| 143 | } |
| 144 | |
| 145 | handle /v2/* { |
| 146 | reverse_proxy registry:5000 |
| 147 | } |
| 148 | |
| 149 | handle /collab/socket.io/* { |
| 150 | uri strip_prefix /collab |
| 151 | reverse_proxy grove-web:3334 |
| 152 | } |
| 153 | |
| 154 | handle /api/repos { |
| 155 | reverse_proxy grove-api:4000 |
| 156 | } |
| 157 | |
| 158 | handle /api/repos/* { |
| 159 | reverse_proxy grove-api:4000 |
| 160 | } |
| 161 | |
| 162 | handle { |
| 163 | reverse_proxy grove-web:3000 |
| 164 | } |
| 165 | |
| 166 | header { |
| 167 | X-Content-Type-Options nosniff |
| 168 | X-Frame-Options DENY |
| 169 | } |
| 170 | } |
| 171 | CADDYEOF |
| 172 | |
| 173 | # ── Write docker-compose.yml ──────────────────────────────────────── |
| 174 | |
| 175 | cat > "${GROVE_DIR}/docker-compose.yml" <<'COMPOSE' |
| 176 | services: |
| 177 | registry: |
| 178 | image: registry:2 |
| 179 | ports: |
| 180 | - "127.0.0.1:5000:5000" |
| 181 | volumes: |
| 182 | - registry-data:/var/lib/registry |
| 183 | environment: |
| 184 | - REGISTRY_STORAGE_DELETE_ENABLED=true |
| 185 | restart: unless-stopped |
| 186 | |
| 187 | mononoke-slapi: |
| 188 | image: ${HUB_DOMAIN}/grove-mononoke:latest |
| 189 | command: |
| 190 | - --listening-host-port |
| 191 | - "0.0.0.0:8443" |
| 192 | - --config-path |
| 193 | - /data/grove/mononoke-config |
| 194 | - --local-configerator-path |
| 195 | - /data/grove/configerator |
| 196 | - --cache-mode |
| 197 | - disabled |
| 198 | - --just-knobs-config-path |
| 199 | - /data/grove/justknobs.json |
| 200 | - --tls-certificate |
| 201 | - /data/grove/tls/server.crt |
| 202 | - --tls-private-key |
| 203 | - /data/grove/tls/server.key |
| 204 | - --tls-ca |
| 205 | - /data/grove/tls/ca.crt |
| 206 | ports: |
| 207 | - "8443:8443" |
| 208 | volumes: |
| 209 | - /data/grove:/data/grove |
| 210 | restart: unless-stopped |
| 211 | |
| 212 | mononoke-git: |
| 213 | image: ${HUB_DOMAIN}/grove-mononoke:latest |
| 214 | entrypoint: ["/usr/local/bin/git_server"] |
| 215 | command: |
| 216 | - --listen-host |
| 217 | - "0.0.0.0" |
| 218 | - --listen-port |
| 219 | - "8080" |
| 220 | - --config-path |
| 221 | - /data/grove/mononoke-config |
| 222 | - --local-configerator-path |
| 223 | - /data/grove/configerator |
| 224 | - --cache-mode |
| 225 | - disabled |
| 226 | - --just-knobs-config-path |
| 227 | - /data/grove/justknobs.json |
| 228 | - --tls-certificate |
| 229 | - /data/grove/tls/server.crt |
| 230 | - --tls-private-key |
| 231 | - /data/grove/tls/server.key |
| 232 | - --tls-ca |
| 233 | - /data/grove/tls/ca.crt |
| 234 | ports: |
| 235 | - "8080:8080" |
| 236 | volumes: |
| 237 | - /data/grove:/data/grove |
| 238 | depends_on: |
| 239 | mononoke-slapi: |
| 240 | condition: service_started |
| 241 | restart: unless-stopped |
| 242 | |
| 243 | grove-bridge: |
| 244 | image: ${HUB_DOMAIN}/grove-mononoke:latest |
| 245 | entrypoint: ["/usr/local/bin/grove_bridge"] |
| 246 | command: |
| 247 | - --listening-host-port |
| 248 | - "0.0.0.0:3100" |
| 249 | - --config-path |
| 250 | - /data/grove/mononoke-config |
| 251 | - --local-configerator-path |
| 252 | - /data/grove/configerator |
| 253 | - --cache-mode |
| 254 | - disabled |
| 255 | - --just-knobs-config-path |
| 256 | - /data/grove/justknobs.json |
| 257 | volumes: |
| 258 | - /data/grove:/data/grove |
| 259 | depends_on: |
| 260 | mononoke-slapi: |
| 261 | condition: service_started |
| 262 | restart: unless-stopped |
| 263 | |
| 264 | grove-api: |
| 265 | image: ${HUB_DOMAIN}/grove-api:latest |
| 266 | environment: |
| 267 | - PORT=4000 |
| 268 | - DATABASE_PATH=/data/grove.db |
| 269 | - JWT_SECRET=${JWT_SECRET} |
| 270 | - GROVE_BRIDGE_URL=http://grove-bridge:3100 |
| 271 | - MONONOKE_CONFIG_PATH=/data/grove/mononoke-config |
| 272 | - CANOPY_ENABLED=true |
| 273 | - CANOPY_WORKSPACE_DIR=/canopy/workspaces |
| 274 | - NODE_ENV=production |
| 275 | volumes: |
| 276 | - /data/grove/api:/data |
| 277 | - /var/run/docker.sock:/var/run/docker.sock |
| 278 | - /data/grove:/data/grove |
| 279 | - canopy-workspaces:/canopy/workspaces |
| 280 | depends_on: |
| 281 | grove-bridge: |
| 282 | condition: service_started |
| 283 | restart: unless-stopped |
| 284 | |
| 285 | grove-web: |
| 286 | image: ${HUB_DOMAIN}/grove-web:latest |
| 287 | volumes: |
| 288 | - collab-data:/data/collab |
| 289 | environment: |
| 290 | - GROVE_API_URL=http://grove-api:4000 |
| 291 | - DATA_DIR=/data/collab |
| 292 | - COLLAB_SOCKET_PORT=3334 |
| 293 | - JWT_SECRET=${JWT_SECRET} |
| 294 | - NODE_ENV=production |
| 295 | depends_on: |
| 296 | - grove-api |
| 297 | restart: unless-stopped |
| 298 | |
| 299 | caddy: |
| 300 | image: caddy:2-alpine |
| 301 | ports: |
| 302 | - "80:80" |
| 303 | - "443:443" |
| 304 | volumes: |
| 305 | - ./Caddyfile:/etc/caddy/Caddyfile:ro |
| 306 | - caddy-data:/data |
| 307 | - caddy-config:/config |
| 308 | depends_on: |
| 309 | - grove-web |
| 310 | - grove-api |
| 311 | restart: unless-stopped |
| 312 | |
| 313 | volumes: |
| 314 | registry-data: |
| 315 | canopy-workspaces: |
| 316 | collab-data: |
| 317 | caddy-data: |
| 318 | caddy-config: |
| 319 | COMPOSE |
| 320 | |
| 321 | # ── Pull images from hub registry ─────────────────────────────────── |
| 322 | |
| 323 | echo "Pulling images from ${HUB_DOMAIN}..." |
| 324 | docker pull "${HUB_DOMAIN}/grove-mononoke:latest" |
| 325 | docker pull "${HUB_DOMAIN}/grove-api:latest" |
| 326 | docker pull "${HUB_DOMAIN}/grove-web:latest" |
| 327 | |
| 328 | # ── Start ──────────────────────────────────────────────────────────── |
| 329 | |
| 330 | cd "${GROVE_DIR}" |
| 331 | docker compose up -d |
| 332 | |
| 333 | # ── Wait for health ────────────────────────────────────────────────── |
| 334 | |
| 335 | echo "Waiting for Grove Bridge to be healthy..." |
| 336 | for i in $(seq 1 60); do |
| 337 | if curl -sf http://localhost:3100/health > /dev/null 2>&1; then |
| 338 | echo "Grove Bridge is healthy." |
| 339 | break |
| 340 | fi |
| 341 | sleep 5 |
| 342 | done |
| 343 | |
| 344 | # ── Notify hub that instance is ready ──────────────────────────────── |
| 345 | |
| 346 | curl -sf -X POST "https://${HUB_DOMAIN}/api/instances/${INSTANCE_ID}/ready" \ |
| 347 | -H "Content-Type: application/json" \ |
| 348 | -d "{\"ip\": \"${DROPLET_IP}\", \"jwt_secret\": \"${JWT_SECRET}\"}" || true |
| 349 | |
| 350 | # ── Done ───────────────────────────────────────────────────────────── |
| 351 | |
| 352 | touch "${GROVE_DIR}/.setup-complete" |
| 353 | echo "" |
| 354 | echo "================================================" |
| 355 | echo " Grove instance deployment complete." |
| 356 | echo " IP: ${DROPLET_IP}" |
| 357 | echo " URL: ${INSTANCE_DOMAIN:+https://${INSTANCE_DOMAIN}}${INSTANCE_DOMAIN:-http://${DROPLET_IP}}" |
| 358 | echo "" |
| 359 | echo " Mononoke SLAPI: :8443 (TLS)" |
| 360 | echo " CA cert: ${DATA_DIR}/tls/ca.crt" |
| 361 | echo "================================================" |
| 362 | |