vela 0.11.2 → 0.11.4

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.11.2",
3
+ "version": "0.11.4",
4
4
  "type": "module",
5
5
  "description": "A CLI for creating and updating SvelteKit projects",
6
6
  "license": "MIT",
@@ -24,17 +24,19 @@
24
24
  "build": "node --experimental-strip-types scripts/build.ts",
25
25
  "dev": "node --experimental-strip-types scripts/build.ts --watch",
26
26
  "clean": "rm -rf dist",
27
- "lint": "npm run lint:ts && npm run lint:format",
27
+ "lint": "npm run lint:ts && npm run lint:format && npm run lint:sh",
28
28
  "lint:ts": "tsc --noEmit",
29
29
  "lint:format": "prettier --check .",
30
30
  "format": "prettier --write .",
31
31
  "test": "vitest run",
32
- "prepare": "npm run build"
32
+ "prepare": "npm run build",
33
+ "lint:sh": "shellcheck -x -P SCRIPTDIR -S warning templates/server/*.sh",
34
+ "test:scripts": "bash scripts/test-server-scripts.sh"
33
35
  },
34
36
  "dependencies": {
35
37
  "@clack/prompts": "^1.7.0",
36
38
  "@faker-js/faker": "^10.6.0",
37
- "@velastack/patterns": "^0.2.2",
39
+ "@velastack/patterns": "^0.2.3",
38
40
  "@velastack/pocketbase-codegen": "^0.1.0",
39
41
  "annotate-json-schema": "^0.1.0",
40
42
  "commander": "^13.1.0",
@@ -62,7 +64,8 @@
62
64
  "prettier": "^3.9.6",
63
65
  "typescript": "^5.6.0",
64
66
  "vite": "^8.2.2",
65
- "vitest": "^4.1.11"
67
+ "vitest": "^4.1.11",
68
+ "shellcheck": "^4.1.0"
66
69
  },
