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