vela 0.11.3 → 0.11.5

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.3",
3
+ "version": "0.11.5",
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.3",
39
+ "@velastack/patterns": "^0.2.4",
38
40
  "@velastack/pocketbase-codegen": "^0.1.0",
39
41
  "annotate-json-schema": "^0.1.0",
40
42
  "commander": "^13.1.0",
@@ -60,6 +62,7 @@
60
62
  "@types/node": "^22.0.0",
61
63
  "esbuild": "^0.28.2",
62
64
  "prettier": "^3.9.6",
65
+ "shellcheck": "^4.1.0",
63
66
  "typescript": "^5.6.0",
64
67
  "vite": "^8.2.2",
65
68
  "vitest": "^4.1.11"
@@ -76,6 +79,18 @@
76
79
  "optional": true
77
80
  }
78
81
  },
82
+ "overrides": {
83
+ "global-agent": "^4.1.3",
84
+ "@xhmikosr/decompress-unzip": {
85
+ "file-type": "^21.3.1"
86
+ },
87
+ "@xhmikosr/decompress-tar": {
88
+ "file-type": "^21.3.1"
89
+ },
90
+ "@felipecrs/decompress-tarxz": {
91
+ "file-type": "^21.3.1"
92
+ }
93
+ },
79
94
  "keywords": [
80
95
  "svelte",
81
96
  "sveltekit",
@@ -16,7 +16,7 @@
16
16
  },