67
70
  "peerDependencies": {
68
71
  "@sveltejs/kit": "^2.57.1",
@@ -5,6 +5,9 @@ export default mergeConfig(
5
5
  viteConfig,
6
6
  defineConfig({
7
7
  test: {
8
+ expect: {
9
+ requireAssertions: true
10
+ },
8
11
  name: 'server',
9
12
  environment: 'node',
10
13
  include: ['src/**/*.{test,spec}.{js,ts}'],
@@ -7,6 +7,11 @@
7
7
  # is put back and the services are restarted before exiting non-zero.
8
8
  #
9
9
  # usage: apply.sh <instance> <release> [options]
10
+ #
11
+ # Only one of apply, destroy, rollback and restore runs for an instance at a
12
+ # time; a second waits for the first (--lock-wait seconds) and then gives up. A
13
+ # release that arrives after a newer one has gone live is refused rather than
14
+ # put live over it.
10
15
  set -Eeuo pipefail
11
16
 
12
17
  SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
@@ -19,6 +24,8 @@ require_provisioned
19
24
  INSTANCE=${1:-}; shift || true
20
25
  RELEASE=${1:-}; shift || true
21
26
  [ -n "$INSTANCE" ] && [ -n "$RELEASE" ] || die "usage: apply.sh <instance> <release> [options]"
27
+ require_instance_id "$INSTANCE"
28
+ require_release_id "$RELEASE"
22
29
 
23
30
  APP_NAME=$INSTANCE
24
31
  APP_ID=$INSTANCE
@@ -30,10 +37,13 @@ KEEP=5
30
37
  PB_VERSION=""
31
38
  BACKEND=1
32
39
  GIT_SHA=""
40
+ LOCK_WAIT=300
33
41
  SU_CREATED=0
42
+ MIGRATED=0
34
43
 
35
44
  while [ $# -gt 0 ]; do
36
45
  case "$1" in
46
+ --lock-wait) LOCK_WAIT=$2; shift 2 ;;
37
47
  --name) APP_NAME=$2; shift 2 ;;
38
48
  --app-id) APP_ID=$2; shift 2 ;;
39
49
  --env) ENV_TAG=$2; shift 2 ;;
@@ -58,10 +68,22 @@ RELEASE_DIR="$APP/releases/$RELEASE"
58
68
  WEB_UNIT=$(unit_web "$INSTANCE")
59
69
  PB_UNIT=$(unit_pb "$INSTANCE")
60
70
 
71
+ lock_instance "$INSTANCE" "$LOCK_WAIT"
72
+
61
73
  [ -d "$RELEASE_DIR" ] || die "release $RELEASE was not uploaded to $RELEASE_DIR"
62
74
  [ -f "$RELEASE_DIR/build/index.js" ] || die \
63
75
  "release $RELEASE has no build/index.js - the app must build with @sveltejs/adapter-node"
64
76
 
77
+ # A release that sorts at or below the active one arrived late: a newer deploy
78
+ # went live while this one was building or waiting for the lock. Putting it
79
+ # live would take the site backwards, so it is dropped - its own directory
80
+ # only; what is live is not touched.
81
+ ACTIVE=$(state_get "$INSTANCE" activeRelease 2>/dev/null || true)
82
+ if [ -n "$ACTIVE" ] && [ -L "$APP/current" ] && ! [[ "$RELEASE" > "$ACTIVE" ]]; then
83
+ rm -rf "${RELEASE_DIR:?}"
84
+ die "release $RELEASE is not newer than the active release $ACTIVE on $INSTANCE - a newer deploy went live first, so this one was dropped"
85
+ fi
86
+
65
87
  mkdir -p "$APP"/{releases,shared,bin,deps} "$APP/shared/pb_data"
66
88
  mkdir -p "$ETC"
67
89
  chmod 0700 "$ETC"
@@ -188,9 +210,11 @@ install_deps() {
188
210
  # so the app user can never swap out the scripts root runs.
189
211
  mkdir -p "$VELA_ROOT/cache/npm"
190
212
  chown -R "$VELA_USER:$VELA_USER" "$VELA_ROOT/cache"
213
+ # 8>&-: npm must not inherit the instance lock, or a child it leaves
214
+ # behind would hold it after this script has exited.
191
215
  ( cd "$deps" && runuser -u "$VELA_USER" -- env \
192
216
  HOME="$deps" npm_config_cache="$VELA_ROOT/cache/npm" \
193
- "${install_cmd[@]}" >&2 ) || die "dependency install failed"
217
+ "${install_cmd[@]}" >&2 8>&- ) || die "dependency install failed"
194
218
  else
195
219
  log "dependencies already installed ($key)"
196
220
  fi
@@ -214,9 +238,21 @@ ACTIVATED=0
214
238
 
215
239
  restore() {
216
240
  log "deploy failed - restoring ${PREVIOUS:-nothing}"
241
+ # The schema moved with this release; the previous one must not run
242
+ # against it. Same steps `vela rollback` takes, best-effort here because
243
+ # the app is being put back either way and the alternative is a dead site.
244
+ if [ "$BACKEND" = "1" ] && [ "$MIGRATED" = "1" ] && [ -n "$PREVIOUS" ] \
245
+ && [ -d "$APP/releases/$PREVIOUS" ]; then
246
+ systemctl stop "$PB_UNIT" >/dev/null 2>&1 || true
247
+ revert_migrations "$APP" "$RELEASE_DIR/migrations" "$APP/releases/$PREVIOUS/migrations" \
248
+ || log "down migrations failed - the database schema is ahead of $PREVIOUS; run 'vela rollback' once it is fixed"
249
+ fi
217
250
  if [ -n "$PREVIOUS" ] && [ -d "$APP/releases/$PREVIOUS" ]; then
218
251
  ln -sfn "$APP/releases/$PREVIOUS" "$APP/.current.tmp"
219
252
  mv -Tf "$APP/.current.tmp" "$APP/current"
253
+ # runtime.env was already written for the release that failed; the
254
+ # services about to restart must read the one they will actually run.
255
+ set_runtime_release "$ETC" "$PREVIOUS"
220
256
  if [ "$BACKEND" = "1" ]; then systemctl restart "$PB_UNIT" >/dev/null 2>&1 || true; fi
221
257
  systemctl restart "$WEB_UNIT" >/dev/null 2>&1 || true
222
258
  else
@@ -245,6 +281,7 @@ if [ "$BACKEND" = "1" ]; then
245
281
  --dir "$APP/shared/pb_data" \
246
282
  --migrationsDir "$RELEASE_DIR/migrations" \
247
283
  migrate up >&2
284
+ MIGRATED=1
248
285
  fi
249
286
 
250
287
  # ---------------------------------------------------- superuser bootstrap
@@ -284,8 +321,11 @@ mv -Tf "$APP/.current.tmp" "$APP/current"
284
321
  if [ "$ENV_TAG" = "prod" ]; then LINK_LABEL=$APP_NAME; else LINK_LABEL="$APP_NAME-$ENV_TAG"; fi
285
322
  LINK_NAME=$(printf '%s' "$LINK_LABEL" | tr -c 'A-Za-z0-9._-' '-')
286
323
  if [ -n "$LINK_NAME" ]; then
287
- ln -sfn "$APP" "$VELA_ROOT/by-name/.link.tmp" && \
288
- mv -Tf "$VELA_ROOT/by-name/.link.tmp" "$VELA_ROOT/by-name/$LINK_NAME" || true
324
+ # A staging name of its own: a fixed one would be shared with every other
325
+ # deploy on the server, and two of them would swap each other's links.
326
+ link_tmp=$(mktemp -u "$VELA_ROOT/by-name/.link.XXXXXX")
327
+ { ln -sfn "$APP" "$link_tmp" && mv -Tf "$link_tmp" "$VELA_ROOT/by-name/$LINK_NAME"; } \
328
+ || rm -f "$link_tmp" || true
289
329
  fi
290
330
 
291
331
  systemctl daemon-reload
@@ -352,6 +392,11 @@ CADDY_SNIPPET="$VELA_ETC/caddy/$INSTANCE.caddy"
352
392
  ROUTE_SNIPPET="$VELA_ETC/caddy/routes/$INSTANCE.route"
353
393
  ROUTING_CHANGED=0
354
394
 
395
+ # From here the release is live and stays live: a routing failure below
396
+ # exits non-zero with the site running the new release and its routes as
397
+ # they were, which is what the messages say.
398
+ caddy_lock
399
+
355
400
  if [ -n "$DOMAIN" ]; then
356
401
  hosts=$(printf '%s' "$DOMAIN" | tr ',' '\n' | sed 's/^ *//; s/ *$//' | grep -v '^$' | paste -sd, - | sed 's/,/, /g')
357
402
  tmp=$(mktemp "$VELA_ETC/caddy/.snippet.XXXXXX")
@@ -359,7 +404,8 @@ if [ -n "$DOMAIN" ]; then
359
404
  printf '# Managed by vela - app %s (%s)\n' "$APP_NAME" "$INSTANCE"
360
405
  printf '%s {\n\treverse_proxy 127.0.0.1:%s\n}\n' "$hosts" "$WEB_PORT"
361
406
  } > "$tmp"
362
- caddy_install "$tmp" "$CADDY_SNIPPET" || die "generated Caddy config for $DOMAIN is invalid"
407
+ caddy_install "$tmp" "$CADDY_SNIPPET" \
408
+ || die "release $RELEASE is live, but the generated Caddy config for $DOMAIN is invalid and was not installed - routing is unchanged"
363
409
  ROUTING_CHANGED=1
364
410
  elif [ -f "$CADDY_SNIPPET" ]; then
365
411
  rm -f "$CADDY_SNIPPET"
@@ -386,7 +432,8 @@ if [ -n "$MANAGED" ]; then
386
432
  printf '\t\theader_up X-Forwarded-For {http.request.header.X-Velastack-Client-IP}\n'
387
433
  printf '\t}\n}\n'
388
434
  } > "$tmp"
389
- caddy_install "$tmp" "$ROUTE_SNIPPET" || die "generated Caddy route for $MANAGED is invalid"
435
+ caddy_install "$tmp" "$ROUTE_SNIPPET" \
436
+ || die "release $RELEASE is live, but the generated Caddy route for $MANAGED is invalid and was not installed - routing is unchanged"
390
437
  ROUTING_CHANGED=1
391
438
  elif [ -f "$ROUTE_SNIPPET" ]; then
392
439
  rm -f "$ROUTE_SNIPPET"
@@ -394,7 +441,7 @@ elif [ -f "$ROUTE_SNIPPET" ]; then
394
441
  fi
395
442
 
396
443
  [ "$ROUTING_CHANGED" = 0 ] || caddy_reload
397
-
444
+ caddy_unlock
398
445
 
399
446
  # ------------------------------------------------------------------- pruning
400
447
 
@@ -404,6 +451,8 @@ if [ "$KEEP" -gt 0 ] 2>/dev/null; then
404
451
  [ -n "$rel" ] || continue
405
452
  [ "$rel" = "$RELEASE" ] && continue
406
453
  [ "$rel" = "$PREVIOUS" ] && continue
454
+ # Uploaded by a deploy that is waiting on the lock: not ours to prune.
455
+ if [[ "$rel" > "$RELEASE" ]]; then continue; fi
407
456
  log "pruning release $rel"
408
457
  rm -rf "${APP:?}/releases/$rel"
409
458
  done
@@ -4,8 +4,9 @@
4
4
  #
5
5
  # Releases and configuration always go; the database and uploads only go with
6
6
  # --purge, so a mistyped instance name cannot silently delete production data.
7
+ # A purge snapshots them into $VELA_ROOT/trash first, kept for two weeks.
7
8
  #
8
- # usage: destroy.sh <instance> [--purge]
9
+ # usage: destroy.sh <instance> [--purge] [--lock-wait <seconds>]
9
10
  set -Eeuo pipefail
10
11
 
11
12
  SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
@@ -16,33 +17,75 @@ require_provisioned
16
17
  [ "$(id -u)" -eq 0 ] || die "destroy must run as root"
17
18
 
18
19
  INSTANCE=${1:-}; shift || true
19
- [ -n "$INSTANCE" ] || die "usage: destroy.sh <instance> [--purge]"
20
+ [ -n "$INSTANCE" ] || die "usage: destroy.sh <instance> [--purge] [--lock-wait <seconds>]"
20
21
  PURGE=0
22
+ LOCK_WAIT=300
21
23
  while [ $# -gt 0 ]; do
22
24
  case "$1" in
23
25
  --purge) PURGE=1; shift ;;
26
+ --lock-wait) LOCK_WAIT=$2; shift 2 ;;
24
27
  *) die "unknown argument: $1" ;;
