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/README.md +17 -2
- package/dist/bin.js +854 -313
- package/dist/bin.js.map +4 -4
- package/package.json +19 -4
- package/templates/minimal/package.template.json +1 -1
- package/templates/minimal/vite.config.ts +1 -1
- package/templates/minimal/vitest.config.ts +3 -0
- package/templates/server/apply.sh +126 -36
- package/templates/server/destroy.sh +49 -6
- package/templates/server/lib.sh +114 -9
- package/templates/server/origin.sh +2 -0
- package/templates/server/restore.sh +7 -4
- package/templates/server/rollback.sh +12 -16
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vela",
|
|
3
|
-
"version": "0.11.
|
|
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.
|
|
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",
|
|
@@ -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-
|
|
4
|
+
import adapter from '@sveltejs/adapter-node';
|
|
5
5
|
|
|
6
6
|
export default defineConfig({
|
|
7
7
|
plugins: [
|
|
@@ -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
|
-
|
|
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
|
|
71
|
-
# already. Recursing here would walk every uploaded file
|
|
72
|
-
# makes deploy time grow with the size of the app's
|
|
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" "$
|
|
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
|
|
79
|
-
# 500 rather than anything that names a cause. This is what the blanket
|
|
80
|
-
# above used to paper over; checking costs one stat, repairing costs a
|
|
81
|
-
# now happens only when something is actually wrong.
|
|
82
|
-
if ! runuser -u "$VELA_USER" -- test -w "$
|
|
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
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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
|
-
|
|
288
|
-
|
|
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"
|
|
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"
|
|
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}'
|
package/templates/server/lib.sh
CHANGED
|
@@ -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
|
-
#
|
|
23
|
-
#
|
|
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
|
-
[ "$(
|
|
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
|
|
36
|
-
|
|
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
|
|
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 -
|
|
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*|
|
|
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
|
|
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}'
|