prometheus/scripts/snapshot.mjs
Local Dev 9f16ce3128 prometheus: weekly s3 snapshot of the browse surface to bns/prometheus
scripts/snapshot.mjs renders every public page (home, browse, publish,
boundaries, 8 categories, every active listing) to <out>/<path>/index.html,
plus /sitemap.xml, /robots.txt, and the two static assets. Writing to a
directory next to the service AND uploading via `aws s3 sync` are two
flags on the same command: --out to pick the directory, --upload to push.

Live routes added for /sitemap.xml and /robots.txt so a crawler hitting
the live service sees the same enumeration the snapshot bakes into the
bucket. renderSitemap / renderRobots in service/seo.js are the single
rendering path both call sites use.

Scope deliberately limited to the browse surface — /submit and the API
routes depend on the live backend and were never going to work on a
static mirror. The browse surface IS what the fallback is for: readers
survive an outage, publishers can't.

systemd unit + timer under deploy/ run the snapshot weekly on Sunday
04:00 UTC with 30 min jitter and Persistent=yes so a missed run after a
reboot catches up. Unit uses the sia-storage AWS profile per
[[sia-storage-credentials-location]] and the per-process endpoint URL
baked in via Environment=.

Local dry-run against an empty DB produced 14 HTML files + sitemap +
robots + the two assets.
2026-10-08 21:42:54 +02:00

163 lines
6 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
/**
* Render Prometheus to static files and upload to Sia as the DR fallback.
*
* The architecture decision (see
* ../../../_coordination/sessions/prometheus-x-architecture.md §4):
* prometheus.x's `ip`+`tls` records point at the live service, but a weekly
* snapshot of the browseable surface is also published to `bns/prometheus/`
* on Sia. Resolver clients that cannot reach the service can fall back to
* the `s3` record and still read what's there — stale by up to a snapshot
* interval, but readable.
*
* Scope — the browse surface, not the dynamic surface:
* /, /browse, /publish, /boundaries, /c/<slug> × 8, /app/<pub>/<slug> × N,
* plus /static/style.css + /static/favicon.svg + /sitemap.xml + /robots.txt.
* NOT snapshotted: /submit (requires the live API), /admin/*, /api/*.
*
* Flags:
* --out <dir> write files here (default: ./build)
* --upload after writing, `aws s3 sync` to bns/prometheus/
* --bucket <name> target bucket (default: bns/prometheus)
* --endpoint <url> S3 endpoint URL for the aws CLI
* --dry-run print the sync command instead of running it
*
* The sync uses the `sia-storage` AWS profile per
* [[sia-storage-credentials-location]].
*/
import { mkdir, writeFile, rm, copyFile } from 'node:fs/promises';
import { createReadStream } from 'node:fs';
import { resolve, dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawn } from 'node:child_process';
import { layout } from '../service/layout.js';
import { home, publish, boundaries } from '../service/pages.js';
import { browse as browsePage } from '../service/pages.js';
import { CATEGORIES, categoryPage, appPage } from '../service/pages-browse.js';
import { allActiveListings } from '../service/db.js';
import { renderSitemap, renderRobots } from '../service/seo.js';
const SELF_DIR = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(SELF_DIR, '..');
const PUBLIC_DIR = resolve(ROOT, 'public');
const args = parseArgs(process.argv.slice(2));
const OUT = resolve(ROOT, args.out || 'build');
const BUCKET = args.bucket || 'bns/prometheus';
const ENDPOINT = args.endpoint || process.env.AWS_ENDPOINT_URL_S3 || process.env.AWS_ENDPOINT_URL;
const log = (...a) => console.log('[snapshot]', ...a);
async function main() {
log(`output: ${OUT}`);
await rm(OUT, { recursive: true, force: true });
await mkdir(OUT, { recursive: true });
const listings = allActiveListings();
log(`rendering ${CATEGORIES.length} category pages + ${listings.length} listings …`);
// Core pages — the "here" path doubles as the output URL.
await writePage('/', home());
await writePage('/browse', browsePage());
await writePage('/publish', publish());
await writePage('/boundaries', boundaries());
// Category pages — fake a request object for the signature.
for (const c of CATEGORIES) {
await writePage(`/c/${c.slug}`, categoryPage({ url: `/c/${c.slug}` }, c.slug));
}
// App detail pages — one per active listing.
for (const r of listings) {
await writePage(`/app/${r.publisher}/${r.slug}`,
appPage({ url: `/app/${r.publisher}/${r.slug}` }, r.publisher, r.slug));
}
// Static assets live next to the rendered HTML at the same /static/ URL.
const staticOut = resolve(OUT, 'static');
await mkdir(staticOut, { recursive: true });
await copyFile(resolve(PUBLIC_DIR, 'style.css'), resolve(staticOut, 'style.css'));
await copyFile(resolve(PUBLIC_DIR, 'favicon.svg'), resolve(staticOut, 'favicon.svg'));
// SEO files at the root of the bucket.
await writeFile(resolve(OUT, 'sitemap.xml'), renderSitemap({
categories: CATEGORIES.map((c) => c.slug),
listings: listings.map((r) => ({ publisher: r.publisher, slug: r.slug, lastmod: r.created_at })),
}), 'utf8');
await writeFile(resolve(OUT, 'robots.txt'), renderRobots(), 'utf8');
log(`wrote ${listings.length + CATEGORIES.length + 6} HTML files + static assets`);
if (args.upload) {
await uploadToSia();
} else {
log(`skipping upload — rerun with --upload (or --dry-run to just print the aws command)`);
}
log('done.');
}
/**
* Resolve the page's URL to the filesystem path the bucket serves at.
*
* / → index.html
* /browse → browse/index.html
* /c/privacy → c/privacy/index.html
* /app/foo.x/bar → app/foo.x/bar/index.html
*
* Directory-with-index-html lets `/browse` and `/browse/` both work on an
* S3-style static host — the bucket's "serve index when the path is a prefix"
* rule picks index.html for us.
*/
async function writePage(urlPath, page) {
const html = layout(page);
const rel = urlPath === '/' ? 'index.html' : urlPath.replace(/^\/+/, '').replace(/\/+$/, '') + '/index.html';
const full = resolve(OUT, rel);
await mkdir(dirname(full), { recursive: true });
await writeFile(full, html, 'utf8');
}
async function uploadToSia() {
const cmd = 'aws';
const argv = [
'--profile', 'sia-storage',
...(ENDPOINT ? ['--endpoint-url', ENDPOINT] : []),
's3', 'sync', OUT + '/', `s3://${BUCKET}/`,
'--delete', // objects dropped from the snapshot vanish from the bucket too
];
if (args['dry-run']) {
log(`DRY RUN: ${cmd} ${argv.join(' ')}`);
return;
}
log(`$ ${cmd} ${argv.join(' ')}`);
await spawnOk(cmd, argv);
}
function spawnOk(cmd, argv) {
return new Promise((resolveP, rejectP) => {
const c = spawn(cmd, argv, { stdio: 'inherit' });
c.on('error', rejectP);
c.on('close', (code) => code === 0 ? resolveP() : rejectP(new Error(`${cmd} exited ${code}`)));
});
}
function parseArgs(argv) {
const out = {};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (!a.startsWith('--')) continue;
const k = a.slice(2);
const next = argv[i + 1];
if (next && !next.startsWith('--')) {
out[k] = next; i++;
} else {
out[k] = true;
}
}
return out;
}
main().catch((err) => {
console.error('[snapshot] FAIL:', err?.stack || err);
process.exit(1);
});