25
28
  esac
26
29
  done
27
30
 
31
+ require_instance_id "$INSTANCE"
32
+
28
33
  APP=$(app_dir "$INSTANCE")
29
34
  ETC=$(etc_dir "$INSTANCE")
30
35
 
36
+ lock_instance "$INSTANCE" "$LOCK_WAIT"
37
+
38
+ # Nothing to remove is a result, not an error: a preview that never deployed
39
+ # still gets a cleanup run when its pull request closes. Nothing is touched,
40
+ # so a mistyped name cannot do harm either way.
41
+ EXISTS=0
42
+ for path in "$APP/state.json" "$APP/releases" "$APP/current" "$ETC"; do
43
+ if [ -e "$path" ]; then EXISTS=1; fi
44
+ done
45
+ if [ "$PURGE" = 1 ] && [ -d "$APP" ]; then EXISTS=1; fi
46
+ if [ "$EXISTS" = 0 ]; then
47
+ log "nothing named $INSTANCE on this server"
48
+ emit_result --arg instance "$INSTANCE" \
49
+ '{instance: $instance, purged: false, existed: false}'
50
+ exit 0
51
+ fi
52
+
31
53
  log "stopping services"
32
54
  for unit in "$(unit_web "$INSTANCE")" "$(unit_pb "$INSTANCE")"; do
