Health answering 200 and the index being complete are different claims. A year of ~31-event backfills left a perfectly healthy API serving a real, correct, complete list of eight mints. A build against that succeeds — it prerenders eight cards — and rsync --delete-after then replaces fifty-five with eight. cashumints-web.service gains a second ExecStartPre after the health wait: count /api/mints, and exit non-zero below MIN_MINTS_FOR_BUILD (default 20, overridable with `systemctl edit`). A refusal aborts the unit before `pnpm build`, and publishing is ExecStartPost, so the previously published site is untouched; the OnFailure alert added in the last commit says why. Counted by the "host": key rather than by counting braces, because the list payload is about to carry a nested object per mint. A curl that fails at all counts as zero, which is below every floor — so an API that fell over between the health check and this line refuses the build instead of sailing through it. Verified against three live APIs: 73 mints passes, a doctored 8-mint database fails with the reason, and a dead port fails. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
130 lines
6.7 KiB
Desktop File
130 lines
6.7 KiB
Desktop File
# /etc/systemd/system/cashumints-web.service
|
|
#
|
|
# The frontend is static: `output: 'static'` in astro.config.mjs, and the whole site is
|
|
# produced ahead of time. There is no frontend build to keep alive, so this unit is a
|
|
# build rather than a daemon — one shot of `pnpm build`, which compiles shared/, renders
|
|
# a social card per mint and prerenders every page from the live API. The daemon that
|
|
# hands the result out is cashumints-site.service.
|
|
#
|
|
# Run it after a deploy:
|
|
# sudo systemctl start cashumints-web
|
|
#
|
|
# Mint pages are prerendered, so new mints and new review counts only appear at the
|
|
# next build; pair this with a .timer for the nightly rebuild. There is deliberately no
|
|
# [Install] section — a rebuild should be scheduled, not fired on every boot.
|
|
|
|
[Unit]
|
|
Description=Rebuild the cashumints.space static site
|
|
# Every page's data comes from the API over loopback, so the API has to be up.
|
|
# Requires= rather than Wants=: a dead API should abort the build, not replace a good
|
|
# site with an empty one.
|
|
Requires=cashumints.service
|
|
After=cashumints.service network-online.target
|
|
Wants=network-online.target
|
|
|
|
# Carry a failure off the machine. `%n` is this unit's own name, so the alert says
|
|
# which one died. cashumints-alert@.service writes to the journal at ERROR always and
|
|
# curls NTFY_URL or WEBHOOK_URL from /etc/cashumints/alert.env when either is set.
|
|
OnFailure=cashumints-alert@%n.service
|
|
|
|
[Service]
|
|
Type=oneshot
|
|
User=cashumints
|
|
Group=cashumints
|
|
WorkingDirectory=/home/cashumints/CashuMints.space
|
|
|
|
# Where the published copy lands. Shared with the API and the site server, and created
|
|
# by systemd with this unit's ownership if it is not there yet.
|
|
StateDirectory=cashumints
|
|
|
|
Environment=NODE_ENV=production
|
|
# Where the build reaches the API. Must match PORT= in cashumints.service.
|
|
Environment=API_URL=http://127.0.0.1:8788
|
|
Environment=SITE_URL=https://cashumints.space
|
|
# Browser-facing origin. Empty means same origin: islands fetch /api/... and nginx
|
|
# forwards it. Set this only if the API ever moves to its own hostname. Declared here
|
|
# even though it is empty, because systemd's environment wins over .env — so what a
|
|
# production build emits cannot drift with an edit to that file.
|
|
Environment=PUBLIC_API_URL=
|
|
|
|
# After= orders the start; it does not wait for the port to accept connections. At boot
|
|
# the API is still opening its database and probing, so block until it reports healthy
|
|
# rather than letting the first fetch die on ECONNREFUSED. /api/health answers 503 until
|
|
# it is genuinely ready, and curl -f treats that as a failure, so the loop keeps waiting.
|
|
ExecStartPre=/usr/bin/timeout 90 /bin/sh -c 'until curl -sf -o /dev/null http://127.0.0.1:8788/api/health; do sleep 1; done'
|
|
|
|
# Then: does the API actually have an index to build a site out of?
|
|
#
|
|
# Health answering 200 says the process is up and its last backfill read something. It
|
|
# does not say how many mints are in the table, and those are different questions — the
|
|
# year of ~31-event backfills had a healthy API serving a real, complete, correct list of
|
|
# eight mints. A build against that succeeds, prerenders eight cards, and rsync happily
|
|
# replaces fifty-five with eight.
|
|
#
|
|
# So count the list before spending twenty minutes building from it. Below the floor
|
|
# this exits non-zero, systemd abandons the unit at ExecStartPre, and — because publishing
|
|
# is ExecStartPost, after the build — the previously published site is never touched. The
|
|
# site stays exactly as it was and the OnFailure alert says why.
|
|
#
|
|
# Counted by the `"host":` key, one per item, rather than by counting `{`: the list
|
|
# payload carries a nested object per mint (its NUT capability switches), so brace
|
|
# counting would report roughly double. No jq: it is not installed on this host and a
|
|
# build gate should not add a dependency to run.
|
|
#
|
|
# `Q` is a double-quote character, built with printf rather than written literally,
|
|
# because this whole command is already inside systemd's single quotes and a quote of
|
|
# either kind in the grep pattern would end the argument early.
|
|
#
|
|
# A curl that fails for any reason leaves `n` empty, `$${n:-0}` reads that as zero, and
|
|
# zero is below every floor — so an API that fell over between the health check above and
|
|
# this line refuses the build rather than sailing through it.
|
|
Environment=MIN_MINTS_FOR_BUILD=20
|
|
ExecStartPre=/bin/sh -c 'Q=$$(printf "\\042"); \
|
|
n=$$(curl -sf --max-time 30 http://127.0.0.1:8788/api/mints | grep -o "$${Q}host$${Q}:" | wc -l); \
|
|
if [ "$${n:-0}" -lt "$$MIN_MINTS_FOR_BUILD" ]; then \
|
|
printf "<3>%s\\n" "refusing to build: /api/mints returned $${n:-0} mints, floor is $$MIN_MINTS_FOR_BUILD. Previous site left untouched."; \
|
|
exit 1; \
|
|
fi; \
|
|
printf "%s\\n" "build gate: $$n mints, floor $$MIN_MINTS_FOR_BUILD"'
|
|
# Check `which pnpm` on the host: a corepack or pnpm-home install sits outside /usr/bin,
|
|
# and systemd's PATH does not include it.
|
|
ExecStart=/usr/bin/pnpm build
|
|
|
|
# Publish, as a separate step from building.
|
|
#
|
|
# `astro build` empties dist before it writes, so the site server cannot read dist
|
|
# directly — a nightly rebuild would be a nightly minute of 404s. It serves this copy
|
|
# instead, and the copy is only touched once a build has succeeded: a failed build
|
|
# leaves the previous site up rather than replacing it with a half-written one, which is
|
|
# the same reason Requires=cashumints.service is above.
|
|
#
|
|
# --delay-updates stages the changed files and renames them in at the end, so the window
|
|
# where the tree is a mix of two builds is a rename rather than a whole transfer, and
|
|
# --delete-after keeps removals from landing before their replacements. Unchanged files
|
|
# — every hashed asset and card, which is nearly all of it — are not touched at all.
|
|
ExecStartPost=/usr/bin/rsync -a --delete-after --delay-updates web/dist/ /var/lib/cashumints/web/
|
|
|
|
# ~200 prerendered pages plus a card per mint. Minutes, not seconds, on a small VPS, and
|
|
# TimeoutStartSec is what bounds a Type=oneshot.
|
|
TimeoutStartSec=1800
|
|
# A nightly rebuild should not starve the API it is reading from.
|
|
Nice=10
|
|
|
|
# The site server runs as cashumints and reads its own files, so this no longer has to
|
|
# be world-readable — it was 0022 for nginx, back when nginx opened the files as
|
|
# www-data. Kept at 0022 anyway: rsync preserves these modes into the published copy,
|
|
# and a readable static site is easier to inspect than one that needs sudo.
|
|
UMask=0022
|
|
|
|
NoNewPrivileges=true
|
|
PrivateTmp=true
|
|
PrivateDevices=true
|
|
# ProtectHome is deliberately absent, unlike in cashumints.service: this unit writes
|
|
# inside /home/cashumints — web/dist, web/public/og, web/src/generated and the pnpm
|
|
# store are all under it.
|
|
ProtectSystem=full
|
|
ProtectKernelTunables=true
|
|
ProtectKernelModules=true
|
|
ProtectControlGroups=true
|
|
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|