17
17
  "devDependencies": {
18
18
  "@lucide/svelte": "^1.40.0",
19
- "@sveltejs/adapter-auto": "^7.0.1",
19
+ "@sveltejs/adapter-node": "^5.5.7",
20
20
  "@sveltejs/kit": "^2.70.3",
21
21
  "@sveltejs/vite-plugin-svelte": "^7.3.0",
22
22
  "@tailwindcss/forms": "^0.5.11",
@@ -1,7 +1,7 @@
1
1
  import { defineConfig } from 'vite';
2
2
  import tailwindcss from '@tailwindcss/vite';
3
3
  import { sveltekit } from '@sveltejs/kit/vite';
4
- import adapter from '@sveltejs/adapter-auto';
4
+ import adapter from '@sveltejs/adapter-node';
5
5
 
6
6
  export default defineConfig({
7
7
  plugins: [
@@ -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,32 +68,84 @@ 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
 
65
- mkdir -p "$APP"/{releases,shared,bin,deps} "$APP/shared/pb_data"
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
+
87
+ # Whether the release this one replaces had a PocketBase backend. The CLI
88
+ # detects the backend from the project on every deploy, so an instance can gain
89
+ # one (`vela bless` between two deploys) or lose one; both are handled below.
90
+ # Empty on a first deploy.
91
+ PREVIOUS_BACKEND=""
92
+ if [ -f "$(state_file "$INSTANCE")" ]; then
93
+ PREVIOUS_BACKEND=$(instance_backend "$INSTANCE")
94
+ fi
95
+
96
+ # Where this app keeps state that has to outlive a release.
97
+ #
98
+ # An app that works this out from its own working directory puts it inside the
99
+ # release, which is the one place it cannot survive: `current` moves on the next
100
+ # deploy and the pruner deletes what it left behind. An instance with a database
101
+ # shares PocketBase's directory, so anything the app writes there is inside the
102
+ # archives `vela backup` takes and inside the directory `vela restore` swaps. An
103
+ # instance without one gets a directory of its own, which nothing backs up -
104
+ # there is no database to back it up alongside.
105
+ if [ "$BACKEND" = "1" ]; then
106
+ APP_DATA_DIR="$APP/shared/pb_data"
107
+ else
108
+ APP_DATA_DIR="$APP/shared/data"
109
+ fi
110
+
111
+ mkdir -p "$APP"/{releases,shared,bin,deps} "$APP_DATA_DIR"
66
112
  mkdir -p "$ETC"
67
113
  chmod 0700 "$ETC"
68
114
 
69
115
  # Only the directories just created need their ownership set, and only at the
70
- # top level: everything below pb_data is written by PocketBase as $VELA_USER
71
- # already. Recursing here would walk every uploaded file on every deploy, which
72
- # makes deploy time grow with the size of the app's storage forever.
116
+ # top level: everything below the data directory is written by PocketBase (or
117
+ # the app) as $VELA_USER already. Recursing here would walk every uploaded file
118
+ # on every deploy, which makes deploy time grow with the size of the app's
119
+ # storage forever.
73
120
  chown "$VELA_USER:$VELA_USER" \
74
- "$APP" "$APP/releases" "$APP/shared" "$APP/shared/pb_data" "$APP/bin" "$APP/deps"
121
+ "$APP" "$APP/releases" "$APP/shared" "$APP_DATA_DIR" "$APP/bin" "$APP/deps"
75
122
  # The release is the exception - rsync uploaded it as whoever we ssh'd in as.
76
123
  chown -R "$VELA_USER:$VELA_USER" "$RELEASE_DIR"
77
124
 
78
- # A pb_data the app cannot write is a dead instance, and it fails as an opaque
79
- # 500 rather than anything that names a cause. This is what the blanket recurse
80
- # above used to paper over; checking costs one stat, repairing costs a walk that
81
- # now happens only when something is actually wrong.
82
- if ! runuser -u "$VELA_USER" -- test -w "$APP/shared/pb_data"; then
125
+ # A data directory the app cannot write is a dead instance, and it fails as an
126
+ # opaque 500 rather than anything that names a cause. This is what the blanket
127
+ # recurse above used to paper over; checking costs one stat, repairing costs a
128
+ # walk that now happens only when something is actually wrong.
129
+ if ! runuser -u "$VELA_USER" -- test -w "$APP_DATA_DIR"; then
83
130
  log "repairing ownership under shared/"
84
131
  chown -R "$VELA_USER:$VELA_USER" "$APP/shared"
85
132
  fi
86
133
 
134
+ # An instance gaining a backend moves its data directory: VELA_DATA_DIR was
135
+ # shared/data and is now shared/pb_data, which is what `vela backup` archives.
136
+ # Whatever the app kept follows it, into a pb_data that PocketBase has not yet
137
+ # written - once there is a database in there, nothing is moved over it.
138
+ if [ "$PREVIOUS_BACKEND" = "false" ] && [ "$BACKEND" = "1" ] \
139
+ && [ -d "$APP/shared/data" ] && [ -n "$(ls -A "$APP/shared/data")" ]; then
140
+ if [ ! -e "$APP/shared/pb_data/data.db" ]; then
141
+ log "adding a backend - moving shared/data into shared/pb_data"
142
+ find "$APP/shared/data" -mindepth 1 -maxdepth 1 -exec mv -t "$APP/shared/pb_data" {} +
143
+ else
144
+ log "warning: this instance now has a backend, and its data directory is $APP/shared/pb_data"
145
+ log "warning: what the app kept in $APP/shared/data was left there"
146
+ fi
147
+ fi
148
+
87
149
  # ---------------------------------------------------------------- ports & env
88
150
 
89
151
  PORTS=$(allocate_ports "$INSTANCE")
@@ -104,23 +166,6 @@ fi
104
166
  # Nothing here reads or rewrites that file.
105
167
  [ -f "$ETC/env" ] || { : > "$ETC/env"; chmod 0600 "$ETC/env"; chown root:root "$ETC/env"; }
106
168
 
107
- # Where this app keeps state that has to outlive a release.
108
- #
109
- # An app that works this out from its own working directory puts it inside the
110
- # release, which is the one place it cannot survive: `current` moves on the next
111
- # deploy and the pruner deletes what it left behind. An instance with a database
112
- # shares PocketBase's directory, so anything the app writes there is inside the
113
- # archives `vela backup` takes and inside the directory `vela restore` swaps. An
114
- # instance without one gets a directory of its own, which nothing backs up -
115
- # there is no database to back it up alongside.
116
- if [ "$BACKEND" = "1" ]; then
117
- APP_DATA_DIR="$APP/shared/pb_data"
118
- else
119
- APP_DATA_DIR="$APP/shared/data"
120
- mkdir -p "$APP_DATA_DIR"
121
- chown "$VELA_USER:$VELA_USER" "$APP_DATA_DIR"
122
- fi
123
-
124
169
  runtime_tmp=$(mktemp "$ETC/.runtime.XXXXXX")
125
170
  {
126
171
  printf '# Generated by vela on each deploy. Edit /etc/vela/apps/%s/env instead.\n' "$INSTANCE"
@@ -128,8 +173,14 @@ runtime_tmp=$(mktemp "$ETC/.runtime.XXXXXX")
128
173
  printf 'HOST=127.0.0.1\n'
129
174
  printf 'PORT=%s\n' "$WEB_PORT"
130
175
  printf 'ORIGIN=%s\n' "$ORIGIN"
131
- printf 'PB_PORT=%s\n' "$PB_PORT"
132
- printf 'POCKETBASE_URL=http://127.0.0.1:%s\n' "$PB_PORT"
176
+ # Only an instance with a PocketBase gets pointed at one: a URL to a port
177
+ # nothing listens on is not a setting, it is a trap. Kept for one more
178
+ # deploy when the backend is being removed, so that a failed deploy can put
179
+ # the previous release - which still needs it - back.
180
+ if [ "$BACKEND" = "1" ] || [ "$PREVIOUS_BACKEND" = "true" ]; then
181
+ printf 'PB_PORT=%s\n' "$PB_PORT"
182
+ printf 'POCKETBASE_URL=http://127.0.0.1:%s\n' "$PB_PORT"
183
+ fi
133
184
  printf 'VELA_DATA_DIR=%s\n' "$APP_DATA_DIR"
134
185
  printf 'VELA_APP_ID=%s\n' "$APP_ID"
135
186
  printf 'VELA_APP_NAME=%s\n' "$APP_NAME"
@@ -188,9 +239,11 @@ install_deps() {
188
239
  # so the app user can never swap out the scripts root runs.
189
240
  mkdir -p "$VELA_ROOT/cache/npm"
190
241
  chown -R "$VELA_USER:$VELA_USER" "$VELA_ROOT/cache"
242
+ # 8>&-: npm must not inherit the instance lock, or a child it leaves
243
+ # behind would hold it after this script has exited.
191
244
  ( cd "$deps" && runuser -u "$VELA_USER" -- env \
192
245
  HOME="$deps" npm_config_cache="$VELA_ROOT/cache/npm" \
193
- "${install_cmd[@]}" >&2 ) || die "dependency install failed"
246
+ "${install_cmd[@]}" >&2 8>&- ) || die "dependency install failed"
194
247
  else
195
248
  log "dependencies already installed ($key)"
196
249
  fi
@@ -214,10 +267,25 @@ ACTIVATED=0
214
267
 
215
268
  restore() {
216
269
  log "deploy failed - restoring ${PREVIOUS:-nothing}"
270
+ # The schema moved with this release; the previous one must not run
271
+ # against it. Same steps `vela rollback` takes, best-effort here because
272
+ # the app is being put back either way and the alternative is a dead site.
273
+ if [ "$BACKEND" = "1" ] && [ "$MIGRATED" = "1" ] && [ -n "$PREVIOUS" ] \
274
+ && [ -d "$APP/releases/$PREVIOUS" ]; then
275
+ systemctl stop "$PB_UNIT" >/dev/null 2>&1 || true
276
+ revert_migrations "$APP" "$RELEASE_DIR/migrations" "$APP/releases/$PREVIOUS/migrations" \
277
+ || log "down migrations failed - the database schema is ahead of $PREVIOUS; run 'vela rollback' once it is fixed"
278
+ fi
217
279
  if [ -n "$PREVIOUS" ] && [ -d "$APP/releases/$PREVIOUS" ]; then
218
280
  ln -sfn "$APP/releases/$PREVIOUS" "$APP/.current.tmp"
219
281
  mv -Tf "$APP/.current.tmp" "$APP/current"
220
- if [ "$BACKEND" = "1" ]; then systemctl restart "$PB_UNIT" >/dev/null 2>&1 || true; fi
282
+ # runtime.env was already written for the release that failed; the
283
+ # services about to restart must read the one they will actually run.
284
+ set_runtime_release "$ETC" "$PREVIOUS"
285
+ # The previous release needs its PocketBase whether or not this one did.
286
+ if [ "$BACKEND" = "1" ] || [ "$PREVIOUS_BACKEND" = "true" ]; then
287
+ systemctl restart "$PB_UNIT" >/dev/null 2>&1 || true
288
+ fi
221
289
  systemctl restart "$WEB_UNIT" >/dev/null 2>&1 || true
222
290
  else
223
291
  systemctl stop "$WEB_UNIT" >/dev/null 2>&1 || true
@@ -245,6 +313,7 @@ if [ "$BACKEND" = "1" ]; then
245
313
  --dir "$APP/shared/pb_data" \
246
314
  --migrationsDir "$RELEASE_DIR/migrations" \
247
315
  migrate up >&2
316
+ MIGRATED=1
248
317
  fi
249
318
 
250
319
  # ---------------------------------------------------- superuser bootstrap
@@ -284,8 +353,11 @@ mv -Tf "$APP/.current.tmp" "$APP/current"
284
353
  if [ "$ENV_TAG" = "prod" ]; then LINK_LABEL=$APP_NAME; else LINK_LABEL="$APP_NAME-$ENV_TAG"; fi
285
354
  LINK_NAME=$(printf '%s' "$LINK_LABEL" | tr -c 'A-Za-z0-9._-' '-')
286
355
  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
356
+ # A staging name of its own: a fixed one would be shared with every other
357
+ # deploy on the server, and two of them would swap each other's links.
358
+ link_tmp=$(mktemp -u "$VELA_ROOT/by-name/.link.XXXXXX")
359
+ { ln -sfn "$APP" "$link_tmp" && mv -Tf "$link_tmp" "$VELA_ROOT/by-name/$LINK_NAME"; } \
360
+ || rm -f "$link_tmp" || true
289
361
  fi
290
362
 
291
363
  systemctl daemon-reload
@@ -325,6 +397,15 @@ wait_for_http "http://127.0.0.1:$WEB_PORT$HEALTH_PATH" 60 0.5 \
325
397
 
326
398
  ACTIVATED=1
327
399
 
400
+ # An instance that no longer has a backend keeps its database on disk - only
401
+ # `vela destroy deployment --purge` removes data - but the unit serving it is
402
+ # retired, or `vela status` would keep reporting a PocketBase and every restart
403
+ # would bring it back up.
404
+ if [ "$PREVIOUS_BACKEND" = "true" ] && [ "$BACKEND" = "0" ]; then
405
+ log "removing the backend - stopping PocketBase; $APP/shared/pb_data is kept"
406
+ systemctl disable --now "$PB_UNIT" >/dev/null 2>&1 || true
407
+ fi
408
+
328
409
  # --------------------------------------------------------------------- state
329
410
 
330
411
  state_merge "$INSTANCE" "$(jq -c -n \
@@ -352,6 +433,11 @@ CADDY_SNIPPET="$VELA_ETC/caddy/$INSTANCE.caddy"
352
433
  ROUTE_SNIPPET="$VELA_ETC/caddy/routes/$INSTANCE.route"
353
434
  ROUTING_CHANGED=0
354
435
 
436
+ # From here the release is live and stays live: a routing failure below
437
+ # exits non-zero with the site running the new release and its routes as
438
+ # they were, which is what the messages say.
439
+ caddy_lock
440
+
355
441
  if [ -n "$DOMAIN" ]; then
356
442
  hosts=$(printf '%s' "$DOMAIN" | tr ',' '\n' | sed 's/^ *//; s/ *$//' | grep -v '^$' | paste -sd, - | sed 's/,/, /g')
357
443
  tmp=$(mktemp "$VELA_ETC/caddy/.snippet.XXXXXX")
@@ -359,7 +445,8 @@ if [ -n "$DOMAIN" ]; then
359
445
  printf '# Managed by vela - app %s (%s)\n' "$APP_NAME" "$INSTANCE"
360
446
  printf '%s {\n\treverse_proxy 127.0.0.1:%s\n}\n' "$hosts" "$WEB_PORT"
361
447
  } > "$tmp"
362
- caddy_install "$tmp" "$CADDY_SNIPPET" || die "generated Caddy config for $DOMAIN is invalid"
448
+ caddy_install "$tmp" "$CADDY_SNIPPET" \
449
+ || die "release $RELEASE is live, but the generated Caddy config for $DOMAIN is invalid and was not installed - routing is unchanged"
363
450
  ROUTING_CHANGED=1
364
451
  elif [ -f "$CADDY_SNIPPET" ]; then
365
452
  rm -f "$CADDY_SNIPPET"
@@ -386,7 +473,8 @@ if [ -n "$MANAGED" ]; then
386
473
  printf '\t\theader_up X-Forwarded-For {http.request.header.X-Velastack-Client-IP}\n'
387
474
  printf '\t}\n}\n'
388
475
  } > "$tmp"
389
- caddy_install "$tmp" "$ROUTE_SNIPPET" || die "generated Caddy route for $MANAGED is invalid"
476
+ caddy_install "$tmp" "$ROUTE_SNIPPET" \
477
+ || die "release $RELEASE is live, but the generated Caddy route for $MANAGED is invalid and was not installed - routing is unchanged"
390
478
  ROUTING_CHANGED=1
391
479
  elif [ -f "$ROUTE_SNIPPET" ]; then
392
480
  rm -f "$ROUTE_SNIPPET"
@@ -394,7 +482,7 @@ elif [ -f "$ROUTE_SNIPPET" ]; then
394
482
  fi
395
483
 
396
484
  [ "$ROUTING_CHANGED" = 0 ] || caddy_reload
397
-
485
+ caddy_unlock
398
486
 
399
487
  # ------------------------------------------------------------------- pruning
400
488
 
@@ -404,6 +492,8 @@ if [ "$KEEP" -gt 0 ] 2>/dev/null; then
404
492
  [ -n "$rel" ] || continue
405
493
  [ "$rel" = "$RELEASE" ] && continue
406
494
  [ "$rel" = "$PREVIOUS" ] && continue
495
+ # Uploaded by a deploy that is waiting on the lock: not ours to prune.
496
+ if [[ "$rel" > "$RELEASE" ]]; then continue; fi
407
497
  log "pruning release $rel"
408
498
  rm -rf "${APP:?}/releases/$rel"
409
499
  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,15 +15,63 @@ 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
  }
21
60
 
22
- # Refuse on a frontend-only instance. Absent state is treated as having one, so
23
- # this only ever fires on an instance that was deployed with --backend 0.
61
+ # Whether an instance was last deployed with a PocketBase backend: `true` or
62
+ # `false`. Absent state, or state from before the flag existed, is treated as
63
+ # having one, so this only ever answers `false` for an instance that was
64
+ # deployed with --backend 0.
65
+ instance_backend() {
66
+ local instance=$1 value
67
+ value=$(state_get "$instance" backend 2>/dev/null || echo true)
68
+ [ "$value" = "false" ] && printf 'false' || printf 'true'
69
+ }
70
+
71
+ # Refuse on a frontend-only instance.
24
72
  require_backend() {
25
73
  local instance=$1
26
- [ "$(state_get "$instance" backend 2>/dev/null || echo true)" = "true" ] \
74
+ [ "$(instance_backend "$instance")" = "true" ] \
27
75
  || die "$instance has no database - there is nothing to back up or restore"
28
76
  }
29
77
 
@@ -32,8 +80,10 @@ state_get() {
32
80
  local instance=$1 key=$2 file
33
81
  file=$(state_file "$instance")
34
82
  [ -f "$file" ] || return 1
35
- # `// empty` would swallow a legitimate `false`, so test for the key itself.
36
- jq -er --arg k "$key" 'if has($k) and .[$k] != null then .[$k] else empty end' "$file" 2>/dev/null
83
+ # Presence is checked on its own: `// empty` would swallow a legitimate
84
+ # `false`, and so would `-e` on the read, which exits non-zero for one.
85
+ jq -e --arg k "$key" 'has($k) and .[$k] != null' "$file" >/dev/null 2>&1 || return 1
86
+ jq -r --arg k "$key" '.[$k]' "$file" 2>/dev/null
37
87
  }
38
88
 
39
89
  # Merge a JSON object into an instance's state file, atomically.
@@ -115,14 +165,19 @@ unit_pb() { printf 'vela-pb@%s.service' "$1"; }
115
165
 
116
166
  unit_active() { systemctl is-active --quiet "$1"; }
117
167
 
118
- # Poll an HTTP endpoint until it answers with a non-5xx status.
168
+ # Poll an HTTP endpoint until it answers like a running app.
169
+ #
170
+ # Success, a redirect, or a refusal that proves something is home (401, 403 -
171
+ # a health path behind auth). A 404 is not that: it is what a wrong
172
+ # --health-path or a route that never mounted looks like, and it used to pass.
173
+ # No `-f`: curl has to report the status of an error response, not fail on it.
119
174
  wait_for_http() {
120
175
  local url=$1 attempts=${2:-60} delay=${3:-0.5} code
121
176
  local i=0
122
177
  while [ "$i" -lt "$attempts" ]; do
123
- code=$(curl -fsS -o /dev/null -w '%{http_code}' --max-time 5 "$url" 2>/dev/null || echo 000)
178
+ code=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 5 "$url" 2>/dev/null || echo 000)
124
179
  case "$code" in
125
- 2*|3*|4*) return 0 ;;
180
+ 2*|3*|401|403) return 0 ;;
126
181
  esac
127
182
  i=$((i + 1))
128
183
  sleep "$delay"
@@ -142,6 +197,39 @@ migrations_ahead() {
142
197
  printf '%s' "$count"
143
198
  }
144
199
 
200
+ # Revert the migrations `from_dir` has that `to_dir` lacks. They run from
201
+ # `from_dir` - the release that introduced them owns their down steps - and
202
+ # PocketBase reverts by count, which `migrations_ahead` supplies. The
203
+ # instance's PocketBase must be stopped: `migrate down` opens the database
204
+ # directly. Returns non-zero if PocketBase refuses; the caller decides how bad
205
+ # that is.
206
+ #
207
+ # usage: revert_migrations <app_dir> <from_dir> <to_dir>
208
+ revert_migrations() {
209
+ local app=$1 from=$2 to=$3 ahead
210
+ ahead=$(migrations_ahead "$from" "$to")
211
+ [ "$ahead" -gt 0 ] || return 0
212
+ log "reverting $ahead migration(s) the previous release does not have"
213
+ runuser -u "$VELA_USER" -- "$app/bin/pocketbase" \
214
+ --dir "$app/shared/pb_data" \
215
+ --migrationsDir "$from" \
216
+ migrate down "$ahead" >&2
217
+ }
218
+
219
+ # Point an instance's runtime.env at a release. Rewritten whole and renamed
220
+ # into place, the way apply.sh writes it, so systemd never reads half a line.
221
+ #
222
+ # usage: set_runtime_release <etc_dir> <release>
223
+ set_runtime_release() {
224
+ local etc=$1 release=$2 tmp
225
+ [ -f "$etc/runtime.env" ] || return 0
226
+ tmp=$(mktemp "$etc/.runtime.XXXXXX")
227
+ sed "s|^VELA_RELEASE=.*|VELA_RELEASE=$release|" "$etc/runtime.env" > "$tmp"
228
+ chmod 0600 "$tmp"
229
+ chown root:root "$tmp" 2>/dev/null || true
230
+ mv -f "$tmp" "$etc/runtime.env"
231
+ }
232
+
145
233
  emit_result() { printf 'VELA_RESULT %s\n' "$(jq -c -n "$@")"; }
146
234
 
147
235
  # Read one value out of a vela-managed env file. `vela env` writes values with
@@ -216,7 +304,7 @@ reconcile_superuser() {
216
304
 
217
305
  # Prove the app's own credentials actually sign in to its database.
218
306
  #
219
- # `wait_for_http` accepts 4xx as healthy, so a PocketBase whose database no
307
+ # `wait_for_http` accepts a 401 as healthy, so a PocketBase whose database no
220
308
  # longer matches `$ETC/env` sails through the health gate and then answers every
221
309
  # render with a 401. Only an actual login catches that. The password travels
222
310
  # through the environment rather than argv, so it never appears in `ps`.
@@ -266,3 +354,20 @@ caddy_install() {
266
354
  caddy_reload() {
267
355
  systemctl reload caddy >/dev/null 2>&1 || systemctl restart caddy
268
356
  }
357
+
358
+ # Serialize every change to the Caddy config across instances.
359
+ #
360
+ # `caddy_valid` checks the whole Caddyfile, so two scripts installing snippets
361
+ # at once would each judge the other's: a bad snippet from one deploy would
362
+ # have the other roll back a good route of its own. Held from the first
363
+ # snippet write through the reload. Descriptor 7; blocking, since a Caddy
364
+ # change takes well under a second.
365
+ caddy_lock() {
366
+ mkdir -p "$VELA_ROOT/state"
367
+ exec 7>"$VELA_ROOT/state/.caddy.lock"
368
+ flock 7
369
+ }
370
+
371
+ caddy_unlock() {
372
+ exec 7>&-
373
+ }
@@ -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}'