33
55
  systemctl disable --now "$unit" >/dev/null 2>&1 || true
34
56
  done
35
57
 
36
58
  log "removing routing"
59
+ caddy_lock
37
60
  rm -f "$VELA_ETC/caddy/$INSTANCE.caddy" "$VELA_ETC/caddy/routes/$INSTANCE.route"
38
61
  caddy_reload || true
62
+ caddy_unlock
39
63
 
40
64
  log "removing releases"
41
- rm -rf "$APP/releases" "$APP/deps" "$APP/current" "$APP/bin"
65
+ rm -rf "${APP:?}/releases" "${APP:?}/deps" "${APP:?}/current" "${APP:?}/bin"
42
66
 
67
+ SNAPSHOT=""
43
68
  if [ "$PURGE" = "1" ]; then
69
+ # The services are stopped, so the database is quiet: this is the one copy
70
+ # of it that will exist once the purge runs. Root-only, pruned after two
71
+ # weeks, and taken before anything is removed - a snapshot that fails
72
+ # leaves the instance's data where it was.
73
+ TRASH="$VELA_ROOT/trash"
74
+ mkdir -p "$TRASH"
75
+ chmod 0700 "$TRASH"
76
+ find "$TRASH" -maxdepth 1 -name '*.tar.gz' -mtime +14 -delete 2>/dev/null || true
77
+ members=()
78
+ [ -d "$APP/shared" ] && members+=("${APP#/}/shared")
79
+ [ -d "$ETC" ] && members+=("${ETC#/}")
80
+ if [ "${#members[@]}" -gt 0 ]; then
81
+ SNAPSHOT="$TRASH/$INSTANCE-$(date -u +%Y%m%dT%H%M%SZ).tar.gz"
82
+ log "snapshotting data and configuration to $SNAPSHOT"
83
+ tar -C / -czf "$SNAPSHOT" "${members[@]}" \
84
+ || die "could not snapshot $INSTANCE before purging - its data was left in place"
85
+ chmod 0600 "$SNAPSHOT"
86
+ fi
44
87
  log "purging data and configuration"
