Merge remote-tracking branch 'origin/release/v1.46.0' into fix/v146-move-safety

This commit is contained in:
Marc-André Bombeck committed 2026-10-25 16:49:06 +01:00
commit 0085915a56
53 files changed
+1620 -246

No files matched your search

+21 -7
View File
@@ -202,7 +202,10 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
#
# Set this to "1" ONLY when every public request to HealthLog terminates
# at Cloudflare (Tunnel, Workers, classic proxy) before it reaches your
# origin. Deployments behind nginx, Caddy, Traefik, an AWS ALB, or any
# origin, and the origin accepts connections from Cloudflare alone (a
# Tunnel, or a firewall that admits only Cloudflare's address ranges): the
# header is believed from every connection, before any other check.
# Deployments behind nginx, Caddy, Traefik, an AWS ALB, or any
# direct-to-Next.js exposure MUST leave this off.
#
# Security implications when the flag is mis-set:
@@ -224,7 +227,10 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
# Number of trusted reverse-proxy hops in front of the app. The client IP is
# read that many entries from the right of the X-Forwarded-For chain. Set 0
# for a directly-exposed host; 1 (the default when unset) behind a single
# reverse proxy. X-Real-IP is read only at 1. A wrong value makes the app
# reverse proxy. X-Real-IP is read only at 1. Forwarding headers are read only
# from a connection whose own address is not public (loopback, private, CGNAT,
# unique-local, link-local, reserved or multicast; the full list is in
# docs/self-hosting/reverse-proxy.md). A wrong value makes the app
# refuse the headers, log one warning, and share one rate-limit bucket across
# all anonymous callers.
# TRUST_PROXY_HOPS="1"
@@ -292,7 +298,9 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
# Set "false" to stop the nightly dump.
# DB_BACKUP_ENABLED="true"
# Where dumps are written. The bundled compose file mounts the `backups`
# volume here; change it only together with that mount.
# volume here; change it only together with that mount. In a container a
# directory that is not on a mount of its own sits in the container's layer
# and is lost at the next update, so no dump is kept there.
# DB_BACKUP_DIR="/var/lib/healthlog/backups"
# Five-field cron, in Europe/Berlin time like every scheduled job.
# DB_BACKUP_SCHEDULE="30 1 * * *"
@@ -353,11 +361,17 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
# Auto-deploy webhook (v1.4.15 — phase C2)
# -----------------------------------------------------------------------------
# Coolify (or any CI) pings POST /api/internal/deploy-webhook after a deploy
# attempt with success/failure. We verify the request via this shared secret
# carried in the `X-Deploy-Webhook-Secret` header (timing-safe compare).
# Generate with `openssl rand -hex 32`. Configure the SAME value in Coolify's
# Notifications → Webhook custom-headers panel. See docs/audit/v1415-auto-deploy.md.
# attempt with success/failure. A request authenticates with this shared
# secret in the `X-Deploy-Webhook-Secret` header (timing-safe compare; what
# Coolify's stock sender does, with no replay protection), or by signing:
# `X-Deploy-Webhook-Signature: sha256=<hex>`, the HMAC-SHA256 under this
# secret of the `X-Deploy-Webhook-Timestamp` value, a dot and the raw body,
# the timestamp within 5 minutes. Generate with `openssl rand -hex 32`.
# Configure the SAME value in Coolify's Notifications → Webhook
# custom-headers panel. See docs/ops/deploy.md.
# DEPLOY_WEBHOOK_SECRET=""
# "true" accepts signed requests only; leave unset for Coolify's stock sender.
# DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=""
# -----------------------------------------------------------------------------
+18 -11
View File
@@ -429,7 +429,9 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# Set "false" to stop the nightly dump.
# DB_BACKUP_ENABLED="true"
# Where dumps are written. The bundled compose file mounts the `backups`
# volume here; change it only together with that mount.
# volume here; change it only together with that mount. In a container a
# directory that is not on a mount of its own sits in the container's layer
# and is lost at the next update, so no dump is kept there.
# DB_BACKUP_DIR="/var/lib/healthlog/backups"
# Five-field cron, in Europe/Berlin time like every scheduled job.
# DB_BACKUP_SCHEDULE="30 1 * * *"
@@ -552,21 +554,26 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# -----------------------------------------------------------------------------
# Client-IP resolution -- optional (required behind Cloudflare)
# -----------------------------------------------------------------------------
# When the stack sits behind Cloudflare, the real visitor IP arrives in the
# `cf-connecting-ip` header; the X-Forwarded-For chain otherwise yields the
# Cloudflare edge IP, so the audit-log overview stores the CF datacentre
# address and the geo lookup resolves the wrong location. Set this to "1"
# ONLY when Cloudflare is in front -- a direct-exposed self-host must leave
# it unset, or an attacker could forge the header. Off by default.
# Number of trusted reverse-proxy hops in front of the app; the client IP is
# read that many entries from the right of the X-Forwarded-For chain. 0 for a
# directly-exposed host (the connection's own address is used), 1 (the
# default when unset) behind a single reverse proxy; X-Real-IP is read only
# at 1. Forwarding headers are never read from a connection that comes from a
# public address: only a proxy on loopback or a private network is believed. A wrong value makes the app refuse the
# headers, log one warning, and collapse anonymous callers into one
# rate-limit bucket.
# at 1. Forwarding headers are read only from a connection whose own address
# is not public: loopback, private, CGNAT, unique-local, link-local, reserved
# or multicast (the full list is in docs/self-hosting/reverse-proxy.md). A
# wrong value makes the app refuse the headers, log one warning, and collapse
# anonymous callers into one rate-limit bucket.
# TRUST_PROXY_HOPS="1"
#
# Behind Cloudflare the real visitor IP arrives in the `cf-connecting-ip`
# header; the X-Forwarded-For chain otherwise yields the Cloudflare edge IP,
# so the audit-log overview stores the CF datacentre address and the geo
# lookup resolves the wrong location. "1" believes that header from EVERY
# connection, before the checks above, so set it ONLY when the origin accepts
# connections from Cloudflare alone (a Cloudflare Tunnel, or a firewall that
# admits only Cloudflare's address ranges). An origin that also answers on
# its own public address lets any caller forge the address that rate limits
# and the audit log record. Off by default.
# TRUST_CF_CONNECTING_IP="1"
# Memory cap of the app container (read by docker-compose.yml, not by the app).
+2
View File
@@ -120,6 +120,8 @@ RUN addgroup --system --gid 1001 nodejs && \
# Where the database dumps go (`DB_BACKUP_DIR`). Owned by the app user, so a
# fresh named volume mounted here starts with that owner: Docker fills an
# empty named volume from the image directory it covers, ownership included.
# Without a mount the directory stays in the container's layer; the app reads
# its mount table, sees that, and keeps no dump here (dump-core.mjs).
RUN mkdir -p /var/lib/healthlog/backups && \
chown nextjs:nodejs /var/lib/healthlog/backups && \
chmod 700 /var/lib/healthlog/backups
+14 -10
View File
@@ -92,8 +92,10 @@ x-healthlog-env: &healthlog_env
TELEGRAM_WEBHOOK_SECRET: "${TELEGRAM_WEBHOOK_SECRET:-}"
# v1.18.11 (W3) — client-IP resolution + geo lookup. Behind Cloudflare
# the real visitor IP is in `cf-connecting-ip`; set
# TRUST_CF_CONNECTING_IP=1 ONLY when Cloudflare is in front (off by
# default — a direct-exposed host must not trust a forgeable header).
# TRUST_CF_CONNECTING_IP=1 ONLY when the origin accepts connections from
# Cloudflare alone (a Tunnel, or a firewall admitting only Cloudflare's
# ranges): the header is believed from every connection (off by default;
# see docs/self-hosting/reverse-proxy.md).
# IP_GEO_LOOKUP_URL overrides the default ipwho.is provider (HTTPS by
# default); IP_GEO_LOOKUP_DISABLED=1 stops all third-party IP egress.
# IP_GEO_ALLOW_INSECURE=true permits a plain-HTTP provider URL (e.g. the
@@ -297,16 +299,18 @@ x-healthlog-env: &healthlog_env
OIDC_SCOPES: "${OIDC_SCOPES:-}"
OIDC_BUTTON_LABEL: "${OIDC_BUTTON_LABEL:-}"
OIDC_ONLY: "${OIDC_ONLY:-}"
# Deploy-result webhook (optional). DEPLOY_WEBHOOK_SECRET authenticates
# POST /api/internal/deploy-webhook; DEPLOY_LOGS_URL is the
# deployment-dashboard URL (e.g. the Coolify UI) linked in
# deploy-failure notifications. Listed here so operator values in
# .env reach the container (the `environment:` block is a whitelist).
# Deploy-result webhook (optional). POST /api/internal/deploy-webhook
# authenticates either with DEPLOY_WEBHOOK_SECRET in the
# X-Deploy-Webhook-Secret header (what Coolify's stock sender can do; no
# replay protection) or with X-Deploy-Webhook-Signature: sha256=<HMAC of
# "<X-Deploy-Webhook-Timestamp>.<body>" under the secret>, the timestamp
# within 5 minutes. DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=true accepts signed
# requests only. DEPLOY_LOGS_URL is the deployment-dashboard URL (e.g. the
# Coolify UI) linked in deploy-failure notifications. Listed here so
# operator values in .env reach the container (the `environment:` block is
# a whitelist). See docs/ops/deploy.md.
DEPLOY_WEBHOOK_SECRET: "${DEPLOY_WEBHOOK_SECRET:-}"
DEPLOY_LOGS_URL: "${DEPLOY_LOGS_URL:-}"
# Optional replay hardening: when "true", the deploy webhook
# requires a fresh X-Deploy-Webhook-Timestamp header (±5 min).
# See docs/ops/deploy.md.
DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP: "${DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP:-}"
# TLS leaf SPKI-change alarm baseline (optional). The native client
# pins the served TLS leaf; this is the known-good pin set the monitor
+4 -2
View File
@@ -90,8 +90,10 @@ echo "HealthLog: Database is reachable."
node scripts/pending-migrations.mjs || true
# When migrations are pending, dump the database into DB_BACKUP_DIR first, so
# the state before them can be restored (docs/ops/disaster-recovery.md). No
# writable directory: a warning, and the boot goes on. A writable directory
# and a failed dump: the boot stops here, before anything is migrated.
# directory that survives the container (none writable, or the image's own
# without the backups volume): a warning, and the boot goes on. A kept
# directory and a failed dump: the boot stops here, before anything is
# migrated.
if ! node scripts/db-dump.mjs pre-migration; then
echo "HealthLog: ERROR — not migrating without the dump. Free disk space or fix the error above and start again." >&2
echo "HealthLog: to migrate without a dump this once, set DB_BACKUP_BEFORE_MIGRATE=false." >&2
+2
View File
@@ -79798,6 +79798,7 @@ components:
- local-empty
- local-unsupported
- decrypt-error
- resource-limit
- raster-failed
- provider-error
- pdf-needs-anthropic
@@ -80370,6 +80371,7 @@ components:
- local-empty
- local-unsupported
- decrypt-error
- resource-limit
- raster-failed
- provider-error
- pdf-needs-anthropic
+2 -2
View File
@@ -21,8 +21,8 @@ The weekly, pre-wipe and off-host copies carry the whole record, document
files and ECG traces included, with the database's ciphertext inside them as
stored. An uploaded copy holds whatever its file held. The personal
export is portable: readable values, and documents as metadata only. It is
meant for the person to keep and read. A restore refuses it when the account
holds any document, because the file has no document content to put back.
meant for the person to keep and read. A restore refuses it when the file
lists any document, because it carries no document content to put back.
Two kinds of data are not in any account copy: workout GPS routes and sample
series. Credentials, devices, share links and account grants are left out on
+17 -15
View File
@@ -36,12 +36,13 @@ docker compose up -d app
```
Step 2 takes the pre-migration dump too, in the one-off container, before it
migrates. When the backups directory is writable and that dump fails (a full
disk, most often), the entrypoint stops before migrating and says so; free
the space and run the step again, or set `DB_BACKUP_BEFORE_MIGRATE=false` for
one start without the dump. Without a writable directory (an install that
runs the image without the `backups` volume) the log carries a warning, no
dump is taken, and the migrations run.
migrates. When the backups directory is on its volume and that dump fails (a
full disk, most often), the entrypoint stops before migrating and says so;
free the space and run the step again, or set `DB_BACKUP_BEFORE_MIGRATE=false`
for one start without the dump. Without the `backups` volume the directory
is not writable, or it is the image's own and sits inside the container that
the update replaces; either way the log carries a warning, no dump is taken,
and the migrations run.
Use `docker compose run` rather than a plain `docker run` for step 2. A
plain `docker run` starts outside the stack's network, where `db` does not
@@ -236,19 +237,20 @@ roll back:
app version reads forward-compatible-ish shapes for nearly every
release pair shipped on the v1.4.x line.
3. If a column-drop migration ships in the rollback target, hand-roll
the column back via a fresh forward migration (`pnpm prisma migrate
dev --create-only --name vX_Y_Z_rollback_<col>`), edit the SQL,
commit, and re-deploy. Never `migrate resolve --rolled-back` against
the column back via a fresh forward migration
(`pnpm prisma migrate dev --create-only --name vX_Y_Z_rollback_<col>`),
edit the SQL, commit, and re-deploy. Never `migrate resolve --rolled-back` against
production unless you've taken a fresh `pg_dump` first.
4. Rolling back from v1.40.2 or later to v1.40.1 or earlier: an account
that saved the Coach lookback "Last year" reads its whole Coach
preference blob as defaults on the older version, metric exclusions
included, until the setting is saved again. Ask affected accounts to
pick another lookback before the rollback, or re-save their exclusions
after it.
When the data itself has to go back to before the update, restore the
pre-migration dump with the previous image; the steps are in
[`disaster-recovery.md`](disaster-recovery.md#going-back-to-before-a-migration). 4. Rolling back from v1.40.2 or later to v1.40.1 or earlier: an account
that saved the Coach lookback "Last year" reads its whole Coach
preference blob as defaults on the older version, metric exclusions
included, until the setting is saved again. Ask affected accounts to
pick another lookback before the rollback, or re-save their exclusions
after it.
[`disaster-recovery.md`](disaster-recovery.md#going-back-to-before-a-migration).
## Catalog templates
+36 -6
View File
@@ -40,14 +40,22 @@ A dump holds every account that existed when it was taken, including one
deleted since. Deleted accounts leave the dumps as they age out: after
`DB_BACKUP_KEEP` nights on the server, and after your lifecycle rule in the
bucket. Give that rule a prefix that covers `database/` too; the admin
backups page reads the bucket's rules and says when none does. This is the
backups page reads the bucket's rules and says when none does, and the
recovery readiness card calls it attention and tells the admins once. This is the
retention period to state where you tell people that deleting their account
deletes their data.
Without a writable `DB_BACKUP_DIR` (an install that runs the image without the
`backups` volume), the nightly dump is uploaded off-host and nothing is kept
on the server, and no dump is taken before migrations; with no bucket either,
no dump is taken at all. The start log and the admin page say so.
A dump is kept on the server only when `DB_BACKUP_DIR` survives the container
being replaced: a mounted volume or bind mount, or any writable directory when
HealthLog does not run in a container. The image creates the default
directory, so an install that runs it without the `backups` volume can write
there, but the directory sits in the container's own layer and the next update
throws it away. HealthLog reads the container's mount table to tell the two
apart and treats such a directory, or one on a tmpfs, like one it cannot
write: the nightly dump is uploaded off-host and nothing is kept on the
server, and no dump is taken before migrations; with no bucket either, no
dump is taken at all. The start log, the admin backups page and the recovery
readiness card say so.
The per-account copies (the weekly in-database copy, the nightly per-account
objects) roll one account back on the same server. They cannot rebuild a
@@ -149,10 +157,32 @@ docker compose up -d
```
The dump before a migration is taken while the new image starts. If it fails
on a writable directory (a full disk, most often), the start stops before
on a directory that keeps it (a full disk, most often), the start stops before
anything is migrated and the log names the switch: `DB_BACKUP_BEFORE_MIGRATE=false`
starts without it, once.
## Knowing the copies are there
Nobody opens the backups page until the day it matters, so the server tells
the admins instead:
- The **Recovery readiness** card, on the admin overview and on Admin,
Backups, judges every copy in one verdict each: the database dump (fresh,
failed, or kept nowhere that survives the container), the weekly account
copies, the off-host copies, the last restore drill, whether the bucket is
set up, whether its lifecycle rule ever removes the dumps under
`database/`, and the recovery kit.
- The **recovery kit** under Admin, Encryption lists every key a restore
needs (the table above), by id and fingerprint, and lets an admin check a
saved copy and confirm it. It is due again after any key change.
- An hourly check sends each admin a **Backup health** notification
(`BACKUP_HEALTH`) when a signal on the card turns to needs attention or at
risk, a reminder once a day while one stays at risk, and one more when it
recovers. Each admin chooses the channels for it under Notifications.
[Getting started](../self-hosting/getting-started.md#save-the-recovery-kit)
walks through saving the kit.
## How this is tested
`scripts/compose-dr-drill.sh` plays this page against real containers: it
+2 -1
View File
@@ -295,7 +295,8 @@ key, a re-keyed install. The kit also links the
The **Recovery readiness** card on the admin overview and the Backups page
judges every backup signal in one place: the database dump, the weekly account
copies, the off-host copies, the last restore drill, whether off-host copies
are set up, and the recovery kit. An hourly check sends each admin a
are set up, whether the bucket ever removes old database dumps, and the
recovery kit. An hourly check sends each admin a
**Backup health** notification when a signal turns to "needs attention" or "at
risk", once a day while it stays at risk, and once when it recovers. Route or
switch it off per channel under Notifications.
+39 -11
View File
@@ -16,17 +16,18 @@ tailnet with no extra container. See
## Pre-flight — env vars the proxy interacts with
| Variable | Required for | Notes |
| --------------------- | ---------------------------------------------- | ---------------------------------------- |
| `NEXT_PUBLIC_APP_URL` | Every public-facing absolute URL the UI emits | Must match the hostname the proxy serves |
| `APP_URL` | Server-side absolute URLs from background jobs | Mirror of the above; both must match |
| `TRUST_PROXY_HOPS` | `X-Forwarded-For` chain trust | Defaults to `1` — one trusted hop |
| Variable | Required for | Notes |
| ------------------------ | ---------------------------------------------- | ---------------------------------------- |
| `NEXT_PUBLIC_APP_URL` | Every public-facing absolute URL the UI emits | Must match the hostname the proxy serves |
| `APP_URL` | Server-side absolute URLs from background jobs | Mirror of the above; both must match |
| `TRUST_PROXY_HOPS` | `X-Forwarded-For` chain trust | Defaults to `1` — one trusted hop |
| `TRUST_CF_CONNECTING_IP` | Reading the visitor address Cloudflare sends | Off unless `1`; see the warning below |
Set the URL pair to the public origin (`https://your-instance.example.com`)
and restart the `app` container. The image reads both at startup; no
rebuild needed.
All three variables are on the shipped `docker-compose.yml`
All four variables are on the shipped `docker-compose.yml`
`environment:` whitelist, so a value in `.env` reaches the container
without editing the compose file. (Before v1.37.33 the whitelist was
missing `TRUST_PROXY_HOPS`; on those versions add
@@ -51,18 +52,45 @@ A misconfigured count logs a one-shot warning to stderr and collapses
every anonymous caller into one shared rate-limit bucket. Match the
value to the actual hop count.
Forwarding headers are believed only from a connection that could come
from a proxy: loopback, a private or unique-local network, or CGNAT (a
Tailscale address). A request that reaches HealthLog straight from a public
Forwarding headers are believed only from a connection whose own address
is not a public one, which is where a proxy you run connects from:
- IPv4: loopback (`127.0.0.0/8`), `0.0.0.0/8`, the private ranges
(`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), CGNAT
(`100.64.0.0/10`, Tailscale), link-local (`169.254.0.0/16`), the reserved
blocks `192.0.0.0/24` and `198.18.0.0/15`, and everything from
`224.0.0.0` up (multicast and reserved);
- IPv6: `::` and `::1`, link-local (`fe80::/10`), site-local (`fec0::/10`),
unique-local (`fc00::/7`, Tailscale), multicast (`ff00::/8`), and an
IPv4-mapped or embedded address from one of the IPv4 ranges above.
The reserved and multicast blocks are in the list because no host on the
internet can open a connection from them, so a peer there sits on a network
you run. A request that reaches HealthLog straight from a public
address carries headers its sender wrote, so HealthLog ignores them and
uses the connection's own address, and says so once in the log. A proxy on
the same host or Docker network connects from a private address, so the
usual setups above need nothing. If your proxy reaches HealthLog over a
public address, put it on a private network with HealthLog instead (or, for
Cloudflare straight to the origin, use `TRUST_CF_CONNECTING_IP=1`). The log
public address, put it on a private network with HealthLog instead. The log
line also shows up when the published port is open to the internet beside
the proxy; bind it to the proxy's network or to `127.0.0.1` then.
### `TRUST_CF_CONNECTING_IP` — only when the origin takes Cloudflare only
With `TRUST_CF_CONNECTING_IP=1` HealthLog takes the visitor's address from
the `CF-Connecting-IP` header, before any of the checks above: from every
connection, whatever its address, and whatever `TRUST_PROXY_HOPS` says.
Cloudflare sets that header on every request through its edge, but any
client can send it too. So the flag is safe only when nothing but
Cloudflare can reach the origin: a Cloudflare Tunnel with no published
port, or a firewall that admits only
[Cloudflare's address ranges](https://www.cloudflare.com/ips/) to the port
HealthLog or its proxy listens on (Authenticated Origin Pulls on top is
better still). If the origin also answers on its own public address, a
caller who finds it chooses the address every per-IP rate limit, the audit
log and the sign-in locations record, and changes it on every request.
Without Cloudflare in front, leave the flag unset.
`X-Real-IP` gets the same trust as `X-Forwarded-For`. It is read only
when `TRUST_PROXY_HOPS` is exactly `1` and the request carries no
`X-Forwarded-For`, which covers a single proxy that sets only
+2 -1
View File
@@ -101,5 +101,6 @@ and host names before it is shown.
`docker compose logs app` prints every wide event as one JSON line;
`LOG_LEVEL=debug` adds debug events. With `LOKI_ENDPOINT` set the same events
go to Loki, and GlitchTip (Admin, General) receives unexpected server errors.
go to Loki, and GlitchTip (Admin, Integrations, once a DSN is set there)
receives unexpected server errors.
Each line carries `request_id`, so an error id finds its line there too.
+14 -1
View File
@@ -54,6 +54,15 @@ example `UPDATE_CHECK_DISABLED=1`. The full list is in
To use your own GeoLite2 databases, add the directory under Additional Storage
and point `GEOLITE2_DIR` at its mount path ([geolite2.md](geolite2.md)).
HealthLog writes a whole-database dump every night, and one before each update
that brings migrations, into `/var/lib/healthlog/backups` inside the app
container. Give that path a dataset of its own (a host path or an ixVolume,
writable by uid `1001`), under Additional Storage if the form has no field for
it. Without one the directory sits inside the container, which an update
replaces, so HealthLog keeps no dump there: the start log, the admin backups
page and the recovery readiness card say so, and with an off-host bucket the
nightly dump goes to the bucket only.
## Plain HTTP on the LAN
Most NAS installs are reached over `http://`. Leave **Secure Cookies** off for
@@ -113,6 +122,10 @@ back.
The file lands in the Postgres Data Storage dataset, next to the database
files, so copy it off the NAS as well.
- **The nightly dump.** With a dataset on `/var/lib/healthlog/backups` (see
above), the newest seven nightly dumps and the last two taken before a
migration stay there; [disaster-recovery.md](../ops/disaster-recovery.md)
restores one onto a new server.
- **Off-host.** HealthLog can push encrypted nightly backups to any
S3-compatible bucket; see [backup-restore.md](../ops/backup-restore.md).
@@ -216,7 +229,7 @@ the menu next to Custom App, Install via YAML, using these values:
| Health check | `GET /api/health`, `200` when ready |
| Start period | 10 min, like the image: migrations run at every boot and wait out a held lock |
| Database | PostgreSQL 18 (16 works too), no extensions, in its own container with its own dataset |
| Volumes | none for the app; the database gets its own dataset |
| Volumes | `/var/lib/healthlog/backups` for the database dumps (uid `1001`); the database its own |
| Variable | Required | Notes |
| ----------------------- | -------- | ---------------------------------------------------------------------------------------- |
+19 -8
View File
@@ -6,10 +6,13 @@ self-contained install (app + database as one unit) and is recommended. The
Unraid UI, but because CA templates describe a single container you install
PostgreSQL separately and wire the two together.
HealthLog stores all persistent state in PostgreSQL — the app container itself is
stateless (the offline geo-IP databases are either baked in at build time or
mounted read-only; replaceable either way, not state). A backup of
the database is a backup of everything.
HealthLog stores all persistent state in PostgreSQL. The app container keeps one
more thing, its database dumps: every night, and before each update that brings
migrations, it writes a whole-database dump to `/var/lib/healthlog/backups`
(the offline geo-IP databases are either baked in at build time or mounted
read-only; replaceable either way, not state). A backup of the database is a
backup of everything, and those dumps are that backup, so that path needs a
share of its own.
## What you need
@@ -103,12 +106,20 @@ Fill in the fields:
| `SESSION_COOKIE_SECURE` | `false` for plain-HTTP LAN access |
| `NEXT_PUBLIC_APP_URL` | `http://tower:3000` (or your domain) |
| `APP_URL` | same as `NEXT_PUBLIC_APP_URL` |
| Database dumps | `/mnt/user/appdata/healthlog/backups` (the default) |
Replace `<password>` and `<postgres-host>` with the values from Step 1. Apply,
then open `http://tower:3000` — the first registered user becomes the admin.
Replace `<password>` and `<postgres-host>` with the values from Step 1. The
dumps folder must be writable by the app's user, uid `1001`; create it once
from the terminal with
`mkdir -p /mnt/user/appdata/healthlog/backups && chown 1001:1001 /mnt/user/appdata/healthlog/backups`.
Apply, then open `http://tower:3000` — the first registered user becomes the
admin.
The HealthLog container has no persistent volume of its own; all state is in the
PostgreSQL container you installed in Step 1. Back that one up.
All account data is in the PostgreSQL container you installed in Step 1, and
the nightly dumps of it land in the dumps folder. Without that path the dumps
would sit inside the HealthLog container, which every update replaces, so
HealthLog keeps none: the start log, the admin backups page and the recovery
readiness card say so. Back up both.
---
+2
View File
@@ -63,4 +63,6 @@
<Config Name="ENCRYPTION_KEYS" Target="ENCRYPTION_KEYS" Default="" Mode="" Description="Optional. Key-rotation map, e.g. {&quot;v1&quot;:&quot;&lt;hex&gt;&quot;,&quot;v2&quot;:&quot;&lt;hex&gt;&quot;}. Leave empty unless rotating; use ENCRYPTION_KEY instead." Type="Variable" Display="advanced" Required="false" Mask="true"/>
<Config Name="ENCRYPTION_ACTIVE_KEY_ID" Target="ENCRYPTION_ACTIVE_KEY_ID" Default="" Mode="" Description="Optional. Active key id for the ENCRYPTION_KEYS map (e.g. v2). Leave empty when using ENCRYPTION_KEY." Type="Variable" Display="advanced" Required="false" Mask="false"/>
<Config Name="Database dumps" Target="/var/lib/healthlog/backups" Default="/mnt/user/appdata/healthlog/backups" Mode="rw" Description="Where HealthLog keeps its nightly database dump and the one it takes before each update that brings migrations. Without this path the dumps would sit inside the container, which an update replaces, so none is kept. Must be writable by uid 1001 (chown 1001:1001 the folder once)." Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/healthlog/backups</Config>
</Container>
+21 -6
View File
@@ -8346,7 +8346,9 @@
"noDirectory": "Dieser Server kann nicht nach {dir} schreiben und es ist kein externer Speicher eingerichtet. Deshalb entsteht kein Datenbank-Dump. Binde das Backup-Volume ein.",
"retentionLocal": "Gelöschte Konten bleiben in den Dumps, bis diese aussortiert werden: Auf diesem Server bleiben die neuesten {nights}.",
"retentionOffhost": "Externe Kopien entfernt die Lebenszyklus-Regel des Buckets nach {days} Tagen.",
"retentionOffhostMissing": "Keine Lebenszyklus-Regel im Bucket gilt für database/. Dumps dort und die gelöschten Konten darin werden deshalb nie entfernt. Lege eine Regel für dieses Präfix an."
"retentionOffhostMissing": "Keine Lebenszyklus-Regel im Bucket gilt für database/. Dumps dort und die gelöschten Konten darin werden deshalb nie entfernt. Lege eine Regel für dieses Präfix an.",
"notPersistentOffhost": "{dir} liegt nicht auf dauerhaftem Speicher (im Container statt auf einem eingebundenen Volume, oder im Arbeitsspeicher). Ein Dump dort wäre beim nächsten Update verloren. Dumps gehen deshalb nur extern: Hier wird keiner aufbewahrt, und vor einer Migration wird keiner erstellt. Binde das Backup-Volume ein.",
"notPersistent": "{dir} liegt nicht auf dauerhaftem Speicher (im Container statt auf einem eingebundenen Volume, oder im Arbeitsspeicher), und es ist kein externer Bucket eingerichtet. Damit wird nirgends ein Datenbank-Dump aufbewahrt. Binde das Backup-Volume ein."
}
},
"danger-zone": {
@@ -8660,7 +8662,8 @@
"offhost_copies": "Externe Kopien",
"restore_drill": "Wiederherstellungsprobe",
"offhost_setup": "Externe Sicherung",
"recovery_kit": "Wiederherstellungs-Kit"
"recovery_kit": "Wiederherstellungs-Kit",
"dump_retention": "Dumps im Bucket"
},
"at": {
"database_dump": "Neuester Dump: {date}",
@@ -8676,7 +8679,8 @@
"offhost_copies": "Sicherungen öffnen",
"restore_drill": "Sicherungen öffnen",
"offhost_setup": "Sicherungen öffnen",
"recovery_kit": "Kit öffnen"
"recovery_kit": "Kit öffnen",
"dump_retention": "Sicherungen öffnen"
},
"reason": {
"database_dump": {
@@ -8689,7 +8693,9 @@
"stale": "Der neueste Datenbank-Dump ist {hours} Stunden alt.",
"last_failed": "Der letzte Datenbank-Dump ist fehlgeschlagen. Der davor ist noch aktuell.",
"fresh": "Der neueste Datenbank-Dump ist aktuell.",
"nowhere_to_write": "Das Dump-Verzeichnis {directory} ist nicht beschreibbar und es ist kein externer Bucket eingerichtet, daher kann kein Dump aufbewahrt werden."
"nowhere_to_write": "Das Dump-Verzeichnis {directory} ist nicht beschreibbar und es ist kein externer Bucket eingerichtet, daher kann kein Dump aufbewahrt werden.",
"not_persistent": "Das Dump-Verzeichnis {directory} liegt nicht auf dauerhaftem Speicher, und es ist kein externer Bucket eingerichtet. Jeder Dump wäre beim nächsten Update verloren. Binde das Backup-Volume ein.",
"fresh_offhost_only": "Der neueste Datenbank-Dump ist aktuell und liegt im externen Bucket. Auf diesem Server wird keiner aufbewahrt, weil {directory} nicht auf dauerhaftem Speicher liegt, und vor einer Migration wird keiner erstellt."
},
"weekly_copy": {
"no_accounts": "Es gibt noch keine Konten zum Kopieren.",
@@ -8723,6 +8729,13 @@
"no_admin": "Es gibt noch kein Admin-Konto.",
"not_confirmed": "Niemand hat eine Kopie der Schlüssel bestätigt, die eine Wiederherstellung braucht: {keys}.",
"not_confirmed_grace": "Sichere eine Kopie der Schlüssel, die eine Wiederherstellung braucht: {keys}."
},
"dump_retention": {
"not_configured": "Es ist kein externer Bucket eingerichtet.",
"no_offhost_dumps": "Es geht kein Datenbank-Dump in den Bucket.",
"missing": "Keine Lebenszyklus-Regel im Bucket gilt für database/. Dumps dort und die gelöschten Konten darin werden deshalb nie entfernt. Lege eine Regel für dieses Präfix an.",
"configured": "Der Bucket entfernt Dumps nach {days} Tagen.",
"unknown": "Der Bucket gibt nicht an, ob eine Regel alte Dumps entfernt. Prüfe, ob eine Lebenszyklus-Regel database/ erfasst."
}
},
"recovered": {
@@ -8731,7 +8744,8 @@
"offhost_copies": "Jedes Konto hat wieder eine aktuelle externe Kopie.",
"restore_drill": "Die Wiederherstellungsprobe ist wieder bestanden.",
"offhost_setup": "Externe Kopien sind jetzt eingerichtet.",
"recovery_kit": "Das Wiederherstellungs-Kit ist wieder bestätigt."
"recovery_kit": "Das Wiederherstellungs-Kit ist wieder bestätigt.",
"dump_retention": "Eine Lebenszyklus-Regel im Bucket erfasst jetzt die Datenbank-Dumps."
}
}
},
@@ -11863,7 +11877,8 @@
"raster-failed": "Die PDF-Seiten konnten zum Lesen nicht gerendert werden. Gescannte Fax-Kompressionsformate sind eine bekannte Ursache.",
"provider-error": "Der KI-Anbieter konnte dieses Dokument beim letzten Versuch nicht lesen.",
"pdf-needs-anthropic": "Der eingerichtete KI-Anbieter kann keine PDF-Dateien lesen. Ein Claude-Anbieter oder lokale OCR kann es.",
"empty-transcription": "Der KI-Anbieter hat für dieses Dokument keinen Text geliefert."
"empty-transcription": "Der KI-Anbieter hat für dieses Dokument keinen Text geliefert.",
"resource-limit": "Dieses PDF ist zu groß oder zu komplex, um es hier zu lesen. Die Datei selbst ist in Ordnung und lässt sich weiterhin öffnen."
}
},
"loadError": "Dieses Dokument konnte nicht geladen werden.",
+21 -6
View File
@@ -8346,7 +8346,9 @@
"noDirectory": "This server cannot write to {dir} and no off-host bucket is set, so no database dump can be taken. Mount the backups volume.",
"retentionLocal": "Deleted accounts stay in the dumps until those age out: the newest {nights} are kept on this server.",
"retentionOffhost": "Off-host copies are removed after {days} days by the bucket's lifecycle rule.",
"retentionOffhostMissing": "No lifecycle rule in the bucket covers database/, so dumps there, and the deleted accounts inside them, are never removed. Add a rule for that prefix."
"retentionOffhostMissing": "No lifecycle rule in the bucket covers database/, so dumps there, and the deleted accounts inside them, are never removed. Add a rule for that prefix.",
"notPersistentOffhost": "{dir} is not on persistent storage (it sits inside the container rather than on a mounted volume, or in memory), so a dump written there would be lost at the next update. Dumps go off-host only: none is kept here and none is taken before a migration. Mount the backups volume.",
"notPersistent": "{dir} is not on persistent storage (it sits inside the container rather than on a mounted volume, or in memory) and no off-host bucket is set, so no database dump is kept anywhere. Mount the backups volume."
}
},
"danger-zone": {
@@ -8660,7 +8662,8 @@
"offhost_copies": "Off-host copies",
"restore_drill": "Restore drill",
"offhost_setup": "Off-host backup",
"recovery_kit": "Recovery kit"
"recovery_kit": "Recovery kit",
"dump_retention": "Dumps in the bucket"
},
"at": {
"database_dump": "Newest dump: {date}",
@@ -8676,7 +8679,8 @@
"offhost_copies": "Open backups",
"restore_drill": "Open backups",
"offhost_setup": "Open backups",
"recovery_kit": "Open the kit"
"recovery_kit": "Open the kit",
"dump_retention": "Open backups"
},
"reason": {
"database_dump": {
@@ -8689,7 +8693,9 @@
"stale": "The newest database dump is {hours} hours old.",
"last_failed": "The last database dump failed. The one before it is still recent.",
"fresh": "The newest database dump is recent.",
"nowhere_to_write": "The dump directory {directory} cannot be written to and no off-host bucket is set up, so no dump can be kept."
"nowhere_to_write": "The dump directory {directory} cannot be written to and no off-host bucket is set up, so no dump can be kept.",
"not_persistent": "The dump directory {directory} is not on persistent storage and no off-host bucket is set up, so every dump would be lost at the next update. Mount the backups volume.",
"fresh_offhost_only": "The newest database dump is recent and in the off-host bucket. None is kept on this server, because {directory} is not on persistent storage, and none is taken before a migration."
},
"weekly_copy": {
"no_accounts": "There are no accounts to copy yet.",
@@ -8723,6 +8729,13 @@
"no_admin": "There is no admin account yet.",
"not_confirmed": "Nobody has confirmed a copy of the keys a restore needs: {keys}.",
"not_confirmed_grace": "Save a copy of the keys a restore needs: {keys}."
},
"dump_retention": {
"not_configured": "No off-host bucket is set up.",
"no_offhost_dumps": "No database dump goes to the bucket.",
"missing": "No lifecycle rule in the bucket covers database/, so dumps there, and the deleted accounts inside them, are never removed. Add a rule for that prefix.",
"configured": "The bucket removes dumps after {days} days.",
"unknown": "The bucket does not say whether a rule removes old dumps. Check that a lifecycle rule covers database/."
}
},
"recovered": {
@@ -8731,7 +8744,8 @@
"offhost_copies": "Every account has a recent off-host copy again.",
"restore_drill": "The restore drill passed again.",
"offhost_setup": "Off-host copies are set up now.",
"recovery_kit": "The recovery kit is confirmed again."
"recovery_kit": "The recovery kit is confirmed again.",
"dump_retention": "A lifecycle rule in the bucket covers the database dumps now."
}
}
},
@@ -11863,7 +11877,8 @@
"raster-failed": "The PDF pages couldn't be rendered for reading. Scanned fax compression formats are a known cause.",
"provider-error": "The AI provider couldn't read this document on the last try.",
"pdf-needs-anthropic": "The configured AI provider can't read PDF files. A Claude provider or local OCR can.",
"empty-transcription": "The AI provider returned no text for this document."
"empty-transcription": "The AI provider returned no text for this document.",
"resource-limit": "This PDF is too large or complex to read here. The file itself is fine and still opens."
}
},
"loadError": "Couldn't load this document.",
+21 -6
View File
@@ -8346,7 +8346,9 @@
"noDirectory": "Este servidor no puede escribir en {dir} y no hay almacenamiento externo configurado, así que no se puede hacer ningún volcado. Monta el volumen de copias.",
"retentionLocal": "Las cuentas eliminadas siguen en los volcados hasta que estos caducan: en este servidor se guardan los {nights} más recientes.",
"retentionOffhost": "La regla de ciclo de vida del bucket elimina las copias externas tras {days} días.",
"retentionOffhostMissing": "Ninguna regla de ciclo de vida del bucket cubre database/, así que los volcados de ahí, y las cuentas eliminadas que contienen, nunca se borran. Añade una regla para ese prefijo."
"retentionOffhostMissing": "Ninguna regla de ciclo de vida del bucket cubre database/, así que los volcados de ahí, y las cuentas eliminadas que contienen, nunca se borran. Añade una regla para ese prefijo.",
"notPersistentOffhost": "{dir} no está en almacenamiento persistente (está dentro del contenedor y no en un volumen montado, o en memoria), así que un volcado guardado ahí se perdería en la próxima actualización. Los volcados solo se guardan fuera: aquí no queda ninguno y no se hace ninguno antes de una migración. Monta el volumen de copias.",
"notPersistent": "{dir} no está en almacenamiento persistente (está dentro del contenedor y no en un volumen montado, o en memoria) y no hay un bucket externo configurado, así que no se conserva ningún volcado de la base de datos en ningún sitio. Monta el volumen de copias."
}
},
"danger-zone": {
@@ -8660,7 +8662,8 @@
"offhost_copies": "Copias fuera del host",
"restore_drill": "Simulacro de restauración",
"offhost_setup": "Copia fuera del host",
"recovery_kit": "Kit de recuperación"
"recovery_kit": "Kit de recuperación",
"dump_retention": "Volcados en el bucket"
},
"at": {
"database_dump": "Volcado más reciente: {date}",
@@ -8676,7 +8679,8 @@
"offhost_copies": "Abrir copias de seguridad",
"restore_drill": "Abrir copias de seguridad",
"offhost_setup": "Abrir copias de seguridad",
"recovery_kit": "Abrir el kit"
"recovery_kit": "Abrir el kit",
"dump_retention": "Abrir copias de seguridad"
},
"reason": {
"database_dump": {
@@ -8689,7 +8693,9 @@
"stale": "El volcado de la base de datos más reciente tiene {hours} horas.",
"last_failed": "El último volcado de la base de datos falló. El anterior sigue siendo reciente.",
"fresh": "El último volcado de la base de datos es reciente.",
"nowhere_to_write": "No se puede escribir en el directorio de volcados {directory} y no hay ningún bucket externo configurado, así que no se puede guardar ningún volcado."
"nowhere_to_write": "No se puede escribir en el directorio de volcados {directory} y no hay ningún bucket externo configurado, así que no se puede guardar ningún volcado.",
"not_persistent": "El directorio de volcados {directory} no está en almacenamiento persistente y no hay un bucket externo configurado, así que cada volcado se perdería en la próxima actualización. Monta el volumen de copias.",
"fresh_offhost_only": "El volcado más reciente de la base de datos es actual y está en el bucket externo. En este servidor no se conserva ninguno, porque {directory} no está en almacenamiento persistente, y no se hace ninguno antes de una migración."
},
"weekly_copy": {
"no_accounts": "Todavía no hay cuentas que copiar.",
@@ -8723,6 +8729,13 @@
"no_admin": "Todavía no hay ninguna cuenta de administrador.",
"not_confirmed": "Nadie ha confirmado una copia de las claves que necesita una restauración: {keys}.",
"not_confirmed_grace": "Guarda una copia de las claves que necesita una restauración: {keys}."
},
"dump_retention": {
"not_configured": "No hay ningún bucket externo configurado.",
"no_offhost_dumps": "Ningún volcado de la base de datos va al bucket.",
"missing": "Ninguna regla de ciclo de vida del bucket cubre database/, así que los volcados de ahí, y las cuentas eliminadas que contienen, nunca se borran. Añade una regla para ese prefijo.",
"configured": "El bucket borra los volcados a los {days} días.",
"unknown": "El bucket no indica si alguna regla borra los volcados antiguos. Comprueba que una regla de ciclo de vida cubra database/."
}
},
"recovered": {
@@ -8731,7 +8744,8 @@
"offhost_copies": "Cada cuenta vuelve a tener una copia fuera del host reciente.",
"restore_drill": "El simulacro de restauración vuelve a superarse.",
"offhost_setup": "Las copias fuera del host ya están configuradas.",
"recovery_kit": "El kit de recuperación vuelve a estar confirmado."
"recovery_kit": "El kit de recuperación vuelve a estar confirmado.",
"dump_retention": "Ahora una regla de ciclo de vida del bucket cubre los volcados de la base de datos."
}
}
},
@@ -11863,7 +11877,8 @@
"raster-failed": "Las páginas del PDF no se pudieron procesar para su lectura. Los formatos de compresión de fax escaneados son una causa conocida.",
"provider-error": "El proveedor de IA no pudo leer este documento en el último intento.",
"pdf-needs-anthropic": "El proveedor de IA configurado no puede leer archivos PDF. Un proveedor Claude o el OCR local sí pueden.",
"empty-transcription": "El proveedor de IA no devolvió texto para este documento."
"empty-transcription": "El proveedor de IA no devolvió texto para este documento.",
"resource-limit": "Este PDF es demasiado grande o complejo para leerlo aquí. El archivo en sí está bien y se sigue pudiendo abrir."
}
},
"loadError": "No se pudo cargar este documento.",
+21 -6
View File
@@ -8346,7 +8346,9 @@
"noDirectory": "Ce serveur ne peut pas écrire dans {dir} et aucun stockage hors site n'est configuré : aucun dump ne peut être fait. Montez le volume de sauvegarde.",
"retentionLocal": "Les comptes supprimés restent dans les dumps jusqu'à leur expiration : ce serveur conserve les {nights} plus récents.",
"retentionOffhost": "La règle de cycle de vie du bucket supprime les copies hors site après {days} jours.",
"retentionOffhostMissing": "Aucune règle de cycle de vie du bucket ne couvre database/ : les dumps qui s'y trouvent, et les comptes supprimés qu'ils contiennent, ne sont jamais effacés. Ajoutez une règle pour ce préfixe."
"retentionOffhostMissing": "Aucune règle de cycle de vie du bucket ne couvre database/ : les dumps qui s'y trouvent, et les comptes supprimés qu'ils contiennent, ne sont jamais effacés. Ajoutez une règle pour ce préfixe.",
"notPersistentOffhost": "{dir} n'est pas sur un stockage persistant (il se trouve dans le conteneur et non sur un volume monté, ou en mémoire) : un dump écrit là serait perdu à la prochaine mise à jour. Les dumps partent uniquement hors site : aucun n'est conservé ici et aucun n'est pris avant une migration. Montez le volume de sauvegarde.",
"notPersistent": "{dir} n'est pas sur un stockage persistant (il se trouve dans le conteneur et non sur un volume monté, ou en mémoire) et aucun bucket hors site n'est configuré : aucun dump de la base n'est conservé nulle part. Montez le volume de sauvegarde."
}
},
"danger-zone": {
@@ -8660,7 +8662,8 @@
"offhost_copies": "Copies hors hôte",
"restore_drill": "Test de restauration",
"offhost_setup": "Sauvegarde hors hôte",
"recovery_kit": "Kit de récupération"
"recovery_kit": "Kit de récupération",
"dump_retention": "Dumps dans le bucket"
},
"at": {
"database_dump": "Dump le plus récent : {date}",
@@ -8676,7 +8679,8 @@
"offhost_copies": "Ouvrir les sauvegardes",
"restore_drill": "Ouvrir les sauvegardes",
"offhost_setup": "Ouvrir les sauvegardes",
"recovery_kit": "Ouvrir le kit"
"recovery_kit": "Ouvrir le kit",
"dump_retention": "Ouvrir les sauvegardes"
},
"reason": {
"database_dump": {
@@ -8689,7 +8693,9 @@
"stale": "Le dump de la base de données le plus récent date de {hours} heures.",
"last_failed": "Le dernier dump de la base de données a échoué. Le précédent est encore récent.",
"fresh": "Le dernier dump de la base de données est récent.",
"nowhere_to_write": "Le dossier des dumps {directory} n'est pas accessible en écriture et aucun bucket externe n'est configuré, donc aucun dump ne peut être conservé."
"nowhere_to_write": "Le dossier des dumps {directory} n'est pas accessible en écriture et aucun bucket externe n'est configuré, donc aucun dump ne peut être conservé.",
"not_persistent": "Le répertoire des dumps {directory} n'est pas sur un stockage persistant et aucun bucket hors site n'est configuré : chaque dump serait perdu à la prochaine mise à jour. Montez le volume de sauvegarde.",
"fresh_offhost_only": "Le dump de base le plus récent est à jour et se trouve dans le bucket hors site. Aucun n'est conservé sur ce serveur, car {directory} n'est pas sur un stockage persistant, et aucun n'est pris avant une migration."
},
"weekly_copy": {
"no_accounts": "Il n'y a pas encore de compte à copier.",
@@ -8723,6 +8729,13 @@
"no_admin": "Il n'y a pas encore de compte administrateur.",
"not_confirmed": "Personne n'a confirmé de copie des clés nécessaires à une restauration : {keys}.",
"not_confirmed_grace": "Sauvegardez une copie des clés nécessaires à une restauration : {keys}."
},
"dump_retention": {
"not_configured": "Aucun bucket hors site n'est configuré.",
"no_offhost_dumps": "Aucun dump de la base ne part dans le bucket.",
"missing": "Aucune règle de cycle de vie du bucket ne couvre database/ : les dumps qui s'y trouvent, et les comptes supprimés qu'ils contiennent, ne sont jamais effacés. Ajoutez une règle pour ce préfixe.",
"configured": "Le bucket efface les dumps au bout de {days} jours.",
"unknown": "Le bucket n'indique pas si une règle efface les anciens dumps. Vérifiez qu'une règle de cycle de vie couvre database/."
}
},
"recovered": {
@@ -8731,7 +8744,8 @@
"offhost_copies": "Chaque compte a de nouveau une copie hors hôte récente.",
"restore_drill": "Le test de restauration réussit de nouveau.",
"offhost_setup": "Les copies hors hôte sont maintenant configurées.",
"recovery_kit": "Le kit de récupération est de nouveau confirmé."
"recovery_kit": "Le kit de récupération est de nouveau confirmé.",
"dump_retention": "Une règle de cycle de vie du bucket couvre désormais les dumps de la base."
}
}
},
@@ -11863,7 +11877,8 @@
"raster-failed": "Les pages du PDF n’ont pas pu être rendues pour la lecture. Les formats de compression de fax numérisés en sont une cause connue.",
"provider-error": "Le fournisseur d’IA n’a pas pu lire ce document lors de la dernière tentative.",
"pdf-needs-anthropic": "Le fournisseur d’IA configuré ne peut pas lire les fichiers PDF. Un fournisseur Claude ou l’OCR local le peut.",
"empty-transcription": "Le fournisseur d’IA n’a renvoyé aucun texte pour ce document."
"empty-transcription": "Le fournisseur d’IA n’a renvoyé aucun texte pour ce document.",
"resource-limit": "Ce PDF est trop volumineux ou trop complexe pour être lu ici. Le fichier lui-même est intact et s'ouvre toujours."
}
},
"loadError": "Impossible de charger ce document.",
+21 -6
View File
@@ -8346,7 +8346,9 @@
"noDirectory": "Questo server non può scrivere in {dir} e non è configurato alcuno storage esterno, quindi non si può fare alcun dump. Monta il volume dei backup.",
"retentionLocal": "Gli account eliminati restano nei dump finché questi non scadono: su questo server restano i {nights} più recenti.",
"retentionOffhost": "La regola del ciclo di vita del bucket elimina le copie esterne dopo {days} giorni.",
"retentionOffhostMissing": "Nessuna regola del ciclo di vita del bucket copre database/, quindi i dump lì, e gli account eliminati che contengono, non vengono mai rimossi. Aggiungi una regola per quel prefisso."
"retentionOffhostMissing": "Nessuna regola del ciclo di vita del bucket copre database/, quindi i dump lì, e gli account eliminati che contengono, non vengono mai rimossi. Aggiungi una regola per quel prefisso.",
"notPersistentOffhost": "{dir} non è su uno spazio persistente (si trova dentro il container e non su un volume montato, oppure in memoria), quindi un dump scritto lì andrebbe perso al prossimo aggiornamento. I dump vanno solo fuori sede: qui non ne resta nessuno e prima di una migrazione non ne viene fatto nessuno. Monta il volume dei backup.",
"notPersistent": "{dir} non è su uno spazio persistente (si trova dentro il container e non su un volume montato, oppure in memoria) e non è configurato alcun bucket esterno, quindi nessun dump del database viene conservato da nessuna parte. Monta il volume dei backup."
}
},
"danger-zone": {
@@ -8660,7 +8662,8 @@
"offhost_copies": "Copie fuori host",
"restore_drill": "Prova di ripristino",
"offhost_setup": "Backup fuori host",
"recovery_kit": "Kit di recupero"
"recovery_kit": "Kit di recupero",
"dump_retention": "Dump nel bucket"
},
"at": {
"database_dump": "Dump più recente: {date}",
@@ -8676,7 +8679,8 @@
"offhost_copies": "Apri i backup",
"restore_drill": "Apri i backup",
"offhost_setup": "Apri i backup",
"recovery_kit": "Apri il kit"
"recovery_kit": "Apri il kit",
"dump_retention": "Apri i backup"
},
"reason": {
"database_dump": {
@@ -8689,7 +8693,9 @@
"stale": "Il dump del database più recente ha {hours} ore.",
"last_failed": "L'ultimo dump del database non è riuscito. Quello precedente è ancora recente.",
"fresh": "L'ultimo dump del database è recente.",
"nowhere_to_write": "La cartella dei dump {directory} non è scrivibile e non è configurato nessun bucket esterno, quindi non si può conservare alcun dump."
"nowhere_to_write": "La cartella dei dump {directory} non è scrivibile e non è configurato nessun bucket esterno, quindi non si può conservare alcun dump.",
"not_persistent": "La cartella dei dump {directory} non è su uno spazio persistente e non è configurato alcun bucket esterno, quindi ogni dump andrebbe perso al prossimo aggiornamento. Monta il volume dei backup.",
"fresh_offhost_only": "Il dump del database più recente è aggiornato e si trova nel bucket esterno. Su questo server non ne viene conservato nessuno, perché {directory} non è su uno spazio persistente, e prima di una migrazione non ne viene fatto nessuno."
},
"weekly_copy": {
"no_accounts": "Non ci sono ancora account da copiare.",
@@ -8723,6 +8729,13 @@
"no_admin": "Non esiste ancora un account amministratore.",
"not_confirmed": "Nessuno ha confermato una copia delle chiavi necessarie per un ripristino: {keys}.",
"not_confirmed_grace": "Salva una copia delle chiavi necessarie per un ripristino: {keys}."
},
"dump_retention": {
"not_configured": "Non è configurato alcun bucket esterno.",
"no_offhost_dumps": "Nessun dump del database va nel bucket.",
"missing": "Nessuna regola del ciclo di vita del bucket copre database/, quindi i dump lì, e gli account eliminati che contengono, non vengono mai rimossi. Aggiungi una regola per quel prefisso.",
"configured": "Il bucket rimuove i dump dopo {days} giorni.",
"unknown": "Il bucket non dice se una regola rimuove i dump vecchi. Verifica che una regola di ciclo di vita copra database/."
}
},
"recovered": {
@@ -8731,7 +8744,8 @@
"offhost_copies": "Ogni account ha di nuovo una copia fuori host recente.",
"restore_drill": "La prova di ripristino è di nuovo riuscita.",
"offhost_setup": "Le copie fuori host ora sono configurate.",
"recovery_kit": "Il kit di recupero è di nuovo confermato."
"recovery_kit": "Il kit di recupero è di nuovo confermato.",
"dump_retention": "Ora una regola di ciclo di vita del bucket copre i dump del database."
}
}
},
@@ -11863,7 +11877,8 @@
"raster-failed": "Le pagine del PDF non possono essere elaborate per la lettura. I formati di compressione fax digitalizzati sono una causa nota.",
"provider-error": "Il provider IA non è riuscito a leggere questo documento all’ultimo tentativo.",
"pdf-needs-anthropic": "Il provider IA configurato non può leggere i file PDF. Un provider Claude o l’OCR locale sì.",
"empty-transcription": "Il provider IA non ha restituito testo per questo documento."
"empty-transcription": "Il provider IA non ha restituito testo per questo documento.",
"resource-limit": "Questo PDF è troppo grande o complesso per essere letto qui. Il file in sé è a posto e si apre ancora."
}
},
"loadError": "Impossibile caricare questo documento.",
+21 -6
View File
@@ -8321,7 +8321,9 @@
"noDirectory": "이 서버는 {dir}에 쓸 수 없고 외부 저장소도 설정되지 않아 데이터베이스 덤프를 만들 수 없습니다. 백업 볼륨을 마운트하세요.",
"retentionLocal": "삭제된 계정은 덤프가 만료될 때까지 덤프에 남습니다. 이 서버에는 최신 덤프 {nights}개가 보관됩니다.",
"retentionOffhost": "버킷의 수명 주기 규칙이 외부 사본을 {days}일 후에 삭제합니다.",
"retentionOffhostMissing": "버킷의 어떤 수명 주기 규칙도 database/를 다루지 않아 그곳의 덤프와 그 안의 삭제된 계정이 절대 삭제되지 않습니다. 이 접두사에 대한 규칙을 추가하세요."
"retentionOffhostMissing": "버킷의 어떤 수명 주기 규칙도 database/를 다루지 않아 그곳의 덤프와 그 안의 삭제된 계정이 절대 삭제되지 않습니다. 이 접두사에 대한 규칙을 추가하세요.",
"notPersistentOffhost": "{dir}은(는) 영구 저장소에 있지 않습니다(마운트된 볼륨이 아니라 컨테이너 안이나 메모리에 있음). 그래서 여기에 쓴 덤프는 다음 업데이트 때 사라집니다. 덤프는 외부 저장소로만 가며, 여기에는 보관되지 않고 마이그레이션 전에도 만들어지지 않습니다. 백업 볼륨을 마운트하세요.",
"notPersistent": "{dir}은(는) 영구 저장소에 있지 않고(마운트된 볼륨이 아니라 컨테이너 안이나 메모리에 있음) 외부 버킷도 설정되지 않아, 데이터베이스 덤프가 어디에도 보관되지 않습니다. 백업 볼륨을 마운트하세요."
}
},
"danger-zone": {
@@ -8660,7 +8662,8 @@
"offhost_copies": "외부 보관 사본",
"restore_drill": "복원 훈련",
"offhost_setup": "외부 보관 백업",
"recovery_kit": "복구 키트"
"recovery_kit": "복구 키트",
"dump_retention": "버킷의 덤프"
},
"at": {
"database_dump": "최신 덤프: {date}",
@@ -8676,7 +8679,8 @@
"offhost_copies": "백업 열기",
"restore_drill": "백업 열기",
"offhost_setup": "백업 열기",
"recovery_kit": "키트 열기"
"recovery_kit": "키트 열기",
"dump_retention": "백업 열기"
},
"reason": {
"database_dump": {
@@ -8689,7 +8693,9 @@
"stale": "최신 데이터베이스 덤프가 {hours}시간 전 것입니다.",
"last_failed": "마지막 데이터베이스 덤프가 실패했습니다. 그 전 덤프는 아직 최근 것입니다.",
"fresh": "최신 데이터베이스 덤프가 최근 것입니다.",
"nowhere_to_write": "덤프 디렉터리 {directory}에 쓸 수 없고 외부 버킷도 설정되어 있지 않아 덤프를 보관할 수 없습니다."
"nowhere_to_write": "덤프 디렉터리 {directory}에 쓸 수 없고 외부 버킷도 설정되어 있지 않아 덤프를 보관할 수 없습니다.",
"not_persistent": "덤프 디렉터리 {directory}이(가) 영구 저장소에 있지 않고 외부 버킷도 설정되지 않아, 모든 덤프가 다음 업데이트 때 사라집니다. 백업 볼륨을 마운트하세요.",
"fresh_offhost_only": "최신 데이터베이스 덤프는 최근 것이며 외부 버킷에 있습니다. {directory}이(가) 영구 저장소에 있지 않아 이 서버에는 보관되지 않으며, 마이그레이션 전에도 만들어지지 않습니다."
},
"weekly_copy": {
"no_accounts": "아직 복사할 계정이 없습니다.",
@@ -8723,6 +8729,13 @@
"no_admin": "아직 관리자 계정이 없습니다.",
"not_confirmed": "복원에 필요한 키의 사본을 아무도 확인하지 않았습니다: {keys}.",
"not_confirmed_grace": "복원에 필요한 키의 사본을 보관하세요: {keys}."
},
"dump_retention": {
"not_configured": "외부 버킷이 설정되지 않았습니다.",
"no_offhost_dumps": "버킷으로 가는 데이터베이스 덤프가 없습니다.",
"missing": "버킷의 어떤 수명 주기 규칙도 database/를 다루지 않아 그곳의 덤프와 그 안의 삭제된 계정이 절대 삭제되지 않습니다. 이 접두사에 대한 규칙을 추가하세요.",
"configured": "버킷은 {days}일 후에 덤프를 삭제합니다.",
"unknown": "버킷이 오래된 덤프를 삭제하는 규칙이 있는지 알려 주지 않습니다. 수명 주기 규칙이 database/를 포함하는지 확인하세요."
}
},
"recovered": {
@@ -8731,7 +8744,8 @@
"offhost_copies": "모든 계정에 다시 최근 외부 보관 사본이 있습니다.",
"restore_drill": "복원 훈련을 다시 통과했습니다.",
"offhost_setup": "이제 외부 보관 사본이 설정되었습니다.",
"recovery_kit": "복구 키트가 다시 확인되었습니다."
"recovery_kit": "복구 키트가 다시 확인되었습니다.",
"dump_retention": "이제 버킷의 수명 주기 규칙이 데이터베이스 덤프를 포함합니다."
}
}
},
@@ -11863,7 +11877,8 @@
"raster-failed": "PDF 페이지를 읽기용으로 렌더링하지 못했어요. 스캔 팩스 압축 형식이 흔한 원인이에요.",
"provider-error": "지난번에 AI 제공자가 이 문서를 읽지 못했어요.",
"pdf-needs-anthropic": "설정된 AI 제공자는 PDF 파일을 읽을 수 없어요. Claude 제공자나 로컬 OCR로는 읽을 수 있어요.",
"empty-transcription": "AI 제공자가 이 문서에서 텍스트를 찾지 못했어요."
"empty-transcription": "AI 제공자가 이 문서에서 텍스트를 찾지 못했어요.",
"resource-limit": "이 PDF는 너무 크거나 복잡해서 여기서 읽을 수 없습니다. 파일 자체는 정상이며 계속 열 수 있습니다."
}
},
"loadError": "이 문서를 불러오지 못했어요.",
+21 -6
View File
@@ -8346,7 +8346,9 @@
"noDirectory": "Deze server kan niet schrijven naar {dir} en er is geen externe opslag ingesteld, dus er kan geen databasedump worden gemaakt. Koppel het back-upvolume.",
"retentionLocal": "Verwijderde accounts blijven in de dumps tot die verlopen: op deze server blijven de nieuwste {nights} bewaard.",
"retentionOffhost": "De levenscyclusregel van de bucket verwijdert externe kopieën na {days} dagen.",
"retentionOffhostMissing": "Geen levenscyclusregel in de bucket geldt voor database/, dus dumps daar, en de verwijderde accounts erin, worden nooit verwijderd. Voeg een regel voor dat voorvoegsel toe."
"retentionOffhostMissing": "Geen levenscyclusregel in de bucket geldt voor database/, dus dumps daar, en de verwijderde accounts erin, worden nooit verwijderd. Voeg een regel voor dat voorvoegsel toe.",
"notPersistentOffhost": "{dir} staat niet op blijvende opslag (in de container in plaats van op een gekoppeld volume, of in het geheugen), dus een dump die daar wordt geschreven gaat bij de volgende update verloren. Dumps gaan alleen extern: hier wordt er geen bewaard en vóór een migratie wordt er geen gemaakt. Koppel het back-upvolume.",
"notPersistent": "{dir} staat niet op blijvende opslag (in de container in plaats van op een gekoppeld volume, of in het geheugen) en er is geen externe bucket ingesteld, dus nergens wordt een databasedump bewaard. Koppel het back-upvolume."
}
},
"danger-zone": {
@@ -8660,7 +8662,8 @@
"offhost_copies": "Externe kopieën",
"restore_drill": "Hersteltest",
"offhost_setup": "Externe back-up",
"recovery_kit": "Herstelkit"
"recovery_kit": "Herstelkit",
"dump_retention": "Dumps in de bucket"
},
"at": {
"database_dump": "Nieuwste dump: {date}",
@@ -8676,7 +8679,8 @@
"offhost_copies": "Back-ups openen",
"restore_drill": "Back-ups openen",
"offhost_setup": "Back-ups openen",
"recovery_kit": "Kit openen"
"recovery_kit": "Kit openen",
"dump_retention": "Back-ups openen"
},
"reason": {
"database_dump": {
@@ -8689,7 +8693,9 @@
"stale": "De nieuwste databasedump is {hours} uur oud.",
"last_failed": "De laatste databasedump is mislukt. De dump daarvoor is nog recent.",
"fresh": "De nieuwste databasedump is recent.",
"nowhere_to_write": "De dumpmap {directory} is niet beschrijfbaar en er is geen externe bucket ingesteld, dus er kan geen dump worden bewaard."
"nowhere_to_write": "De dumpmap {directory} is niet beschrijfbaar en er is geen externe bucket ingesteld, dus er kan geen dump worden bewaard.",
"not_persistent": "De dumpmap {directory} staat niet op blijvende opslag en er is geen externe bucket ingesteld, dus elke dump gaat bij de volgende update verloren. Koppel het back-upvolume.",
"fresh_offhost_only": "De nieuwste databasedump is recent en staat in de externe bucket. Op deze server wordt er geen bewaard, omdat {directory} niet op blijvende opslag staat, en vóór een migratie wordt er geen gemaakt."
},
"weekly_copy": {
"no_accounts": "Er zijn nog geen accounts om te kopiëren.",
@@ -8723,6 +8729,13 @@
"no_admin": "Er is nog geen beheerdersaccount.",
"not_confirmed": "Niemand heeft een kopie bevestigd van de sleutels die een herstel nodig heeft: {keys}.",
"not_confirmed_grace": "Bewaar een kopie van de sleutels die een herstel nodig heeft: {keys}."
},
"dump_retention": {
"not_configured": "Er is geen externe bucket ingesteld.",
"no_offhost_dumps": "Er gaat geen databasedump naar de bucket.",
"missing": "Geen levenscyclusregel in de bucket geldt voor database/, dus dumps daar, en de verwijderde accounts erin, worden nooit verwijderd. Voeg een regel voor dat voorvoegsel toe.",
"configured": "De bucket verwijdert dumps na {days} dagen.",
"unknown": "De bucket zegt niet of een regel oude dumps verwijdert. Controleer of een levenscyclusregel database/ dekt."
}
},
"recovered": {
@@ -8731,7 +8744,8 @@
"offhost_copies": "Elk account heeft weer een recente externe kopie.",
"restore_drill": "De hersteltest is weer geslaagd.",
"offhost_setup": "Externe kopieën zijn nu ingesteld.",
"recovery_kit": "De herstelkit is weer bevestigd."
"recovery_kit": "De herstelkit is weer bevestigd.",
"dump_retention": "Een levenscyclusregel in de bucket dekt nu de databasedumps."
}
}
},
@@ -11863,7 +11877,8 @@
"raster-failed": "De PDF-pagina's konden niet worden weergegeven om te lezen. Compressieformaten van gescande faxen zijn een bekende oorzaak.",
"provider-error": "De AI-provider kon dit document bij de laatste poging niet lezen.",
"pdf-needs-anthropic": "De ingestelde AI-provider kan geen PDF-bestanden lezen. Een Claude-provider of lokale OCR kan dat wel.",
"empty-transcription": "De AI-provider gaf geen tekst terug voor dit document."
"empty-transcription": "De AI-provider gaf geen tekst terug voor dit document.",
"resource-limit": "Deze pdf is te groot of te complex om hier te lezen. Het bestand zelf is in orde en opent nog steeds."
}
},
"loadError": "Dit document kon niet worden geladen.",
+21 -6
View File
@@ -8346,7 +8346,9 @@
"noDirectory": "Ten serwer nie może zapisywać w {dir} i nie ustawiono magazynu zewnętrznego, więc nie da się wykonać zrzutu bazy. Podłącz wolumin kopii.",
"retentionLocal": "Usunięte konta pozostają w zrzutach, dopóki te nie wygasną: na tym serwerze zostaje {nights} najnowszych.",
"retentionOffhost": "Reguła cyklu życia bucketu usuwa kopie zewnętrzne po {days} dniach.",
"retentionOffhostMissing": "Żadna reguła cyklu życia bucketu nie obejmuje database/, więc zrzuty tam, wraz z usuniętymi kontami w nich, nigdy nie są usuwane. Dodaj regułę dla tego prefiksu."
"retentionOffhostMissing": "Żadna reguła cyklu życia bucketu nie obejmuje database/, więc zrzuty tam, wraz z usuniętymi kontami w nich, nigdy nie są usuwane. Dodaj regułę dla tego prefiksu.",
"notPersistentOffhost": "{dir} nie leży na trwałym nośniku (jest wewnątrz kontenera zamiast na podłączonym woluminie albo w pamięci), więc zapisany tam zrzut przepadnie przy następnej aktualizacji. Zrzuty trafiają tylko poza serwer: żaden nie zostaje tutaj i żaden nie powstaje przed migracją. Podłącz wolumin kopii.",
"notPersistent": "{dir} nie leży na trwałym nośniku (jest wewnątrz kontenera zamiast na podłączonym woluminie albo w pamięci), a zewnętrzny bucket nie jest ustawiony, więc żaden zrzut bazy nie jest nigdzie przechowywany. Podłącz wolumin kopii."
}
},
"danger-zone": {
@@ -8660,7 +8662,8 @@
"offhost_copies": "Kopie poza hostem",
"restore_drill": "Próba przywracania",
"offhost_setup": "Kopia poza hostem",
"recovery_kit": "Zestaw odzyskiwania"
"recovery_kit": "Zestaw odzyskiwania",
"dump_retention": "Zrzuty w buckecie"
},
"at": {
"database_dump": "Najnowszy zrzut: {date}",
@@ -8676,7 +8679,8 @@
"offhost_copies": "Otwórz kopie zapasowe",
"restore_drill": "Otwórz kopie zapasowe",
"offhost_setup": "Otwórz kopie zapasowe",
"recovery_kit": "Otwórz zestaw"
"recovery_kit": "Otwórz zestaw",
"dump_retention": "Otwórz kopie zapasowe"
},
"reason": {
"database_dump": {
@@ -8689,7 +8693,9 @@
"stale": "Najnowszy zrzut bazy danych ma {hours} h.",
"last_failed": "Ostatni zrzut bazy danych nie powiódł się. Poprzedni jest jeszcze aktualny.",
"fresh": "Najnowszy zrzut bazy danych jest aktualny.",
"nowhere_to_write": "Katalog zrzutów {directory} nie jest zapisywalny i nie skonfigurowano zewnętrznego bucketu, więc żaden zrzut nie może zostać zachowany."
"nowhere_to_write": "Katalog zrzutów {directory} nie jest zapisywalny i nie skonfigurowano zewnętrznego bucketu, więc żaden zrzut nie może zostać zachowany.",
"not_persistent": "Katalog zrzutów {directory} nie leży na trwałym nośniku, a zewnętrzny bucket nie jest ustawiony, więc każdy zrzut przepadnie przy następnej aktualizacji. Podłącz wolumin kopii.",
"fresh_offhost_only": "Najnowszy zrzut bazy jest aktualny i leży w zewnętrznym buckecie. Na tym serwerze żaden nie zostaje, bo {directory} nie leży na trwałym nośniku, i żaden nie powstaje przed migracją."
},
"weekly_copy": {
"no_accounts": "Nie ma jeszcze kont do skopiowania.",
@@ -8723,6 +8729,13 @@
"no_admin": "Nie ma jeszcze konta administratora.",
"not_confirmed": "Nikt nie potwierdził kopii kluczy potrzebnych do przywrócenia: {keys}.",
"not_confirmed_grace": "Zapisz kopię kluczy potrzebnych do przywrócenia: {keys}."
},
"dump_retention": {
"not_configured": "Zewnętrzny bucket nie jest ustawiony.",
"no_offhost_dumps": "Żaden zrzut bazy nie trafia do bucketu.",
"missing": "Żadna reguła cyklu życia bucketu nie obejmuje database/, więc zrzuty tam, wraz z usuniętymi kontami w nich, nigdy nie są usuwane. Dodaj regułę dla tego prefiksu.",
"configured": "Bucket usuwa zrzuty po {days} dniach.",
"unknown": "Bucket nie podaje, czy jakaś reguła usuwa stare zrzuty. Sprawdź, czy reguła cyklu życia obejmuje database/."
}
},
"recovered": {
@@ -8731,7 +8744,8 @@
"offhost_copies": "Każde konto znów ma aktualną kopię poza hostem.",
"restore_drill": "Próba przywracania znów się powiodła.",
"offhost_setup": "Kopie poza hostem są już skonfigurowane.",
"recovery_kit": "Zestaw odzyskiwania jest znów potwierdzony."
"recovery_kit": "Zestaw odzyskiwania jest znów potwierdzony.",
"dump_retention": "Reguła cyklu życia w buckecie obejmuje teraz zrzuty bazy."
}
}
},
@@ -11863,7 +11877,8 @@
"raster-failed": "Nie udało się przetworzyć stron PDF do odczytu. Znaną przyczyną są skany w faksowych formatach kompresji.",
"provider-error": "Dostawca AI nie zdołał odczytać tego dokumentu przy ostatniej próbie.",
"pdf-needs-anthropic": "Skonfigurowany dostawca AI nie odczytuje plików PDF. Poradzi sobie z tym dostawca Claude lub lokalny OCR.",
"empty-transcription": "Dostawca AI nie zwrócił tekstu dla tego dokumentu."
"empty-transcription": "Dostawca AI nie zwrócił tekstu dla tego dokumentu.",
"resource-limit": "Ten PDF jest zbyt duży lub zbyt złożony, żeby odczytać go tutaj. Sam plik jest w porządku i nadal się otwiera."
}
},
"loadError": "Nie udało się wczytać tego dokumentu.",
+21 -6
View File
@@ -8346,7 +8346,9 @@
"noDirectory": "Este servidor não consegue gravar em {dir} e não há armazenamento externo configurado, então nenhum dump pode ser feito. Monte o volume de backups.",
"retentionLocal": "Contas excluídas continuam nos dumps até que eles expirem: este servidor guarda os {nights} mais recentes.",
"retentionOffhost": "A regra de ciclo de vida do bucket remove as cópias externas após {days} dias.",
"retentionOffhostMissing": "Nenhuma regra de ciclo de vida do bucket cobre database/, então os dumps ali, e as contas excluídas dentro deles, nunca são removidos. Adicione uma regra para esse prefixo."
"retentionOffhostMissing": "Nenhuma regra de ciclo de vida do bucket cobre database/, então os dumps ali, e as contas excluídas dentro deles, nunca são removidos. Adicione uma regra para esse prefixo.",
"notPersistentOffhost": "{dir} não está em armazenamento persistente (fica dentro do contêiner e não num volume montado, ou na memória), então um dump gravado ali se perde na próxima atualização. Os dumps vão só para fora: nenhum fica aqui e nenhum é feito antes de uma migração. Monte o volume de backups.",
"notPersistent": "{dir} não está em armazenamento persistente (fica dentro do contêiner e não num volume montado, ou na memória) e nenhum bucket externo está configurado, então nenhum dump do banco é guardado em lugar nenhum. Monte o volume de backups."
}
},
"danger-zone": {
@@ -8660,7 +8662,8 @@
"offhost_copies": "Cópias fora do host",
"restore_drill": "Teste de restauração",
"offhost_setup": "Backup fora do host",
"recovery_kit": "Kit de recuperação"
"recovery_kit": "Kit de recuperação",
"dump_retention": "Dumps no bucket"
},
"at": {
"database_dump": "Dump mais recente: {date}",
@@ -8676,7 +8679,8 @@
"offhost_copies": "Abrir backups",
"restore_drill": "Abrir backups",
"offhost_setup": "Abrir backups",
"recovery_kit": "Abrir o kit"
"recovery_kit": "Abrir o kit",
"dump_retention": "Abrir backups"
},
"reason": {
"database_dump": {
@@ -8689,7 +8693,9 @@
"stale": "O dump do banco de dados mais recente tem {hours} horas.",
"last_failed": "O último dump do banco de dados falhou. O anterior ainda é recente.",
"fresh": "O dump do banco de dados mais recente está em dia.",
"nowhere_to_write": "Não é possível gravar no diretório de dumps {directory} e nenhum bucket externo está configurado, então nenhum dump pode ser guardado."
"nowhere_to_write": "Não é possível gravar no diretório de dumps {directory} e nenhum bucket externo está configurado, então nenhum dump pode ser guardado.",
"not_persistent": "A pasta de dumps {directory} não está em armazenamento persistente e nenhum bucket externo está configurado, então cada dump se perde na próxima atualização. Monte o volume de backups.",
"fresh_offhost_only": "O dump mais recente do banco é atual e está no bucket externo. Nenhum fica neste servidor, porque {directory} não está em armazenamento persistente, e nenhum é feito antes de uma migração."
},
"weekly_copy": {
"no_accounts": "Ainda não há contas para copiar.",
@@ -8723,6 +8729,13 @@
"no_admin": "Ainda não há uma conta de administrador.",
"not_confirmed": "Ninguém confirmou uma cópia das chaves de que uma restauração precisa: {keys}.",
"not_confirmed_grace": "Guarde uma cópia das chaves de que uma restauração precisa: {keys}."
},
"dump_retention": {
"not_configured": "Nenhum bucket externo está configurado.",
"no_offhost_dumps": "Nenhum dump do banco vai para o bucket.",
"missing": "Nenhuma regra de ciclo de vida do bucket cobre database/, então os dumps ali, e as contas excluídas dentro deles, nunca são removidos. Adicione uma regra para esse prefixo.",
"configured": "O bucket remove os dumps após {days} dias.",
"unknown": "O bucket não informa se alguma regra remove dumps antigos. Confira se uma regra de ciclo de vida cobre database/."
}
},
"recovered": {
@@ -8731,7 +8744,8 @@
"offhost_copies": "Todas as contas têm novamente uma cópia recente fora do host.",
"restore_drill": "O teste de restauração passou novamente.",
"offhost_setup": "As cópias fora do host estão configuradas agora.",
"recovery_kit": "O kit de recuperação está confirmado novamente."
"recovery_kit": "O kit de recuperação está confirmado novamente.",
"dump_retention": "Agora uma regra de ciclo de vida do bucket cobre os dumps do banco."
}
}
},
@@ -11863,7 +11877,8 @@
"raster-failed": "As páginas do PDF não puderam ser renderizadas para leitura. Formatos de compressão de fax digitalizado são uma causa conhecida.",
"provider-error": "O provedor de IA não conseguiu ler este documento na última tentativa.",
"pdf-needs-anthropic": "O provedor de IA configurado não consegue ler arquivos PDF. Um provedor Claude ou OCR local consegue.",
"empty-transcription": "O provedor de IA não retornou texto para este documento."
"empty-transcription": "O provedor de IA não retornou texto para este documento.",
"resource-limit": "Este PDF é grande ou complexo demais para ser lido aqui. O arquivo em si está bem e continua abrindo."
}
},
"loadError": "Não foi possível carregar este documento.",
+7 -5
View File
@@ -6022,7 +6022,9 @@ model DatabaseBackupRun {
/// Size of the dump in bytes.
dumpBytes BigInt? @map("dump_bytes")
/// `written` (kept in DB_BACKUP_DIR), `no_directory` (no writable
/// directory, the dump went off-host only), or `failed`.
/// directory, the dump went off-host only), `not_persistent` (writable but
/// in the container's layer or in memory, so nothing was kept there),
/// `skipped`, or `failed`.
localResult String @map("local_result")
/// `uploaded`, `not_configured` or `failed`.
offhostResult String @map("offhost_result")
@@ -9075,10 +9077,10 @@ model InboundDocument {
/// Why the most recent index attempt produced NO index — a closed string
/// enum (`DocumentIndexOutcomeValue` in
/// `src/lib/validations/inbound-documents.ts`): local-empty |
/// local-unsupported | decrypt-error | raster-failed | provider-error |
/// pdf-needs-anthropic | empty-transcription. Null after a successful
/// attempt (the `DocumentContentIndex` row itself is the success signal)
/// and before the first attempt. Lets the detail view tell "never tried"
/// local-unsupported | decrypt-error | resource-limit | raster-failed |
/// provider-error | pdf-needs-anthropic | empty-transcription. Null after a
/// successful attempt (the `DocumentContentIndex` row itself is the success
/// signal) and before the first attempt. Lets the detail view tell "never tried"
/// from "tried and here is why it failed" instead of showing a bare
/// not-indexed state for both.
lastIndexOutcome String? @map("last_index_outcome")
+34
View File
@@ -6,6 +6,10 @@
# 1. Build the app image from this checkout and start the bundled compose
# stack with fixed throwaway keys, and an S3-compatible bucket outside
# it (Versity S3 Gateway, pinned) as the off-host target.
# First without the `backups` volume: the run must keep nothing in the
# container's layer, say so in its ledger row and the start log, and
# still upload the dump. Then the stack is thrown away and started again
# as shipped.
# 2. Seed real data: the demo seed (documents, encrypted notes, years of
# readings) and a Guardian with a managed profile, written by the app's
# own code; then, through the API, a reading with an encrypted note.
@@ -184,6 +188,36 @@ docker run -d --name "$BUCKET" --network "$BUCKET" -p "127.0.0.1:$BUCKET_PORT:70
"mkdir -p /data/$BACKUP_S3_BUCKET && exec versitygw --access $BACKUP_S3_ACCESS_KEY --secret $BACKUP_S3_SECRET_KEY posix /data" \
>/dev/null || fail "the bucket did not start"
step "a server without the backups volume keeps no dump on itself"
# The image creates the dump directory, so without the volume it is writable,
# and it sits in the container's layer that the next update throws away. No
# dump may be kept, or counted as kept, there; the bucket still gets one.
cat >"$WORK/no-volume.yml" <<YAML
services:
app:
volumes: !reset []
YAML
stack -f "$WORK/no-volume.yml" up -d app >"$WORK/up.log" 2>&1 ||
{ tail -20 "$WORK/up.log" >&2; fail "the stack without the volume did not start"; }
wait_healthy
i=0
until RUN=$(sql "SELECT status || ' ' || local_result || ' ' || offhost_result FROM database_backup_runs WHERE status <> 'running' ORDER BY started_at DESC LIMIT 1") && [ -n "$RUN" ]; do
i=$((i + 1))
[ "$i" -lt 120 ] || fail "no database-backup run within four minutes without the volume"
sleep 2
done
[ "$RUN" = "succeeded not_persistent uploaded" ] ||
fail "without the volume the run read '$RUN', not 'succeeded not_persistent uploaded'"
LEFT=$(docker exec "$PROJECT-app" sh -c 'ls -A /var/lib/healthlog/backups')
[ -z "$LEFT" ] || fail "a dump was left in the container's layer: $LEFT"
# Into a file first: `grep -q` stops reading at the match, and with pipefail
# the SIGPIPE that leaves `docker logs` would read as a miss.
docker logs "$PROJECT-app" >"$WORK/no-volume.log" 2>&1
grep -q "is inside the container, not on a mounted volume" "$WORK/no-volume.log" ||
fail "the start log does not say the dump directory is inside the container"
echo " $RUN, nothing in the container's layer"
stack down -v --remove-orphans >/dev/null 2>&1
step "a running server with real data"
stack up -d app >"$WORK/up.log" 2>&1 || { tail -20 "$WORK/up.log" >&2; fail "the stack did not start"; }
wait_healthy
+19 -7
View File
@@ -13,9 +13,11 @@
*
* Exit 0: the dump was written, or there was nothing to do (no pending
* migration, a new database, `DB_BACKUP_BEFORE_MIGRATE=false`), or there is no
* writable directory to put it in, which is said as a warning and is not a
* reason to keep the app down. Exit 1: the directory is writable and the dump
* failed. The entrypoint then stops the boot and names the switch that skips
* directory to put it in that survives the container (none writable, or only
* the image's own directory without the volume, which the update that brings
* these migrations is about to throw away), which is said as a warning and is
* not a reason to keep the app down. Exit 1: the directory is kept and the
* dump failed. The entrypoint then stops the boot and names the switch that skips
* the dump, because migrating without the way back the operator was promised
* is the one thing this step exists to prevent.
*
@@ -28,9 +30,9 @@ import { pathToFileURL } from "node:url";
import {
applyRetention,
backupDirectoryStatus,
connect,
createDatabaseDump,
isWritableDirectory,
latestMigration,
listUserTables,
connectionFromUrl,
@@ -60,7 +62,11 @@ function mb(bytes) {
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
export async function preMigrationDump(env = process.env) {
/**
* @param {Record<string, string | undefined>} [env]
* @param {{ directoryStatus?: typeof backupDirectoryStatus }} [options]
*/
export async function preMigrationDump(env = process.env, options = {}) {
if (!preMigrationDumpEnabled(env)) {
log("DB_BACKUP_BEFORE_MIGRATE=false, no dump before migrating.");
return 0;
@@ -101,9 +107,15 @@ export async function preMigrationDump(env = process.env) {
}
const dir = resolveBackupDir(env);
if (!isWritableDirectory(dir)) {
const status = (options.directoryStatus ?? backupDirectoryStatus)(dir);
if (!status.persistent) {
const why = !status.writable
? "is not writable"
: status.reason === "memory"
? "is in memory (tmpfs) and would not keep a dump past a restart"
: "is inside the container, not on a mounted volume, and would not keep a dump past this update";
warn(
`${dir} is not writable, so no dump is taken before the ${pending.length} pending migration(s). ` +
`${dir} ${why}, so no dump is taken before the ${pending.length} pending migration(s). ` +
"Mount the backups volume (docker-compose.yml) to keep a way back; continuing.",
);
return 0;
+13 -3
View File
@@ -260,11 +260,16 @@
"variables": [
{
"name": "DEPLOY_WEBHOOK_SECRET",
"purpose": "Shared secret for the X-Deploy-Webhook-Secret header on POST /api/internal/deploy-webhook. 64 hex chars."
"purpose": "Shared secret of POST /api/internal/deploy-webhook. 64 hex chars. A request authenticates by sending it in the X-Deploy-Webhook-Secret header (Coolify's stock sender; no replay protection), or by signing: X-Deploy-Webhook-Signature: sha256=<HMAC-SHA256 under this secret of the X-Deploy-Webhook-Timestamp value, a dot and the raw body>, the timestamp within 5 minutes."
},
{
"name": "DEPLOY_LOGS_URL",
"purpose": "URL of the deployment dashboard (e.g. the Coolify UI) linked in deploy-failure notifications. Optional; when unset the notification omits the link."
},
{
"name": "DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP",
"purpose": "\"true\" (or \"1\") accepts only signed deploy-webhook requests (X-Deploy-Webhook-Signature with a fresh X-Deploy-Webhook-Timestamp); the secret header alone is refused with 401. Leave unset for Coolify's stock sender, which can only send the secret header.",
"example": "true"
}
]
},
@@ -372,7 +377,7 @@
},
{
"name": "DB_BACKUP_DIR",
"purpose": "Directory the dumps are written to and restored from. Default /var/lib/healthlog/backups, the `backups` volume. Without a writable directory the nightly dump only goes off-host and no dump is taken before migrations.",
"purpose": "Directory the dumps are written to and restored from. Default /var/lib/healthlog/backups, the `backups` volume. In a container it must be a mount of its own: a directory in the container's layer is lost at the next update and counts as none. Without one the nightly dump only goes off-host and no dump is taken before migrations.",
"example": "/var/lib/healthlog/backups"
},
{
@@ -424,7 +429,12 @@
},
{
"name": "TRUST_PROXY_HOPS",
"purpose": "Trusted reverse-proxy hop count; the client IP is read that many entries from the right of X-Forwarded-For.",
"purpose": "Trusted reverse-proxy hop count; the client IP is read that many entries from the right of X-Forwarded-For. Forwarding headers are read only from a peer whose own address is not public (loopback, private, CGNAT, unique-local, link-local, reserved, multicast).",
"example": "1"
},
{
"name": "TRUST_CF_CONNECTING_IP",
"purpose": "\"1\" takes the client IP from Cloudflare's CF-Connecting-IP header, from every connection and before the peer check. Safe only when the origin accepts connections from Cloudflare alone (a Tunnel, or a firewall admitting only Cloudflare's ranges); otherwise any caller forges the address rate limits and the audit log record. Off by default.",
"example": "1"
},
{
@@ -1,7 +1,7 @@
{
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 84,
"total": 94948,
"total": 94928,
"entries": {
"app/medications/page-client.tsx": 839,
"components/charts/mood-chart.tsx": 1166,
@@ -66,7 +66,7 @@
"lib/jobs/coach-nudge.ts": 1084,
"lib/jobs/insight-pregenerate.ts": 1189,
"lib/jobs/medication-intake-import.ts": 819,
"lib/jobs/offhost-backup.ts": 1114,
"lib/jobs/offhost-backup.ts": 1094,
"lib/jobs/reaction-line.ts": 803,
"lib/managed-profiles/handover.ts": 986,
"lib/mcp/record-search.ts": 1467,
+2 -2
View File
@@ -1,7 +1,7 @@
{
"$comment": "as never casts in tests, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.",
"count": 524,
"total": 5489,
"total": 5488,
"entries": {
"__tests__/api/auth/login/mfa-branch.test.ts": 10,
"__tests__/api/auth/mfa/verify.test.ts": 9,
@@ -361,7 +361,7 @@
"lib/documents/__tests__/assist-kinds.test.ts": 1,
"lib/documents/__tests__/commit.test.ts": 9,
"lib/documents/__tests__/fenced-chat-pipeline.test.ts": 6,
"lib/documents/__tests__/index-document.test.ts": 23,
"lib/documents/__tests__/index-document.test.ts": 22,
"lib/documents/__tests__/index-outcome-record.test.ts": 14,
"lib/documents/__tests__/prepare-vision-raster-failure.test.ts": 6,
"lib/documents/__tests__/provider-order.test.ts": 1,
@@ -63,6 +63,8 @@ const databaseHealth = {
directory: "/var/lib/healthlog/backups",
keepNightly: 7,
localDirWritable: true,
localPersistent: true,
localReason: "volume",
offhostConfigured: true,
lastGood: {
at: "2026-09-21T23:31:40.000Z",
@@ -4,7 +4,8 @@
* Every backup signal with its verdict, its time and where to fix it: the
* database dump, the weekly copy, the off-host copies, the last restore drill
* (no time window: a failed drill stays failed until a later one passes), the
* off-host setup and the recovery kit. The same judgement the hourly
* off-host setup, whether the bucket ever removes the database dumps, and the
* recovery kit. The same judgement the hourly
* evaluation pushes to the admins (`src/lib/recovery/readiness-model.ts`).
* Cookie-only admin.
*/
@@ -17,6 +17,8 @@ const HEALTHY: DatabaseBackupHealth = {
directory: "/var/lib/healthlog/backups",
keepNightly: 7,
localDirWritable: true,
localPersistent: true,
localReason: "volume",
offhostConfigured: true,
lastGood: {
at: "2026-10-24T23:31:00.000Z",
@@ -77,17 +79,36 @@ describe("<DatabaseDumpRow>", () => {
});
it("says when dumps go off-host only, or cannot be taken at all", () => {
expect(render({ ...HEALTHY, localDirWritable: false }, null)).toContain(
"dumps go off-host only",
const unwritable = {
...HEALTHY,
localDirWritable: false,
localPersistent: false,
localReason: "not_writable" as const,
};
expect(render(unwritable, null)).toContain("dumps go off-host only");
expect(render({ ...unwritable, offhostConfigured: false }, null)).toContain(
"no database dump can be taken",
);
expect(
render(
{ ...HEALTHY, localDirWritable: false, offhostConfigured: false },
null,
),
).toContain("no database dump can be taken");
expect(render({ ...HEALTHY, lastGood: null }, null)).toContain(
"No database dump yet",
);
});
it("does not promise dumps on this server from a directory inside the container", () => {
const layer = {
...HEALTHY,
localPersistent: false,
localReason: "container_layer" as const,
lastGood: { ...HEALTHY.lastGood!, local: false },
};
const html = render(layer);
expect(html).toContain('data-slot="database-dump-setup"');
expect(html).toContain("lost at the next update");
expect(html).toContain("Dumps go off-host only");
expect(html).not.toContain("kept on this server");
expect(html).toContain("removed after 30 days");
const nowhere = render({ ...layer, offhostConfigured: false }, null);
expect(nowhere).toContain("no database dump is kept anywhere");
expect(nowhere).not.toContain('data-slot="database-dump-retention"');
});
});
@@ -6,7 +6,10 @@
* The account copies below it restore one account on this server; the dump is
* the copy that rebuilds the server. So the row leads the card and says three
* things: when the newest good dump was taken and where it is, whether the
* newest run failed, and whether this server can keep a dump at all.
* newest run failed, and whether this server can keep a dump at all. A
* directory inside the container (the image's own, without the volume) is
* writable and still keeps nothing past the next update, so "kept on this
* server" is only said of a directory that survives it.
*/
import { AlertTriangle, HardDrive } from "lucide-react";
@@ -45,7 +48,15 @@ export function DatabaseDumpRow({
let setupProblem: string | null = null;
if (!health.enabled) {
setupProblem = t("admin.section.backups.database.disabled");
} else if (!health.localDirWritable) {
} else if (health.localDirWritable && !health.localPersistent) {
setupProblem = health.offhostConfigured
? t("admin.section.backups.database.notPersistentOffhost", {
dir: health.directory,
})
: t("admin.section.backups.database.notPersistent", {
dir: health.directory,
});
} else if (!health.localPersistent) {
setupProblem = health.offhostConfigured
? t("admin.section.backups.database.noDirectoryOffhost", {
dir: health.directory,
@@ -55,6 +66,19 @@ export function DatabaseDumpRow({
});
}
const retention = [
health.localPersistent
? t("admin.section.backups.database.retentionLocal", {
nights: health.keepNightly,
})
: null,
bucketLifecycle?.state === "configured"
? t("admin.section.backups.database.retentionOffhost", {
days: bucketLifecycle.expirationDays ?? 0,
})
: null,
].filter((line): line is string => line !== null);
return (
<div className="space-y-2" data-slot="database-dump">
<div className="flex items-start gap-2">
@@ -80,19 +104,14 @@ export function DatabaseDumpRow({
</p>
{/* How long a deleted account stays in the dumps: the operator's
side of the deletion promise (GDPR retention of backups). */}
<p
className="text-muted-foreground text-xs"
data-slot="database-dump-retention"
>
{t("admin.section.backups.database.retentionLocal", {
nights: health.keepNightly,
})}
{bucketLifecycle?.state === "configured"
? ` ${t("admin.section.backups.database.retentionOffhost", {
days: bucketLifecycle.expirationDays ?? 0,
})}`
: null}
</p>
{retention.length > 0 ? (
<p
className="text-muted-foreground text-xs"
data-slot="database-dump-retention"
>
{retention.join(" ")}
</p>
) : null}
</div>
</div>
@@ -3,7 +3,8 @@
/**
* `<RecoveryReadinessCard>` — every backup signal on one card: the database
* dump, the weekly account copies, the off-host copies, the last restore
* drill, the off-host setup and the recovery kit. Each row says its verdict,
* drill, the off-host setup, the bucket's rule for the dumps and the recovery
* kit. Each row says its verdict,
* the time it is about and where to fix it.
*
* No time window anywhere: a failed drill stays on this card until a later
+42
View File
@@ -407,6 +407,48 @@ describe("getClientIp against the connection's own peer", () => {
).toBe("203.0.113.6");
});
// The ranges docs/self-hosting/reverse-proxy.md lists, one address each.
// Reserved and multicast stay in: no host on the internet can complete a
// TCP handshake from them, so a peer there is on a network the operator
// runs, like a private one.
it("reads forwarding headers from every range the reverse-proxy page lists", () => {
for (const proxy of [
"127.0.0.2", // loopback
"0.0.0.1", // 0.0.0.0/8
"10.1.2.3", // private
"172.31.0.9",
"192.168.1.20",
"100.64.0.1", // CGNAT (Tailscale)
"169.254.10.10", // link-local
"192.0.0.8", // IETF protocol assignments
"198.18.0.4", // benchmarking
"240.0.0.1", // reserved
"fd7a:115c:a1e0::1", // unique-local (Tailscale)
"fe80::1", // link-local
"fec0::1", // site-local
"::ffff:10.0.0.3", // IPv4-mapped private
]) {
expect(
getClientIp(stamped(proxy, { "x-forwarded-for": "203.0.113.7" })),
proxy,
).toBe("203.0.113.7");
}
for (const outside of ["8.8.8.8", "2001:4860:4860::8888"]) {
expect(
getClientIp(stamped(outside, { "x-forwarded-for": "203.0.113.7" })),
).toBe(outside);
}
});
it("believes cf-connecting-ip from any peer once the flag is on, which is why the origin must take Cloudflare only", () => {
process.env.TRUST_CF_CONNECTING_IP = "1";
expect(
getClientIp(
stamped("198.51.100.7", { "cf-connecting-ip": "203.0.113.9" }),
),
).toBe("203.0.113.9");
});
it("reads an IPv4 peer that the socket reports in IPv6 form", () => {
expect(
getClientIp(
@@ -158,43 +158,67 @@ describe("collectReadiness — the database dump", () => {
BACKUP_S3_SECRET_KEY: "s",
BACKUP_ENCRYPTION_KEY: HEX32,
};
const STATUS = {
volume: { writable: true, persistent: true, reason: "volume" },
layer: { writable: true, persistent: false, reason: "container_layer" },
memory: { writable: true, persistent: false, reason: "memory" },
none: { writable: false, persistent: false, reason: "not_writable" },
} as const;
const dumpLine = (
env: Record<string, string | undefined>,
writable: boolean,
status: keyof typeof STATUS,
) =>
collectReadiness(env, () => writable).lines.find(
collectReadiness(env, () => STATUS[status]).lines.find(
(l) => l.label === "Database dump",
);
it("names the schedule, the directory and the copies kept", () => {
const line = dumpLine(baseEnv(), true);
const line = dumpLine(baseEnv(), "volume");
expect(line?.status).toBe("ok");
expect(line?.detail).toBe(
"nightly (30 1 * * *) into /var/lib/healthlog/backups, keeping 7",
);
expect(dumpLine({ ...baseEnv(), ...offhost }, true)?.detail).toContain(
expect(dumpLine({ ...baseEnv(), ...offhost }, "volume")?.detail).toContain(
"copied off-host",
);
});
it("warns when nothing can be kept on the server", () => {
expect(dumpLine({ ...baseEnv(), ...offhost }, false)).toMatchObject({
expect(dumpLine({ ...baseEnv(), ...offhost }, "none")).toMatchObject({
status: "warn",
detail: expect.stringContaining("goes off-host only"),
});
expect(dumpLine(baseEnv(), false)).toMatchObject({
expect(dumpLine(baseEnv(), "none")).toMatchObject({
status: "warn",
detail: expect.stringContaining("no database dump can be taken"),
detail: expect.stringContaining("no database dump is kept anywhere"),
});
});
it("does not call a directory inside the container kept: it is writable and lost at the next update", () => {
const offhostOnly = dumpLine({ ...baseEnv(), ...offhost }, "layer");
expect(offhostOnly?.status).toBe("warn");
expect(offhostOnly?.detail).toContain(
"/var/lib/healthlog/backups is inside the container, not on a mounted volume",
);
expect(offhostOnly?.detail).toContain("none is kept on this server");
expect(offhostOnly?.detail).toContain("none is taken before a migration");
expect(offhostOnly?.detail).not.toContain("keeping 7");
expect(dumpLine(baseEnv(), "layer")).toMatchObject({
status: "warn",
detail: expect.stringContaining("no database dump is kept anywhere"),
});
expect(dumpLine(baseEnv(), "memory")?.detail).toContain(
"lost at the next restart",
);
});
it("warns when it is switched off, and names a schedule it ignored", () => {
expect(
dumpLine({ ...baseEnv(), DB_BACKUP_ENABLED: "false" }, true),
dumpLine({ ...baseEnv(), DB_BACKUP_ENABLED: "false" }, "volume"),
).toMatchObject({ status: "warn" });
const report = collectReadiness(
{ ...baseEnv(), DB_BACKUP_SCHEDULE: "nightly" },
() => true,
() => STATUS.volume,
);
expect(
report.lines.find((l) => l.label === "DB_BACKUP_SCHEDULE")?.detail,
+28 -18
View File
@@ -20,11 +20,12 @@ import {
legacyAnyHostConfigured,
} from "@/lib/ai/local-host-allowlist";
import {
backupDirectoryStatus,
databaseBackupEnabled,
isWritableDirectory,
resolveBackupDir,
resolveBackupSchedule,
resolveDailyKeep,
type BackupDirectoryStatus,
} from "@/lib/database-backup/dump-core.mjs";
export interface ReadinessLine {
@@ -74,7 +75,7 @@ function anySet(env: Env, keys: string[]): boolean {
*/
export function databaseBackupReadiness(
env: Env,
writable: (dir: string) => boolean,
directoryStatus: (dir: string) => BackupDirectoryStatus,
): ReadinessLine[] {
const label = "Database dump";
if (!databaseBackupEnabled(env)) {
@@ -104,35 +105,44 @@ export function databaseBackupReadiness(
"BACKUP_S3_SECRET_KEY",
"BACKUP_ENCRYPTION_KEY",
]);
if (writable(dir)) {
const status = directoryStatus(dir);
if (status.persistent) {
lines.push({
label,
status: "ok",
detail: `nightly (${schedule.cron}) into ${dir}, keeping ${resolveDailyKeep(env)}${offhost ? ", copied off-host" : ""}`,
});
} else if (offhost) {
lines.push({
label,
status: "warn",
detail: `${dir} is not writable: the nightly dump goes off-host only and none is kept on this server. Mount the backups volume (see docker-compose.yml) or set DB_BACKUP_DIR`,
});
} else {
lines.push({
label,
status: "warn",
detail: `${dir} is not writable and no off-host bucket is configured: no database dump can be taken. Mount the backups volume (see docker-compose.yml) or set DB_BACKUP_DIR`,
});
return lines;
}
// Writable but thrown away with the container (the image's own directory
// without the volume) or at a restart (a tmpfs): no dump is kept there,
// because one that is would be counted as kept and then lost.
const where = !status.writable
? `${dir} is not writable`
: status.reason === "memory"
? `${dir} is in memory (tmpfs), so a dump there is lost at the next restart`
: `${dir} is inside the container, not on a mounted volume, so a dump there is lost at the next update`;
const fix =
"Mount the backups volume (see docker-compose.yml) or set DB_BACKUP_DIR to a mounted directory";
lines.push({
label,
status: "warn",
detail: offhost
? `${where}: the nightly dump goes off-host only, none is kept on this server and none is taken before a migration. ${fix}`
: `${where} and no off-host bucket is configured: no database dump is kept anywhere. ${fix}`,
});
return lines;
}
/**
* Inspect the environment and build the readiness report. Pure apart from
* `writable`, the probe of the dump directory, which tests replace.
* `directoryStatus`, the probe of the dump directory, which tests replace.
*/
export function collectReadiness(
env: Env = process.env,
writable: (dir: string) => boolean = isWritableDirectory,
directoryStatus: (
dir: string,
) => BackupDirectoryStatus = backupDirectoryStatus,
): ReadinessReport {
const lines: ReadinessLine[] = [];
@@ -211,7 +221,7 @@ export function collectReadiness(
});
}
lines.push(...databaseBackupReadiness(env, writable));
lines.push(...databaseBackupReadiness(env, directoryStatus));
// --- Optional subsystems (informational) --------------------------------
const optional: Array<[string, boolean]> = [
@@ -25,6 +25,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import {
applyRetention,
backupDirectoryStatus,
bucketConfigFromEnv,
pickBucketDump,
buildManifest,
@@ -37,13 +38,17 @@ import {
formatDumpTimestamp,
keyFingerprints,
listDumpSets,
mountContaining,
offhostObjectKey,
parseMountInfo,
parseDumpFileName,
parseManifest,
resolveBackupSchedule,
resolveDailyKeep,
selectForDeletion,
type DirectoryProbe,
type DumpSet,
type Env,
} from "../dump-core.mjs";
import type { S3Like } from "@/lib/jobs/offhost-backup";
@@ -687,3 +692,168 @@ describe("finding a dump in the bucket", () => {
).toBeNull();
});
});
describe("whether the dump directory survives the container", () => {
// /proc/self/mountinfo of the app container under the bundled compose
// file, trimmed to the lines that matter, with and without the volume.
const ROOT =
"1470 1351 0:330 / / rw,relatime master:396 - overlay overlay rw,lowerdir=/var/lib/docker/overlay2/l/A:/var/lib/docker/overlay2/l/B,upperdir=/var/lib/docker/overlay2/x/diff,workdir=/var/lib/docker/overlay2/x/work";
const COMMON = [
"1471 1470 0:333 / /proc rw,nosuid,nodev,noexec,relatime - proc proc rw",
"1472 1470 0:334 / /dev rw,nosuid - tmpfs tmpfs rw,size=65536k,mode=755",
"1481 1470 8:1 /var/lib/docker/containers/c/resolv.conf /etc/resolv.conf rw,relatime - ext4 /dev/sda1 rw",
"1483 1470 8:1 /var/lib/docker/containers/c/hosts /etc/hosts rw,relatime - ext4 /dev/sda1 rw",
];
const VOLUME =
"1480 1470 8:1 /var/lib/docker/volumes/healthlog_backups/_data /var/lib/healthlog/backups rw,relatime master:1 - ext4 /dev/sda1 rw,discard";
const DIR = "/var/lib/healthlog/backups";
function probe(
over: Partial<DirectoryProbe> & { files?: Record<string, string> } = {},
): DirectoryProbe {
const { files = {}, ...rest } = over;
return {
writable: () => true,
realpath: (p) => p,
readText: (p) => files[p] ?? null,
exists: (p) => p in files,
device: () => null,
env: {},
...rest,
};
}
const inDocker = (...lines: string[]) =>
probe({
files: {
"/.dockerenv": "",
"/proc/self/mountinfo": [ROOT, ...COMMON, ...lines].join("\n"),
},
});
it("reads mount points and types, escapes undone, odd lines skipped", () => {
const mounts = parseMountInfo(
[
ROOT,
"90 1470 8:1 / /mnt/my\\040disk rw - ext4 /dev/sdb1 rw",
"not a mount line",
"",
].join("\n"),
);
expect(mounts).toEqual([
{ mountPoint: "/", fsType: "overlay" },
{ mountPoint: "/mnt/my disk", fsType: "ext4" },
]);
});
it("finds the longest mount that holds a path, by whole segments", () => {
const mounts = [
{ mountPoint: "/", fsType: "overlay" },
{ mountPoint: "/var/lib/healthlog/back", fsType: "ext4" },
{ mountPoint: "/var/lib/healthlog", fsType: "xfs" },
];
expect(mountContaining(DIR, mounts)?.mountPoint).toBe("/var/lib/healthlog");
expect(mountContaining("/var/lib/other", mounts)?.mountPoint).toBe("/");
expect(mountContaining("/var/lib/healthlog", mounts)?.fsType).toBe("xfs");
});
it("counts the backups volume as kept", () => {
expect(backupDirectoryStatus(DIR, inDocker(VOLUME))).toEqual({
writable: true,
persistent: true,
reason: "volume",
});
});
it("does not count the image's own directory without the volume, though it is writable", () => {
expect(backupDirectoryStatus(DIR, inDocker())).toEqual({
writable: true,
persistent: false,
reason: "container_layer",
});
});
it("counts a volume on a parent directory, and follows a symlink to where the files land", () => {
const parent =
"1480 1470 8:1 /srv/healthlog /var/lib/healthlog rw - ext4 /dev/sda1 rw";
expect(backupDirectoryStatus(DIR, inDocker(parent)).persistent).toBe(true);
const linked = {
...inDocker(VOLUME),
realpath: (p: string) => (p === "/backups" ? DIR : p),
};
expect(backupDirectoryStatus("/backups", linked).reason).toBe("volume");
expect(backupDirectoryStatus("/elsewhere", inDocker(VOLUME)).reason).toBe(
"container_layer",
);
});
it("does not count a tmpfs as kept", () => {
const tmpfs =
"1480 1470 0:400 / /var/lib/healthlog/backups rw - tmpfs tmpfs rw,size=1g";
expect(backupDirectoryStatus(DIR, inDocker(tmpfs))).toEqual({
writable: true,
persistent: false,
reason: "memory",
});
});
it("recognises a container by Podman's marker, Kubernetes, the cgroup or an overlay root", () => {
const table = [ROOT, ...COMMON].join("\n");
const cases: Array<{ files?: Record<string, string>; env?: Env }> = [
{ files: { "/run/.containerenv": "" } },
{ env: { KUBERNETES_SERVICE_HOST: "10.0.0.1" } },
{ files: { "/proc/self/cgroup": "12:pids:/docker/0123abcd\n" } },
{},
];
for (const extra of cases) {
const p = probe({
env: extra.env ?? {},
files: { ...extra.files, "/proc/self/mountinfo": table },
});
expect(backupDirectoryStatus(DIR, p).reason).toBe("container_layer");
}
});
it("treats a writable directory outside a container as kept: a host, a VM", () => {
const host = probe({
files: {
"/proc/self/cgroup": "0::/user.slice/user-1000.slice\n",
"/proc/self/mountinfo":
"22 1 259:2 / / rw,relatime shared:1 - ext4 /dev/nvme0n1p2 rw",
},
});
expect(backupDirectoryStatus(DIR, host)).toEqual({
writable: true,
persistent: true,
reason: "host",
});
// No /proc at all (macOS, a BSD): a host too.
expect(backupDirectoryStatus(DIR, probe()).reason).toBe("host");
});
it("falls back to the device numbers in a container without a mount table", () => {
const same = probe({ files: { "/.dockerenv": "" }, device: () => 42 });
expect(backupDirectoryStatus(DIR, same).reason).toBe("container_layer");
const apart = probe({
files: { "/.dockerenv": "" },
device: (p) => (p === "/" ? 42 : 7),
});
expect(backupDirectoryStatus(DIR, apart).reason).toBe("volume");
});
it("says when nothing can be written at all, before anything else", () => {
expect(
backupDirectoryStatus(DIR, {
...inDocker(VOLUME),
writable: () => false,
}),
).toEqual({ writable: false, persistent: false, reason: "not_writable" });
});
it("answers for a real directory on this machine without throwing", () => {
const status = backupDirectoryStatus(tempDir());
expect(status.writable).toBe(true);
expect(["host", "volume", "container_layer", "memory"]).toContain(
status.reason,
);
});
});
+190
View File
@@ -33,10 +33,13 @@ import {
constants as fsConstants,
createReadStream,
createWriteStream,
existsSync,
mkdirSync,
openSync,
readFileSync,
readSync,
readdirSync,
realpathSync,
statSync,
} from "node:fs";
import { readFile, rename, rm, stat, writeFile } from "node:fs/promises";
@@ -222,6 +225,193 @@ export function isWritableDirectory(dir) {
}
}
// ── Whether a dump kept there survives ─────────────────────────────────────
/**
* File systems that live in memory: a dump on one is gone at the next restart.
*/
const EPHEMERAL_FS_TYPES = new Set(["tmpfs", "ramfs"]);
/**
* cgroup paths that name a container runtime (cgroup v1; under v2 a container
* sees `0::/`, and the marker files or the root mount answer instead).
*/
const CONTAINER_CGROUP = /\/(docker|kubepods|containerd|libpod|podman)[/-]/;
/**
* @typedef {{ mountPoint: string; fsType: string }} MountEntry
*/
/** Undo the octal escapes the kernel writes for space, tab, newline, `\`. */
function unescapeMountPath(raw) {
return raw.replace(/\\([0-7]{3})/g, (_, oct) =>
String.fromCharCode(Number.parseInt(oct, 8)),
);
}
/**
* The mount points and file system types in `/proc/self/mountinfo` text.
* Lines that do not have the documented shape are skipped.
*
* @param {string} text
* @returns {MountEntry[]}
*/
export function parseMountInfo(text) {
/** @type {MountEntry[]} */
const mounts = [];
for (const line of text.split("\n")) {
const sep = line.indexOf(" - ");
if (sep < 0) continue;
const left = line.slice(0, sep).split(" ");
const right = line.slice(sep + 3).split(" ");
if (left.length < 6 || !right[0]) continue;
mounts.push({ mountPoint: unescapeMountPath(left[4]), fsType: right[0] });
}
return mounts;
}
/**
* The mount `path` lives on: the longest mount point that is `path` or one of
* its parents. A later entry for the same point wins, as it covers the earlier
* one. Null when nothing matches (no `/` in the list).
*
* @param {string} path an absolute path, symlinks resolved
* @param {MountEntry[]} mounts
* @returns {MountEntry | null}
*/
export function mountContaining(path, mounts) {
/** @type {MountEntry | null} */
let best = null;
for (const mount of mounts) {
const point = mount.mountPoint;
const covers =
point === "/" ||
path === point ||
path.startsWith(point.endsWith("/") ? point : `${point}/`);
if (covers && (best === null || point.length >= best.mountPoint.length)) {
best = mount;
}
}
return best;
}
/**
* @typedef {{
* writable: (dir: string) => boolean;
* realpath: (path: string) => string;
* readText: (path: string) => string | null;
* exists: (path: string) => boolean;
* device: (path: string) => number | null;
* env: Env;
* }} DirectoryProbe
*/
/** @type {DirectoryProbe} */
const SYSTEM_PROBE = {
writable: isWritableDirectory,
realpath: (path) => {
try {
return realpathSync(path);
} catch {
return path;
}
},
readText: (path) => {
try {
return readFileSync(path, "utf8");
} catch {
return null;
}
},
exists: (path) => existsSync(path),
device: (path) => {
try {
return statSync(path).dev;
} catch {
return null;
}
},
env: process.env,
};
/**
* Whether this process runs in a container whose root file system is thrown
* away when the container is replaced: Docker (`/.dockerenv`), Podman
* (`/run/.containerenv`), Kubernetes, a container cgroup, or a root file
* system that is an overlay. A host, a VM or a system container with a disk
* of its own answers false.
*
* @param {DirectoryProbe} probe
* @param {MountEntry[] | null} mounts
*/
export function runsInContainer(probe, mounts) {
if (probe.exists("/.dockerenv") || probe.exists("/run/.containerenv")) {
return true;
}
if ((probe.env.KUBERNETES_SERVICE_HOST ?? "").trim()) return true;
if (CONTAINER_CGROUP.test(probe.readText("/proc/self/cgroup") ?? "")) {
return true;
}
const root = mounts ? mountContaining("/", mounts) : null;
return root?.mountPoint === "/" && root.fsType === "overlay";
}
/**
* @typedef {{
* writable: boolean;
* persistent: boolean;
* reason: "volume" | "host" | "container_layer" | "memory" | "not_writable";
* }} BackupDirectoryStatus
*/
/**
* Whether a dump written to `dir` is still there after the container is
* replaced, which is what "kept on this server" promises.
*
* volume — in a container, on a mount of its own (the `backups`
* volume, a bind mount): it survives.
* host — not in a container: a writable directory is a disk.
* container_layer — in a container, on its root file system: an update
* replaces the container and the dump with it. The image
* creates the default directory, so without the volume it
* is writable and looks fine; this is the case to catch.
* memory — on a tmpfs: gone at the next restart.
* not_writable — nothing can be written there at all.
*
* Never throws. `probe` is the file system; tests hand in their own.
*
* @param {string} dir
* @param {DirectoryProbe} [probe]
* @returns {BackupDirectoryStatus}
*/
export function backupDirectoryStatus(dir, probe = SYSTEM_PROBE) {
if (!probe.writable(dir)) {
return { writable: false, persistent: false, reason: "not_writable" };
}
const text = probe.readText("/proc/self/mountinfo");
const mounts = text === null ? null : parseMountInfo(text);
if (!runsInContainer(probe, mounts)) {
return { writable: true, persistent: true, reason: "host" };
}
const real = probe.realpath(dir);
const mount = mounts ? mountContaining(real, mounts) : null;
if (mount) {
if (EPHEMERAL_FS_TYPES.has(mount.fsType)) {
return { writable: true, persistent: false, reason: "memory" };
}
return mount.mountPoint === "/"
? { writable: true, persistent: false, reason: "container_layer" }
: { writable: true, persistent: true, reason: "volume" };
}
// No mount table to read: a directory on the root's own device is in the
// container's layer.
const own = probe.device(real);
const root = probe.device("/");
return own !== null && own === root
? { writable: true, persistent: false, reason: "container_layer" }
: { writable: true, persistent: true, reason: "volume" };
}
// ── Keys ───────────────────────────────────────────────────────────────────
/**
+32 -10
View File
@@ -3,8 +3,8 @@
* alert can act on.
*
* Read from the ledger the job writes (`DatabaseBackupRun`), plus two facts of
* this process: whether `DB_BACKUP_DIR` is writable here, and whether the
* off-host bucket is configured. A ledger row that says a copy went off-host
* this process: whether `DB_BACKUP_DIR` is writable here and survives the
* container being replaced, and whether the off-host bucket is configured. A ledger row that says a copy went off-host
* outranks this process's environment, for the reason the off-host card gives
* (a split install can hand the bucket variables to the worker only).
*/
@@ -13,10 +13,11 @@ import { DATABASE_BACKUP_EXPIRE_SECONDS } from "@/lib/jobs/database-backup-queue
import { offhostBackupConfigured } from "@/lib/jobs/offhost-backup";
import {
backupDirectoryStatus,
databaseBackupEnabled,
isWritableDirectory,
resolveBackupDir,
resolveDailyKeep,
type BackupDirectoryStatus,
} from "./dump-core.mjs";
export interface DatabaseBackupHealth {
@@ -30,8 +31,20 @@ export interface DatabaseBackupHealth {
* page says.
*/
keepNightly: number;
/** This process can write there. False means dumps only go off-host. */
/** This process can write there. */
localDirWritable: boolean;
/**
* A dump written there survives an update: the directory is on a volume or
* a bind mount, or the app does not run in a container. False means no dump
* is kept on this server, whatever an older ledger row says, and dumps only
* go off-host. `localReason` says why.
*/
localPersistent: boolean;
/**
* `volume`, `host`, `container_layer` (the image's own directory, without
* the volume mounted), `memory` (a tmpfs) or `not_writable`.
*/
localReason: BackupDirectoryStatus["reason"];
/** The off-host bucket is configured, or a dump has been uploaded to it. */
offhostConfigured: boolean;
/** The newest dump that was kept on the server or reached the bucket. */
@@ -55,15 +68,22 @@ const ABANDONED_AFTER_MS = DATABASE_BACKUP_EXPIRE_SECONDS * 1000;
export async function getDatabaseBackupHealth(
now: Date = new Date(),
env: NodeJS.ProcessEnv = process.env,
directoryStatus: (
dir: string,
) => BackupDirectoryStatus = backupDirectoryStatus,
): Promise<DatabaseBackupHealth> {
const directory = resolveBackupDir(env);
const local = directoryStatus(directory);
// A dump recorded as written into a directory that does not survive the
// container was lost with the container that wrote it: only a copy in the
// bucket still counts.
const keptWhere = local.persistent
? [{ localResult: "written" }, { offhostResult: "uploaded" }]
: [{ offhostResult: "uploaded" }];
const [lastGood, lastFailed, lastAbandoned, everUploaded] = await Promise.all(
[
prisma.databaseBackupRun.findFirst({
where: {
fileName: { not: null },
OR: [{ localResult: "written" }, { offhostResult: "uploaded" }],
},
where: { fileName: { not: null }, OR: keptWhere },
orderBy: { startedAt: "desc" },
select: {
startedAt: true,
@@ -108,7 +128,9 @@ export async function getDatabaseBackupHealth(
enabled: databaseBackupEnabled(env),
directory,
keepNightly: resolveDailyKeep(env),
localDirWritable: isWritableDirectory(directory),
localDirWritable: local.writable,
localPersistent: local.persistent,
localReason: local.reason,
offhostConfigured: offhostBackupConfigured() || everUploaded > 0,
lastGood: lastGood
? {
@@ -116,7 +138,7 @@ export async function getDatabaseBackupHealth(
fileName: lastGood.fileName ?? "",
sizeBytes:
lastGood.dumpBytes === null ? null : Number(lastGood.dumpBytes),
local: lastGood.localResult === "written",
local: local.persistent && lastGood.localResult === "written",
offhost: lastGood.offhostResult === "uploaded",
}
: null,
+28 -14
View File
@@ -9,11 +9,14 @@
* under `database/` in the same envelope the per-account copies use, sealed
* with `BACKUP_ENCRYPTION_KEY`.
*
* Without a writable directory (an install that runs the image without the
* volume) the dump is written to a temporary directory, uploaded and removed:
* the off-host copy still happens, nothing is kept on the server, and the
* ledger and the boot summary say so. With neither, the run is skipped and
* recorded as such.
* Without a directory that survives the container (an install that runs the
* image without the volume: the image's own directory is writable but sits in
* the container's layer, which the next update throws away) the dump is
* written to a temporary directory, uploaded and removed: the off-host copy
* still happens, nothing is kept on the server, and the ledger and the boot
* summary say so. A dump left in the container's layer would be counted as
* kept and lost with the container, so none is. With neither, the run is
* skipped and recorded as such.
*
* Every run leaves one `DatabaseBackupRun` row: started, then finished with
* what happened on each leg. `getDatabaseBackupHealth` reads them.
@@ -35,19 +38,20 @@ import packageJson from "../../../package.json";
import {
applyRetention,
backupDirectoryStatus,
createDatabaseDump,
databaseBackupEnabled,
isWritableDirectory,
offhostObjectKey,
resolveBackupDir,
resolveDailyKeep,
type BackupDirectoryStatus,
} from "./dump-core.mjs";
/** How long ledger rows are kept. A run a day; a year and a bit of history. */
const LEDGER_RETENTION_DAYS = 400;
export type DatabaseBackupLocalResult =
"written" | "no_directory" | "failed" | "skipped";
"written" | "no_directory" | "not_persistent" | "failed" | "skipped";
export type DatabaseBackupOffhostResult =
"uploaded" | "not_configured" | "failed" | "skipped";
@@ -69,6 +73,8 @@ export interface RunDatabaseBackupOptions {
env?: NodeJS.ProcessEnv;
/** A bucket double for tests; the configured bucket otherwise. */
s3?: S3Like;
/** Where `DB_BACKUP_DIR` stands; the file system's answer otherwise. */
directoryStatus?: (dir: string) => BackupDirectoryStatus;
}
/** The running build's version, as `/api/version` answers it. */
@@ -111,7 +117,9 @@ export async function runDatabaseBackup(
const env = options.env ?? process.env;
const now = options.now ?? new Date();
const dir = resolveBackupDir(env);
const localWritable = isWritableDirectory(dir);
const directory = (options.directoryStatus ?? backupDirectoryStatus)(dir);
// Kept here only where it survives the container being replaced.
const keepLocal = directory.persistent;
const offhostConfig = (() => {
try {
return loadOffhostConfig();
@@ -128,7 +136,11 @@ export async function runDatabaseBackup(
status: "failed",
fileName: null,
dumpBytes: null,
local: localWritable ? "failed" : "no_directory",
local: keepLocal
? "failed"
: directory.writable
? "not_persistent"
: "no_directory",
offhost: offhostConfig ? "failed" : "not_configured",
removed: 0,
error: null,
@@ -150,11 +162,13 @@ export async function runDatabaseBackup(
report.local = "skipped";
report.offhost = "skipped";
report.error = "DB_BACKUP_ENABLED is false";
} else if (!localWritable && !offhostConfig) {
} else if (!keepLocal && !offhostConfig) {
report.status = "skipped";
report.error = `no writable backup directory (${dir}) and no off-host bucket configured`;
report.error = directory.writable
? `the backup directory (${dir}) is not on persistent storage, so a dump there would be lost with the container, and no off-host bucket is configured`
: `no writable backup directory (${dir}) and no off-host bucket configured`;
} else {
const target = localWritable
const target = keepLocal
? dir
: (scratch = await mkdtemp(join(tmpdir(), "healthlog-dump-")));
const dump = await createDatabaseDump({
@@ -167,7 +181,7 @@ export async function runDatabaseBackup(
buildSha: env.NEXT_PUBLIC_APP_BUILD_SHA?.trim() || null,
deadline: options.deadline,
});
if (localWritable) report.local = "written";
if (keepLocal) report.local = "written";
report.fileName = basename(dump.dumpPath);
report.dumpBytes = dump.manifest.dump.bytes;
@@ -188,7 +202,7 @@ export async function runDatabaseBackup(
}
}
if (localWritable) {
if (keepLocal) {
const removed = await applyRetention(dir, {
dailyKeep: resolveDailyKeep(env),
now: now.getTime(),
@@ -115,7 +115,7 @@ beforeEach(() => {
ok: true,
text: "glucose fasting creatinine values report",
source: "local-pdf",
} as never);
});
// Default: the auto-read toggle is ON (so the external-pick provider path is
// exercised); the OFF-specific tests override this explicitly.
vi.mocked(documentAutoReadEnabled).mockResolvedValue(true);
@@ -284,12 +284,44 @@ describe("indexDocumentContent — local fallback path", () => {
vi.mocked(localExtractText).mockResolvedValue({
ok: false,
reason: "empty",
} as never);
});
const outcome = await indexDocumentContent("user-1", "doc-1");
expect(outcome).toEqual({ indexed: false, reason: "local-empty" });
expect(upsertContentIndex).not.toHaveBeenCalled();
});
it("reports a PDF past the reader's limits as resource-limit, not as a file that would not open", async () => {
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({
chain: [],
pick: null,
localOcrEnabled: false,
withheld: null,
});
vi.mocked(localExtractText).mockResolvedValue({
ok: false,
reason: "resource-limit",
});
const outcome = await indexDocumentContent("user-1", "doc-1");
expect(outcome).toEqual({ indexed: false, reason: "resource-limit" });
expect(upsertContentIndex).not.toHaveBeenCalled();
});
it("keeps resource-limit over a provider note: the provider never got further either", async () => {
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue(PICK as never);
vi.mocked(prepareVisionInput).mockResolvedValue({
ok: false,
reason: "rasterFailed",
});
vi.mocked(localExtractText).mockResolvedValue({
ok: false,
reason: "resource-limit",
});
const outcome = await indexDocumentContent("user-1", "doc-1");
expect(outcome).toEqual({ indexed: false, reason: "resource-limit" });
});
});
describe("indexDocumentContent — the documentAi capability", () => {
@@ -0,0 +1,53 @@
import { describe, expect, it, vi } from "vitest";
/**
* A PDF that crosses the reader's memory or time bound is reported as such
* (`resource-limit`), not as a read error, which the index job records as a
* stored file that would not open. The bound itself is the isolated reader's
* (`../pdf-isolation.ts`); here it is the error it throws.
*/
const { annotate, logCaught, readPdfText } = vi.hoisted(() => ({
annotate: vi.fn(),
logCaught: vi.fn(),
readPdfText: vi.fn(),
}));
vi.mock("@/lib/logging/context", () => ({ annotate }));
vi.mock("@/lib/logging/signal", () => ({ logCaught }));
vi.mock("../pdfjs", async (importOriginal) => ({
...(await importOriginal<typeof import("../pdfjs")>()),
readPdfText,
}));
import { PdfResourceLimitError } from "../pdfjs";
import { extractPdfText, localExtractText } from "../local-extract";
describe("a PDF past the reader's limits", () => {
it.each(["memory", "time", "exit"] as const)(
"answers resource-limit for the %s bound, without the error log a broken file gets",
async (limit) => {
readPdfText.mockRejectedValueOnce(new PdfResourceLimitError(limit));
annotate.mockClear();
logCaught.mockClear();
const bytes = Buffer.from("%PDF-1.7 large");
expect(await extractPdfText(bytes)).toEqual({
ok: false,
reason: "resource-limit",
});
expect(logCaught).not.toHaveBeenCalled();
expect(annotate).toHaveBeenCalledWith({
action: { name: "documents.localIndex.resourceLimit" },
meta: { limit, bytes: bytes.length },
});
},
);
it("still answers error for anything else the reader throws", async () => {
const broken = new Error("Invalid PDF structure");
broken.name = "InvalidPDFException";
readPdfText.mockRejectedValueOnce(broken);
expect(
await localExtractText(Buffer.from("%PDF-1.7"), "application/pdf"),
).toEqual({ ok: false, reason: "error" });
});
});
+7
View File
@@ -78,6 +78,7 @@ export type IndexFailureReason =
| "local-empty"
| "local-unsupported"
| "decrypt-error"
| "resource-limit"
| "raster-failed"
| "provider-error"
| "pdf-needs-anthropic"
@@ -342,6 +343,12 @@ async function tryLocalIndex(
if (result.reason === "unsupported") {
return { indexed: false, reason: "local-unsupported" };
}
if (result.reason === "resource-limit") {
// Too large or too complex for the reader's bounds: said as such, and
// final like the thumbnail's refusal of the same bytes. Nothing retries
// it on its own; a person can still ask for a read.
return { indexed: false, reason: "resource-limit" };
}
if (result.reason === "error") {
return { indexed: false, reason: "decrypt-error" };
}
+20 -3
View File
@@ -32,7 +32,7 @@ import { Buffer } from "node:buffer";
import { annotate } from "@/lib/logging/context";
import { logCaught } from "@/lib/logging/signal";
import { readPdfText } from "./pdfjs";
import { PdfResourceLimitError, readPdfText } from "./pdfjs";
/**
* Minimum joined text length for a PDF to count as a real text-layer document.
@@ -54,15 +54,23 @@ export type LocalExtractSource = "local-pdf" | "local-ocr";
* a provider vision pass is the only way to read it.
* - `unsupported` → a MIME the local path cannot read without OCR (images) or
* at all; leave un-indexed locally.
* - `resource-limit` → the PDF crossed the reader's memory or time bound
* (`PdfResourceLimitError`): too large or too complex to
* read here. The same bytes cross it again, so it is a
* final answer, as it is for thumbnails.
* - `error` → the file was malformed / unreadable; treat as un-indexed.
*/
export type LocalExtractResult =
| { ok: true; text: string; source: LocalExtractSource }
| { ok: false; reason: "empty" | "unsupported" | "error" };
| {
ok: false;
reason: "empty" | "unsupported" | "resource-limit" | "error";
};
/**
* Extract a text-layer PDF's embedded text server-side. Returns `empty` for a
* scanned/no-text-layer PDF and `error` for a malformed/encrypted one — never
* scanned/no-text-layer PDF, `resource-limit` for one past the reader's memory
* or time bound, and `error` for a malformed/encrypted one — never
* throws, so a bad document can never abort the caller (the upload/backfill job
* just leaves it un-indexed). An `error` leaves a `warn` line naming what
* pdf.js threw, because the outcome alone does not say why a document stayed
@@ -76,6 +84,15 @@ export async function extractPdfText(
// Bound the page walk so a pathological many-page PDF costs no more.
text = (await readPdfText(buffer, LOCAL_MAX_PAGES)).trim();
} catch (err) {
if (err instanceof PdfResourceLimitError) {
// Not a fault of the file's bytes being unreadable: they are readable,
// only not within the bounds this server gives one document.
annotate({
action: { name: "documents.localIndex.resourceLimit" },
meta: { limit: err.limit, bytes: buffer.length },
});
return { ok: false, reason: "resource-limit" };
}
const errorType = err instanceof Error ? err.name || "Error" : typeof err;
annotate({
action: { name: "documents.localIndex.extractFailed" },
+1
View File
@@ -300,6 +300,7 @@ const inboundDocument = z
"local-empty",
"local-unsupported",
"decrypt-error",
"resource-limit",
"raster-failed",
"provider-error",
"pdf-needs-anthropic",
@@ -12,6 +12,7 @@ import type { DatabaseBackupHealth } from "@/lib/recovery/database-dump-health";
import {
DUMP_FAILING_AFTER_HOURS,
evaluateDatabaseDump,
evaluateDumpRetention,
evaluateOffhostCopies,
evaluateOffhostSetup,
evaluateRecoveryKit,
@@ -45,11 +46,15 @@ function dump(
lastFailureReason,
...rest
} = over;
// A directory that cannot be written keeps nothing either.
const writable = rest.localDirWritable ?? true;
return {
enabled: true,
directory: "/data/db-backups",
keepNightly: 7,
localDirWritable: true,
localPersistent: writable,
localReason: writable ? "volume" : "not_writable",
offhostConfigured: false,
lastGood: lastGoodAt
? {
@@ -79,6 +84,7 @@ function inputs(over: Partial<ReadinessInputs> = {}): ReadinessInputs {
weekly: { accounts: [{ createdAt: ago(60 * DAY), lastCopyAt: ago(DAY) }] },
offhost: {
configured: true,
databaseLifecycle: { state: "configured", expirationDays: 30 },
accounts: [
{ lastAttemptAt: ago(10 * HOUR), lastSuccessAt: ago(10 * HOUR) },
],
@@ -172,6 +178,37 @@ describe("database dump", () => {
).toBe("ok");
});
it("does not count a writable directory inside the container as a place to keep dumps", () => {
const layer = {
localDirWritable: true,
localPersistent: false,
localReason: "container_layer" as const,
};
// No bucket: the dumps written there are lost at the next update.
expect(evalDump(dump(layer))).toMatchObject({
state: "failing",
reason: "not_persistent",
params: { directory: "/data/db-backups" },
});
// A bucket: fine, and the tile says the bucket holds the only copy.
expect(evalDump(dump({ ...layer, offhostConfigured: true }))).toMatchObject(
{
state: "ok",
reason: "fresh_offhost_only",
params: { directory: "/data/db-backups" },
},
);
// The same directory on a volume reads as before.
expect(evalDump(dump({ offhostConfigured: true }))).toMatchObject({
state: "ok",
reason: "fresh",
});
// Switched off with nowhere lasting to write: not news.
expect(
evalDump(dump({ ...layer, enabled: false, lastGoodAt: null })),
).toMatchObject({ state: "inactive", reason: "disabled" });
});
it("gives a newly switched-on dump 36 hours before calling it failing", () => {
const fresh = dump({ lastGoodAt: null });
expect(evalDump(fresh, ago(HOUR))).toMatchObject({
@@ -192,6 +229,76 @@ describe("database dump", () => {
});
});
describe("dump retention in the bucket", () => {
const goingOffhost = dump({ offhostConfigured: true });
const evalRule = (
databaseLifecycle: ReadinessInputs["offhost"]["databaseLifecycle"],
d: ReadinessInputs["dump"] = goingOffhost,
configured = true,
) =>
evaluateDumpRetention({
dump: d,
offhost: { configured, databaseLifecycle, accounts: [] },
});
it("asks for a rule when dumps go to a bucket that never removes them", () => {
expect(evalRule({ state: "missing", expirationDays: null })).toMatchObject({
state: "attention",
reason: "missing",
fixPath: "/admin/backups",
});
});
it("names the expiry when a rule covers database/, and stays quiet when the bucket will not say", () => {
expect(evalRule({ state: "configured", expirationDays: 30 })).toMatchObject(
{ state: "ok", reason: "configured", params: { days: 30 } },
);
expect(evalRule({ state: "unknown", expirationDays: null })).toMatchObject({
state: "ok",
reason: "unknown",
});
expect(evalRule(null)).toMatchObject({ state: "ok", reason: "unknown" });
});
it("does not apply without a bucket, or while no dump goes to it", () => {
const missing = { state: "missing", expirationDays: null } as const;
expect(evalRule(missing, goingOffhost, false)).toMatchObject({
state: "inactive",
reason: "not_configured",
});
for (const d of [
null,
"unreadable" as const,
dump({ offhostConfigured: false }),
dump({ offhostConfigured: true, enabled: false }),
]) {
expect(evalRule(missing, d)).toMatchObject({
state: "inactive",
reason: "no_offhost_dumps",
});
}
});
it("moves the overall verdict to attention on its own", () => {
const r = evaluateRecoveryReadiness(
inputs({
dump: goingOffhost,
offhost: {
configured: true,
databaseLifecycle: { state: "missing", expirationDays: null },
accounts: [
{ lastAttemptAt: ago(10 * HOUR), lastSuccessAt: ago(10 * HOUR) },
],
},
}),
);
expect(
r.signals.filter((x) => x.state !== "ok" && x.state !== "inactive"),
).toEqual([expect.objectContaining({ id: "dump_retention" })]);
expect(r.state).toBe("attention");
});
});
describe("weekly copy", () => {
const weekly = (
...accounts: Array<{ createdAt: Date; lastCopyAt: Date | null }>
@@ -254,7 +361,11 @@ describe("off-host copies", () => {
lastAttemptAt: Date | null;
lastSuccessAt: Date | null;
}>
) => evaluateOffhostCopies({ now: NOW, offhost: { configured, accounts } });
) =>
evaluateOffhostCopies({
now: NOW,
offhost: { configured, databaseLifecycle: null, accounts },
});
it("does not apply while no bucket is configured", () => {
expect(offhost(false).state).toBe("inactive");
@@ -302,7 +413,10 @@ describe("off-host copies", () => {
describe("restore drill", () => {
const drill = (d: ReadinessInputs["drill"], configured = true) =>
evaluateRestoreDrill({ offhost: { configured, accounts: [] }, drill: d });
evaluateRestoreDrill({
offhost: { configured, databaseLifecycle: null, accounts: [] },
drill: d,
});
const run = {
lastRunAt: ago(5 * DAY),
lastSuccessAt: null as Date | null,
@@ -351,7 +465,7 @@ describe("off-host setup", () => {
evaluateOffhostSetup({
now: NOW,
firstAdminAt,
offhost: { configured, accounts: [] },
offhost: { configured, databaseLifecycle: null, accounts: [] },
});
it("asks for a bucket 7 days after the first admin exists", () => {
+70 -10
View File
@@ -7,8 +7,11 @@
* ordinary row. A failed monthly drill was on the failing-jobs card three days
* in thirty. An off-host bucket that was never configured showed only on the
* backups page, and the key step confirmed one key of the several a restore
* needs. This module turns them into six signals with one verdict each, which
* the readiness tile shows and the hourly evaluation pushes to every admin.
* needs. A bucket whose lifecycle rule left `database/` out kept every dump,
* and every deleted account inside it, forever, and only the backups page
* said so. This module turns them into seven signals with one verdict each,
* which the readiness tile shows and the hourly evaluation pushes to every
* admin.
*
* Pure: the inputs are read elsewhere (`readiness.ts`), so each threshold is a
* unit test away and the tile and the push cannot disagree about a verdict.
@@ -26,6 +29,7 @@ import {
classifyOffhostBackup,
type OffhostBackupFreshness,
} from "@/lib/jobs/offhost-backup-freshness";
import type { LifecycleVerdict } from "@/lib/jobs/offhost-lifecycle";
import type { DatabaseBackupHealth } from "@/lib/recovery/database-dump-health";
import type { KitEntry } from "@/lib/recovery/kit-rules";
@@ -50,6 +54,7 @@ export const READINESS_SIGNALS = [
"offhost_copies",
"restore_drill",
"offhost_setup",
"dump_retention",
"recovery_kit",
] as const;
export type ReadinessSignalId = (typeof READINESS_SIGNALS)[number];
@@ -69,6 +74,7 @@ export const READINESS_FIX_PATHS: Record<ReadinessSignalId, string> = {
offhost_copies: "/admin/backups",
restore_drill: "/admin/backups",
offhost_setup: "/admin/backups",
dump_retention: "/admin/backups",
recovery_kit: "/admin/encryption",
};
@@ -121,6 +127,12 @@ export interface ReadinessInputs {
};
offhost: {
configured: boolean;
/**
* The bucket's lifecycle rules for keys under `database/`, as the backups
* page reads them (`probeOffhostLifecycle`). Null when no bucket is
* configured.
*/
databaseLifecycle: LifecycleVerdict | null;
accounts: ReadonlyArray<{
lastAttemptAt: Date | null;
lastSuccessAt: Date | null;
@@ -185,19 +197,24 @@ export function evaluateDatabaseDump(
// directory, no bucket) switched off what it could not do, which is not
// news.
const neverUsed = dump.lastGood === null && dump.lastFailure === null;
const cannotWrite = !dump.localDirWritable && !dump.offhostConfigured;
const cannotWrite = !dump.localPersistent && !dump.offhostConfigured;
return neverUsed && cannotWrite
? signal(id, "inactive", "disabled", null)
: signal(id, "attention", "disabled", lastGoodAt);
}
const lastFailureAt = dump.lastFailure ? new Date(dump.lastFailure.at) : null;
const failureReason = dump.lastFailure?.error ?? null;
// A directory this process cannot write is a valid setup when the dumps go
// to the bucket; with no bucket either, no dump can be kept anywhere.
if (!dump.localDirWritable && !dump.offhostConfigured) {
return signal(id, "failing", "nowhere_to_write", lastGoodAt, {
directory: dump.directory,
});
// A directory that keeps nothing (not writable, or writable inside the
// container and lost at the next update) is a valid setup when the dumps
// go to the bucket; with no bucket either, no dump is kept anywhere.
if (!dump.localPersistent && !dump.offhostConfigured) {
return signal(
id,
"failing",
dump.localDirWritable ? "not_persistent" : "nowhere_to_write",
lastGoodAt,
{ directory: dump.directory },
);
}
const limitMs = DUMP_FAILING_AFTER_HOURS * HOUR_MS;
const failedSinceGood =
@@ -242,7 +259,13 @@ export function evaluateDatabaseDump(
failureReason,
);
}
return signal(id, "ok", "fresh", lastGoodAt);
// Fine, and worth saying: the bucket holds the only copy, and no dump is
// taken before a migration.
return dump.localPersistent
? signal(id, "ok", "fresh", lastGoodAt)
: signal(id, "ok", "fresh_offhost_only", lastGoodAt, {
directory: dump.directory,
});
}
/**
@@ -374,6 +397,42 @@ export function evaluateOffhostSetup(
: signal(id, "ok", "not_configured_grace", graceEnds);
}
/**
* Whether the bucket ever removes the database dumps. Each one holds every
* account that existed when it was taken, so a bucket with no rule for
* `database/` keeps a deleted account's data for as long as the bucket
* exists. Attention, not failing: every restore still works. A bucket that
* will not say (a credential without the permission, a target without the
* call) is not news either way.
*/
export function evaluateDumpRetention(
input: Pick<ReadinessInputs, "dump" | "offhost">,
): ReadinessSignal {
const id = "dump_retention";
if (!input.offhost.configured) {
return signal(id, "inactive", "not_configured", null);
}
const { dump } = input;
const dumpsGoOffhost =
dump !== null &&
dump !== "unreadable" &&
dump.enabled &&
dump.offhostConfigured;
if (!dumpsGoOffhost) {
return signal(id, "inactive", "no_offhost_dumps", null);
}
const rule = input.offhost.databaseLifecycle;
if (rule?.state === "missing") {
return signal(id, "attention", "missing", null);
}
if (rule?.state === "configured" && rule.expirationDays !== null) {
return signal(id, "ok", "configured", null, {
days: rule.expirationDays,
});
}
return signal(id, "ok", "unknown", null);
}
export function evaluateRecoveryKit(
input: Pick<ReadinessInputs, "now" | "firstAdminAt" | "kit">,
): ReadinessSignal {
@@ -420,6 +479,7 @@ export function evaluateRecoveryReadiness(
evaluateOffhostCopies(inputs),
evaluateRestoreDrill(inputs),
evaluateOffhostSetup(inputs),
evaluateDumpRetention(inputs),
evaluateRecoveryKit(inputs),
];
const state = signals.reduce<Exclude<ReadinessState, "inactive">>(
+11 -1
View File
@@ -11,12 +11,17 @@
* - the off-host copies: the per-account ledger the nightly run writes, and
* the same "configured" rule the backups page uses (the environment, or a
* ledger row as proof that an upload happened);
* - the bucket's lifecycle rule for `database/`: the same cached probe the
* backups page shows, asked only when a bucket is configured;
* - the drill: its persisted last outcome, with no time window;
* - the kit: the same assessment the kit card shows;
* - the dump: whatever the dump module reports, through a provider.
*/
import { prisma } from "@/lib/db";
import { offhostBackupConfigured } from "@/lib/jobs/offhost-backup";
import {
offhostBackupConfigured,
probeOffhostLifecycle,
} from "@/lib/jobs/offhost-backup";
import {
readDatabaseBackupHealth,
type DatabaseBackupHealthProvider,
@@ -74,6 +79,10 @@ export async function readReadinessInputs(
const configured =
offhostBackupConfigured() ||
accounts.some((account) => account.offhostBackupState !== null);
// Never throws: a bucket that cannot be asked answers `unknown`.
const databaseLifecycle = configured
? (await probeOffhostLifecycle()).database
: null;
return {
now,
@@ -88,6 +97,7 @@ export async function readReadinessInputs(
},
offhost: {
configured,
databaseLifecycle,
accounts: accounts.map((account) => ({
lastAttemptAt: account.offhostBackupState?.lastAttemptAt ?? null,
lastSuccessAt: account.offhostBackupState?.lastSuccessAt ?? null,
+3 -3
View File
@@ -107,14 +107,14 @@ export type DocumentSummaryStateValue =
/**
* Refs #776 — why a document's most recent content-index attempt produced no
* index. Mirrors the free-String `lastIndexOutcome` column (closed here, not
* in the schema, so a new reason needs no migration); the detail view renders
* a plain-words line per value instead of a bare "not indexed" that cannot
* tell "never tried" from "tried and failed".
* in the schema, so a new reason needs no migration); the detail view says
* each in plain words, so "never tried" and "tried and failed" differ.
*/
export const DOCUMENT_INDEX_OUTCOMES = [
"local-empty",
"local-unsupported",
"decrypt-error",
"resource-limit",
"raster-failed",
"provider-error",
"pdf-needs-anthropic",
+118
View File
@@ -303,6 +303,94 @@ describe("the nightly database dump", () => {
});
});
it("keeps nothing in a directory inside the container, and stops counting what was written there", async () => {
const { mkdirSync } = await import("node:fs");
const layerDir = join(work, "container-layer");
mkdirSync(layerDir);
// The image's own directory without the volume: writable, and thrown
// away with the container at the next update.
const layer = () =>
({
writable: true,
persistent: false,
reason: "container_layer",
}) as const;
const env = backupEnv({ DB_BACKUP_DIR: layerDir });
const skipped = await runDatabaseBackup(prisma, {
now: new Date("2026-10-24T05:00:00Z"),
env,
directoryStatus: layer,
});
expect(skipped).toMatchObject({
status: "skipped",
local: "not_persistent",
offhost: "not_configured",
});
expect(skipped.error).toMatch(/not on persistent storage/);
expect(readdirSync(layerDir)).toEqual([]);
// Read as keeping nothing, the newest dump that still exists is the one
// that reached the bucket on the 20th, not the local-only one of the
// 23rd; read as a volume, it is the 23rd's.
const asLayer = await getDatabaseBackupHealth(
new Date("2026-10-24T05:30:00Z"),
env,
layer,
);
expect(asLayer).toMatchObject({
localDirWritable: true,
localPersistent: false,
localReason: "container_layer",
lastGood: {
fileName: "healthlog-20261020T013000Z-daily.dump",
local: false,
offhost: true,
},
});
const asVolume = await getDatabaseBackupHealth(
new Date("2026-10-24T05:30:00Z"),
backupEnv(),
);
expect(asVolume.localPersistent).toBe(true);
expect(asVolume.lastGood).toMatchObject({
fileName: "healthlog-20261023T013000Z-daily.dump",
local: true,
});
vi.stubEnv("BACKUP_S3_ENDPOINT", "https://bucket.invalid");
vi.stubEnv("BACKUP_S3_BUCKET", "b");
vi.stubEnv("BACKUP_S3_ACCESS_KEY", "a");
vi.stubEnv("BACKUP_S3_SECRET_KEY", "s");
vi.stubEnv("BACKUP_ENCRYPTION_KEY", OFFHOST_KEY);
const bucket = memoryBucket();
const uploaded = await runDatabaseBackup(prisma, {
now: new Date("2026-10-24T06:00:00Z"),
env: backupEnv({ DB_BACKUP_DIR: layerDir }),
s3: bucket as never,
directoryStatus: layer,
});
vi.unstubAllEnvs();
expect(uploaded).toMatchObject({
status: "succeeded",
local: "not_persistent",
offhost: "uploaded",
removed: 0,
});
expect(readdirSync(layerDir)).toEqual([]);
expect([...bucket.objects.keys()].sort()).toEqual([
"database/healthlog-20261024T060000Z-daily.dump.enc",
"database/healthlog-20261024T060000Z-daily.manifest.json.enc",
]);
const row = await prisma.databaseBackupRun.findFirst({
where: { startedAt: new Date("2026-10-24T06:00:00Z") },
});
expect(row).toMatchObject({
localResult: "not_persistent",
offhostResult: "uploaded",
});
});
describe("restoring it", () => {
const target = `restore_${process.pid}_${Date.now()}`;
let targetUrl = "";
@@ -468,6 +556,36 @@ describe("the dump before migrating", () => {
);
});
it("takes no dump into a directory inside the container, says why, and lets the boot go on", async () => {
const { preMigrationDump } = await import("../../scripts/db-dump.mjs");
const before = readdirSync(preDir).sort();
const errors: string[] = [];
const spyErr = vi
.spyOn(console, "error")
.mockImplementation((l: string) => void errors.push(String(l)));
const spyLog = vi.spyOn(console, "log").mockImplementation(() => {});
try {
const code = await preMigrationDump(
{ ...process.env, DB_BACKUP_DIR: preDir },
{
directoryStatus: () => ({
writable: true,
persistent: false,
reason: "container_layer",
}),
},
);
expect(code).toBe(0);
} finally {
spyErr.mockRestore();
spyLog.mockRestore();
}
expect(errors.join("\n")).toMatch(
/is inside the container, not on a mounted volume, and would not keep a dump past this update, so no dump is taken before the 1 pending migration/,
);
expect(readdirSync(preDir).sort()).toEqual(before);
});
it("stops the boot when the dump fails on a writable directory, and leaves no dump behind", async () => {
// A second of its own, so the name cannot meet the last dump's.
await new Promise((r) => setTimeout(r, 1100));
@@ -0,0 +1,134 @@
/**
* The dump-retention signal through the real readiness reader against
* Postgres: the reader asks the bucket's lifecycle rules for `database/` the
* way the backups page does, only while a bucket is configured, and the model
* turns a missing rule into attention.
*
* The bucket call itself is stubbed (`probeOffhostLifecycle`); the parsing of
* the rules it returns is covered by the lifecycle unit tests.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { DatabaseBackupHealth } from "@/lib/database-backup/health";
import type { OffhostLifecycle } from "@/lib/jobs/offhost-lifecycle";
const probe = vi.fn<() => Promise<OffhostLifecycle>>();
vi.mock("@/lib/jobs/offhost-backup", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/jobs/offhost-backup")>()),
probeOffhostLifecycle: () => probe(),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
import { getPrismaClient, truncateAllTables } from "./setup";
const NOW = new Date();
/** A dump that went to the bucket an hour ago. */
const dumpGoingOffhost = async (): Promise<DatabaseBackupHealth> => ({
enabled: true,
directory: "/var/lib/healthlog/backups",
keepNightly: 7,
localDirWritable: true,
localPersistent: true,
localReason: "volume",
offhostConfigured: true,
lastGood: {
at: new Date(NOW.getTime() - 3_600_000).toISOString(),
fileName: "healthlog-20261025T013000Z-daily.dump",
sizeBytes: 1024,
local: true,
offhost: true,
},
lastFailure: null,
});
function useBucket(): void {
vi.stubEnv("BACKUP_S3_ENDPOINT", "https://bucket.invalid");
vi.stubEnv("BACKUP_S3_BUCKET", "b");
vi.stubEnv("BACKUP_S3_ACCESS_KEY", "a");
vi.stubEnv("BACKUP_S3_SECRET_KEY", "s");
vi.stubEnv("BACKUP_ENCRYPTION_KEY", "e".repeat(64));
}
async function retentionSignal() {
const { readRecoveryReadiness } = await import("@/lib/recovery/readiness");
const readiness = await readRecoveryReadiness({
now: NOW,
dumpHealth: dumpGoingOffhost,
});
return {
overall: readiness.state,
signal: readiness.signals.find((s) => s.id === "dump_retention"),
};
}
beforeEach(async () => {
probe.mockReset();
for (const name of [
"BACKUP_S3_ENDPOINT",
"BACKUP_S3_BUCKET",
"BACKUP_S3_ACCESS_KEY",
"BACKUP_S3_SECRET_KEY",
"BACKUP_ENCRYPTION_KEY",
]) {
vi.stubEnv(name, "");
}
const prisma = getPrismaClient();
await truncateAllTables(prisma);
await prisma.user.create({
data: {
id: "user-retention-admin",
username: "retention-admin",
role: "ADMIN",
},
});
});
afterEach(() => {
vi.unstubAllEnvs();
});
describe("dump retention in the readiness verdict (real Postgres)", () => {
it("calls a bucket with no rule for database/ attention", async () => {
useBucket();
probe.mockResolvedValue({
state: "configured",
expirationDays: 30,
database: { state: "missing", expirationDays: null },
});
const { signal } = await retentionSignal();
expect(signal).toMatchObject({
state: "attention",
reason: "missing",
fixPath: "/admin/backups",
});
expect(probe).toHaveBeenCalledTimes(1);
});
it("is ok with the rule's expiry once one covers database/", async () => {
useBucket();
probe.mockResolvedValue({
state: "configured",
expirationDays: 30,
database: { state: "configured", expirationDays: 45 },
});
expect((await retentionSignal()).signal).toMatchObject({
state: "ok",
reason: "configured",
params: { days: 45 },
});
});
it("does not ask a bucket that is not configured", async () => {
const { signal } = await retentionSignal();
expect(signal).toMatchObject({
state: "inactive",
reason: "not_configured",
});
expect(probe).not.toHaveBeenCalled();
});
});