vela 0.10.5 → 0.10.7
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/dist/bin.js +2637 -1922
- package/dist/bin.js.map +4 -4
- package/package.json +1 -1
- package/templates/minimal/_gitignore +2 -0
- package/templates/minimal/data/README.md +25 -0
- package/templates/minimal/package.template.json +1 -1
- package/templates/server/apply.sh +62 -26
- package/templates/server/lib.sh +64 -0
- package/templates/server/restore.sh +269 -0
- package/templates/static/package.template.json +1 -1
package/package.json
CHANGED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# data
|
|
2
|
+
|
|
3
|
+
This app's state, and the only directory in the project that outlives a release.
|
|
4
|
+
|
|
5
|
+
PocketBase keeps `data.db` and `storage/` here. Anything else the app writes —
|
|
6
|
+
its own SQLite database, uploaded files — belongs here too, and nowhere else. A
|
|
7
|
+
path built from `process.cwd()` resolves inside the release directory once the
|
|
8
|
+
app is on a server, which is where the next deploy leaves it behind and the
|
|
9
|
+
pruner eventually deletes it.
|
|
10
|
+
|
|
11
|
+
Ask for the directory rather than working it out:
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { dataPath } from '@velastack/kit/server';
|
|
15
|
+
|
|
16
|
+
const db = new Database(dataPath('app.sqlite'));
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`vela` sets `VELA_DATA_DIR` in every context — `vela dev`, `vela build`, and the
|
|
20
|
+
environment it writes on each deploy — so that one call answers `<project>/data`
|
|
21
|
+
here and `/var/lib/vela/apps/<id>/shared/pb_data` on a server.
|
|
22
|
+
|
|
23
|
+
Because it is PocketBase's own directory, `vela backup` captures everything in
|
|
24
|
+
it and `vela restore` replaces it. `backups/` is the exception: PocketBase
|
|
25
|
+
leaves that out of the archives it writes.
|
|
@@ -63,8 +63,24 @@ PB_UNIT=$(unit_pb "$INSTANCE")
|
|
|
63
63
|
mkdir -p "$APP"/{releases,shared,bin,deps} "$APP/shared/pb_data"
|
|
64
64
|
mkdir -p "$ETC"
|
|
65
65
|
chmod 0700 "$ETC"
|
|
66
|
-
|
|
67
|
-
|
|
66
|
+
|
|
67
|
+
# Only the directories just created need their ownership set, and only at the
|
|
68
|
+
# top level: everything below pb_data is written by PocketBase as $VELA_USER
|
|
69
|
+
# already. Recursing here would walk every uploaded file on every deploy, which
|
|
70
|
+
# makes deploy time grow with the size of the app's storage forever.
|
|
71
|
+
chown "$VELA_USER:$VELA_USER" \
|
|
72
|
+
"$APP" "$APP/releases" "$APP/shared" "$APP/shared/pb_data" "$APP/bin" "$APP/deps"
|
|
73
|
+
# The release is the exception - rsync uploaded it as whoever we ssh'd in as.
|
|
74
|
+
chown -R "$VELA_USER:$VELA_USER" "$RELEASE_DIR"
|
|
75
|
+
|
|
76
|
+
# A pb_data the app cannot write is a dead instance, and it fails as an opaque
|
|
77
|
+
# 500 rather than anything that names a cause. This is what the blanket recurse
|
|
78
|
+
# above used to paper over; checking costs one stat, repairing costs a walk that
|
|
79
|
+
# now happens only when something is actually wrong.
|
|
80
|
+
if ! runuser -u "$VELA_USER" -- test -w "$APP/shared/pb_data"; then
|
|
81
|
+
log "repairing ownership under shared/"
|
|
82
|
+
chown -R "$VELA_USER:$VELA_USER" "$APP/shared"
|
|
83
|
+
fi
|
|
68
84
|
|
|
69
85
|
# ---------------------------------------------------------------- ports & env
|
|
70
86
|
|
|
@@ -83,6 +99,23 @@ fi
|
|
|
83
99
|
# Nothing here reads or rewrites that file.
|
|
84
100
|
[ -f "$ETC/env" ] || { : > "$ETC/env"; chmod 0600 "$ETC/env"; chown root:root "$ETC/env"; }
|
|
85
101
|
|
|
102
|
+
# Where this app keeps state that has to outlive a release.
|
|
103
|
+
#
|
|
104
|
+
# An app that works this out from its own working directory puts it inside the
|
|
105
|
+
# release, which is the one place it cannot survive: `current` moves on the next
|
|
106
|
+
# deploy and the pruner deletes what it left behind. An instance with a database
|
|
107
|
+
# shares PocketBase's directory, so anything the app writes there is inside the
|
|
108
|
+
# archives `vela backup` takes and inside the directory `vela restore` swaps. An
|
|
109
|
+
# instance without one gets a directory of its own, which nothing backs up -
|
|
110
|
+
# there is no database to back it up alongside.
|
|
111
|
+
if [ "$BACKEND" = "1" ]; then
|
|
112
|
+
APP_DATA_DIR="$APP/shared/pb_data"
|
|
113
|
+
else
|
|
114
|
+
APP_DATA_DIR="$APP/shared/data"
|
|
115
|
+
mkdir -p "$APP_DATA_DIR"
|
|
116
|
+
chown "$VELA_USER:$VELA_USER" "$APP_DATA_DIR"
|
|
117
|
+
fi
|
|
118
|
+
|
|
86
119
|
runtime_tmp=$(mktemp "$ETC/.runtime.XXXXXX")
|
|
87
120
|
{
|
|
88
121
|
printf '# Generated by vela on each deploy. Edit /etc/vela/apps/%s/env instead.\n' "$INSTANCE"
|
|
@@ -92,6 +125,7 @@ runtime_tmp=$(mktemp "$ETC/.runtime.XXXXXX")
|
|
|
92
125
|
printf 'ORIGIN=%s\n' "$ORIGIN"
|
|
93
126
|
printf 'PB_PORT=%s\n' "$PB_PORT"
|
|
94
127
|
printf 'POCKETBASE_URL=http://127.0.0.1:%s\n' "$PB_PORT"
|
|
128
|
+
printf 'VELA_DATA_DIR=%s\n' "$APP_DATA_DIR"
|
|
95
129
|
printf 'VELA_APP_ID=%s\n' "$APP_ID"
|
|
96
130
|
printf 'VELA_APP_NAME=%s\n' "$APP_NAME"
|
|
97
131
|
printf 'VELA_ENV=%s\n' "$ENV_TAG"
|
|
@@ -228,30 +262,13 @@ if [ "$BACKEND" = "1" ]; then
|
|
|
228
262
|
SU_CREATED=1
|
|
229
263
|
fi
|
|
230
264
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
if [ "$SU_CREATED" = "1" ]
|
|
236
|
-
|
|
237
|
-
|
|
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"
|
|
265
|
+
reconcile_superuser "$APP" "$ETC" "$SU_EMAIL" "$SU_PASSWORD"
|
|
266
|
+
|
|
267
|
+
# Written only once the database holds the account, so a failure above leaves
|
|
268
|
+
# nothing behind and the next deploy simply tries again.
|
|
269
|
+
if [ "$SU_CREATED" = "1" ]; then
|
|
270
|
+
env_file_append "$ETC/env" POCKETBASE_SUPERUSER_EMAIL "$SU_EMAIL"
|
|
271
|
+
env_file_append "$ETC/env" POCKETBASE_SUPERUSER_PASSWORD "$SU_PASSWORD"
|
|
255
272
|
fi
|
|
256
273
|
fi
|
|
257
274
|
|
|
@@ -274,6 +291,25 @@ if [ "$BACKEND" = "1" ]; then
|
|
|
274
291
|
systemctl restart "$PB_UNIT"
|
|
275
292
|
wait_for_http "http://127.0.0.1:$PB_PORT/api/health" 60 0.5 \
|
|
276
293
|
|| die "PocketBase did not become healthy - journalctl -u $PB_UNIT"
|
|
294
|
+
|
|
295
|
+
# Answering on the port is not the same as agreeing with $ETC/env, and a
|
|
296
|
+
# disagreement shows up as every render 401ing rather than as a failed start.
|
|
297
|
+
#
|
|
298
|
+
# The fingerprint above cannot see a database that was replaced underneath
|
|
299
|
+
# an env file that did not change, so a first failure is repaired rather than
|
|
300
|
+
# reported: force the upsert and ask again. Only a second failure is real.
|
|
301
|
+
if ! assert_superuser_auth "$PB_PORT" "$SU_EMAIL" "$SU_PASSWORD"; then
|
|
302
|
+
log "the database disagrees with this instance's credentials - repairing"
|
|
303
|
+
# Stopped for the upsert: it opens the same SQLite file directly, and a
|
|
304
|
+
# running PocketBase is one writer too many to reason about.
|
|
305
|
+
systemctl stop "$PB_UNIT" >/dev/null 2>&1 || true
|
|
306
|
+
reconcile_superuser "$APP" "$ETC" "$SU_EMAIL" "$SU_PASSWORD" --force
|
|
307
|
+
systemctl start "$PB_UNIT"
|
|
308
|
+
wait_for_http "http://127.0.0.1:$PB_PORT/api/health" 60 0.5 \
|
|
309
|
+
|| die "PocketBase did not come back after the credential repair"
|
|
310
|
+
assert_superuser_auth "$PB_PORT" "$SU_EMAIL" "$SU_PASSWORD" \
|
|
311
|
+
|| die "PocketBase does not accept this instance's superuser credentials"
|
|
312
|
+
fi
|
|
277
313
|
fi
|
|
278
314
|
|
|
279
315
|
log "starting app on 127.0.0.1:$WEB_PORT"
|
package/templates/server/lib.sh
CHANGED
|
@@ -19,6 +19,14 @@ require_provisioned() {
|
|
|
19
19
|
[ -f "$VELA_ETC/provisioned" ] || die "server is not provisioned - run 'vela provision' first"
|
|
20
20
|
}
|
|
21
21
|
|
|
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.
|
|
24
|
+
require_backend() {
|
|
25
|
+
local instance=$1
|
|
26
|
+
[ "$(state_get "$instance" backend 2>/dev/null || echo true)" = "true" ] \
|
|
27
|
+
|| die "$instance has no database - there is nothing to back up or restore"
|
|
28
|
+
}
|
|
29
|
+
|
|
22
30
|
# Read a top-level key out of an instance's state file.
|
|
23
31
|
state_get() {
|
|
24
32
|
local instance=$1 key=$2 file
|
|
@@ -164,3 +172,59 @@ env_file_append() {
|
|
|
164
172
|
random_secret() {
|
|
165
173
|
head -c 24 /dev/urandom | od -An -tx1 | tr -d ' \n'
|
|
166
174
|
}
|
|
175
|
+
|
|
176
|
+
# Make the instance's database agree with the credentials in its env file.
|
|
177
|
+
#
|
|
178
|
+
# The app authenticates to its own PocketBase as a superuser on every render, so
|
|
179
|
+
# the two have to match or every request answers 401. `$ETC/env` is the source of
|
|
180
|
+
# truth: this pushes whatever it holds into the database and records a
|
|
181
|
+
# fingerprint of it, which is what lets an ordinary deploy skip the upsert when
|
|
182
|
+
# nothing has moved. A caller that replaced the database underneath the env file
|
|
183
|
+
# passes --force, because the fingerprint alone cannot see that.
|
|
184
|
+
#
|
|
185
|
+
# usage: reconcile_superuser <app_dir> <etc_dir> <email> <password> [--force]
|
|
186
|
+
reconcile_superuser() {
|
|
187
|
+
local app=$1 etc=$2 email=$3 password=$4 force=${5:-}
|
|
188
|
+
local stamp="$etc/.superuser" fingerprint
|
|
189
|
+
fingerprint=$(printf '%s\n%s' "$email" "$password" | sha256sum | cut -d' ' -f1)
|
|
190
|
+
|
|
191
|
+
if [ "$force" != "--force" ] && [ "$(cat "$stamp" 2>/dev/null || true)" = "$fingerprint" ]; then
|
|
192
|
+
return 0
|
|
193
|
+
fi
|
|
194
|
+
|
|
195
|
+
# The stamp is written only after a successful upsert, so its absence is what
|
|
196
|
+
# distinguishes a first account from a changed one.
|
|
197
|
+
if [ -f "$stamp" ]; then
|
|
198
|
+
log "updating the PocketBase superuser"
|
|
199
|
+
else
|
|
200
|
+
log "creating the PocketBase superuser"
|
|
201
|
+
fi
|
|
202
|
+
|
|
203
|
+
# The password reaches PocketBase as an argument, which is why this runs only
|
|
204
|
+
# on the deploys that need it rather than on every one.
|
|
205
|
+
runuser -u "$VELA_USER" -- "$app/bin/pocketbase" \
|
|
206
|
+
--dir "$app/shared/pb_data" \
|
|
207
|
+
superuser upsert "$email" "$password" >&2 \
|
|
208
|
+
|| die "could not write the PocketBase superuser"
|
|
209
|
+
|
|
210
|
+
# Recorded only once the database holds the account, so a failure above
|
|
211
|
+
# leaves nothing behind and the next deploy simply tries again.
|
|
212
|
+
printf '%s\n' "$fingerprint" > "$stamp"
|
|
213
|
+
chmod 0600 "$stamp"
|
|
214
|
+
chown root:root "$stamp"
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
# Prove the app's own credentials actually sign in to its database.
|
|
218
|
+
#
|
|
219
|
+
# `wait_for_http` accepts 4xx as healthy, so a PocketBase whose database no
|
|
220
|
+
# longer matches `$ETC/env` sails through the health gate and then answers every
|
|
221
|
+
# render with a 401. Only an actual login catches that. The password travels
|
|
222
|
+
# through the environment rather than argv, so it never appears in `ps`.
|
|
223
|
+
assert_superuser_auth() {
|
|
224
|
+
local port=$1 email=$2 password=$3
|
|
225
|
+
VELA_SU_EMAIL=$email VELA_SU_PASSWORD=$password \
|
|
226
|
+
jq -nc '{identity: env.VELA_SU_EMAIL, password: env.VELA_SU_PASSWORD}' \
|
|
227
|
+
| curl -fsS -o /dev/null --max-time 10 \
|
|
228
|
+
-X POST -H 'content-type: application/json' --data-binary @- \
|
|
229
|
+
"http://127.0.0.1:$port/api/collections/_superusers/auth-with-password"
|
|
230
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# Replace one instance's database and uploads from a backup archive.
|
|
4
|
+
#
|
|
5
|
+
# This is the offline counterpart of PocketBase's own restore endpoint, which is
|
|
6
|
+
# not usable here for two reasons: it answers 204 and then does the work in a
|
|
7
|
+
# detached goroutine, so a failure is invisible to whoever asked for it; and it
|
|
8
|
+
# leaves the database holding whatever superuser the archive captured, while
|
|
9
|
+
# /etc/vela/apps/<instance>/env still holds the one the app signs in with on
|
|
10
|
+
# every render. Reconciling those is the whole reason this script exists.
|
|
11
|
+
#
|
|
12
|
+
# The old pb_data is renamed, never deleted. It is the only undo.
|
|
13
|
+
#
|
|
14
|
+
# usage: restore.sh <instance> --archive <path> [options]
|
|
15
|
+
set -Eeuo pipefail
|
|
16
|
+
|
|
17
|
+
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
|
18
|
+
# shellcheck source=lib.sh
|
|
19
|
+
. "$SCRIPT_DIR/lib.sh"
|
|
20
|
+
|
|
21
|
+
require_provisioned
|
|
22
|
+
[ "$(id -u)" -eq 0 ] || die "restore must run as root"
|
|
23
|
+
|
|
24
|
+
INSTANCE=${1:-}; shift || true
|
|
25
|
+
[ -n "$INSTANCE" ] || die "usage: restore.sh <instance> --archive <path> [options]"
|
|
26
|
+
|
|
27
|
+
ARCHIVE=""
|
|
28
|
+
MIGRATE=1
|
|
29
|
+
KEEP_PREVIOUS=1
|
|
30
|
+
CLEANUP_ARCHIVE=0
|
|
31
|
+
HEALTH_PATH=""
|
|
32
|
+
|
|
33
|
+
while [ $# -gt 0 ]; do
|
|
34
|
+
case "$1" in
|
|
35
|
+
--archive) ARCHIVE=$2; shift 2 ;;
|
|
36
|
+
--no-migrate) MIGRATE=0; shift ;;
|
|
37
|
+
--keep-previous) KEEP_PREVIOUS=$2; shift 2 ;;
|
|
38
|
+
--cleanup-archive) CLEANUP_ARCHIVE=1; shift ;;
|
|
39
|
+
--health-path) HEALTH_PATH=$2; shift 2 ;;
|
|
40
|
+
*) die "unknown argument: $1" ;;
|
|
41
|
+
esac
|
|
42
|
+
done
|
|
43
|
+
|
|
44
|
+
[ -n "$ARCHIVE" ] || die "--archive is required"
|
|
45
|
+
|
|
46
|
+
# Commands that drop to the app user inherit this working directory, and the
|
|
47
|
+
# directory the CLI happened to invoke from is usually one it cannot stat.
|
|
48
|
+
cd "$VELA_ROOT"
|
|
49
|
+
|
|
50
|
+
require_backend "$INSTANCE"
|
|
51
|
+
|
|
52
|
+
APP=$(app_dir "$INSTANCE")
|
|
53
|
+
ETC=$(etc_dir "$INSTANCE")
|
|
54
|
+
PB_DATA="$APP/shared/pb_data"
|
|
55
|
+
WEB_UNIT=$(unit_web "$INSTANCE")
|
|
56
|
+
PB_UNIT=$(unit_pb "$INSTANCE")
|
|
57
|
+
STAMP=$(date -u +%Y%m%dT%H%M%SZ)
|
|
58
|
+
PREVIOUS_DIR="$APP/shared/pb_data.pre-restore-$STAMP"
|
|
59
|
+
|
|
60
|
+
[ -d "$APP" ] || die "$INSTANCE is not deployed on this server"
|
|
61
|
+
[ -f "$ARCHIVE" ] || die "no archive at $ARCHIVE"
|
|
62
|
+
|
|
63
|
+
[ -n "$HEALTH_PATH" ] || HEALTH_PATH=$(state_get "$INSTANCE" healthCheckPath 2>/dev/null || echo /)
|
|
64
|
+
|
|
65
|
+
PORTS=$(allocate_ports "$INSTANCE")
|
|
66
|
+
WEB_PORT=$(printf '%s' "$PORTS" | jq -r .web)
|
|
67
|
+
PB_PORT=$(printf '%s' "$PORTS" | jq -r .pb)
|
|
68
|
+
|
|
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
|
+
# ---------------------------------------------------------------- preflight
|
|
74
|
+
#
|
|
75
|
+
# Everything that can be checked before a service stops is checked before a
|
|
76
|
+
# service stops. A restore that fails here has changed nothing at all.
|
|
77
|
+
|
|
78
|
+
log "checking the archive"
|
|
79
|
+
unzip -tq "$ARCHIVE" >/dev/null 2>&1 || die "$ARCHIVE is not a readable zip archive"
|
|
80
|
+
|
|
81
|
+
# Restoring from a key means reading a file out of pb_data/backups - the very
|
|
82
|
+
# directory about to be moved aside. Copy it clear first, or the unpack below
|
|
83
|
+
# would be reading from a path that no longer exists.
|
|
84
|
+
case "$ARCHIVE" in
|
|
85
|
+
"$PB_DATA"/*)
|
|
86
|
+
mkdir -p "$APP/shared/.restore"
|
|
87
|
+
cp -a "$ARCHIVE" "$APP/shared/.restore/"
|
|
88
|
+
ARCHIVE="$APP/shared/.restore/$(basename "$ARCHIVE")"
|
|
89
|
+
CLEANUP_ARCHIVE=1
|
|
90
|
+
;;
|
|
91
|
+
esac
|
|
92
|
+
unzip -l "$ARCHIVE" 2>/dev/null | grep -qE '[[:space:]]data\.db$' \
|
|
93
|
+
|| die "$ARCHIVE has no data.db - it is not a PocketBase backup"
|
|
94
|
+
|
|
95
|
+
# The credentials have to exist before anything moves: without them the restored
|
|
96
|
+
# database cannot be reconciled, and the app would 401 on every render.
|
|
97
|
+
SU_EMAIL=$(env_file_get "$ETC/env" POCKETBASE_SUPERUSER_EMAIL || true)
|
|
98
|
+
SU_PASSWORD=$(env_file_get "$ETC/env" POCKETBASE_SUPERUSER_PASSWORD || true)
|
|
99
|
+
[ -n "$SU_EMAIL" ] && [ -n "$SU_PASSWORD" ] \
|
|
100
|
+
|| die "$INSTANCE has no superuser credentials in its environment - deploy it first"
|
|
101
|
+
|
|
102
|
+
# The archive unpacks beside the directory it replaces, and the old copy is kept
|
|
103
|
+
# rather than deleted, so its space is never reclaimed. What has to be free is
|
|
104
|
+
# therefore the whole unpacked archive - plus the uploads again, if they have to
|
|
105
|
+
# be carried across because the archive was taken while S3 was on.
|
|
106
|
+
ARCHIVE_KB=$(unzip -Zt "$ARCHIVE" | awk 'NR == 1 {print int($3 / 1024) + 1}')
|
|
107
|
+
NEEDED_KB=$((ARCHIVE_KB + ARCHIVE_KB / 10))
|
|
108
|
+
if ! unzip -l "$ARCHIVE" 2>/dev/null | grep -qE '[[:space:]]storage/'; then
|
|
109
|
+
NEEDED_KB=$((NEEDED_KB + $(du -sk "$PB_DATA/storage" 2>/dev/null | cut -f1 || echo 0)))
|
|
110
|
+
fi
|
|
111
|
+
AVAILABLE_KB=$(df -Pk "$APP" | awk 'NR == 2 {print $4}')
|
|
112
|
+
if [ "$AVAILABLE_KB" -lt "$NEEDED_KB" ]; then
|
|
113
|
+
die "not enough disk space: the restore needs $((NEEDED_KB / 1024))M and $((AVAILABLE_KB / 1024))M is free"
|
|
114
|
+
fi
|
|
115
|
+
|
|
116
|
+
# ---------------------------------------------------------------- transaction
|
|
117
|
+
|
|
118
|
+
SWAPPED=0
|
|
119
|
+
RESTORED=0
|
|
120
|
+
STAGE="$APP/shared/.restore-stage.$$"
|
|
121
|
+
|
|
122
|
+
revert() {
|
|
123
|
+
log "restore failed - putting the previous database back"
|
|
124
|
+
if [ "$SWAPPED" = "1" ]; then
|
|
125
|
+
# Never deleted: a half-restored database is still evidence.
|
|
126
|
+
if [ -d "$PB_DATA" ]; then mv -Tf "$PB_DATA" "$APP/shared/pb_data.failed-$STAMP" || true; fi
|
|
127
|
+
# on_exit carries the original failure out; saying `die` here would only
|
|
128
|
+
# replace one non-zero code with another and re-enter this path.
|
|
129
|
+
mv -Tf "$PREVIOUS_DIR" "$PB_DATA" \
|
|
130
|
+
|| log "could not put $PREVIOUS_DIR back at $PB_DATA - this instance needs a hand"
|
|
131
|
+
fi
|
|
132
|
+
rm -rf "$STAGE"
|
|
133
|
+
systemctl start "$PB_UNIT" >/dev/null 2>&1 || true
|
|
134
|
+
systemctl start "$WEB_UNIT" >/dev/null 2>&1 || true
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
on_exit() {
|
|
138
|
+
local code=$?
|
|
139
|
+
if [ "$RESTORED" = "0" ]; then revert; fi
|
|
140
|
+
exit "$code"
|
|
141
|
+
}
|
|
142
|
+
trap on_exit EXIT
|
|
143
|
+
|
|
144
|
+
log "stopping services"
|
|
145
|
+
systemctl stop "$WEB_UNIT" >/dev/null 2>&1 || true
|
|
146
|
+
systemctl stop "$PB_UNIT" >/dev/null 2>&1 || true
|
|
147
|
+
|
|
148
|
+
# Unpacked next to pb_data so that the swap below is a rename on one filesystem
|
|
149
|
+
# rather than a copy of everything the archive holds.
|
|
150
|
+
log "unpacking $(basename "$ARCHIVE")"
|
|
151
|
+
rm -rf "$STAGE"
|
|
152
|
+
mkdir -p "$STAGE"
|
|
153
|
+
unzip -q "$ARCHIVE" -d "$STAGE" || die "could not unpack $ARCHIVE"
|
|
154
|
+
[ -f "$STAGE/data.db" ] || die "$ARCHIVE unpacked without a data.db"
|
|
155
|
+
|
|
156
|
+
# The extraction ran as root. This -R is over a fresh tree of known size, which
|
|
157
|
+
# is the case the recursive form is actually for.
|
|
158
|
+
chown -R "$VELA_USER:$VELA_USER" "$STAGE"
|
|
159
|
+
|
|
160
|
+
# An archive taken while uploads were in S3 has no storage/ at all. Copied, not
|
|
161
|
+
# moved, so the directory set aside below stays a complete rollback.
|
|
162
|
+
if [ ! -d "$STAGE/storage" ] && [ -d "$PB_DATA/storage" ]; then
|
|
163
|
+
log "the archive has no uploads - keeping the ones already on disk"
|
|
164
|
+
cp -a "$PB_DATA/storage" "$STAGE/storage"
|
|
165
|
+
CARRIED_STORAGE=1
|
|
166
|
+
else
|
|
167
|
+
CARRIED_STORAGE=0
|
|
168
|
+
fi
|
|
169
|
+
|
|
170
|
+
# An archive taken from a later release than the one running carries a schema
|
|
171
|
+
# this code has not seen. `migrate up` cannot help - the archive is ahead, not
|
|
172
|
+
# behind - and `migrate down` is not an option either, because the migrations
|
|
173
|
+
# that would reverse it live in a release that may already have been pruned. Say
|
|
174
|
+
# so rather than restoring into a mismatch silently.
|
|
175
|
+
if [ -d "$APP/current/migrations" ]; then
|
|
176
|
+
# `|| true` because this is advisory: an archive old enough to predate the
|
|
177
|
+
# _migrations table would otherwise fail the pipeline and abort the restore.
|
|
178
|
+
AHEAD=$(sqlite3 "$STAGE/data.db" 'select file from _migrations' 2>/dev/null \
|
|
179
|
+
| while read -r file; do
|
|
180
|
+
[ -n "$file" ] || continue
|
|
181
|
+
[ -f "$APP/current/migrations/$file" ] || printf 'x'
|
|
182
|
+
done | wc -c | tr -d ' ' || true)
|
|
183
|
+
if [ "${AHEAD:-0}" -gt 0 ] 2>/dev/null; then
|
|
184
|
+
log "warning: this backup is $AHEAD migration(s) ahead of the running release"
|
|
185
|
+
log "warning: deploy the matching code, or expect the app to see columns it does not know"
|
|
186
|
+
fi
|
|
187
|
+
fi
|
|
188
|
+
|
|
189
|
+
log "replacing the database"
|
|
190
|
+
mv -Tf "$PB_DATA" "$PREVIOUS_DIR"
|
|
191
|
+
SWAPPED=1
|
|
192
|
+
mv -Tf "$STAGE" "$PB_DATA"
|
|
193
|
+
|
|
194
|
+
if [ "$MIGRATE" = "1" ] && [ -d "$APP/current/migrations" ]; then
|
|
195
|
+
log "running migrations"
|
|
196
|
+
runuser -u "$VELA_USER" -- "$APP/bin/pocketbase" \
|
|
197
|
+
--dir "$PB_DATA" \
|
|
198
|
+
--migrationsDir "$APP/current/migrations" \
|
|
199
|
+
migrate up >&2 \
|
|
200
|
+
|| die "migrations failed against the restored database"
|
|
201
|
+
fi
|
|
202
|
+
|
|
203
|
+
# Unconditional, and after the migrations so that _superusers matches the schema
|
|
204
|
+
# the running release expects. --force because the env file did not change: the
|
|
205
|
+
# fingerprint cannot see that the database underneath it did.
|
|
206
|
+
reconcile_superuser "$APP" "$ETC" "$SU_EMAIL" "$SU_PASSWORD" --force
|
|
207
|
+
|
|
208
|
+
log "starting PocketBase on 127.0.0.1:$PB_PORT"
|
|
209
|
+
systemctl start "$PB_UNIT"
|
|
210
|
+
wait_for_http "http://127.0.0.1:$PB_PORT/api/health" 60 0.5 \
|
|
211
|
+
|| die "PocketBase did not become healthy - journalctl -u $PB_UNIT"
|
|
212
|
+
|
|
213
|
+
# wait_for_http accepts 4xx, so the health check above passes even when the app
|
|
214
|
+
# would 401 on every render. Only an actual login proves the restore is usable.
|
|
215
|
+
assert_superuser_auth "$PB_PORT" "$SU_EMAIL" "$SU_PASSWORD" \
|
|
216
|
+
|| die "the restored database does not accept this instance's superuser credentials"
|
|
217
|
+
|
|
218
|
+
log "starting app on 127.0.0.1:$WEB_PORT"
|
|
219
|
+
systemctl start "$WEB_UNIT"
|
|
220
|
+
wait_for_http "http://127.0.0.1:$WEB_PORT$HEALTH_PATH" 60 0.5 \
|
|
221
|
+
|| die "app did not become healthy at $HEALTH_PATH - journalctl -u $WEB_UNIT"
|
|
222
|
+
|
|
223
|
+
RESTORED=1
|
|
224
|
+
|
|
225
|
+
# ------------------------------------------------------- the backup history
|
|
226
|
+
#
|
|
227
|
+
# PocketBase leaves backups/ out of the archives it writes, so the restored
|
|
228
|
+
# pb_data has none and every other snapshot on the box would otherwise be
|
|
229
|
+
# stranded in the directory set aside above. Moved only now that the restore has
|
|
230
|
+
# been proven: until this point any failure still has to be able to put the old
|
|
231
|
+
# directory back exactly as it was.
|
|
232
|
+
|
|
233
|
+
if [ -d "$PREVIOUS_DIR/backups" ]; then
|
|
234
|
+
log "carrying the backup history forward"
|
|
235
|
+
mkdir -p "$PB_DATA/backups"
|
|
236
|
+
find "$PREVIOUS_DIR/backups" -maxdepth 1 -type f -exec mv -t "$PB_DATA/backups" {} +
|
|
237
|
+
chown -R "$VELA_USER:$VELA_USER" "$PB_DATA/backups"
|
|
238
|
+
fi
|
|
239
|
+
|
|
240
|
+
# --------------------------------------------------------------------- state
|
|
241
|
+
|
|
242
|
+
state_merge "$INSTANCE" "$(jq -c -n \
|
|
243
|
+
--arg at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
|
244
|
+
--arg from "$(basename "$ARCHIVE")" \
|
|
245
|
+
--arg previous "$PREVIOUS_DIR" \
|
|
246
|
+
'{restoredAt: $at, restoredFrom: $from, previousDataDir: $previous}')"
|
|
247
|
+
|
|
248
|
+
# ------------------------------------------------------------------- cleanup
|
|
249
|
+
|
|
250
|
+
if [ "$CLEANUP_ARCHIVE" = "1" ]; then
|
|
251
|
+
rm -f "$ARCHIVE"
|
|
252
|
+
rmdir "$APP/shared/.restore" 2>/dev/null || true
|
|
253
|
+
fi
|
|
254
|
+
|
|
255
|
+
if [ "$KEEP_PREVIOUS" -gt 0 ] 2>/dev/null; then
|
|
256
|
+
mapfile -t old < <(ls -1d "$APP"/shared/pb_data.pre-restore-* 2>/dev/null | sort -r | tail -n +$((KEEP_PREVIOUS + 1)))
|
|
257
|
+
for dir in "${old[@]:-}"; do
|
|
258
|
+
[ -n "$dir" ] || continue
|
|
259
|
+
log "removing $(basename "$dir")"
|
|
260
|
+
rm -rf "$dir"
|
|
261
|
+
done
|
|
262
|
+
fi
|
|
263
|
+
|
|
264
|
+
emit_result \
|
|
265
|
+
--arg instance "$INSTANCE" --arg archive "$(basename "$ARCHIVE")" \
|
|
266
|
+
--arg previous "$PREVIOUS_DIR" --argjson migrated "$MIGRATE" \
|
|
267
|
+
--argjson storage "$CARRIED_STORAGE" \
|
|
268
|
+
'{instance: $instance, archive: $archive, previousDataDir: $previous,
|
|
269
|
+
migrated: ($migrated == 1), storageCarriedOver: ($storage == 1)}'
|