45
- rm -rf "$APP" "$ETC"
88
+ rm -rf "${APP:?}" "${ETC:?}"
46
89
  release_ports "$INSTANCE"
47
90
  else
48
91
  log "keeping $APP/shared (pass --purge to remove the database)"
@@ -51,5 +94,5 @@ fi
51
94
 
52
95
  find "$VELA_ROOT/by-name" -maxdepth 1 -type l ! -exec test -e {} \; -delete 2>/dev/null || true
53
96
 
54
- emit_result --arg instance "$INSTANCE" --argjson purged "$PURGE" \
55
- '{instance: $instance, purged: ($purged == 1)}'
97
+ emit_result --arg instance "$INSTANCE" --argjson purged "$PURGE" --arg trash "$SNAPSHOT" \
98
+ '{instance: $instance, purged: ($purged == 1), existed: true, trash: $trash}'
@@ -15,6 +15,45 @@ state_file() { printf '%s/apps/%s/state.json' "$VELA_ROOT" "$1"; }
15
15
  log() { printf ' %s\n' "$*" >&2; }
16
16
  die() { printf 'error: %s\n' "$*" >&2; exit 1; }
17
17
 
18
+ # Release ids are compared as strings, in `sort` and in `[[ < ]]` alike, and
19
+ # the two have to agree whatever locale the server booted with.
20
+ export LC_ALL=C
21
+
22
+ # An instance id is `<appId>` or `<appId>--<envTag>`: lowercase letters and
23
+ # digits joined by single or double dashes, exactly what the CLI's instanceId()
24
+ # produces. It arrives from the CLI, but it ends up in paths that root removes,
25
+ # so its shape is checked here too before anything is touched.
26
+ require_instance_id() {
27
+ [[ $1 =~ ^[a-z0-9]+(-{1,2}[a-z0-9]+)*$ ]] || die "not an instance id: $1"
28
+ }
29
+
30
+ # A release id is a UTC stamp with an optional short suffix: 20260910T141203Z
31
+ # or 20260910T141203Z-a3f9.
32
+ require_release_id() {
33
+ [[ $1 =~ ^[0-9]{8}T[0-9]{6}Z(-[a-z0-9]{1,8})?$ ]] || die "not a release id: $1"
34
+ }
35
+
36
+ # Hold the instance's lock for the rest of this process.
37
+ #
38
+ # Every script that mutates an instance - deploy, destroy, rollback, restore -
39
+ # takes this first, so two of them can never interleave: the second waits for
40
+ # the first, up to `wait` seconds, then gives up. The lock file lives under
41
+ # state/ rather than in the instance's own directory, so a purge cannot unlink
42
+ # a lock somebody else is holding. Descriptor 8; 9 belongs to the port table.
43
+ #
44
+ # usage: lock_instance <instance> [wait_seconds]
45
+ lock_instance() {
46
+ local instance=$1 wait=${2:-300} dir="$VELA_ROOT/state/locks"
47
+ mkdir -p "$dir"
48
+ exec 8>"$dir/$instance.lock"
49
+ if flock -n 8; then return 0; fi
50
+ [ "$wait" -gt 0 ] 2>/dev/null \
51
+ || die "another deploy, destroy, rollback or restore is running for $instance - aborting"
52
+ log "waiting for another operation on $instance to finish (up to ${wait}s)"
53
+ flock -w "$wait" 8 \
54
+ || die "another deploy, destroy, rollback or restore is still running for $instance after ${wait}s - aborting"
55
+ }
56
+
18
57
  require_provisioned() {
19
58
  [ -f "$VELA_ETC/provisioned" ] || die "server is not provisioned - run 'vela provision' first"
20
59
  }
