openvibers/openvibe.host Commits
agent: ovhost shots, the screenshot pass (plan L9) (#293)
Changes
nnew file mode 100644@@@ -0,0 +1,19 @@++[Unit]++Description=OpenVibe screenshot pass: each public page at two viewports, stored in OpenVibe.Media (plan L9)++Documentation=https://github.com/OpenVibers/OpenVibe.Host/blob/main/docs/operator-alerts.md++After=network-online.target++Wants=network-online.target++++[Service]++Type=oneshot++# Root reads the inventory and uploads the PNGs; Chrome runs as the unprivileged ovcheck account++# (runuser), never as root. The pages are the ones the inventory lists for each service ("/" when it++# lists none). The Media credentials are in /etc/openvibe/host.env (OV_OAUTH_CLIENT_SECRET).++User=root++ExecStart=/usr/local/bin/ovhost shots++Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin++Environment=OVHOST_INVENTORY=/etc/openvibe/host.json++Nice=10++TimeoutStartSec=30min++PrivateTmp=true++ProtectSystem=fullnnew file mode 100644@@@ -0,0 +1,10 @@++[Unit]++Description=Capture the public pages of every service every six hours (plan L9, devblog §3.3)++++[Timer]++OnBootSec=10min++OnUnitActiveSec=6h++AccuracySec=1min++++[Install]++WantedBy=timers.target@@@ -102,6 +102,11 @@ Usage: ovhost <command> [args] [--inventory <file>] [--json]check each site in a real browser after every release (anddaily) as ovcheck; metrics for OpenVibeBrowserCheckFailed(openvibe-browsercheck.timer, every 5 minutes)++ shots [--sites live,blog] [--pages /,/pricing] [--dry-run]++ capture each public page at 390x844 and 1440x900 (dark, no++ animations, private parts masked) and store the PNG in++ OpenVibe.Media, in Host's own tenant; 14 days are kept++ (openvibe-shots.timer, every 6 hours)alerts relay [--prometheus <url>] [--dry-run]the alerts firing in Prometheus, sent to Network, which pagesthe operator (openvibe-alerts.timer, every 2 minutes)@@@ -128,7 +133,7 @@ self-update: 0 updated, up to date or dry run · 1 precondition (not root, dirtybackup --all / offsite: 0 everything succeeded · 1 precondition (config, key, lock) · 2 something failed`;--const FLAGS_WITH_VALUE = new Set(['prometheus', 'sites', 'token-file', 'ttl', 'title', 'services', 'severity', 'message', 'state', 'from', 'until', 'reason', 'incident', 'inventory', 'to', 'limit', 'warn-days', 'variant', 'manifest', 'out', 'ready-timeout', 'backup', 'run', 'keep-daily', 'keep-weekly', 'from-host', 'backup-env', 'release', 'commit', 'origin', 'events-env', 'note', 'tail']);++const FLAGS_WITH_VALUE = new Set(['prometheus', 'sites', 'pages', 'token-file', 'ttl', 'title', 'services', 'severity', 'message', 'state', 'from', 'until', 'reason', 'incident', 'inventory', 'to', 'limit', 'warn-days', 'variant', 'manifest', 'out', 'ready-timeout', 'backup', 'run', 'keep-daily', 'keep-weekly', 'from-host', 'backup-env', 'release', 'commit', 'origin', 'events-env', 'note', 'tail']);function parseArgs(argv) {const pos = [];@@@ -617,6 +622,21 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc: x.action === 'skipped' ? `skip ${x.site} ${x.release || '?'} (${x.reason}; last ${x.ok ? 'passed' : 'failed'})` : `ERR ${x.site}: ${x.reason}`);return r.sites.some((x) => x.action === 'error') ? 2 : r.sites.some((x) => x.action === 'checked' && !x.ok) ? 1 : 0;}++ case 'shots': {++ // Plan L9: the screenshot pass (devblog §3.3). Chrome runs as ovcheck; the PNGs go to Media.++ const services = opts.sites ? String(opts.sites).split(',').map((x) => x.trim()).filter(Boolean) : null;++ const pages = opts.pages ? String(opts.pages).split(',').map((x) => x.trim()).filter(Boolean) : null;++ const { media, error } = opts['dry-run'] ? { media: null, error: null } : await lib.shots.mediaFor(ctx, { env });++ if (error && !opts['dry-run']) { out(`[ovhost] the shots cannot be stored: ${error}`); return 1; }++ const r = await lib.shots.shots(ctx, { services, pages, media, dryRun: !!opts['dry-run'], log: (m) => log(m) });++ if (json) emit(r);++ else {++ out(`${r.captured} shot(s) taken, ${r.uploaded} stored${r.pruned ? `, ${r.pruned} pruned` : ''}`);++ for (const s of r.shots) out(` ${s.service} ${s.path} ${s.viewport} ${s.release} ${s.id}`);++ if (r.error) out(`[ovhost] ${r.error}`);++ }++ return r.error ? 2 : 0;++ }case 'alerts': {// WS-H task 11: deliver the firing Prometheus alerts to the operator through Network.if (pos[1] !== 'relay') throw new UsageError('usage: ovhost alerts relay [--prometheus <url>] [--dry-run] [--events-env <file>]');@@@ -42,6 +42,7 @@ module.exports = {alerts: require('./alerts'),nodes: require('./nodes'),browserWatch: require('./browser-watch'),++ shots: require('./shots'),incidents: require('./incidents'),dns: require('./dns'),snapshot,@@@ -250,6 +250,17 @@ function normaliseService(id, raw, defaults) {nginx: s.nginx || null,// The public origin release notifications name (lib/announce.js); default: the manifest's publicOrigin.origin: s.origin == null ? null : httpsOrigin(s.origin, `${where}.origin`),++ // The pages the screenshot pass captures for this service (plan L9): its own list, or "/" when it names++ // none. Public pages only: the origins come from the inventory.++ shots: (() => {++ const sh = s.shots || {};++ const pages = sh.pages == null ? [] : sh.pages;++ if (!Array.isArray(pages)) throw new InventoryError(`${where}.shots.pages must be a list of paths`);++ for (const p of pages) {++ if (typeof p !== 'string' || !p.startsWith('/') || p.length > 200 || /\s/.test(p)) throw new InventoryError(`${where}.shots.pages must be paths such as "/" or "/pricing"`);++ }++ return { pages: pages.slice() };++ })(),// Lifecycle (WS-P task 1): normally the service manifest's `lifecycle` block; an inventory// `lifecycle` replaces it for this host (lib/lifecycle.js resolve(), checked by validate).lifecycle: s.lifecycle == null ? null : s.lifecycle,nnew file mode 100644@@@ -0,0 +1,128 @@++'use strict';++/**++ * OpenVibe.Media, from ovhost: the screenshot pass (plan L9, devblog §3.3).++ *++ * The shots ovhost captures are Media objects in Host's own tenant ("host"), uploaded through the++ * Object API v2 the way OpenVibe.Community and Codes do: init, PUT the bytes, complete. Host reaches++ * Media with its own service token (the client id it already has, in /etc/openvibe/host.env); the++ * grant is media.object.* on the host namespace (Network's default grants).++ *++ * The secret is never printed and never leaves this module: it is a non-enumerable property of the++ * configuration, so JSON.stringify and util.inspect never show it.++ */++const path = require('path');++++const DEFAULT_ENV_FILE = '/etc/openvibe/host.env';++const APP = 'host';++const SCOPE = 'media.object.upload media.object.read media.object.list media.object.delete';++++function parseEnv(text) {++ const out = {};++ for (const line of String(text || '').split('\n')) {++ const m = /^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$/.exec(line);++ if (!m) continue;++ let v = m[2].trim();++ if ((v.startsWith('"') && v.endsWith('"') && v.length >= 2) || (v.startsWith("'") && v.endsWith("'") && v.length >= 2)) v = v.slice(1, -1);++ else v = v.replace(/\s+#.*$/, '');++ out[m[1]] = v;++ }++ return out;++}++++/**++ * -> { file, clientId, networkUrl, mediaUrl } with the secret as a non-enumerable property, or++ * { file, error, notConfigured }.++ */++async function loadCredentials(exec, inv, { file = null, env = {} } = {}) {++ const f = file || env.OVHOST_MEDIA_ENV || (inv.shots && inv.shots.envFile) || DEFAULT_ENV_FILE;++ if (!path.isAbsolute(f)) return { file: f, error: 'the media env file must be an absolute path', notConfigured: true };++ let text;++ try { text = await exec.readFile(f, { privileged: true }); } catch (err) { return { file: f, error: `${f} is not readable (${err.code || 'error'})`, notConfigured: true }; }++ if (text == null) return { file: f, error: `${f} not found`, notConfigured: true };++ const v = parseEnv(text);++ const missing = ['OV_OAUTH_CLIENT_SECRET'].filter((n) => !v[n]);++ if (missing.length) return { file: f, error: `${f} sets no ${missing.join(' and no ')}`, notConfigured: true };++ const network = v.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000';++ const media = v.MEDIA_INTERNAL_URL || v.OV_MEDIA_INTERNAL_URL || 'http://127.0.0.1:4100';++ return Object.defineProperty(++ { file: f, clientId: v.OV_OAUTH_CLIENT_ID || 'host', networkUrl: network.replace(/\/+$/, ''), mediaUrl: media.replace(/\/+$/, '') },++ 'secret', { value: v.OV_OAUTH_CLIENT_SECRET, enumerable: false },++ );++}++++/**++ * The Media client: a token from Network, then the Object API v2 of the "host" tenant.++ * uploadPng() stores one screenshot; list() reads the index; remove() prunes.++ */++function createMedia({ clientId, secret, networkUrl, mediaUrl, app = APP, fetchImpl = globalThis.fetch }) {++ let token = null;++ let tokenAt = 0;++++ async function authHeaders() {++ if (token && Date.now() - tokenAt < 240000) return { Authorization: `Bearer ${token}` };++ const body = new URLSearchParams({++ grant_type: 'client_credentials', client_id: clientId, client_secret: secret,++ audience: 'openvibe.media', scope: SCOPE,++ });++ const res = await fetchImpl(`${networkUrl}/oauth/token`, {++ method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body,++ });++ if (!res.ok) throw new Error(`Network answered ${res.status} for a Media token`);++ const j = await res.json();++ if (!j || !j.access_token) throw new Error('Network answered no access token');++ token = j.access_token;++ tokenAt = Date.now();++ return { Authorization: `Bearer ${token}` };++ }++++ async function request(pathname, { method = 'GET', json, body, headers = {} } = {}) {++ const res = await fetchImpl(`${mediaUrl}${pathname}`, {++ method,++ headers: { Accept: 'application/json', ...(await authHeaders()), ...(json ? { 'Content-Type': 'application/json' } : {}), ...headers },++ body: json ? JSON.stringify(json) : body,++ });++ const text = await res.text();++ if (!res.ok) throw new Error(`Media answered ${res.status}${text ? `: ${text.slice(0, 200)}` : ''}`);++ if (!text) return null;++ try { return JSON.parse(text); } catch { return { raw: text }; }++ }++++ const base = () => `/api/v2/${encodeURIComponent(app)}/objects`;++++ return {++ app,++ /** One PNG: init, PUT the bytes, complete. Answers { id, object }. */++ async uploadPng({ data, mime = 'image/png', kind = 'screenshot', visibility = 'unlisted', metadata = {} }) {++ const init = await request(base(), { method: 'POST', json: { kind, visibility, metadata } });++ const id = init && (init.id || (init.object && init.object.id));++ if (!id) throw new Error('Media answered no object id');++ await request(`${base()}/${encodeURIComponent(id)}/content`, { method: 'PUT', body: data, headers: { 'Content-Type': mime } });++ const done = await request(`${base()}/${encodeURIComponent(id)}/complete`, { method: 'POST', json: {} });++ return { id, object: (done && done.object) || done };++ },++ /** The shots of this tenant, newest first. */++ async list({ kind = null, limit = 100, cursor = null, namespace = null } = {}) {++ const q = new URLSearchParams({ limit: String(limit) });++ if (kind) q.set('kind', kind);++ if (cursor) q.set('cursor', cursor);++ if (namespace) q.set('namespace', namespace);++ const r = await request(`${base()}?${q.toString()}`);++ return { objects: (r && r.objects) || [], next_cursor: (r && r.next_cursor) || null };++ },++ /** Every shot, page by page. */++ async listAll({ kind = 'screenshot', limit = 100 } = {}) {++ const out = [];++ let cursor = null;++ for (;;) {++ const page = await this.list({ kind, limit, cursor });++ out.push(...page.objects);++ if (!page.next_cursor) break;++ cursor = page.next_cursor;++ }++ return out;++ },++ async remove(id) { return await request(`${base()}/${encodeURIComponent(id)}`, { method: 'DELETE' }); },++ };++}++++module.exports = { createMedia, loadCredentials, parseEnv, APP, SCOPE, DEFAULT_ENV_FILE };nnew file mode 100644@@@ -0,0 +1,190 @@++'use strict';++/**++ * `ovhost shots` (plan L9, devblog §3.3): the screenshot pass.++ *++ * ovhost shots [--sites live,blog] [--pages /,/pricing] [--dry-run]++ * (openvibe-shots.timer: every 6 hours)++ *++ * For every service the inventory names, the pages it lists (its "shots.pages" list, or "/" when it++ * names none), at two viewports — 390x844 (a phone: isMobile, hasTouch) and 1440x900 — the page is++ * captured in headless Chrome: dark theme, no animations, and the private parts masked ([data-private],++ * the inputs, and any email address on the page). Chrome runs as the unprivileged ovcheck account,++ * never as root, exactly like the browser check.++ *++ * Every PNG is stored in OpenVibe.Media, in Host's own tenant, with the service, the path, the++ * viewport, the release it was taken on and the sha256 of the bytes. The shots of a page and a++ * viewport are kept for 14 days; the same command prunes the older ones.++ *++ * Only public pages are captured: the origins come from the inventory, and a page that is not a++ * path of a public site is refused before Chrome starts.++ */++const { spawn } = require('child_process');++const crypto = require('crypto');++const fs = require('fs');++const os = require('os');++const path = require('path');++++const { originOf, currentRelease } = require('./browser-watch');++++/** The two viewports of §3.3: a phone and a desktop. */++const VIEWPORTS = Object.freeze([++ { name: 'phone', width: 390, height: 844 },++ { name: 'desktop', width: 1440, height: 900 },++]);++/** How long a shot of a page and a viewport is kept. */++const RETENTION_DAYS = 14;++const RETENTION_MS = RETENTION_DAYS * 24 * 3600 * 1000;++const SCRIPT = path.join(__dirname, '..', 'scripts', 'shots-capture.js');++const SITE_RE = /^[a-z][a-z0-9-]{1,39}$/;++const PAGE_RE = /^\/[A-Za-z0-9._~!$&'()*+,;=:@%/?#-]*$/;++++/** The pages of a service: the inventory's own list, or the home page. */++function pagesOf(inv, id) {++ const entry = inv && inv.services && inv.services[id];++ const list = entry && entry.shots && Array.isArray(entry.shots.pages) ? entry.shots.pages : [];++ return list.length ? list.slice() : ['/'];++}++++/** The capture plan: one target per page and viewport. */++function plan(inv, { services = null, pages = null } = {}) {++ const ids = services && services.length ? services : Object.keys((inv && inv.services) || {});++ const out = [];++ for (const id of ids) {++ if (!SITE_RE.test(id)) continue;++ const origin = originOf(inv, id, null);++ if (!origin) continue;++ let list = pages && pages.length ? pages : pagesOf(inv, id);++ list = list.filter((p) => PAGE_RE.test(String(p)));++ if (!list.length) continue;++ for (const p of list) {++ for (const v of VIEWPORTS) out.push({ service: id, origin, path: p, viewport: v.name, width: v.width, height: v.height });++ }++ }++ return out;++}++++/**++ * Chrome, as the unprivileged account (Chrome never runs as root). The capture script writes the PNGs++ * into a temp directory and prints what it took; the parent hashes and uploads them.++ */++function captureWithChrome(targets, { asUser = 'ovcheck', home = '/var/lib/ovcheck', timeoutMs = 15 * 60 * 1000, spawnFn = spawn } = {}) {++ return new Promise((resolve) => {++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-shots-'));++ const targetsFile = path.join(dir, 'targets.json');++ const outDir = path.join(dir, 'png');++ fs.mkdirSync(outDir, { recursive: true });++ fs.writeFileSync(targetsFile, JSON.stringify(targets));++ const argv = [SCRIPT, '--targets', targetsFile, '--out', outDir];++ const child = asUser++ ? spawnFn('runuser', ['-u', asUser, '--', 'env', `HOME=${home || '/tmp'}`, process.execPath, ...argv], { stdio: ['ignore', 'pipe', 'pipe'] })++ : spawnFn(process.execPath, argv, { stdio: ['ignore', 'pipe', 'pipe'] });++ let out = '', err = '';++ child.stdout.on('data', (c) => { out += c; });++ child.stderr.on('data', (c) => { err += c; });++ const timer = setTimeout(() => { err += '\ntimed out'; child.kill('SIGKILL'); }, timeoutMs);++ child.on('error', (e) => { clearTimeout(timer); resolve({ shots: [], error: e.message }); });++ child.on('close', (code) => {++ clearTimeout(timer);++ let parsed = null;++ try { parsed = JSON.parse(out.split('\n').filter((l) => l.trim().startsWith('{')).pop() || 'null'); } catch { parsed = null; }++ const shots = [];++ for (const s of (parsed && parsed.shots) || []) {++ try {++ const data = fs.readFileSync(s.file);++ shots.push({ ...s, data, hash: crypto.createHash('sha256').update(data).digest('hex') });++ } catch { /* the file the capture wrote is not there: the shot is lost */ }++ }++ fs.rmSync(dir, { recursive: true, force: true });++ resolve({ shots, error: code === 0 || shots.length ? null : (err.split('\n').filter(Boolean).pop() || `the capture exited ${code}`) });++ });++ });++}++++/** The shots to prune: older than the retention, per page and viewport. */++function expired(shots, now = Date.now()) {++ return shots.filter((s) => {++ const at = Date.parse((s.metadata && s.metadata.captured_at) || s.created_at || 0);++ if (!Number.isFinite(at)) return false;++ return now - at > RETENTION_MS;++ });++}++++/**++ * One pass: capture, upload, prune.++ * opts: services, pages, now, capture (the capture function; tests pass a fake one), media (the Media++ * client), asUser, home, dryRun.++ */++async function shots(ctx, {++ services = null, pages = null, now = Date.now(), capture = captureWithChrome,++ media = null, asUser = 'ovcheck', home = '/var/lib/ovcheck', dryRun = false, log = () => {},++} = {}) {++ const { exec, inv } = ctx;++ const targets = plan(inv, { services, pages });++ if (!targets.length) return { captured: 0, uploaded: 0, pruned: 0, targets: [], reason: 'no service to capture' };++++ // The release each service is on: the key of the shot (the same key the browser check uses).++ const releases = {};++ for (const t of targets) if (releases[t.service] === undefined) {++ const r = await currentRelease(exec, t.origin);++ releases[t.service] = r || 'unknown';++ }++++ const captured = await capture(targets, { asUser, home });++ const taken = (captured.shots || []).map((s) => ({++ ...s,++ release: releases[s.service] || 'unknown',++ captured_at: new Date(now).toISOString(),++ }));++++ const uploaded = [];++ if (!dryRun && media) {++ for (const s of taken) {++ if (!s.data) continue;++ const metadata = {++ service: s.service, path: s.path, viewport: s.viewport,++ width: s.width, height: s.height, release: s.release,++ hash: s.hash, captured_at: s.captured_at, source: 'ovhost shots',++ };++ try {++ const r = await media.uploadPng({ data: s.data, metadata });++ uploaded.push({ ...s, id: r.id });++ } catch (err) {++ log(`the shot of ${s.service}${s.path} at ${s.viewport} was not stored: ${err.message}`);++ }++ }++ }++++ let pruned = [];++ if (!dryRun && media) {++ try {++ const all = await media.listAll({ kind: 'screenshot' });++ for (const o of expired(all, now)) {++ try { await media.remove(o.id); pruned.push(o.id); } catch { /* the object stays */ }++ }++ } catch (err) {++ log(`the shots were not pruned: ${err.message}`);++ }++ }++++ return {++ captured: taken.length,++ uploaded: uploaded.length,++ pruned: pruned.length,++ releases,++ shots: uploaded.map((s) => ({ service: s.service, path: s.path, viewport: s.viewport, release: s.release, id: s.id, hash: s.hash })),++ error: captured.error || null,++ };++}++++/** The Media client of this host, from the credentials in the env file (the secret never leaves it). */++async function mediaFor(ctx, { env = {} } = {}) {++ const { loadCredentials, createMedia } = require('./media');++ const creds = await loadCredentials(ctx.exec, ctx.inv, { env });++ if (creds.error) return { media: null, error: creds.error };++ return {++ media: createMedia({ clientId: creds.clientId, secret: creds.secret, networkUrl: creds.networkUrl, mediaUrl: creds.mediaUrl }),++ error: null,++ };++}++++module.exports = { shots, plan, pagesOf, expired, captureWithChrome, mediaFor, VIEWPORTS, RETENTION_DAYS, RETENTION_MS, SCRIPT };nnew file mode 100644@@@ -0,0 +1,83 @@++'use strict';++/**++ * The capture itself (plan L9, devblog §3.3), run by ovhost shots as the unprivileged ovcheck account:++ * Chrome opens each page at each viewport, dark and without animations, and writes a PNG.++ *++ * node scripts/shots-capture.js --targets <file.json> --out <dir>++ *++ * The target file holds [{ service, origin, path, viewport, width, height }]; the script prints++ * { shots: [{ service, path, viewport, width, height, file }] } on stdout. It never prints a page's++ * content, only the file it wrote.++ */++const fs = require('fs');++const path = require('path');++const harness = require('openvibe-shared/browser-harness');++++/** The private parts of a page: what a shot must never show (§3.3). */++const MASK_SELECTOR = '[data-private], input, textarea, select, [data-ov-mask]';++++/** Dark theme, no animations, no caret, and every email address hidden. */++const PREPARE = `(() => {++ const st = document.createElement('style');++ st.setAttribute('data-ov-shots', '1');++ st.textContent = '*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important;scroll-behavior:auto!important}';++ (document.documentElement || document.body).appendChild(st);++ try { if (document.getAnimations) document.getAnimations().forEach((a) => { try { a.finish(); } catch (e) {} }); } catch (e) {}++ const re = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}/g;++ const walker = document.createTreeWalker(document.body || document.documentElement, NodeFilter.SHOW_TEXT);++ const nodes = [];++ while (walker.nextNode()) nodes.push(walker.currentNode);++ for (const node of nodes) {++ const text = node.nodeValue || '';++ re.lastIndex = 0;++ if (!re.test(text)) continue;++ re.lastIndex = 0;++ node.nodeValue = text.replace(re, '••••');++ const el = node.parentElement;++ if (el && !el.closest('[data-private]')) el.setAttribute('data-ov-mask', '1');++ }++ return true;++})()`;++++const sleep = (ms) => new Promise((r) => setTimeout(r, ms));++const safe = (s) => String(s).replace(/[^A-Za-z0-9._-]+/g, '_').slice(0, 60) || 'page';++++function arg(name) {++ const i = process.argv.indexOf(`--${name}`);++ return i > 0 ? process.argv[i + 1] : null;++}++++(async () => {++ const targetsFile = arg('targets');++ const out = arg('out');++ if (!targetsFile || !out) { console.error('shots-capture: --targets and --out are needed'); process.exit(1); }++ const targets = JSON.parse(fs.readFileSync(targetsFile, 'utf8'));++ fs.mkdirSync(out, { recursive: true });++ const shots = [];++ const browser = await harness.launch({});++ const context = await browser.newContext();++ try {++ for (const t of targets) {++ const page = await harness.openPage(browser, context, { width: t.width, height: t.height });++ try {++ // Dark theme before the page loads, so the first paint is the dark one.++ await page.send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: 'dark' }] });++ await page.goto(`${t.origin}${t.path}`, { quietMs: 1200, maxMs: 20000 });++ await page.evaluate(PREPARE);++ await sleep(1200);++ const shot = await page.send('Page.captureScreenshot', { format: 'png', mask: [{ selector: MASK_SELECTOR }] });++ const file = path.join(out, `${safe(t.service)}__${safe(t.path)}__${safe(t.viewport)}.png`);++ fs.writeFileSync(file, Buffer.from(shot.data, 'base64'));++ shots.push({ service: t.service, path: t.path, viewport: t.viewport, width: t.width, height: t.height, file });++ } catch (err) {++ console.error(`shots-capture: ${t.service}${t.path} at ${t.viewport} failed: ${err.message}`);++ } finally {++ await page.close().catch(() => {});++ }++ }++ } finally {++ await browser.close().catch(() => {});++ }++ process.stdout.write(`${JSON.stringify({ shots })}\n`);++ process.exit(0);++})().catch((err) => { console.error(`shots-capture: ${err.message}`); process.exit(2); });nnew file mode 100644@@@ -0,0 +1,94 @@++'use strict';++/**++ * The screenshot pass (lib/shots.js, plan L9, devblog §3.3): a capture is stored in OpenVibe.Media with++ * the service, the path, the viewport, the release and the hash of the bytes; the shots of a page and a++ * viewport are kept for 14 days, and the same command prunes the older ones.++ */++const assert = require('assert');++const { scenario, test, runTests } = require('./helpers');++const { shots, plan, expired, VIEWPORTS, RETENTION_MS } = require('../lib/shots');++++/** A Media that remembers what it was given. */++function fakeMedia(objects = []) {++ const uploads = [];++ const removed = [];++ return {++ uploads, removed, objects,++ async uploadPng({ data, metadata }) {++ const id = `med_${String(uploads.length + 1).padStart(26, '0')}`;++ uploads.push({ id, data, metadata });++ objects.push({ id, metadata, created_at: metadata.captured_at });++ return { id };++ },++ async listAll() { return objects.slice(); },++ async remove(id) { removed.push(id); const i = objects.findIndex((o) => o.id === id); if (i >= 0) objects.splice(i, 1); },++ };++}++++/** A capture that returns the targets it was given, as Chrome would. */++function fakeCapture(data = 'png-bytes') {++ const calls = [];++ const fn = async (targets) => {++ calls.push(targets);++ return { shots: targets.map((t) => ({ ...t, data: Buffer.from(`${data}:${t.path}:${t.viewport}`), hash: `hash-${t.service}-${t.path}-${t.viewport}` })) };++ };++ fn.calls = calls;++ return fn;++}++++function site({ pages = null, release = 'abc1234def56' } = {}) {++ const host = scenario();++ host.inv.services.live.origin = 'https://openvibe.live';++ if (pages) host.inv.services.live.shots = { pages };++ host.http.set('https://openvibe.live/release.json', () => ({ status: 200, body: { service: 'live', release, booted_at: '2026-11-01T00:00:00Z' } }));++ return host;++}++++runTests([++ test('the plan holds both viewports of §3.3 and the pages of the inventory', async () => {++ const host = site({ pages: ['/', '/pricing'] });++ const p = plan(host.inv, { services: ['live'] });++ assert.deepStrictEqual(p.map((t) => [t.path, t.viewport, t.width, t.height]), [++ ['/', 'phone', 390, 844], ['/', 'desktop', 1440, 900],++ ['/pricing', 'phone', 390, 844], ['/pricing', 'desktop', 1440, 900],++ ]);++ assert.deepStrictEqual(VIEWPORTS.map((v) => v.name), ['phone', 'desktop']);++ }),++++ test('without a list of pages the home page is captured', async () => {++ const host = site();++ assert.deepStrictEqual(plan(host.inv, { services: ['live'] }).map((t) => t.path), ['/', '/']);++ }),++++ test('a shot is stored in Media with the service, path, viewport, release and hash', async () => {++ const host = site({ pages: ['/'] });++ const media = fakeMedia();++ const capture = fakeCapture();++ const now = Date.parse('2026-11-06T12:00:00Z');++ const r = await shots(host.ctx(), { services: ['live'], media, capture, now });++ assert.strictEqual(r.captured, 2, 'one shot per viewport');++ assert.strictEqual(r.uploaded, 2);++ assert.strictEqual(media.uploads.length, 2);++ const m = media.uploads[0].metadata;++ assert.strictEqual(m.service, 'live');++ assert.strictEqual(m.path, '/');++ assert.strictEqual(m.viewport, 'phone');++ assert.strictEqual(m.release, 'abc1234def56', 'the release the service was on is the key of the shot');++ assert.strictEqual(m.hash, 'hash-live-/-phone');++ assert.strictEqual(m.captured_at, new Date(now).toISOString());++ }),++++ test('a shot older than 14 days is pruned, a younger one is kept', async () => {++ const now = Date.parse('2026-11-06T12:00:00Z');++ const old = { id: 'med_old', metadata: { captured_at: new Date(now - RETENTION_MS - 60000).toISOString() } };++ const fresh = { id: 'med_fresh', metadata: { captured_at: new Date(now - RETENTION_MS + 60000).toISOString() } };++ assert.deepStrictEqual(expired([old, fresh], now).map((o) => o.id), ['med_old']);++++ const host = site({ pages: ['/'] });++ const media = fakeMedia([old, fresh]);++ const r = await shots(host.ctx(), { services: ['live'], media, capture: fakeCapture(), now });++ assert.deepStrictEqual(media.removed, ['med_old']);++ assert.strictEqual(r.pruned, 1);++ assert.ok(media.objects.some((o) => o.id === 'med_fresh'), 'the young shot stays');++ })++]);
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+[Unit]
+Description=OpenVibe screenshot pass: each public page at two viewports, stored in OpenVibe.Media (plan L9)
+Documentation=https://github.com/OpenVibers/OpenVibe.Host/blob/main/docs/operator-alerts.md
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=oneshot
+# Root reads the inventory and uploads the PNGs; Chrome runs as the unprivileged ovcheck account
+# (runuser), never as root. The pages are the ones the inventory lists for each service ("/" when it
+# lists none). The Media credentials are in /etc/openvibe/host.env (OV_OAUTH_CLIENT_SECRET).
+User=root
+ExecStart=/usr/local/bin/ovhost shots
+Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
+Environment=OVHOST_INVENTORY=/etc/openvibe/host.json
+Nice=10
+TimeoutStartSec=30min
+PrivateTmp=true
+ProtectSystem=full
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+[Unit]
+Description=Capture the public pages of every service every six hours (plan L9, devblog §3.3)
+
+[Timer]
+OnBootSec=10min
+OnUnitActiveSec=6h
+AccuracySec=1min
+
+[Install]
+WantedBy=timers.target
@@ -102,6 +102,11 @@ Usage: ovhost <command> [args] [--inventory <file>] [--json]
@@ -102,6 +102,11 @@ Usage: ovhost <command> [args] [--inventory <file>] [--json]
check each site in a real browser after every release (and
check each site in a real browser after every release (and
daily) as ovcheck; metrics for OpenVibeBrowserCheckFailed
daily) as ovcheck; metrics for OpenVibeBrowserCheckFailed
(openvibe-browsercheck.timer, every 5 minutes)
(openvibe-browsercheck.timer, every 5 minutes)
+ shots [--sites live,blog] [--pages /,/pricing] [--dry-run]
+ capture each public page at 390x844 and 1440x900 (dark, no
+ animations, private parts masked) and store the PNG in
+ OpenVibe.Media, in Host's own tenant; 14 days are kept
+ (openvibe-shots.timer, every 6 hours)
alerts relay [--prometheus <url>] [--dry-run]
alerts relay [--prometheus <url>] [--dry-run]
the alerts firing in Prometheus, sent to Network, which pages
the alerts firing in Prometheus, sent to Network, which pages
the operator (openvibe-alerts.timer, every 2 minutes)
the operator (openvibe-alerts.timer, every 2 minutes)
@@ -128,7 +133,7 @@ self-update: 0 updated, up to date or dry run · 1 precondition (not root, dirty
@@ -128,7 +133,7 @@ self-update: 0 updated, up to date or dry run · 1 precondition (not root, dirty
backup --all / offsite: 0 everything succeeded · 1 precondition (config, key, lock) · 2 something failed
backup --all / offsite: 0 everything succeeded · 1 precondition (config, key, lock) · 2 something failed
`;
`;
-const FLAGS_WITH_VALUE = new Set(['prometheus', 'sites', 'token-file', 'ttl', 'title', 'services', 'severity', 'message', 'state', 'from', 'until', 'reason', 'incident', 'inventory', 'to', 'limit', 'warn-days', 'variant', 'manifest', 'out', 'ready-timeout', 'backup', 'run', 'keep-daily', 'keep-weekly', 'from-host', 'backup-env', 'release', 'commit', 'origin', 'events-env', 'note', 'tail']);
+const FLAGS_WITH_VALUE = new Set(['prometheus', 'sites', 'pages', 'token-file', 'ttl', 'title', 'services', 'severity', 'message', 'state', 'from', 'until', 'reason', 'incident', 'inventory', 'to', 'limit', 'warn-days', 'variant', 'manifest', 'out', 'ready-timeout', 'backup', 'run', 'keep-daily', 'keep-weekly', 'from-host', 'backup-env', 'release', 'commit', 'origin', 'events-env', 'note', 'tail']);
function parseArgs(argv) {
function parseArgs(argv) {
const pos = [];
const pos = [];
@@ -617,6 +622,21 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc
@@ -617,6 +622,21 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc
: x.action === 'skipped' ? `skip ${x.site} ${x.release || '?'} (${x.reason}; last ${x.ok ? 'passed' : 'failed'})` : `ERR ${x.site}: ${x.reason}`);
: x.action === 'skipped' ? `skip ${x.site} ${x.release || '?'} (${x.reason}; last ${x.ok ? 'passed' : 'failed'})` : `ERR ${x.site}: ${x.reason}`);
return r.sites.some((x) => x.action === 'error') ? 2 : r.sites.some((x) => x.action === 'checked' && !x.ok) ? 1 : 0;
return r.sites.some((x) => x.action === 'error') ? 2 : r.sites.some((x) => x.action === 'checked' && !x.ok) ? 1 : 0;
}
}
+ case 'shots': {
+ // Plan L9: the screenshot pass (devblog §3.3). Chrome runs as ovcheck; the PNGs go to Media.
+ const services = opts.sites ? String(opts.sites).split(',').map((x) => x.trim()).filter(Boolean) : null;
+ const pages = opts.pages ? String(opts.pages).split(',').map((x) => x.trim()).filter(Boolean) : null;
+ const { media, error } = opts['dry-run'] ? { media: null, error: null } : await lib.shots.mediaFor(ctx, { env });
+ if (error && !opts['dry-run']) { out(`[ovhost] the shots cannot be stored: ${error}`); return 1; }
+ const r = await lib.shots.shots(ctx, { services, pages, media, dryRun: !!opts['dry-run'], log: (m) => log(m) });
+ if (json) emit(r);
+ else {
+ out(`${r.captured} shot(s) taken, ${r.uploaded} stored${r.pruned ? `, ${r.pruned} pruned` : ''}`);
+ for (const s of r.shots) out(` ${s.service} ${s.path} ${s.viewport} ${s.release} ${s.id}`);
+ if (r.error) out(`[ovhost] ${r.error}`);
+ }
+ return r.error ? 2 : 0;
+ }
case 'alerts': {
case 'alerts': {
// WS-H task 11: deliver the firing Prometheus alerts to the operator through Network.
// WS-H task 11: deliver the firing Prometheus alerts to the operator through Network.
if (pos[1] !== 'relay') throw new UsageError('usage: ovhost alerts relay [--prometheus <url>] [--dry-run] [--events-env <file>]');
if (pos[1] !== 'relay') throw new UsageError('usage: ovhost alerts relay [--prometheus <url>] [--dry-run] [--events-env <file>]');
@@ -42,6 +42,7 @@ module.exports = {
@@ -42,6 +42,7 @@ module.exports = {
alerts: require('./alerts'),
alerts: require('./alerts'),
nodes: require('./nodes'),
nodes: require('./nodes'),
browserWatch: require('./browser-watch'),
browserWatch: require('./browser-watch'),
+ shots: require('./shots'),
incidents: require('./incidents'),
incidents: require('./incidents'),
dns: require('./dns'),
dns: require('./dns'),
snapshot,
snapshot,
@@ -250,6 +250,17 @@ function normaliseService(id, raw, defaults) {
@@ -250,6 +250,17 @@ function normaliseService(id, raw, defaults) {
nginx: s.nginx || null,
nginx: s.nginx || null,
// The public origin release notifications name (lib/announce.js); default: the manifest's publicOrigin.
// The public origin release notifications name (lib/announce.js); default: the manifest's publicOrigin.
origin: s.origin == null ? null : httpsOrigin(s.origin, `${where}.origin`),
origin: s.origin == null ? null : httpsOrigin(s.origin, `${where}.origin`),
+ // The pages the screenshot pass captures for this service (plan L9): its own list, or "/" when it names
+ // none. Public pages only: the origins come from the inventory.
+ shots: (() => {
+ const sh = s.shots || {};
+ const pages = sh.pages == null ? [] : sh.pages;
+ if (!Array.isArray(pages)) throw new InventoryError(`${where}.shots.pages must be a list of paths`);
+ for (const p of pages) {
+ if (typeof p !== 'string' || !p.startsWith('/') || p.length > 200 || /\s/.test(p)) throw new InventoryError(`${where}.shots.pages must be paths such as "/" or "/pricing"`);
+ }
+ return { pages: pages.slice() };
+ })(),
// Lifecycle (WS-P task 1): normally the service manifest's `lifecycle` block; an inventory
// Lifecycle (WS-P task 1): normally the service manifest's `lifecycle` block; an inventory
// `lifecycle` replaces it for this host (lib/lifecycle.js resolve(), checked by validate).
// `lifecycle` replaces it for this host (lib/lifecycle.js resolve(), checked by validate).
lifecycle: s.lifecycle == null ? null : s.lifecycle,
lifecycle: s.lifecycle == null ? null : s.lifecycle,
new file mode 100644
new file mode 100644
@@ -0,0 +1,128 @@
@@ -0,0 +1,128 @@
+'use strict';
+/**
+ * OpenVibe.Media, from ovhost: the screenshot pass (plan L9, devblog §3.3).
+ *
+ * The shots ovhost captures are Media objects in Host's own tenant ("host"), uploaded through the
+ * Object API v2 the way OpenVibe.Community and Codes do: init, PUT the bytes, complete. Host reaches
+ * Media with its own service token (the client id it already has, in /etc/openvibe/host.env); the
+ * grant is media.object.* on the host namespace (Network's default grants).
+ *
+ * The secret is never printed and never leaves this module: it is a non-enumerable property of the
+ * configuration, so JSON.stringify and util.inspect never show it.
+ */
+const path = require('path');
+
+const DEFAULT_ENV_FILE = '/etc/openvibe/host.env';
+const APP = 'host';
+const SCOPE = 'media.object.upload media.object.read media.object.list media.object.delete';
+
+function parseEnv(text) {
+ const out = {};
+ for (const line of String(text || '').split('\n')) {
+ const m = /^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$/.exec(line);
+ if (!m) continue;
+ let v = m[2].trim();
+ if ((v.startsWith('"') && v.endsWith('"') && v.length >= 2) || (v.startsWith("'") && v.endsWith("'") && v.length >= 2)) v = v.slice(1, -1);
+ else v = v.replace(/\s+#.*$/, '');
+ out[m[1]] = v;
+ }
+ return out;
+}
+
+/**
+ * -> { file, clientId, networkUrl, mediaUrl } with the secret as a non-enumerable property, or
+ * { file, error, notConfigured }.
+ */
+async function loadCredentials(exec, inv, { file = null, env = {} } = {}) {
+ const f = file || env.OVHOST_MEDIA_ENV || (inv.shots && inv.shots.envFile) || DEFAULT_ENV_FILE;
+ if (!path.isAbsolute(f)) return { file: f, error: 'the media env file must be an absolute path', notConfigured: true };
+ let text;
+ try { text = await exec.readFile(f, { privileged: true }); } catch (err) { return { file: f, error: `${f} is not readable (${err.code || 'error'})`, notConfigured: true }; }
+ if (text == null) return { file: f, error: `${f} not found`, notConfigured: true };
+ const v = parseEnv(text);
+ const missing = ['OV_OAUTH_CLIENT_SECRET'].filter((n) => !v[n]);
+ if (missing.length) return { file: f, error: `${f} sets no ${missing.join(' and no ')}`, notConfigured: true };
+ const network = v.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000';
+ const media = v.MEDIA_INTERNAL_URL || v.OV_MEDIA_INTERNAL_URL || 'http://127.0.0.1:4100';
+ return Object.defineProperty(
+ { file: f, clientId: v.OV_OAUTH_CLIENT_ID || 'host', networkUrl: network.replace(/\/+$/, ''), mediaUrl: media.replace(/\/+$/, '') },
+ 'secret', { value: v.OV_OAUTH_CLIENT_SECRET, enumerable: false },
+ );
+}
+
+/**
+ * The Media client: a token from Network, then the Object API v2 of the "host" tenant.
+ * uploadPng() stores one screenshot; list() reads the index; remove() prunes.
+ */
+function createMedia({ clientId, secret, networkUrl, mediaUrl, app = APP, fetchImpl = globalThis.fetch }) {
+ let token = null;
+ let tokenAt = 0;
+
+ async function authHeaders() {
+ if (token && Date.now() - tokenAt < 240000) return { Authorization: `Bearer ${token}` };
+ const body = new URLSearchParams({
+ grant_type: 'client_credentials', client_id: clientId, client_secret: secret,
+ audience: 'openvibe.media', scope: SCOPE,
+ });
+ const res = await fetchImpl(`${networkUrl}/oauth/token`, {
+ method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body,
+ });
+ if (!res.ok) throw new Error(`Network answered ${res.status} for a Media token`);
+ const j = await res.json();
+ if (!j || !j.access_token) throw new Error('Network answered no access token');
+ token = j.access_token;
+ tokenAt = Date.now();
+ return { Authorization: `Bearer ${token}` };
+ }
+
+ async function request(pathname, { method = 'GET', json, body, headers = {} } = {}) {
+ const res = await fetchImpl(`${mediaUrl}${pathname}`, {
+ method,
+ headers: { Accept: 'application/json', ...(await authHeaders()), ...(json ? { 'Content-Type': 'application/json' } : {}), ...headers },
+ body: json ? JSON.stringify(json) : body,
+ });
+ const text = await res.text();
+ if (!res.ok) throw new Error(`Media answered ${res.status}${text ? `: ${text.slice(0, 200)}` : ''}`);
+ if (!text) return null;
+ try { return JSON.parse(text); } catch { return { raw: text }; }
+ }
+
+ const base = () => `/api/v2/${encodeURIComponent(app)}/objects`;
+
+ return {
+ app,
+ /** One PNG: init, PUT the bytes, complete. Answers { id, object }. */
+ async uploadPng({ data, mime = 'image/png', kind = 'screenshot', visibility = 'unlisted', metadata = {} }) {
+ const init = await request(base(), { method: 'POST', json: { kind, visibility, metadata } });
+ const id = init && (init.id || (init.object && init.object.id));
+ if (!id) throw new Error('Media answered no object id');
+ await request(`${base()}/${encodeURIComponent(id)}/content`, { method: 'PUT', body: data, headers: { 'Content-Type': mime } });
+ const done = await request(`${base()}/${encodeURIComponent(id)}/complete`, { method: 'POST', json: {} });
+ return { id, object: (done && done.object) || done };
+ },
+ /** The shots of this tenant, newest first. */
+ async list({ kind = null, limit = 100, cursor = null, namespace = null } = {}) {
+ const q = new URLSearchParams({ limit: String(limit) });
+ if (kind) q.set('kind', kind);
+ if (cursor) q.set('cursor', cursor);
+ if (namespace) q.set('namespace', namespace);
+ const r = await request(`${base()}?${q.toString()}`);
+ return { objects: (r && r.objects) || [], next_cursor: (r && r.next_cursor) || null };
+ },
+ /** Every shot, page by page. */
+ async listAll({ kind = 'screenshot', limit = 100 } = {}) {
+ const out = [];
+ let cursor = null;
+ for (;;) {
+ const page = await this.list({ kind, limit, cursor });
+ out.push(...page.objects);
+ if (!page.next_cursor) break;
+ cursor = page.next_cursor;
+ }
+ return out;
+ },
+ async remove(id) { return await request(`${base()}/${encodeURIComponent(id)}`, { method: 'DELETE' }); },
+ };
+}
+
+module.exports = { createMedia, loadCredentials, parseEnv, APP, SCOPE, DEFAULT_ENV_FILE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,190 @@
@@ -0,0 +1,190 @@
+'use strict';
+/**
+ * `ovhost shots` (plan L9, devblog §3.3): the screenshot pass.
+ *
+ * ovhost shots [--sites live,blog] [--pages /,/pricing] [--dry-run]
+ * (openvibe-shots.timer: every 6 hours)
+ *
+ * For every service the inventory names, the pages it lists (its "shots.pages" list, or "/" when it
+ * names none), at two viewports — 390x844 (a phone: isMobile, hasTouch) and 1440x900 — the page is
+ * captured in headless Chrome: dark theme, no animations, and the private parts masked ([data-private],
+ * the inputs, and any email address on the page). Chrome runs as the unprivileged ovcheck account,
+ * never as root, exactly like the browser check.
+ *
+ * Every PNG is stored in OpenVibe.Media, in Host's own tenant, with the service, the path, the
+ * viewport, the release it was taken on and the sha256 of the bytes. The shots of a page and a
+ * viewport are kept for 14 days; the same command prunes the older ones.
+ *
+ * Only public pages are captured: the origins come from the inventory, and a page that is not a
+ * path of a public site is refused before Chrome starts.
+ */
+const { spawn } = require('child_process');
+const crypto = require('crypto');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const { originOf, currentRelease } = require('./browser-watch');
+
+/** The two viewports of §3.3: a phone and a desktop. */
+const VIEWPORTS = Object.freeze([
+ { name: 'phone', width: 390, height: 844 },
+ { name: 'desktop', width: 1440, height: 900 },
+]);
+/** How long a shot of a page and a viewport is kept. */
+const RETENTION_DAYS = 14;
+const RETENTION_MS = RETENTION_DAYS * 24 * 3600 * 1000;
+const SCRIPT = path.join(__dirname, '..', 'scripts', 'shots-capture.js');
+const SITE_RE = /^[a-z][a-z0-9-]{1,39}$/;
+const PAGE_RE = /^\/[A-Za-z0-9._~!$&'()*+,;=:@%/?#-]*$/;
+
+/** The pages of a service: the inventory's own list, or the home page. */
+function pagesOf(inv, id) {
+ const entry = inv && inv.services && inv.services[id];
+ const list = entry && entry.shots && Array.isArray(entry.shots.pages) ? entry.shots.pages : [];
+ return list.length ? list.slice() : ['/'];
+}
+
+/** The capture plan: one target per page and viewport. */
+function plan(inv, { services = null, pages = null } = {}) {
+ const ids = services && services.length ? services : Object.keys((inv && inv.services) || {});
+ const out = [];
+ for (const id of ids) {
+ if (!SITE_RE.test(id)) continue;
+ const origin = originOf(inv, id, null);
+ if (!origin) continue;
+ let list = pages && pages.length ? pages : pagesOf(inv, id);
+ list = list.filter((p) => PAGE_RE.test(String(p)));
+ if (!list.length) continue;
+ for (const p of list) {
+ for (const v of VIEWPORTS) out.push({ service: id, origin, path: p, viewport: v.name, width: v.width, height: v.height });
+ }
+ }
+ return out;
+}
+
+/**
+ * Chrome, as the unprivileged account (Chrome never runs as root). The capture script writes the PNGs
+ * into a temp directory and prints what it took; the parent hashes and uploads them.
+ */
+function captureWithChrome(targets, { asUser = 'ovcheck', home = '/var/lib/ovcheck', timeoutMs = 15 * 60 * 1000, spawnFn = spawn } = {}) {
+ return new Promise((resolve) => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-shots-'));
+ const targetsFile = path.join(dir, 'targets.json');
+ const outDir = path.join(dir, 'png');
+ fs.mkdirSync(outDir, { recursive: true });
+ fs.writeFileSync(targetsFile, JSON.stringify(targets));
+ const argv = [SCRIPT, '--targets', targetsFile, '--out', outDir];
+ const child = asUser
+ ? spawnFn('runuser', ['-u', asUser, '--', 'env', `HOME=${home || '/tmp'}`, process.execPath, ...argv], { stdio: ['ignore', 'pipe', 'pipe'] })
+ : spawnFn(process.execPath, argv, { stdio: ['ignore', 'pipe', 'pipe'] });
+ let out = '', err = '';
+ child.stdout.on('data', (c) => { out += c; });
+ child.stderr.on('data', (c) => { err += c; });
+ const timer = setTimeout(() => { err += '\ntimed out'; child.kill('SIGKILL'); }, timeoutMs);
+ child.on('error', (e) => { clearTimeout(timer); resolve({ shots: [], error: e.message }); });
+ child.on('close', (code) => {
+ clearTimeout(timer);
+ let parsed = null;
+ try { parsed = JSON.parse(out.split('\n').filter((l) => l.trim().startsWith('{')).pop() || 'null'); } catch { parsed = null; }
+ const shots = [];
+ for (const s of (parsed && parsed.shots) || []) {
+ try {
+ const data = fs.readFileSync(s.file);
+ shots.push({ ...s, data, hash: crypto.createHash('sha256').update(data).digest('hex') });
+ } catch { /* the file the capture wrote is not there: the shot is lost */ }
+ }
+ fs.rmSync(dir, { recursive: true, force: true });
+ resolve({ shots, error: code === 0 || shots.length ? null : (err.split('\n').filter(Boolean).pop() || `the capture exited ${code}`) });
+ });
+ });
+}
+
+/** The shots to prune: older than the retention, per page and viewport. */
+function expired(shots, now = Date.now()) {
+ return shots.filter((s) => {
+ const at = Date.parse((s.metadata && s.metadata.captured_at) || s.created_at || 0);
+ if (!Number.isFinite(at)) return false;
+ return now - at > RETENTION_MS;
+ });
+}
+
+/**
+ * One pass: capture, upload, prune.
+ * opts: services, pages, now, capture (the capture function; tests pass a fake one), media (the Media
+ * client), asUser, home, dryRun.
+ */
+async function shots(ctx, {
+ services = null, pages = null, now = Date.now(), capture = captureWithChrome,
+ media = null, asUser = 'ovcheck', home = '/var/lib/ovcheck', dryRun = false, log = () => {},
+} = {}) {
+ const { exec, inv } = ctx;
+ const targets = plan(inv, { services, pages });
+ if (!targets.length) return { captured: 0, uploaded: 0, pruned: 0, targets: [], reason: 'no service to capture' };
+
+ // The release each service is on: the key of the shot (the same key the browser check uses).
+ const releases = {};
+ for (const t of targets) if (releases[t.service] === undefined) {
+ const r = await currentRelease(exec, t.origin);
+ releases[t.service] = r || 'unknown';
+ }
+
+ const captured = await capture(targets, { asUser, home });
+ const taken = (captured.shots || []).map((s) => ({
+ ...s,
+ release: releases[s.service] || 'unknown',
+ captured_at: new Date(now).toISOString(),
+ }));
+
+ const uploaded = [];
+ if (!dryRun && media) {
+ for (const s of taken) {
+ if (!s.data) continue;
+ const metadata = {
+ service: s.service, path: s.path, viewport: s.viewport,
+ width: s.width, height: s.height, release: s.release,
+ hash: s.hash, captured_at: s.captured_at, source: 'ovhost shots',
+ };
+ try {
+ const r = await media.uploadPng({ data: s.data, metadata });
+ uploaded.push({ ...s, id: r.id });
+ } catch (err) {
+ log(`the shot of ${s.service}${s.path} at ${s.viewport} was not stored: ${err.message}`);
+ }
+ }
+ }
+
+ let pruned = [];
+ if (!dryRun && media) {
+ try {
+ const all = await media.listAll({ kind: 'screenshot' });
+ for (const o of expired(all, now)) {
+ try { await media.remove(o.id); pruned.push(o.id); } catch { /* the object stays */ }
+ }
+ } catch (err) {
+ log(`the shots were not pruned: ${err.message}`);
+ }
+ }
+
+ return {
+ captured: taken.length,
+ uploaded: uploaded.length,
+ pruned: pruned.length,
+ releases,
+ shots: uploaded.map((s) => ({ service: s.service, path: s.path, viewport: s.viewport, release: s.release, id: s.id, hash: s.hash })),
+ error: captured.error || null,
+ };
+}
+
+/** The Media client of this host, from the credentials in the env file (the secret never leaves it). */
+async function mediaFor(ctx, { env = {} } = {}) {
+ const { loadCredentials, createMedia } = require('./media');
+ const creds = await loadCredentials(ctx.exec, ctx.inv, { env });
+ if (creds.error) return { media: null, error: creds.error };
+ return {
+ media: createMedia({ clientId: creds.clientId, secret: creds.secret, networkUrl: creds.networkUrl, mediaUrl: creds.mediaUrl }),
+ error: null,
+ };
+}
+
+module.exports = { shots, plan, pagesOf, expired, captureWithChrome, mediaFor, VIEWPORTS, RETENTION_DAYS, RETENTION_MS, SCRIPT };
new file mode 100644
new file mode 100644
@@ -0,0 +1,83 @@
@@ -0,0 +1,83 @@
+'use strict';
+/**
+ * The capture itself (plan L9, devblog §3.3), run by ovhost shots as the unprivileged ovcheck account:
+ * Chrome opens each page at each viewport, dark and without animations, and writes a PNG.
+ *
+ * node scripts/shots-capture.js --targets <file.json> --out <dir>
+ *
+ * The target file holds [{ service, origin, path, viewport, width, height }]; the script prints
+ * { shots: [{ service, path, viewport, width, height, file }] } on stdout. It never prints a page's
+ * content, only the file it wrote.
+ */
+const fs = require('fs');
+const path = require('path');
+const harness = require('openvibe-shared/browser-harness');
+
+/** The private parts of a page: what a shot must never show (§3.3). */
+const MASK_SELECTOR = '[data-private], input, textarea, select, [data-ov-mask]';
+
+/** Dark theme, no animations, no caret, and every email address hidden. */
+const PREPARE = `(() => {
+ const st = document.createElement('style');
+ st.setAttribute('data-ov-shots', '1');
+ st.textContent = '*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important;scroll-behavior:auto!important}';
+ (document.documentElement || document.body).appendChild(st);
+ try { if (document.getAnimations) document.getAnimations().forEach((a) => { try { a.finish(); } catch (e) {} }); } catch (e) {}
+ const re = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}/g;
+ const walker = document.createTreeWalker(document.body || document.documentElement, NodeFilter.SHOW_TEXT);
+ const nodes = [];
+ while (walker.nextNode()) nodes.push(walker.currentNode);
+ for (const node of nodes) {
+ const text = node.nodeValue || '';
+ re.lastIndex = 0;
+ if (!re.test(text)) continue;
+ re.lastIndex = 0;
+ node.nodeValue = text.replace(re, '••••');
+ const el = node.parentElement;
+ if (el && !el.closest('[data-private]')) el.setAttribute('data-ov-mask', '1');
+ }
+ return true;
+})()`;
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+const safe = (s) => String(s).replace(/[^A-Za-z0-9._-]+/g, '_').slice(0, 60) || 'page';
+
+function arg(name) {
+ const i = process.argv.indexOf(`--${name}`);
+ return i > 0 ? process.argv[i + 1] : null;
+}
+
+(async () => {
+ const targetsFile = arg('targets');
+ const out = arg('out');
+ if (!targetsFile || !out) { console.error('shots-capture: --targets and --out are needed'); process.exit(1); }
+ const targets = JSON.parse(fs.readFileSync(targetsFile, 'utf8'));
+ fs.mkdirSync(out, { recursive: true });
+ const shots = [];
+ const browser = await harness.launch({});
+ const context = await browser.newContext();
+ try {
+ for (const t of targets) {
+ const page = await harness.openPage(browser, context, { width: t.width, height: t.height });
+ try {
+ // Dark theme before the page loads, so the first paint is the dark one.
+ await page.send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: 'dark' }] });
+ await page.goto(`${t.origin}${t.path}`, { quietMs: 1200, maxMs: 20000 });
+ await page.evaluate(PREPARE);
+ await sleep(1200);
+ const shot = await page.send('Page.captureScreenshot', { format: 'png', mask: [{ selector: MASK_SELECTOR }] });
+ const file = path.join(out, `${safe(t.service)}__${safe(t.path)}__${safe(t.viewport)}.png`);
+ fs.writeFileSync(file, Buffer.from(shot.data, 'base64'));
+ shots.push({ service: t.service, path: t.path, viewport: t.viewport, width: t.width, height: t.height, file });
+ } catch (err) {
+ console.error(`shots-capture: ${t.service}${t.path} at ${t.viewport} failed: ${err.message}`);
+ } finally {
+ await page.close().catch(() => {});
+ }
+ }
+ } finally {
+ await browser.close().catch(() => {});
+ }
+ process.stdout.write(`${JSON.stringify({ shots })}\n`);
+ process.exit(0);
+})().catch((err) => { console.error(`shots-capture: ${err.message}`); process.exit(2); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,94 @@
@@ -0,0 +1,94 @@
+'use strict';
+/**
+ * The screenshot pass (lib/shots.js, plan L9, devblog §3.3): a capture is stored in OpenVibe.Media with
+ * the service, the path, the viewport, the release and the hash of the bytes; the shots of a page and a
+ * viewport are kept for 14 days, and the same command prunes the older ones.
+ */
+const assert = require('assert');
+const { scenario, test, runTests } = require('./helpers');
+const { shots, plan, expired, VIEWPORTS, RETENTION_MS } = require('../lib/shots');
+
+/** A Media that remembers what it was given. */
+function fakeMedia(objects = []) {
+ const uploads = [];
+ const removed = [];
+ return {
+ uploads, removed, objects,
+ async uploadPng({ data, metadata }) {
+ const id = `med_${String(uploads.length + 1).padStart(26, '0')}`;
+ uploads.push({ id, data, metadata });
+ objects.push({ id, metadata, created_at: metadata.captured_at });
+ return { id };
+ },
+ async listAll() { return objects.slice(); },
+ async remove(id) { removed.push(id); const i = objects.findIndex((o) => o.id === id); if (i >= 0) objects.splice(i, 1); },
+ };
+}
+
+/** A capture that returns the targets it was given, as Chrome would. */
+function fakeCapture(data = 'png-bytes') {
+ const calls = [];
+ const fn = async (targets) => {
+ calls.push(targets);
+ return { shots: targets.map((t) => ({ ...t, data: Buffer.from(`${data}:${t.path}:${t.viewport}`), hash: `hash-${t.service}-${t.path}-${t.viewport}` })) };
+ };
+ fn.calls = calls;
+ return fn;
+}
+
+function site({ pages = null, release = 'abc1234def56' } = {}) {
+ const host = scenario();
+ host.inv.services.live.origin = 'https://openvibe.live';
+ if (pages) host.inv.services.live.shots = { pages };
+ host.http.set('https://openvibe.live/release.json', () => ({ status: 200, body: { service: 'live', release, booted_at: '2026-11-01T00:00:00Z' } }));
+ return host;
+}
+
+runTests([
+ test('the plan holds both viewports of §3.3 and the pages of the inventory', async () => {
+ const host = site({ pages: ['/', '/pricing'] });
+ const p = plan(host.inv, { services: ['live'] });
+ assert.deepStrictEqual(p.map((t) => [t.path, t.viewport, t.width, t.height]), [
+ ['/', 'phone', 390, 844], ['/', 'desktop', 1440, 900],
+ ['/pricing', 'phone', 390, 844], ['/pricing', 'desktop', 1440, 900],
+ ]);
+ assert.deepStrictEqual(VIEWPORTS.map((v) => v.name), ['phone', 'desktop']);
+ }),
+
+ test('without a list of pages the home page is captured', async () => {
+ const host = site();
+ assert.deepStrictEqual(plan(host.inv, { services: ['live'] }).map((t) => t.path), ['/', '/']);
+ }),
+
+ test('a shot is stored in Media with the service, path, viewport, release and hash', async () => {
+ const host = site({ pages: ['/'] });
+ const media = fakeMedia();
+ const capture = fakeCapture();
+ const now = Date.parse('2026-11-06T12:00:00Z');
+ const r = await shots(host.ctx(), { services: ['live'], media, capture, now });
+ assert.strictEqual(r.captured, 2, 'one shot per viewport');
+ assert.strictEqual(r.uploaded, 2);
+ assert.strictEqual(media.uploads.length, 2);
+ const m = media.uploads[0].metadata;
+ assert.strictEqual(m.service, 'live');
+ assert.strictEqual(m.path, '/');
+ assert.strictEqual(m.viewport, 'phone');
+ assert.strictEqual(m.release, 'abc1234def56', 'the release the service was on is the key of the shot');
+ assert.strictEqual(m.hash, 'hash-live-/-phone');
+ assert.strictEqual(m.captured_at, new Date(now).toISOString());
+ }),
+
+ test('a shot older than 14 days is pruned, a younger one is kept', async () => {
+ const now = Date.parse('2026-11-06T12:00:00Z');
+ const old = { id: 'med_old', metadata: { captured_at: new Date(now - RETENTION_MS - 60000).toISOString() } };
+ const fresh = { id: 'med_fresh', metadata: { captured_at: new Date(now - RETENTION_MS + 60000).toISOString() } };
+ assert.deepStrictEqual(expired([old, fresh], now).map((o) => o.id), ['med_old']);
+
+ const host = site({ pages: ['/'] });
+ const media = fakeMedia([old, fresh]);
+ const r = await shots(host.ctx(), { services: ['live'], media, capture: fakeCapture(), now });
+ assert.deepStrictEqual(media.removed, ['med_old']);
+ assert.strictEqual(r.pruned, 1);
+ assert.ok(media.objects.some((o) => o.id === 'med_fresh'), 'the young shot stays');
+ })
+]);