vela 0.10.2 → 0.10.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vela",
3
- "version": "0.10.2",
3
+ "version": "0.10.3",
4
4
  "type": "module",
5
5
  "description": "A CLI for creating and updating SvelteKit projects",
6
6
  "license": "MIT",
@@ -0,0 +1,349 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Activate an uploaded release for one instance.
4
+ #
5
+ # The CLI has already put the release files in place; everything that makes them
6
+ # live happens here, in one transaction. If any step fails the previous release
7
+ # is put back and the services are restarted before exiting non-zero.
8
+ #
9
+ # usage: apply.sh <instance> <release> [options]
10
+ set -Eeuo pipefail
11
+
12
+ SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
13
+ # shellcheck source=lib.sh
14
+ . "$SCRIPT_DIR/lib.sh"
15
+
16
+ require_provisioned
17
+ [ "$(id -u)" -eq 0 ] || die "apply must run as root"
18
+
19
+ INSTANCE=${1:-}; shift || true
20
+ RELEASE=${1:-}; shift || true
21
+ [ -n "$INSTANCE" ] && [ -n "$RELEASE" ] || die "usage: apply.sh <instance> <release> [options]"
22
+
23
+ APP_NAME=$INSTANCE
24
+ APP_ID=$INSTANCE
25
+ ENV_TAG=prod
26
+ DOMAIN=""
27
+ HEALTH_PATH=/
28
+ KEEP=5
29
+ PB_VERSION=""
30
+ BACKEND=1
31
+ GIT_SHA=""
32
+ SU_CREATED=0
33
+
34
+ while [ $# -gt 0 ]; do
35
+ case "$1" in
36
+ --name) APP_NAME=$2; shift 2 ;;
37
+ --app-id) APP_ID=$2; shift 2 ;;
38
+ --env) ENV_TAG=$2; shift 2 ;;
39
+ --domain) DOMAIN=$2; shift 2 ;;
40
+ --health-path) HEALTH_PATH=$2; shift 2 ;;
41
+ --keep) KEEP=$2; shift 2 ;;
42
+ --pb-version) PB_VERSION=$2; shift 2 ;;
43
+ --backend) BACKEND=$2; shift 2 ;;
44
+ --git-sha) GIT_SHA=$2; shift 2 ;;
45
+ *) die "unknown argument: $1" ;;
46
+ esac
47
+ done
48
+
49
+ # Commands that drop to the app user inherit this working directory, and the
50
+ # directory the CLI happened to invoke from is usually one it cannot stat.
51
+ cd "$VELA_ROOT"
52
+
53
+ APP=$(app_dir "$INSTANCE")
54
+ ETC=$(etc_dir "$INSTANCE")
55
+ RELEASE_DIR="$APP/releases/$RELEASE"
56
+ WEB_UNIT=$(unit_web "$INSTANCE")
57
+ PB_UNIT=$(unit_pb "$INSTANCE")
58
+
59
+ [ -d "$RELEASE_DIR" ] || die "release $RELEASE was not uploaded to $RELEASE_DIR"
60
+ [ -f "$RELEASE_DIR/build/index.js" ] || die \
61
+ "release $RELEASE has no build/index.js - the app must build with @sveltejs/adapter-node"
62
+
63
+ mkdir -p "$APP"/{releases,shared,bin,deps} "$APP/shared/pb_data"
64
+ mkdir -p "$ETC"
65
+ chmod 0700 "$ETC"
66
+ chown -R "$VELA_USER:$VELA_USER" "$APP"
67
+ chown "$VELA_USER:$VELA_USER" "$RELEASE_DIR"
68
+
69
+ # ---------------------------------------------------------------- ports & env
70
+
71
+ PORTS=$(allocate_ports "$INSTANCE")
72
+ WEB_PORT=$(printf '%s' "$PORTS" | jq -r .web)
73
+ PB_PORT=$(printf '%s' "$PORTS" | jq -r .pb)
74
+
75
+ PRIMARY_DOMAIN=$(printf '%s' "$DOMAIN" | cut -d, -f1 | tr -d ' ')
76
+ if [ -n "$PRIMARY_DOMAIN" ]; then
77
+ ORIGIN="https://$PRIMARY_DOMAIN"
78
+ else
79
+ ORIGIN="http://127.0.0.1:$WEB_PORT"
80
+ fi
81
+
82
+ # Production secrets live in $ETC/env and are managed only by `vela env`.
83
+ # Nothing here reads or rewrites that file.
84
+ [ -f "$ETC/env" ] || { : > "$ETC/env"; chmod 0600 "$ETC/env"; chown root:root "$ETC/env"; }
85
+
86
+ runtime_tmp=$(mktemp "$ETC/.runtime.XXXXXX")
87
+ {
88
+ printf '# Generated by vela on each deploy. Edit /etc/vela/apps/%s/env instead.\n' "$INSTANCE"
89
+ printf 'NODE_ENV=production\n'
90
+ printf 'HOST=127.0.0.1\n'
91
+ printf 'PORT=%s\n' "$WEB_PORT"
92
+ printf 'ORIGIN=%s\n' "$ORIGIN"
93
+ printf 'PB_PORT=%s\n' "$PB_PORT"
94
+ printf 'POCKETBASE_URL=http://127.0.0.1:%s\n' "$PB_PORT"
95
+ printf 'VELA_APP_ID=%s\n' "$APP_ID"
96
+ printf 'VELA_APP_NAME=%s\n' "$APP_NAME"
97
+ printf 'VELA_ENV=%s\n' "$ENV_TAG"
98
+ printf 'VELA_RELEASE=%s\n' "$RELEASE"
99
+ } > "$runtime_tmp"
100
+ chmod 0600 "$runtime_tmp"
101
+ chown root:root "$runtime_tmp"
102
+ mv -f "$runtime_tmp" "$ETC/runtime.env"
103
+
104
+ # ------------------------------------------------------------------- runtime
105
+
106
+ if [ "$BACKEND" = "1" ]; then
107
+ [ -n "$PB_VERSION" ] || die "--pb-version is required for an app with a backend"
108
+ PB_BIN=$("$SCRIPT_DIR/pocketbase.sh" "$PB_VERSION" | tail -1)
109
+ ln -sfn "$PB_BIN" "$APP/bin/.pocketbase.tmp"
110
+ mv -Tf "$APP/bin/.pocketbase.tmp" "$APP/bin/pocketbase"
111
+ fi
112
+
113
+ # --------------------------------------------------------------- dependencies
114
+ #
115
+ # node_modules is keyed by the lockfile hash and shared between releases that
116
+ # pin the same dependencies: redeploys of unchanged dependencies skip the
117
+ # install entirely, and a rollback still finds the tree its own lockfile
118
+ # described.
119
+
120
+ install_deps() {
121
+ local lock="$RELEASE_DIR/package-lock.json"
122
+ local key
123
+ local -a install_cmd
124
+ # `--no-engine-strict`: vela projects set engine-strict for development, but a
125
+ # production install takes only `dependencies` - a dev tool that wants a newer
126
+ # Node than the server runs is not a reason to refuse the deploy.
127
+ local -a flags=(--omit=dev --no-audit --no-fund --no-engine-strict --loglevel=error)
128
+ if [ -f "$lock" ]; then
129
+ key=$(sha256sum "$lock" | cut -c1-16)
130
+ install_cmd=(npm ci "${flags[@]}")
131
+ elif [ -f "$RELEASE_DIR/package.json" ]; then
132
+ key=$(sha256sum "$RELEASE_DIR/package.json" | cut -c1-16)
133
+ install_cmd=(npm install "${flags[@]}")
134
+ else
135
+ log "no package.json in release - skipping dependency install"
136
+ return 0
137
+ fi
138
+
139
+ local deps="$APP/deps/$key"
140
+ if [ ! -d "$deps/node_modules" ]; then
141
+ log "installing dependencies ($key)"
142
+ rm -rf "$deps"
143
+ mkdir -p "$deps"
144
+ cp "$RELEASE_DIR/package.json" "$deps/"
145
+ if [ -f "$lock" ]; then cp "$lock" "$deps/"; fi
146
+ if [ -f "$RELEASE_DIR/.npmrc" ]; then cp "$RELEASE_DIR/.npmrc" "$deps/"; fi
147
+ chown -R "$VELA_USER:$VELA_USER" "$deps"
148
+ # npm needs a writable HOME and cache; $VELA_ROOT itself stays root-owned
149
+ # so the app user can never swap out the scripts root runs.
150
+ mkdir -p "$VELA_ROOT/cache/npm"
151
+ chown -R "$VELA_USER:$VELA_USER" "$VELA_ROOT/cache"
152
+ ( cd "$deps" && runuser -u "$VELA_USER" -- env \
153
+ HOME="$deps" npm_config_cache="$VELA_ROOT/cache/npm" \
154
+ "${install_cmd[@]}" >&2 ) || die "dependency install failed"
155
+ else
156
+ log "dependencies already installed ($key)"
157
+ fi
158
+ ln -sfn "$deps/node_modules" "$RELEASE_DIR/.node_modules.tmp"
159
+ mv -Tf "$RELEASE_DIR/.node_modules.tmp" "$RELEASE_DIR/node_modules"
160
+ printf '%s' "$key" > "$RELEASE_DIR/.vela-deps"
161
+ }
162
+ install_deps
163
+
164
+ # --------------------------------------------------------------- the swap
165
+
166
+ PREVIOUS=""
167
+ if [ -L "$APP/current" ]; then
168
+ PREVIOUS=$(basename "$(readlink -f "$APP/current")")
169
+ fi
170
+
171
+ # From here on the instance is being mutated. Any exit before ACTIVATED=1 puts
172
+ # the previous release back - a failed deploy must never leave a dead service.
173
+ IN_TRANSACTION=0
174
+ ACTIVATED=0
175
+
176
+ restore() {
177
+ log "deploy failed - restoring ${PREVIOUS:-nothing}"
178
+ if [ -n "$PREVIOUS" ] && [ -d "$APP/releases/$PREVIOUS" ]; then
179
+ ln -sfn "$APP/releases/$PREVIOUS" "$APP/.current.tmp"
180
+ mv -Tf "$APP/.current.tmp" "$APP/current"
181
+ if [ "$BACKEND" = "1" ]; then systemctl restart "$PB_UNIT" >/dev/null 2>&1 || true; fi
182
+ systemctl restart "$WEB_UNIT" >/dev/null 2>&1 || true
183
+ else
184
+ systemctl stop "$WEB_UNIT" >/dev/null 2>&1 || true
185
+ if [ "$BACKEND" = "1" ]; then systemctl stop "$PB_UNIT" >/dev/null 2>&1 || true; fi
186
+ fi
187
+ }
188
+
189
+ on_exit() {
190
+ local code=$?
191
+ if [ "$IN_TRANSACTION" = "1" ] && [ "$ACTIVATED" = "0" ]; then restore; fi
192
+ exit "$code"
193
+ }
194
+ trap on_exit EXIT
195
+
196
+ IN_TRANSACTION=1
197
+
198
+ log "stopping services"
199
+ systemctl stop "$WEB_UNIT" >/dev/null 2>&1 || true
200
+ if [ "$BACKEND" = "1" ]; then
201
+ systemctl stop "$PB_UNIT" >/dev/null 2>&1 || true
202
+
203
+ if [ -d "$RELEASE_DIR/migrations" ]; then
204
+ log "running migrations"
205
+ runuser -u "$VELA_USER" -- "$APP/bin/pocketbase" \
206
+ --dir "$APP/shared/pb_data" \
207
+ --migrationsDir "$RELEASE_DIR/migrations" \
208
+ migrate up >&2
209
+ fi
210
+
211
+ # ---------------------------------------------------- superuser bootstrap
212
+ #
213
+ # The app authenticates to its own PocketBase as a superuser on every render,
214
+ # so a database without one answers every request with a 401 and the deploy
215
+ # fails its health check. A brand new instance has an empty pb_data, which is
216
+ # why the account is created here rather than assumed to exist.
217
+ #
218
+ # $ETC/env is otherwise `vela env`'s alone. This is the single exception, and
219
+ # it writes only on the deploy that creates the account. From then on the env
220
+ # file is the source of truth: a value changed there is pushed into the
221
+ # database by the next deploy, which is what keeps the two from drifting.
222
+ SU_EMAIL=$(env_file_get "$ETC/env" POCKETBASE_SUPERUSER_EMAIL || true)
223
+ SU_PASSWORD=$(env_file_get "$ETC/env" POCKETBASE_SUPERUSER_PASSWORD || true)
224
+
225
+ if [ -z "$SU_EMAIL" ] || [ -z "$SU_PASSWORD" ]; then
226
+ SU_EMAIL=${SU_EMAIL:-admin@${PRIMARY_DOMAIN:-$INSTANCE.invalid}}
227
+ SU_PASSWORD=$(random_secret)
228
+ SU_CREATED=1
229
+ fi
230
+
231
+ # The password reaches PocketBase as an argument, so the upsert runs only on
232
+ # the deploys that need it rather than on every one.
233
+ SU_STAMP="$ETC/.superuser"
234
+ SU_FINGERPRINT=$(printf '%s\n%s' "$SU_EMAIL" "$SU_PASSWORD" | sha256sum | cut -d' ' -f1)
235
+ if [ "$SU_CREATED" = "1" ] || [ "$(cat "$SU_STAMP" 2>/dev/null || true)" != "$SU_FINGERPRINT" ]; then
236
+ if [ "$SU_CREATED" = "1" ]; then
237
+ log "creating the PocketBase superuser"
238
+ else
239
+ log "updating the PocketBase superuser"
240
+ fi
241
+ runuser -u "$VELA_USER" -- "$APP/bin/pocketbase" \
242
+ --dir "$APP/shared/pb_data" \
243
+ superuser upsert "$SU_EMAIL" "$SU_PASSWORD" >&2 \
244
+ || die "could not create the PocketBase superuser"
245
+
246
+ # Recorded only once the database holds the account, so a failure above
247
+ # leaves nothing behind and the next deploy simply tries again.
248
+ if [ "$SU_CREATED" = "1" ]; then
249
+ env_file_append "$ETC/env" POCKETBASE_SUPERUSER_EMAIL "$SU_EMAIL"
250
+ env_file_append "$ETC/env" POCKETBASE_SUPERUSER_PASSWORD "$SU_PASSWORD"
251
+ fi
252
+ printf '%s\n' "$SU_FINGERPRINT" > "$SU_STAMP"
253
+ chmod 0600 "$SU_STAMP"
254
+ chown root:root "$SU_STAMP"
255
+ fi
256
+ fi
257
+
258
+ log "activating release $RELEASE"
259
+ ln -sfn "$RELEASE_DIR" "$APP/.current.tmp"
260
+ mv -Tf "$APP/.current.tmp" "$APP/current"
261
+ # A human-readable alias for the opaque app id, purely for looking around.
262
+ if [ "$ENV_TAG" = "prod" ]; then LINK_LABEL=$APP_NAME; else LINK_LABEL="$APP_NAME-$ENV_TAG"; fi
263
+ LINK_NAME=$(printf '%s' "$LINK_LABEL" | tr -c 'A-Za-z0-9._-' '-')
264
+ if [ -n "$LINK_NAME" ]; then
265
+ ln -sfn "$APP" "$VELA_ROOT/by-name/.link.tmp" && \
266
+ mv -Tf "$VELA_ROOT/by-name/.link.tmp" "$VELA_ROOT/by-name/$LINK_NAME" || true
267
+ fi
268
+
269
+ systemctl daemon-reload
270
+
271
+ if [ "$BACKEND" = "1" ]; then
272
+ log "starting PocketBase on 127.0.0.1:$PB_PORT"
273
+ systemctl enable "$PB_UNIT" >/dev/null 2>&1 || true
274
+ systemctl restart "$PB_UNIT"
275
+ wait_for_http "http://127.0.0.1:$PB_PORT/api/health" 60 0.5 \
276
+ || die "PocketBase did not become healthy - journalctl -u $PB_UNIT"
277
+ fi
278
+
279
+ log "starting app on 127.0.0.1:$WEB_PORT"
280
+ systemctl enable "$WEB_UNIT" >/dev/null 2>&1 || true
281
+ systemctl restart "$WEB_UNIT"
282
+ wait_for_http "http://127.0.0.1:$WEB_PORT$HEALTH_PATH" 60 0.5 \
283
+ || die "app did not become healthy at $HEALTH_PATH - journalctl -u $WEB_UNIT"
284
+
285
+ ACTIVATED=1
286
+
287
+ # --------------------------------------------------------------------- state
288
+
289
+ state_merge "$INSTANCE" "$(jq -c -n \
290
+ --arg app "$APP_ID" --arg name "$APP_NAME" --arg env "$ENV_TAG" \
291
+ --arg instance "$INSTANCE" --arg release "$RELEASE" --arg previous "$PREVIOUS" \
292
+ --arg domain "$DOMAIN" --arg health "$HEALTH_PATH" --arg pb "$PB_VERSION" \
293
+ --arg sha "$GIT_SHA" --arg at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
294
+ --argjson web "$WEB_PORT" --argjson pbport "$PB_PORT" --argjson backend "$BACKEND" \
295
+ '{appId: $app, name: $name, env: $env, instance: $instance,
296
+ activeRelease: $release, previousRelease: $previous, domain: $domain,
297
+ healthCheckPath: $health, pocketbaseVersion: $pb, gitSha: $sha,
298
+ webPort: $web, pbPort: $pbport, backend: ($backend == 1), deployedAt: $at}')"
299
+
300
+ # ------------------------------------------------------------------ routing
301
+
302
+ CADDY_SNIPPET="$VELA_ETC/caddy/$INSTANCE.caddy"
303
+ if [ -n "$DOMAIN" ]; then
304
+ hosts=$(printf '%s' "$DOMAIN" | tr ',' '\n' | sed 's/^ *//; s/ *$//' | grep -v '^$' | paste -sd, - | sed 's/,/, /g')
305
+ tmp=$(mktemp "$VELA_ETC/caddy/.snippet.XXXXXX")
306
+ {
307
+ printf '# Managed by vela - app %s (%s)\n' "$APP_NAME" "$INSTANCE"
308
+ printf '%s {\n\treverse_proxy 127.0.0.1:%s\n}\n' "$hosts" "$WEB_PORT"
309
+ } > "$tmp"
310
+ chmod 0644 "$tmp"
311
+ mv -f "$tmp" "$CADDY_SNIPPET"
312
+ if ! caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile >/dev/null 2>&1; then
313
+ rm -f "$CADDY_SNIPPET"
314
+ die "generated Caddy config for $DOMAIN is invalid"
315
+ fi
316
+ systemctl reload caddy
317
+ elif [ -f "$CADDY_SNIPPET" ]; then
318
+ rm -f "$CADDY_SNIPPET"
319
+ systemctl reload caddy || true
320
+ fi
321
+
322
+
323
+ # ------------------------------------------------------------------- pruning
324
+
325
+ if [ "$KEEP" -gt 0 ] 2>/dev/null; then
326
+ mapfile -t old < <(ls -1 "$APP/releases" | sort -r | tail -n +$((KEEP + 1)))
327
+ for rel in "${old[@]:-}"; do
328
+ [ -n "$rel" ] || continue
329
+ [ "$rel" = "$RELEASE" ] && continue
330
+ [ "$rel" = "$PREVIOUS" ] && continue
331
+ log "pruning release $rel"
332
+ rm -rf "${APP:?}/releases/$rel"
333
+ done
334
+ # Drop dependency trees no surviving release refers to.
335
+ for deps in "$APP"/deps/*; do
336
+ [ -d "$deps" ] || continue
337
+ key=$(basename "$deps")
338
+ if ! grep -rqs -- "$key" "$APP"/releases/*/.vela-deps; then
339
+ log "pruning dependencies $key"
340
+ rm -rf "$deps"
341
+ fi
342
+ done
343
+ fi
344
+
345
+ emit_result \
346
+ --arg instance "$INSTANCE" --arg release "$RELEASE" --arg domain "$DOMAIN" \
347
+ --arg url "$ORIGIN" --argjson web "$WEB_PORT" --argjson pb "$PB_PORT" --argjson su "$SU_CREATED" \
348
+ '{instance: $instance, release: $release, domain: $domain, url: $url,
349
+ webPort: $web, pbPort: $pb, superuserCreated: ($su == 1)}'
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Remove one instance from the server.
4
+ #
5
+ # Releases and configuration always go; the database and uploads only go with
6
+ # --purge, so a mistyped instance name cannot silently delete production data.
7
+ #
8
+ # usage: destroy.sh <instance> [--purge]
9
+ set -Eeuo pipefail
10
+
11
+ SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
12
+ # shellcheck source=lib.sh
13
+ . "$SCRIPT_DIR/lib.sh"
14
+
15
+ require_provisioned
16
+ [ "$(id -u)" -eq 0 ] || die "destroy must run as root"
17
+
18
+ INSTANCE=${1:-}; shift || true
19
+ [ -n "$INSTANCE" ] || die "usage: destroy.sh <instance> [--purge]"
20
+ PURGE=0
21
+ while [ $# -gt 0 ]; do
22
+ case "$1" in
23
+ --purge) PURGE=1; shift ;;
24
+ *) die "unknown argument: $1" ;;
25
+ esac
26
+ done
27
+
28
+ APP=$(app_dir "$INSTANCE")
29
+ ETC=$(etc_dir "$INSTANCE")
30
+
31
+ log "stopping services"
32
+ for unit in "$(unit_web "$INSTANCE")" "$(unit_pb "$INSTANCE")"; do
33
+ systemctl disable --now "$unit" >/dev/null 2>&1 || true
34
+ done
35
+
36
+ log "removing routing"
37
+ rm -f "$VELA_ETC/caddy/$INSTANCE.caddy"
38
+ systemctl reload caddy >/dev/null 2>&1 || true
39
+
40
+ log "removing releases"
41
+ rm -rf "$APP/releases" "$APP/deps" "$APP/current" "$APP/bin"
42
+
43
+ if [ "$PURGE" = "1" ]; then
44
+ log "purging data and configuration"
45
+ rm -rf "$APP" "$ETC"
46
+ release_ports "$INSTANCE"
47
+ else
48
+ log "keeping $APP/shared (pass --purge to remove the database)"
49
+ rm -f "$APP/state.json"
50
+ fi
51
+
52
+ find "$VELA_ROOT/by-name" -maxdepth 1 -type l ! -exec test -e {} \; -delete 2>/dev/null || true
53
+
54
+ emit_result --arg instance "$INSTANCE" --argjson purged "$PURGE" \
55
+ '{instance: $instance, purged: ($purged == 1)}'
@@ -0,0 +1,166 @@
1
+ #!/usr/bin/env bash
2
+ # Shared helpers for the vela server scripts. Sourced, never run directly.
3
+
4
+ VELA_ROOT=${VELA_ROOT:-/var/lib/vela}
5
+ VELA_ETC=${VELA_ETC:-/etc/vela}
6
+ VELA_USER=${VELA_USER:-vela}
7
+ VELA_WEB_PORT_BASE=${VELA_WEB_PORT_BASE:-4100}
8
+ VELA_PB_PORT_BASE=${VELA_PB_PORT_BASE:-8100}
9
+ VELA_PORT_RANGE=${VELA_PORT_RANGE:-900}
10
+
11
+ app_dir() { printf '%s/apps/%s' "$VELA_ROOT" "$1"; }
12
+ etc_dir() { printf '%s/apps/%s' "$VELA_ETC" "$1"; }
13
+ state_file() { printf '%s/apps/%s/state.json' "$VELA_ROOT" "$1"; }
14
+
15
+ log() { printf ' %s\n' "$*" >&2; }
16
+ die() { printf 'error: %s\n' "$*" >&2; exit 1; }
17
+
18
+ require_provisioned() {
19
+ [ -f "$VELA_ETC/provisioned" ] || die "server is not provisioned - run 'vela provision' first"
20
+ }
21
+
22
+ # Read a top-level key out of an instance's state file.
23
+ state_get() {
24
+ local instance=$1 key=$2 file
25
+ file=$(state_file "$instance")
26
+ [ -f "$file" ] || return 1
27
+ # `// empty` would swallow a legitimate `false`, so test for the key itself.
28
+ jq -er --arg k "$key" 'if has($k) and .[$k] != null then .[$k] else empty end' "$file" 2>/dev/null
29
+ }
30
+
31
+ # Merge a JSON object into an instance's state file, atomically.
32
+ state_merge() {
33
+ local instance=$1 patch=$2 file tmp
34
+ file=$(state_file "$instance")
35
+ mkdir -p "$(dirname "$file")"
36
+ [ -f "$file" ] || echo '{}' > "$file"
37
+ tmp=$(mktemp "$(dirname "$file")/.state.XXXXXX")
38
+ jq -S --argjson patch "$patch" '. * $patch' "$file" > "$tmp"
39
+ chown "$VELA_USER:$VELA_USER" "$tmp" 2>/dev/null || true
40
+ mv -f "$tmp" "$file"
41
+ }
42
+
43
+ # Assign a web/PocketBase port pair to an instance, or echo the existing one.
44
+ # Ports live in one server-wide file guarded by flock so that concurrent
45
+ # deploys of different apps cannot land on the same port.
46
+ allocate_ports() {
47
+ local instance=$1
48
+ local ports_file="$VELA_ROOT/state/ports.json"
49
+ local lock="$VELA_ROOT/state/.ports.lock"
50
+ mkdir -p "$VELA_ROOT/state"
51
+ [ -f "$ports_file" ] || echo '{}' > "$ports_file"
52
+ touch "$lock"
53
+
54
+ (
55
+ flock 9
56
+ local existing
57
+ existing=$(jq -er --arg i "$instance" '.[$i] // empty' "$ports_file")
58
+ if [ -n "$existing" ]; then
59
+ printf '%s' "$existing"
60
+ exit 0
61
+ fi
62
+
63
+ local offset=0 web pb
64
+ while [ "$offset" -lt "$VELA_PORT_RANGE" ]; do
65
+ web=$((VELA_WEB_PORT_BASE + offset))
66
+ pb=$((VELA_PB_PORT_BASE + offset))
67
+ if ! jq -e --argjson w "$web" 'to_entries | any(.value.web == $w)' "$ports_file" >/dev/null \
68
+ && ! port_in_use "$web" && ! port_in_use "$pb"; then
69
+ local tmp
70
+ tmp=$(mktemp "$VELA_ROOT/state/.ports.XXXXXX")
71
+ jq -S --arg i "$instance" --argjson w "$web" --argjson p "$pb" \
72
+ '.[$i] = {web: $w, pb: $p}' "$ports_file" > "$tmp"
73
+ mv -f "$tmp" "$ports_file"
74
+ jq -c -n --argjson w "$web" --argjson p "$pb" '{web: $w, pb: $p}'
75
+ exit 0
76
+ fi
77
+ offset=$((offset + 1))
78
+ done
79
+ die "no free port pair in range"
80
+ ) 9>"$lock"
81
+ }
82
+
83
+ release_ports() {
84
+ local instance=$1
85
+ local ports_file="$VELA_ROOT/state/ports.json"
86
+ local lock="$VELA_ROOT/state/.ports.lock"
87
+ [ -f "$ports_file" ] || return 0
88
+ (
89
+ flock 9
90
+ local tmp
91
+ tmp=$(mktemp "$VELA_ROOT/state/.ports.XXXXXX")
92
+ jq -S --arg i "$instance" 'del(.[$i])' "$ports_file" > "$tmp"
93
+ mv -f "$tmp" "$ports_file"
94
+ ) 9>"$lock"
95
+ }
96
+
97
+ port_in_use() {
98
+ local port=$1
99
+ if command -v ss >/dev/null 2>&1; then
100
+ ss -ltn "sport = :$port" 2>/dev/null | tail -n +2 | grep -q . && return 0
101
+ fi
102
+ return 1
103
+ }
104
+
105
+ unit_web() { printf 'vela-web@%s.service' "$1"; }
106
+ unit_pb() { printf 'vela-pb@%s.service' "$1"; }
107
+
108
+ unit_active() { systemctl is-active --quiet "$1"; }
109
+
110
+ # Poll an HTTP endpoint until it answers with a non-5xx status.
111
+ wait_for_http() {
112
+ local url=$1 attempts=${2:-60} delay=${3:-0.5} code
113
+ local i=0
114
+ while [ "$i" -lt "$attempts" ]; do
115
+ code=$(curl -fsS -o /dev/null -w '%{http_code}' --max-time 5 "$url" 2>/dev/null || echo 000)
116
+ case "$code" in
117
+ 2*|3*|4*) return 0 ;;
118
+ esac
119
+ i=$((i + 1))
120
+ sleep "$delay"
121
+ done
122
+ return 1
123
+ }
124
+
125
+ # How many migrations the current release has that the target release does not.
126
+ # PocketBase reverts by count, so this is what `migrate down` needs.
127
+ migrations_ahead() {
128
+ local current_dir=$1 target_dir=$2 count=0 f
129
+ [ -d "$current_dir" ] || { printf '0'; return 0; }
130
+ for f in "$current_dir"/*.js; do
131
+ [ -f "$f" ] || continue
132
+ if [ ! -f "$target_dir/$(basename "$f")" ]; then count=$((count + 1)); fi
133
+ done
134
+ printf '%s' "$count"
135
+ }
136
+
137
+ emit_result() { printf 'VELA_RESULT %s\n' "$(jq -c -n "$@")"; }
138
+
139
+ # Read one value out of a vela-managed env file. `vela env` writes values with
140
+ # systemd's quoting, whose escapes (\\, \", \n, \t) are also JSON's, so jq
141
+ # decodes them exactly.
142
+ env_file_get() {
143
+ local file=$1 key=$2 raw
144
+ [ -f "$file" ] || return 1
145
+ raw=$(sed -n "s/^[[:space:]]*${key}=//p" "$file" | head -n1)
146
+ [ -n "$raw" ] || return 1
147
+ case "$raw" in
148
+ '"'*) printf '%s' "$raw" | jq -r . ;;
149
+ *) printf '%s' "$raw" ;;
150
+ esac
151
+ }
152
+
153
+ # Append a value in the same quoted form `vela env` uses, so it round-trips.
154
+ env_file_append() {
155
+ local file=$1 key=$2 value=$3
156
+ [ -f "$file" ] || : > "$file"
157
+ if [ -s "$file" ] && [ -n "$(tail -c1 "$file")" ]; then printf '\n' >> "$file"; fi
158
+ printf '%s=%s\n' "$key" "$(jq -rn --arg v "$value" '$v | @json')" >> "$file"
159
+ chmod 0600 "$file"
160
+ chown root:root "$file"
161
+ }
162
+
163
+ # 24 bytes of hex - no shell metacharacters, nothing to escape anywhere it goes.
164
+ random_secret() {
165
+ head -c 24 /dev/urandom | od -An -tx1 | tr -d ' \n'
166
+ }
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Ensure a given PocketBase version is available on this server and print its
4
+ # path. Versions live side by side so two apps can pin different ones and a
5
+ # rollback never has to downgrade a shared binary.
6
+ #
7
+ # usage: pocketbase.sh <version>
8
+ set -euo pipefail
9
+
10
+ SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
11
+ # shellcheck source=lib.sh
12
+ . "$SCRIPT_DIR/lib.sh"
13
+
14
+ VERSION=${1:-}
15
+ [ -n "$VERSION" ] || die "pocketbase.sh needs a version"
16
+
17
+ DEST="$VELA_ROOT/runtime/pocketbase/$VERSION"
18
+ BIN="$DEST/pocketbase"
19
+
20
+ if [ -x "$BIN" ]; then
21
+ printf '%s\n' "$BIN"
22
+ exit 0
23
+ fi
24
+
25
+ case "$(uname -m)" in
26
+ x86_64) NPM_PKG=pocketbase-server-linux-x64 ;;
27
+ aarch64|arm64) NPM_PKG=pocketbase-server-linux-arm64 ;;
28
+ *) die "unsupported architecture: $(uname -m)" ;;
29
+ esac
30
+
31
+ # The same npm package the CLI runs locally, so the server and a developer's
32
+ # machine are always on the identical PocketBase build.
33
+ tmp=$(mktemp -d)
34
+ trap 'rm -rf "$tmp"' EXIT
35
+ (
36
+ cd "$tmp"
37
+ npm init -y >/dev/null 2>&1
38
+ npm install --no-audit --no-fund --loglevel=error "$NPM_PKG@$VERSION" >/dev/null
39
+ )
40
+ [ -f "$tmp/node_modules/$NPM_PKG/bin/pocketbase" ] \
41
+ || die "PocketBase $VERSION could not be installed from $NPM_PKG"
42
+
43
+ mkdir -p "$DEST"
44
+ install -m 0755 "$tmp/node_modules/$NPM_PKG/bin/pocketbase" "$BIN"
45
+ printf '%s\n' "$BIN"