@@ -115,14 +154,19 @@ unit_pb() { printf 'vela-pb@%s.service' "$1"; }
115
154
 
116
155
  unit_active() { systemctl is-active --quiet "$1"; }
117
156
 
118
- # Poll an HTTP endpoint until it answers with a non-5xx status.
157
+ # Poll an HTTP endpoint until it answers like a running app.
158
+ #
159
+ # Success, a redirect, or a refusal that proves something is home (401, 403 -
160
+ # a health path behind auth). A 404 is not that: it is what a wrong
161
+ # --health-path or a route that never mounted looks like, and it used to pass.
162
+ # No `-f`: curl has to report the status of an error response, not fail on it.
119
163
  wait_for_http() {
120
164
  local url=$1 attempts=${2:-60} delay=${3:-0.5} code
121
165
  local i=0
122
166
  while [ "$i" -lt "$attempts" ]; do
123
- code=$(curl -fsS -o /dev/null -w '%{http_code}' --max-time 5 "$url" 2>/dev/null || echo 000)
167
+ code=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 5 "$url" 2>/dev/null || echo 000)
124
168
  case "$code" in
125
- 2*|3*|4*) return 0 ;;
169
+ 2*|3*|401|403) return 0 ;;
126
170
  esac
127
171
  i=$((i + 1))
128
172
  sleep "$delay"
@@ -142,6 +186,39 @@ migrations_ahead() {
142
186
  printf '%s' "$count"
143
187
  }
144
188
 
189
+ # Revert the migrations `from_dir` has that `to_dir` lacks. They run from
190
+ # `from_dir` - the release that introduced them owns their down steps - and
191
+ # PocketBase reverts by count, which `migrations_ahead` supplies. The
192
+ # instance's PocketBase must be stopped: `migrate down` opens the database
193
+ # directly. Returns non-zero if PocketBase refuses; the caller decides how bad
194
+ # that is.
195
+ #
196
+ # usage: revert_migrations <app_dir> <from_dir> <to_dir>
197
+ revert_migrations() {
198
+ local app=$1 from=$2 to=$3 ahead
199
+ ahead=$(migrations_ahead "$from" "$to")
200
+ [ "$ahead" -gt 0 ] || return 0
201
+ log "reverting $ahead migration(s) the previous release does not have"
202
+ runuser -u "$VELA_USER" -- "$app/bin/pocketbase" \
203
+ --dir "$app/shared/pb_data" \
204
+ --migrationsDir "$from" \
205
+ migrate down "$ahead" >&2
206
+ }
207
+
208
+ # Point an instance's runtime.env at a release. Rewritten whole and renamed
209
+ # into place, the way apply.sh writes it, so systemd never reads half a line.
210
+ #
211
+ # usage: set_runtime_release <etc_dir> <release>
212
+ set_runtime_release() {
213
+ local etc=$1 release=$2 tmp
214
+ [ -f "$etc/runtime.env" ] || return 0
215
+ tmp=$(mktemp "$etc/.runtime.XXXXXX")
216
+ sed "s|^VELA_RELEASE=.*|VELA_RELEASE=$release|" "$etc/runtime.env" > "$tmp"
217
+ chmod 0600 "$tmp"
218
+ chown root:root "$tmp" 2>/dev/null || true
219
+ mv -f "$tmp" "$etc/runtime.env"
220
+ }
221
+
145
222
  emit_result() { printf 'VELA_RESULT %s\n' "$(jq -c -n "$@")"; }
146
223
 
147
224
  # Read one value out of a vela-managed env file. `vela env` writes values with
@@ -216,7 +293,7 @@ reconcile_superuser() {
216
293
 
217
294
  # Prove the app's own credentials actually sign in to its database.
218
295
  #
219
- # `wait_for_http` accepts 4xx as healthy, so a PocketBase whose database no
296
+ # `wait_for_http` accepts a 401 as healthy, so a PocketBase whose database no
220
297
  # longer matches `$ETC/env` sails through the health gate and then answers every
221
298
  # render with a 401. Only an actual login catches that. The password travels
222
299
  # through the environment rather than argv, so it never appears in `ps`.
@@ -266,3 +343,20 @@ caddy_install() {
266
343
  caddy_reload() {
267
344
  systemctl reload caddy >/dev/null 2>&1 || systemctl restart caddy
268
345
  }
346
+
347
+ # Serialize every change to the Caddy config across instances.
348
+ #
349
+ # `caddy_valid` checks the whole Caddyfile, so two scripts installing snippets
350
+ # at once would each judge the other's: a bad snippet from one deploy would
351
+ # have the other roll back a good route of its own. Held from the first
352
+ # snippet write through the reload. Descriptor 7; blocking, since a Caddy
353
+ # change takes well under a second.
354
+ caddy_lock() {
355
+ mkdir -p "$VELA_ROOT/state"
356
+ exec 7>"$VELA_ROOT/state/.caddy.lock"
357
+ flock 7
358
+ }
359
+
360
+ caddy_unlock() {
361
+ exec 7>&-
362
+ }
@@ -51,6 +51,7 @@ if [ -f "$SNIPPET" ] && [ "$(cat "$SNIPPET")" = "$desired" ]; then
51
51
  exit 0
52
52
  fi
53
53
 
54
+ caddy_lock
54
55
  tmp=$(mktemp "$VELA_ETC/caddy/.origin.XXXXXX")
55
56
  printf '%s\n' "$desired" > "$tmp"
56
57
  # Readable by the caddy user (which is what `caddy reload` runs as) and no one
@@ -58,5 +59,6 @@ printf '%s\n' "$desired" > "$tmp"
58
59
  caddy_install "$tmp" "$SNIPPET" 0640 root:caddy \
59
60
  || die "generated origin config for $HOST is invalid"
60
61
  caddy_reload
62
+ caddy_unlock
61
63
 
62
64
  emit_result --arg host "$HOST" '{originHost: $host, changed: true}'
@@ -23,16 +23,19 @@ require_provisioned
23
23
 
24
24
  INSTANCE=${1:-}; shift || true
25
25
  [ -n "$INSTANCE" ] || die "usage: restore.sh <instance> --archive <path> [options]"
26
+ require_instance_id "$INSTANCE"
26
27
 
27
28
  ARCHIVE=""
28
29
  MIGRATE=1
29
30
  KEEP_PREVIOUS=1
30
31
  CLEANUP_ARCHIVE=0
31
32
  HEALTH_PATH=""
33
+ LOCK_WAIT=300
32
34
 
33
35
  while [ $# -gt 0 ]; do
34
36
  case "$1" in
35
37
  --archive) ARCHIVE=$2; shift 2 ;;
38
+ --lock-wait) LOCK_WAIT=$2; shift 2 ;;
36
39
  --no-migrate) MIGRATE=0; shift ;;
37
40
  --keep-previous) KEEP_PREVIOUS=$2; shift 2 ;;
38
41
  --cleanup-archive) CLEANUP_ARCHIVE=1; shift ;;
@@ -47,6 +50,10 @@ done
47
50
  # directory the CLI happened to invoke from is usually one it cannot stat.
48
51
  cd "$VELA_ROOT"
49
52
 
53
+ # Taken before anything is read: a deploy or another restore in flight would
54
+ # otherwise have this one moving a pb_data it is about to replace.
55
+ lock_instance "$INSTANCE" "$LOCK_WAIT"
56
+
50
57
  require_backend "$INSTANCE"
51
58
 
52
59
  APP=$(app_dir "$INSTANCE")
@@ -66,10 +73,6 @@ PORTS=$(allocate_ports "$INSTANCE")
66
73
  WEB_PORT=$(printf '%s' "$PORTS" | jq -r .web)
67
74
  PB_PORT=$(printf '%s' "$PORTS" | jq -r .pb)
68
75
 
69
- # Two restores at once would each move the other's pb_data aside.
70
- exec 9>"$APP/.restore.lock"
71
- flock -n 9 || die "another restore is already running for $INSTANCE"
72
-
73
76
  # ---------------------------------------------------------------- preflight
74
77
  #
75
78
  # Everything that can be checked before a service stops is checked before a
@@ -2,7 +2,7 @@
2
2
  #
3
3
  # Put the previous release back for one instance.
4
4
  #
5
- # usage: rollback.sh <instance> [--to <release>]
5
+ # usage: rollback.sh <instance> [--to <release>] [--lock-wait <seconds>]
6
6
  set -Eeuo pipefail
7
7
 
8
8
  SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
@@ -13,21 +13,26 @@ require_provisioned
13
13
  [ "$(id -u)" -eq 0 ] || die "rollback must run as root"
14
14
 
15
15
  INSTANCE=${1:-}; shift || true
16
- [ -n "$INSTANCE" ] || die "usage: rollback.sh <instance> [--to <release>]"
16
+ [ -n "$INSTANCE" ] || die "usage: rollback.sh <instance> [--to <release>] [--lock-wait <seconds>]"
17
+ require_instance_id "$INSTANCE"
17
18
 
18
19
  TARGET=""
20
+ LOCK_WAIT=300
19
21
  while [ $# -gt 0 ]; do
20
22
  case "$1" in
21
23
  --to) TARGET=$2; shift 2 ;;
24
+ --lock-wait) LOCK_WAIT=$2; shift 2 ;;
22
25
  *) die "unknown argument: $1" ;;
23
26
  esac
24
27
  done
28
+ [ -z "$TARGET" ] || require_release_id "$TARGET"
25
29
 
26
30
  # Commands that drop to the app user inherit this working directory, and the
27
31
  # directory the CLI happened to invoke from is usually one it cannot stat.
28
32
  cd "$VELA_ROOT"
29
33
 
30
34
  APP=$(app_dir "$INSTANCE")
35
+ lock_instance "$INSTANCE" "$LOCK_WAIT"
31
36
  [ -f "$(state_file "$INSTANCE")" ] || die "no instance $INSTANCE on this server"
32
37
 
33
38
  CURRENT=$(state_get "$INSTANCE" activeRelease || echo "")
@@ -50,15 +55,8 @@ if [ "$BACKEND" = "true" ]; then
50
55
  # Down migrations belong to the release being left behind, so they run from
51
56
  # the current release's migration set before the symlink moves.
52
57
  if [ -n "$CURRENT" ] && [ -d "$APP/releases/$CURRENT/migrations" ]; then
53
- AHEAD=$(migrations_ahead "$APP/releases/$CURRENT/migrations" "$APP/releases/$TARGET/migrations")
54
- if [ "$AHEAD" -gt 0 ]; then
55
- log "reverting $AHEAD migration(s) introduced after $TARGET"
56
- runuser -u "$VELA_USER" -- "$APP/bin/pocketbase" \
57
- --dir "$APP/shared/pb_data" \
58
- --migrationsDir "$APP/releases/$CURRENT/migrations" \
59
- migrate down "$AHEAD" >&2 \
60
- || die "down migrations failed - the app is still stopped"
61
- fi
58
+ revert_migrations "$APP" "$APP/releases/$CURRENT/migrations" "$APP/releases/$TARGET/migrations" \
59
+ || die "down migrations failed - the app is still stopped"
62
60
  fi
63
61
  fi
64
62
 
@@ -68,9 +66,7 @@ mv -Tf "$APP/.current.tmp" "$APP/current"
68
66
  # The running release is part of the instance's environment, so it has to move
69
67
  # with the symlink.
70
68
  ETC=$(etc_dir "$INSTANCE")
71
- if [ -f "$ETC/runtime.env" ]; then
72
- sed -i "s|^VELA_RELEASE=.*|VELA_RELEASE=$TARGET|" "$ETC/runtime.env"
73
- fi
69
+ set_runtime_release "$ETC" "$TARGET"
74
70
 
75
71
  if [ "$BACKEND" = "true" ]; then
76
72
  systemctl restart "$PB_UNIT"