openvibers/openvibe.host Commits
Incident, maintenance and DNS controls (roadmap WS-N task 12; Contracts v0.66.0, pinned from 0.62.0). ovhost incident open|update|list and maintenance schedule|start|complete post to openvibe.network/status with Host's token (network.status.incident). ovhost freeze|unfreeze hold deploys of a service or all (exit 6; --force goes through loudly; rollbacks never frozen), and a window scheduled with --freeze is lifted by complete. ovhost dns list|ensure|delete drives Cloudflare from the operator's workstation: token from --token-file or ~/.config/cloudflare-token, never printed; dry run unless --apply; web records name their proxy mode; DNS-only names and _ service records never proxied; duplicates refused; no inventory needed. docs/incidents-and-dns.md; test/incidents.test.js, test/dns.test.js
Changes
nnew file mode 100644@@@ -0,0 +1,60 @@++# Incidents, maintenance, freezes and DNS++++Roadmap WS-N task 12. Containers are decided in [ADR-032](https://github.com/OpenVibers/OpenVibe.Contracts/blob/main/docs/adr/ADR-032-containers.md): platform services stay systemd units.++++## Incidents and maintenance (the public status page)++++Every command here posts to openvibe.network/status (Contracts 0.66.0 `network.status-incident-request@1`).++It uses Host's service token (`network.status.incident`) from `/etc/openvibe/host.env`, the same credentials as++release notifications.++++```bash++sudo ovhost incident open --title "Live streams stall on start" --services live --severity major \++ --message "New streams take a minute to start. We are on it."++sudo ovhost incident update inc_… --state identified --message "A restart left the ingest socket unbound."++sudo ovhost incident update inc_… --state resolved --message "Rebound; streams start at once again."++sudo ovhost incident list++++sudo ovhost maintenance schedule --title "Media storage move" --services media \++ --from 2026-09-28T03:00:00Z --until 2026-09-28T04:00:00Z --message "Uploads pause for up to an hour." --freeze++sudo ovhost maintenance start inc_… --message "Moving now."++sudo ovhost maintenance complete inc_… --message "Done." # also lifts the window's freezes++```++++- **Incident states:** investigating, identified, monitoring, resolved.++- **Maintenance states:** scheduled, in_progress, completed.++- **Closed:** a resolved incident or completed window refuses further updates.++- **Messages are public:** no internal hostnames, secrets or personal data.++- **Staff admins** can post the same through `POST /api/v1/status/incidents` with their session.++++## Freezes (deploy holds)++++```bash++sudo ovhost freeze live --reason "incident: streams stall" --incident inc_…++sudo ovhost freeze all --reason "network-wide change window"++sudo ovhost freeze # what is frozen++sudo ovhost unfreeze live++```++++- **A frozen service's `ovhost deploy`** refuses with exit 6. `--force` deploys through and says so in the log.++- **Rollbacks are never frozen:** they are how an incident ends.++- **Storage:** freezes live in `/var/lib/openvibe-host/freeze/`.++++## DNS (Cloudflare)++++These run on the operator's workstation, where the token is (`~/.config/cloudflare-token`, or `--token-file`).++The production host holds no Cloudflare token. No inventory is needed.++++```bash++node lib/cli.js dns list openvibe.live++node lib/cli.js dns ensure beta.openvibe.live CNAME openvibe.live --proxied # dry run++node lib/cli.js dns ensure beta.openvibe.live CNAME openvibe.live --proxied --apply++node lib/cli.js dns delete beta.openvibe.live CNAME --apply++```++++- **Proxy mode:** web records name theirs (`--proxied` or `--dns-only`).++- **Never proxied:** names in the inventory's `dns.dnsOnly`. The default list is `ingest.openvibe.live`,++ `relay.openvibe.live`, `turn.openvibe.live`, `cname.openvibe.host` and `ingest.openre.stream`, as of++ 2026-09-26. Service records (`_dmarc`, `*._domainkey`) are never proxied either.++- **One record per name and type:** duplicates of a name and type are refused and must be resolved by hand.++- **Dry run by default:** nothing changes without `--apply`. The token is never printed or written.@@@ -62,6 +62,17 @@ Usage: ovhost <command> [args] [--inventory <file>] [--json]declared by the inventory) to <prefix>-archive/; never deletes themarchive list the archives off-hostarchive restore <stamp> <path> --out <dir> one archived file back, checked against the manifest++ dns list <zone> Cloudflare records (run where the token is: --token-file, or++ ~/.config/cloudflare-token; the host holds none)++ dns ensure <name> <type> <content> (--proxied|--dns-only) [--ttl <s>] [--apply] dry run by default++ dns delete <name> <type> [--apply]++ incident open --title <t> --services a,b [--severity minor|major|critical] --message <m>++ incident update <inc_…> --state identified|monitoring|resolved --message <m>++ incident list incidents and maintenance on openvibe.network/status++ maintenance schedule --title <t> --services a,b --from <iso> --until <iso> --message <m> [--freeze]++ maintenance start|complete <inc_…> --message <m> (complete lifts the window's freezes)++ freeze <service|all> --reason <text> [--incident <inc_…>] deploys refuse it (exit 6) unless --force++ unfreeze <service|all> lift a freeze (freeze with no argument lists them)browser-watch [--sites live,...] [--force]check each site in a real browser after every release (anddaily) as ovcheck; metrics for OpenVibeBrowserCheckFailed@@@ -87,7 +98,7 @@ drill: 0 passed · 1 precondition (not root, port in use, no backup, unsupportedbackup --all / offsite: 0 everything succeeded · 1 precondition (config, key, lock) · 2 something failed`;--const FLAGS_WITH_VALUE = new Set(['prometheus', 'sites', 'inventory', 'to', 'limit', 'warn-days', 'variant', 'manifest', 'out', 'ready-timeout', 'wildcard-cert', 'backup', 'run', 'keep-daily', 'keep-weekly', 'from-host', 'backup-env', 'release', 'commit', 'origin', 'events-env', 'note']);++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', 'wildcard-cert', 'backup', 'run', 'keep-daily', 'keep-weekly', 'from-host', 'backup-env', 'release', 'commit', 'origin', 'events-env', 'note']);function parseArgs(argv) {const pos = [];@@@ -148,7 +159,12 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => procconst needService = () => { if (!pos[1]) throw new UsageError(`${cmd} needs a <service>`); return pos[1]; };try {-- const inv = await lib.inventory.load(exec, { file: opts.inventory, env });++ // The DNS adapter runs on the operator's workstation (where the Cloudflare token is), which has no inventory.++ let inv;++ try { inv = await lib.inventory.load(exec, { file: opts.inventory, env }); } catch (err) {++ if (cmd !== 'dns' || opts.inventory || !(err instanceof InventoryError)) throw err;++ inv = { dns: { dnsOnly: lib.dns.DEFAULT_DNS_ONLY }, services: {} };++ }const ctx = { exec, inv, log };switch (cmd) {case 'validate': {@@@ -374,6 +390,84 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => procelse out(`OFFSITE ${o.ok ? 'ok' : 'FAILED'}: ${o.uploaded.length} file(s) to s3://${o.bucket}/${o.location}${o.failures.length ? `; failures: ${o.failures.map((f) => `${f.service ? `${f.service}/` : ''}${f.file}: ${f.error}`).join('; ')}` : ''}`);return o.ok ? 0 : 2;}++ case 'dns': {++ // WS-N task 12: the DNS adapter (lib/dns.js; Cloudflare API).++ const dns = lib.dns;++ const conn = { tokenFile: opts['token-file'], env };++ const row = (r) => `${r.name} ${r.type} ${r.content}${r.proxied ? ' proxied' : ''} ttl ${r.ttl === 1 ? 'auto' : r.ttl}`;++ try {++ const sub = pos[1];++ if (sub === 'list') {++ if (!pos[2]) throw new UsageError('usage: ovhost dns list <zone>');++ const r = await dns.list(ctx, pos[2], conn);++ if (json) emit(r); else { out(`zone ${r.zone}: ${r.records.length} record(s)`); for (const x of r.records) out(` ${row(x)}`); }++ return 0;++ }++ if (sub === 'ensure' || sub === 'delete') {++ if (!pos[2] || !pos[3] || (sub === 'ensure' && !pos[4])) throw new UsageError(sub === 'ensure' ? 'usage: ovhost dns ensure <name> <type> <content> (--proxied|--dns-only) [--ttl <s>] [--apply]' : 'usage: ovhost dns delete <name> <type> [--apply]');++ if (opts.proxied && opts['dns-only']) throw new UsageError('--proxied or --dns-only, not both');++ const r = sub === 'ensure'++ ? await dns.ensure(ctx, { name: pos[2], type: pos[3], content: pos[4], proxied: opts.proxied ? true : opts['dns-only'] ? false : undefined, ttl: opts.ttl != null ? Number(opts.ttl) : undefined }, { apply: !!opts.apply, ...conn })++ : await dns.remove(ctx, { name: pos[2], type: pos[3] }, { apply: !!opts.apply, ...conn });++ if (json) emit(r);++ else {++ out(`${r.dryRun ? 'would ' : ''}${r.action} in ${r.zone}${r.dryRun ? ' (dry run; --apply to change)' : ''}`);++ if (r.before) out(` before: ${row(r.before)}`);++ if (r.after) out(` after: ${row(r.after)}`);++ }++ return 0;++ }++ throw new UsageError('usage: ovhost dns list|ensure|delete …');++ } catch (err) {++ if (err instanceof dns.DnsError) { (json ? emit({ ok: false, error: err.message }) : out(`[ovhost] ✗ ${err.message}`)); return err.exitCode; }++ throw err;++ }++ }++ case 'incident':++ case 'maintenance':++ case 'freeze':++ case 'unfreeze': {++ // WS-N task 12: incident and maintenance controls (lib/incidents.js).++ const inc = lib.incidents;++ const conn = { envFile: opts['events-env'], env };++ const show = (i) => `${i.id} ${i.kind === 'maintenance' ? 'maintenance' : (i.severity || 'incident')} ${i.state} ${i.services.join(',')} ${i.title}${i.ends_at ? ` (until ${i.ends_at})` : ''}`;++ try {++ if (cmd === 'freeze') {++ if (!pos[1]) { const f = await inc.freezes(ctx); if (json) emit(f); else if (!f.length) out('nothing is frozen'); else for (const x of f) out(`${x.service} since ${x.at} ${x.reason}${x.incident ? ` (${x.incident})` : ''}`); return 0; }++ const r = await inc.freeze(ctx, pos[1], { reason: opts.reason, incident: opts.incident, by: env.SUDO_USER || env.USER || null });++ if (json) emit(r); else out(`frozen: ${r.service} (${r.reason}); deploys refuse it until ovhost unfreeze ${r.service}`);++ return 0;++ }++ if (cmd === 'unfreeze') {++ if (!pos[1]) throw new UsageError('usage: ovhost unfreeze <service|all>');++ const had = await inc.unfreeze(ctx, pos[1]);++ out(had ? `unfrozen: ${pos[1]}` : `${pos[1]} was not frozen`);++ return 0;++ }++ const sub = pos[1];++ let r;++ if (cmd === 'incident') {++ if (sub === 'open') r = await inc.open(ctx, opts, conn);++ else if (sub === 'update') r = await inc.update(ctx, pos[2], opts, conn);++ else if (sub === 'list') { const l = await inc.list(ctx, conn); if (json) emit(l); else { out(`active: ${l.active.length}`); for (const i of l.active) out(` ${show(i)}`); out(`closed in the last 30 days: ${l.recent.length}`); for (const i of l.recent) out(` ${show(i)}`); } return 0; }++ else throw new UsageError('usage: ovhost incident open|update|list …');++ } else {++ if (sub === 'schedule') {++ r = await inc.schedule(ctx, opts, conn);++ if (opts.freeze) for (const s of r.services) await inc.freeze(ctx, s, { reason: `maintenance: ${r.title}`, incident: r.id, by: env.SUDO_USER || env.USER || null });++ } else if (sub === 'start') r = await inc.update(ctx, pos[2], { state: 'in_progress', message: opts.message }, conn);++ else if (sub === 'complete') {++ r = await inc.update(ctx, pos[2], { state: 'completed', message: opts.message }, conn);++ for (const f of await inc.freezes(ctx)) if (f.incident === r.id) await inc.unfreeze(ctx, f.service);++ } else throw new UsageError('usage: ovhost maintenance schedule|start|complete …');++ }++ if (json) emit(r); else out(show(r));++ return 0;++ } catch (err) {++ if (err instanceof inc.IncidentsError) { (json ? emit({ ok: false, error: err.message }) : out(`[ovhost] ✗ ${err.message}`)); return err.exitCode; }++ throw err;++ }++ }case 'browser-watch': {// WS-H task 11: the release UX check. A failure pages through the alerts relay.const sites = String(opts.sites || 'live').split(',').map((x) => x.trim()).filter(Boolean);nnew file mode 100644@@@ -0,0 +1,130 @@++'use strict';++/**++ * The DNS adapter (roadmap WS-N task 12): Cloudflare's API, behind ovhost.++ *++ * ovhost dns list <zone>++ * ovhost dns ensure <name> <type> <content> (--proxied | --dns-only) [--ttl <s>] [--apply]++ * ovhost dns delete <name> <type> [--apply]++ *++ * The token is the operator's: --token-file <path>, else $CLOUDFLARE_API_TOKEN_FILE, else++ * ~/.config/cloudflare-token, else $CLOUDFLARE_API_TOKEN. It never leaves the machine that runs the command,++ * is never printed or written, and the production host holds none (so these commands run from the++ * operator's workstation). Changes are a dry run unless --apply.++ *++ * The zone is the longest suffix of <name> among the zones the token can see. `ensure` makes exactly one++ * record of that name and type with that content and proxy mode: it creates, updates in place, or reports++ * `unchanged`; several records of the same name and type are refused (resolve by hand). A/AAAA/CNAME need an++ * explicit --proxied or --dns-only. Names the inventory lists as DNS-only (`dns.dnsOnly`: RTMP, TURN, JSMPEG,++ * the custom-domain CNAME target; see the Cloudflare layout) are never proxied.++ */++const path = require('path');++++const API = 'https://api.cloudflare.com/client/v4';++const TYPES = ['A', 'AAAA', 'CNAME', 'TXT', 'MX', 'CAA'];++const PROXYABLE = new Set(['A', 'AAAA', 'CNAME']);++// DNS-only on 2026-09-26 (Cloudflare layout): RTMP/SRT ingest, the TURN relay, the custom-domain CNAME target.++// Mail records (_dmarc, *._domainkey) are DNS-only by their nature and are refused by the underscore rule.++const DEFAULT_DNS_ONLY = Object.freeze(['ingest.openvibe.live', 'relay.openvibe.live', 'turn.openvibe.live', 'cname.openvibe.host', 'ingest.openre.stream']);++const NAME_RE = /^(?=.{1,253}$)(\*\.)?([a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;++++class DnsError extends Error {++ constructor(message, exitCode = 2) { super(message); this.name = 'DnsError'; this.exitCode = exitCode; }++}++++async function loadToken(exec, { tokenFile = null, env = {} } = {}) {++ const candidates = [tokenFile, env.CLOUDFLARE_API_TOKEN_FILE, env.HOME ? path.join(env.HOME, '.config', 'cloudflare-token') : null].filter(Boolean);++ for (const f of candidates) {++ let text = null;++ try { text = await exec.readFile(f); } catch { text = null; }++ if (text && text.trim()) return text.trim();++ if (f === tokenFile) throw new DnsError(`--token-file ${f} is not readable`, 1);++ }++ if (env.CLOUDFLARE_API_TOKEN) return String(env.CLOUDFLARE_API_TOKEN).trim();++ throw new DnsError('no Cloudflare token: pass --token-file, or run this from the operator\'s workstation (~/.config/cloudflare-token); the host holds none', 1);++}++++function client(exec, token) {++ return async function cf(method, route, body = null) {++ const r = await exec.request(`${API}${route}`, {++ method, headers: { Authorization: `Bearer ${token}`, Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}) },++ body: body ? JSON.stringify(body) : null, timeoutMs: 15000,++ });++ let json = null;++ try { json = JSON.parse(r.body); } catch { json = null; }++ if (!json || json.success !== true) {++ const msg = json && Array.isArray(json.errors) && json.errors.length ? json.errors.map((e) => `${e.code}: ${e.message}`).join('; ') : (r.status ? `HTTP ${r.status}` : (r.error || 'no answer'));++ throw new DnsError(`Cloudflare refused ${method} ${route.split('?')[0]}: ${msg}`);++ }++ return json.result;++ };++}++++async function zoneFor(cf, name) {++ const labels = name.replace(/^\*\./, '').split('.');++ for (let i = 0; i < labels.length - 1; i++) {++ const candidate = labels.slice(i).join('.');++ const zones = await cf('GET', `/zones?name=${encodeURIComponent(candidate)}&per_page=5`);++ if (Array.isArray(zones) && zones.length) return zones[0];++ }++ throw new DnsError(`no zone for ${name} is visible to this token`, 1);++}++++const show = (r) => ({ name: r.name, type: r.type, content: r.content, proxied: !!r.proxied, ttl: r.ttl });++++async function list(ctx, zoneName, opts = {}) {++ const cf = client(ctx.exec, await loadToken(ctx.exec, opts));++ const zone = await zoneFor(cf, zoneName);++ const out = [];++ for (let page = 1; page <= 20; page++) {++ const rows = await cf('GET', `/zones/${zone.id}/dns_records?per_page=100&page=${page}`);++ out.push(...rows.map(show));++ if (rows.length < 100) break;++ }++ return { zone: zone.name, records: out.sort((a, b) => a.name.localeCompare(b.name) || a.type.localeCompare(b.type)) };++}++++function checkRecord(ctx, { name, type, content, proxied, ttl }) {++ if (!NAME_RE.test(String(name || '').toLowerCase())) throw new DnsError(`not a DNS name: ${name}`, 1);++ if (!TYPES.includes(type)) throw new DnsError(`type is one of ${TYPES.join(', ')}`, 1);++ if (!content) throw new DnsError('content is required', 1);++ if (PROXYABLE.has(type) && proxied == null) throw new DnsError(`${type} records need --proxied or --dns-only (Cloudflare layout: web traffic proxied; RTMP, TURN, JSMPEG and the CNAME target DNS-only)`, 1);++ if (!PROXYABLE.has(type) && proxied) throw new DnsError(`${type} records cannot be proxied`, 1);++ const dnsOnly = new Set(((ctx.inv && ctx.inv.dns && ctx.inv.dns.dnsOnly) || []).map((n) => String(n).toLowerCase()));++ if (proxied && dnsOnly.has(String(name).toLowerCase())) throw new DnsError(`${name} is DNS-only by the inventory (dns.dnsOnly): it carries non-HTTP traffic or is the CNAME target`, 1);++ if (proxied && /(^|\.)_/.test(String(name))) throw new DnsError(`${name} is a service record (_dmarc, _domainkey…): never proxied`, 1);++ if (ttl != null && !(Number.isInteger(ttl) && (ttl === 1 || (ttl >= 60 && ttl <= 86400)))) throw new DnsError('ttl is 1 (automatic) or 60–86400 seconds', 1);++}++++/** Make exactly one <name> <type> record with this content and proxy mode. → { action, zone, before?, after } */++async function ensure(ctx, rec, { apply = false, ...opts } = {}) {++ const want = { name: String(rec.name).toLowerCase(), type: String(rec.type).toUpperCase(), content: String(rec.content), proxied: rec.proxied, ttl: rec.ttl == null ? 1 : Number(rec.ttl) };++ checkRecord(ctx, want);++ const cf = client(ctx.exec, await loadToken(ctx.exec, opts));++ const zone = await zoneFor(cf, want.name);++ const existing = await cf('GET', `/zones/${zone.id}/dns_records?type=${want.type}&name=${encodeURIComponent(want.name)}`);++ if (existing.length > 1) throw new DnsError(`${existing.length} ${want.type} records named ${want.name}: resolve them by hand first`);++ const body = { type: want.type, name: want.name, content: want.content, ttl: want.ttl, ...(PROXYABLE.has(want.type) ? { proxied: !!want.proxied } : {}) };++ const cur = existing[0] || null;++ const same = cur && cur.content === body.content && (!PROXYABLE.has(want.type) || !!cur.proxied === !!body.proxied) && (cur.ttl === body.ttl || (body.proxied && cur.ttl === 1));++ if (same) return { action: 'unchanged', zone: zone.name, after: show(cur) };++ const action = cur ? 'update' : 'create';++ if (!apply) return { action, dryRun: true, zone: zone.name, ...(cur ? { before: show(cur) } : {}), after: show({ ...body, proxied: !!body.proxied }) };++ const done = cur ? await cf('PUT', `/zones/${zone.id}/dns_records/${cur.id}`, body) : await cf('POST', `/zones/${zone.id}/dns_records`, body);++ return { action, zone: zone.name, ...(cur ? { before: show(cur) } : {}), after: show(done) };++}++++async function remove(ctx, { name, type }, { apply = false, ...opts } = {}) {++ const n = String(name).toLowerCase(); const t = String(type).toUpperCase();++ if (!NAME_RE.test(n)) throw new DnsError(`not a DNS name: ${name}`, 1);++ if (!TYPES.includes(t)) throw new DnsError(`type is one of ${TYPES.join(', ')}`, 1);++ const cf = client(ctx.exec, await loadToken(ctx.exec, opts));++ const zone = await zoneFor(cf, n);++ const existing = await cf('GET', `/zones/${zone.id}/dns_records?type=${t}&name=${encodeURIComponent(n)}`);++ if (!existing.length) return { action: 'absent', zone: zone.name };++ if (existing.length > 1) throw new DnsError(`${existing.length} ${t} records named ${n}: resolve them by hand`);++ if (!apply) return { action: 'delete', dryRun: true, zone: zone.name, before: show(existing[0]) };++ await cf('DELETE', `/zones/${zone.id}/dns_records/${existing[0].id}`);++ return { action: 'delete', zone: zone.name, before: show(existing[0]) };++}++++module.exports = { list, ensure, remove, loadToken, DnsError, TYPES, DEFAULT_DNS_ONLY };nnew file mode 100644@@@ -0,0 +1,108 @@++'use strict';++/**++ * Incident and maintenance controls (roadmap WS-N task 12).++ *++ * ovhost incident open --title <t> --services a,b [--severity minor|major|critical] --message <m>++ * ovhost incident update <inc_…> --state identified|monitoring|resolved --message <m>++ * ovhost incident list++ * ovhost maintenance schedule --title <t> --services a,b --from <iso> --until <iso> --message <m> [--freeze]++ * ovhost maintenance start|complete <inc_…> --message <m>++ * ovhost freeze <service|all> --reason <text> [--incident <inc_…>]++ * ovhost unfreeze <service|all>++ *++ * Incidents and windows are posted to OpenVibe.Network's status page (POST /api/v1/status/incidents,++ * Contracts 0.66.0 network.status-incident-request@1) with Host's service token (network.status.incident),++ * from the same credentials as release notifications (/etc/openvibe/host.env; the secret and token are never++ * printed). Messages are for the public: no internal hostnames, secrets or personal data.++ *++ * A freeze is local: <stateDir>/freeze/<service>.json (or all.json). `ovhost deploy` refuses a frozen service++ * (exit 6) unless --force; rollbacks are never frozen, since they are how an incident ends. A maintenance window++ * scheduled with --freeze freezes its services until `maintenance complete`.++ */++const path = require('path');++const { loadCredentials } = require('./announce');++++const SERVICE_RE = /^[a-z][a-z0-9-]{1,39}$/;++const INCIDENT_RE = /^inc_[0-9A-HJKMNP-TV-Z]{26}$/;++++class IncidentsError extends Error {++ constructor(message, exitCode = 2) { super(message); this.name = 'IncidentsError'; this.exitCode = exitCode; }++}++++async function call(ctx, method, route, body, { envFile = null, env = {} } = {}) {++ const creds = await loadCredentials(ctx.exec, ctx.inv, { file: envFile, env });++ if (creds.error) throw new IncidentsError(`not configured: ${creds.error}`);++ const net = creds.networkUrl;++ let token = null;++ if (method !== 'GET') {++ const form = new URLSearchParams({ grant_type: 'client_credentials', client_id: creds.clientId, client_secret: creds.secret, audience: 'openvibe.network', scope: 'network.status.incident' });++ const t = await ctx.exec.request(`${net}/oauth/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }, body: form.toString(), timeoutMs: 5000 });++ try { token = t.status === 200 ? JSON.parse(t.body).access_token : null; } catch { token = null; }++ if (!token) throw new IncidentsError(t.status ? `the token endpoint answered ${t.status}` : `Network unreachable (${t.error || 'no answer'})`);++ }++ const r = await ctx.exec.request(`${net}/api/v1/status/incidents${route}`, {++ method, headers: { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}) },++ body: body ? JSON.stringify(body) : null, timeoutMs: 10000,++ });++ let json = null;++ try { json = JSON.parse(r.body); } catch { json = null; }++ if (r.status < 200 || r.status >= 300) throw new IncidentsError(`Network answered ${r.status || 'nothing'}${json && (json.detail || json.error) ? `: ${json.detail || json.error}` : ''}`);++ return json;++}++++function services(list) {++ const s = String(list || '').split(',').map((x) => x.trim()).filter(Boolean);++ if (!s.length) throw new IncidentsError('--services names at least one service id', 1);++ for (const x of s) if (!SERVICE_RE.test(x)) throw new IncidentsError(`not a service id: ${x}`, 1);++ return s;++}++const needId = (id) => { if (!INCIDENT_RE.test(String(id || ''))) throw new IncidentsError('name the incident: inc_…', 1); return id; };++const needText = (v, flag) => { if (!v || v === true || !String(v).trim()) throw new IncidentsError(`${flag} is required`, 1); return String(v).trim(); };++++const open = (ctx, o, conn) => call(ctx, 'POST', '', {++ kind: 'incident', title: needText(o.title, '--title'), severity: o.severity || 'minor', services: services(o.services), message: needText(o.message, '--message'),++}, conn);++const update = (ctx, id, o, conn) => call(ctx, 'POST', `/${needId(id)}/updates`, { state: needText(o.state, '--state'), message: needText(o.message, '--message'), ...(o.until ? { ends_at: new Date(o.until).toISOString() } : {}) }, conn);++const list = (ctx, conn) => call(ctx, 'GET', '', null, conn);++function schedule(ctx, o, conn) {++ const from = Date.parse(o.from); const until = Date.parse(o.until);++ if (!Number.isFinite(from) || !Number.isFinite(until)) throw new IncidentsError('--from and --until are ISO 8601 times', 1);++ return call(ctx, 'POST', '', { kind: 'maintenance', title: needText(o.title, '--title'), services: services(o.services), message: needText(o.message, '--message'), starts_at: new Date(from).toISOString(), ends_at: new Date(until).toISOString() }, conn);++}++++// ── Freezes ──++const freezeDir = (inv) => path.join(inv.stateDir, 'freeze');++const freezeFile = (inv, id) => path.join(freezeDir(inv), `${id}.json`);++++async function freeze(ctx, id, { reason, incident = null, by = null, now = new Date().toISOString() } = {}) {++ if (id !== 'all' && !SERVICE_RE.test(String(id))) throw new IncidentsError(`not a service id: ${id}`, 1);++ const rec = { service: id, reason: needText(reason, '--reason'), incident: incident || null, by: by || null, at: now };++ await ctx.exec.mkdir(freezeDir(ctx.inv), { mode: 0o750 });++ await ctx.exec.writeFile(freezeFile(ctx.inv, id), `${JSON.stringify(rec, null, 2)}\n`, { mode: 0o640 });++ return rec;++}++async function unfreeze(ctx, id) {++ if (id !== 'all' && !SERVICE_RE.test(String(id))) throw new IncidentsError(`not a service id: ${id}`, 1);++ const had = await frozen(ctx, id, { exact: true });++ if (had) await ctx.exec.removeFile(freezeFile(ctx.inv, id));++ return !!had;++}++/** The freeze that holds a service (its own, else 'all'), or null. exact: only its own file. */++async function frozen(ctx, id, { exact = false } = {}) {++ for (const f of exact ? [id] : [id, 'all']) {++ let rec = null;++ try { rec = JSON.parse((await ctx.exec.readFile(freezeFile(ctx.inv, f))) || 'null'); } catch { rec = null; }++ if (rec) return rec;++ }++ return null;++}++async function freezes(ctx) {++ const out = [];++ for (const e of (await ctx.exec.readdir(freezeDir(ctx.inv))) || []) {++ if (!e.name.endsWith('.json')) continue;++ try { const r = JSON.parse((await ctx.exec.readFile(path.join(freezeDir(ctx.inv), e.name))) || 'null'); if (r) out.push(r); } catch { /* skip */ }++ }++ return out.sort((a, b) => a.service.localeCompare(b.service));++}++++module.exports = { open, update, list, schedule, freeze, unfreeze, frozen, freezes, IncidentsError, INCIDENT_RE };@@@ -36,6 +36,8 @@ module.exports = {archive: require('./archive'),alerts: require('./alerts'),browserWatch: require('./browser-watch'),++ incidents: require('./incidents'),++ dns: require('./dns'),snapshot,drill,DrillError,@@@ -333,6 +333,8 @@ function normalise(raw) {},// Release notifications (lib/announce.js): the env file with Host's service credentials.events: { envFile: raw.events && raw.events.envFile ? abs(raw.events.envFile, 'events.envFile') : null },++ // The DNS adapter (lib/dns.js): names never proxied by Cloudflare (non-HTTP traffic, the CNAME target).++ dns: { dnsOnly: Array.isArray(raw.dns && raw.dns.dnsOnly) ? raw.dns.dnsOnly.map(String) : require('./dns').DEFAULT_DNS_ONLY },services: {},};for (const [id, svc] of Object.entries(raw.services)) {@@@ -19,7 +19,7 @@** Exit codes: 0 ok · 1 usage/precondition · 2 validation failed, nothing restarted ·* 3 not ready, rolled back and serving · 4 rollback failed — MANUAL INTERVENTION ·-- * 5 protected sessions active (refused, or --wait-idle gave up)++ * 5 protected sessions active (refused, or --wait-idle gave up) · 6 frozen (ovhost freeze)*/const path = require('path');const { git } = require('./git');@@@ -31,7 +31,7 @@ const releases = require('./releases');const lock = require('./lock');const nginx = require('./nginx');--const EXIT = { OK: 0, USAGE: 1, VALIDATION: 2, ROLLED_BACK: 3, ROLLBACK_FAILED: 4, PROTECTED: 5 };++const EXIT = { OK: 0, USAGE: 1, VALIDATION: 2, ROLLED_BACK: 3, ROLLBACK_FAILED: 4, PROTECTED: 5, FROZEN: 6 };class OpError extends Error {constructor(message, exitCode, extra = {}) {@@@ -415,6 +415,12 @@ async function apply(ctx, svc, mode, opts = {}) {async function deploy(ctx, id, opts = {}) {const svc = require('./inventory').service(ctx.inv, id);++ // A freeze (ovhost freeze, or maintenance schedule --freeze; lib/incidents.js) holds deploys, never rollbacks.++ const hold = await require('./incidents').frozen(ctx, svc.id);++ if (hold) {++ if (!opts.force) throw new OpError(`${svc.id} is frozen${hold.service === 'all' ? ' (all services)' : ''} since ${hold.at}: ${hold.reason}${hold.incident ? ` (${hold.incident})` : ''}. ovhost unfreeze ${hold.service}, or --force`, EXIT.FROZEN);++ ctx.log(`!!! --force: deploying ${svc.id} through the freeze (${hold.reason})`);++ }return apply(ctx, svc, 'deploy', opts);}@@@ -16,7 +16,7 @@"dotenv": "^16.4.7","express": "^4.21.2","express-rate-limit": "^7.5.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.62.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.66.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"},@@@ -1412,9 +1412,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.62.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.62.0",-- "integrity": "sha512-mmVQToHBY77NrZEHqOBoPuRdZ/bnAzp11E7VkBrN9maPdGCoG8YIJqIq/Hi/dFiDrLJtAcdXRKl/tahdWrkkdQ==",++ "version": "0.66.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.66.0",++ "integrity": "sha512-iArup+k2iUI4ddMDVgVrf8xTCbEB5LaV0Da/1Bt5k3eH2TJpJ7yIEtBgBee0UZ6taNd2bZsZ6zJOkamqCAa4nA==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -31,7 +31,7 @@"dotenv": "^16.4.7","express": "^4.21.2","express-rate-limit": "^7.5.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.62.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.66.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"},nnew file mode 100644@@@ -0,0 +1,90 @@++'use strict';++/**++ * The DNS adapter (lib/dns.js, WS-N task 12): Cloudflare's API with the operator's token (never printed);++ * dry run unless --apply; ensure creates, updates or leaves alone; DNS-only names and service records are++ * never proxied; web records name their proxy mode; duplicate records are refused; runs without an inventory.++ */++const assert = require('assert');++const { scenario, test, runTests } = require('./helpers');++const { createFakeHost } = require('./fake-host');++const { main } = require('../lib/cli');++++const TOKEN = 'cf_TOKEN_MUST_NEVER_APPEAR_91b0';++const API = 'https://api.cloudflare.com/client/v4';++++function cloudflare(host, records = []) {++ host.cf = { records: records.map((r, i) => ({ id: `rec${i}`, ttl: 1, proxied: false, ...r })), writes: [] };++ const ok = (result) => ({ status: 200, body: { success: true, errors: [], result } });++ host.http.set(`${API}/zones?name=openvibe.live&per_page=5`, ({ headers }) => { assert.strictEqual(headers.Authorization, `Bearer ${TOKEN}`); return ok([{ id: 'z1', name: 'openvibe.live' }]); });++ for (const n of ['www.openvibe.live', 'ingest.openvibe.live', '_dmarc.openvibe.live', 'new.openvibe.live']) host.http.set(`${API}/zones?name=${n}&per_page=5`, () => ok([]));++ host.http.set(`${API}/zones/z1/dns_records?per_page=100&page=1`, () => ok(host.cf.records));++ for (const [name, type] of [['www.openvibe.live', 'A'], ['new.openvibe.live', 'CNAME'], ['ingest.openvibe.live', 'A'], ['_dmarc.openvibe.live', 'CNAME'], ['dup.openvibe.live', 'A']]) {++ host.http.set(`${API}/zones/z1/dns_records?type=${type}&name=${encodeURIComponent(name)}`, () => ok(host.cf.records.filter((r) => r.name === name && r.type === type)));++ }++ host.http.set(`${API}/zones?name=dup.openvibe.live&per_page=5`, () => ok([]));++ host.http.set(`${API}/zones/z1/dns_records`, ({ method, body }) => { const b = JSON.parse(body); host.cf.writes.push([method, b]); const rec = { id: `rec${host.cf.records.length}`, ...b }; host.cf.records.push(rec); return ok(rec); });++ for (let i = 0; i < 10; i++) {++ host.http.set(`${API}/zones/z1/dns_records/rec${i}`, ({ method, body }) => {++ host.cf.writes.push([method, body ? JSON.parse(body) : null]);++ const k = host.cf.records.findIndex((r) => r.id === `rec${i}`);++ if (method === 'DELETE') { host.cf.records.splice(k, 1); return ok({ id: `rec${i}` }); }++ host.cf.records[k] = { ...host.cf.records[k], ...JSON.parse(body) };++ return ok(host.cf.records[k]);++ });++ }++ return host;++}++++runTests([++ test('list, then ensure: dry run by default, create / update / unchanged with --apply; the token never shows', async () => {++ const host = cloudflare(scenario(), [{ name: 'www.openvibe.live', type: 'A', content: '203.0.113.5', proxied: true }]);++ const env = { CLOUDFLARE_API_TOKEN: TOKEN };++ const cli = (...a) => main(a, { exec: host.exec, out: (s) => (host.lines = (host.lines || []).concat(s)), env });++ const run = async (...a) => { host.lines = []; const code = await cli(...a); return { code, out: host.lines.join('\n') }; };++ let r = await run('dns', 'list', 'openvibe.live');++ assert.strictEqual(r.code, 0, r.out);++ assert.match(r.out, /zone openvibe\.live: 1 record\(s\)/);++ assert.match(r.out, /www\.openvibe\.live {2}A {2}203\.0\.113\.5 {2}proxied/);++ r = await run('dns', 'ensure', 'www.openvibe.live', 'A', '203.0.113.5', '--proxied');++ assert.match(r.out, /unchanged in openvibe\.live/);++ r = await run('dns', 'ensure', 'www.openvibe.live', 'A', '203.0.113.9', '--proxied');++ assert.match(r.out, /would update in openvibe\.live \(dry run; --apply to change\)/);++ assert.strictEqual(host.cf.writes.length, 0, 'a dry run writes nothing');++ r = await run('dns', 'ensure', 'www.openvibe.live', 'A', '203.0.113.9', '--proxied', '--apply');++ assert.match(r.out, /^update in openvibe\.live/m);++ assert.deepStrictEqual(host.cf.writes[0], ['PUT', { type: 'A', name: 'www.openvibe.live', content: '203.0.113.9', ttl: 1, proxied: true }]);++ r = await run('dns', 'ensure', 'new.openvibe.live', 'CNAME', 'openvibe.live', '--proxied', '--apply');++ assert.match(r.out, /^create in openvibe\.live/m);++ r = await run('dns', 'delete', 'new.openvibe.live', 'CNAME');++ assert.match(r.out, /would delete/);++ r = await run('dns', 'delete', 'new.openvibe.live', 'CNAME', '--apply');++ assert.match(r.out, /^delete in openvibe\.live/m);++ assert.ok(!JSON.stringify(host.calls).includes(TOKEN) && !r.out.includes(TOKEN), 'the token appears in no call record or output');++ }),++ test('guards: proxy mode required, DNS-only names and service records never proxied, duplicates refused, bad input', async () => {++ const host = cloudflare(scenario(), [{ name: 'dup.openvibe.live', type: 'A', content: '1.1.1.1' }, { name: 'dup.openvibe.live', type: 'A', content: '1.1.1.2' }]);++ const env = { CLOUDFLARE_API_TOKEN: TOKEN };++ const run = async (...a) => { const lines = []; const code = await main(a, { exec: host.exec, out: (s) => lines.push(s), env }); return { code, out: lines.join('\n') }; };++ let r = await run('dns', 'ensure', 'www.openvibe.live', 'A', '203.0.113.5');++ assert.deepStrictEqual([r.code, /need --proxied or --dns-only/.test(r.out)], [1, true]);++ r = await run('dns', 'ensure', 'ingest.openvibe.live', 'A', '203.0.113.5', '--proxied');++ assert.deepStrictEqual([r.code, /DNS-only by the inventory/.test(r.out)], [1, true]);++ r = await run('dns', 'ensure', '_dmarc.openvibe.live', 'CNAME', 'x.example', '--proxied');++ assert.deepStrictEqual([r.code, /never proxied/.test(r.out)], [1, true]);++ r = await run('dns', 'ensure', 'dup.openvibe.live', 'A', '1.1.1.1', '--dns-only');++ assert.deepStrictEqual([r.code, /2 A records named dup\.openvibe\.live/.test(r.out)], [2, true]);++ assert.strictEqual((await run('dns', 'ensure', 'Not A Name', 'A', '1.1.1.1', '--dns-only')).code, 1);++ assert.strictEqual((await run('dns', 'ensure', 'www.openvibe.live', 'SRV', 'x', '--dns-only')).code, 1);++ assert.strictEqual((await run('dns', 'ensure', 'www.openvibe.live', 'A', '1.1.1.1', '--proxied', '--dns-only')).code, 1);++ assert.strictEqual(host.cf.writes.length, 0);++ }),++ test('no token: a clear refusal; no inventory needed on the workstation', async () => {++ const bare = createFakeHost(); // no /etc/openvibe/host.json++ const lines = [];++ const code = await main(['dns', 'list', 'openvibe.live'], { exec: bare.exec, out: (s) => lines.push(s), env: {} });++ assert.strictEqual(code, 1);++ assert.match(lines.join('\n'), /no Cloudflare token/);++ const other = await main(['status'], { exec: bare.exec, out: () => {}, env: {} });++ assert.strictEqual(other, 1, 'other commands still need the inventory');++ }),++]);nnew file mode 100644@@@ -0,0 +1,122 @@++'use strict';++/**++ * Incident and maintenance controls (lib/incidents.js, WS-N task 12): ovhost incident / maintenance post to++ * Network's status page with Host's token (network.status.incident); freezes hold deploys (exit 6) but never++ * rollbacks; --force goes through and says so; a window scheduled with --freeze is lifted by complete.++ */++const assert = require('assert');++const contracts = require('openvibe-contracts');++const { scenario, push, test, runTests, SECRET } = require('./helpers');++++const TOKEN = 'tok_INCIDENT_TOKEN_MUST_NEVER_APPEAR_7a2c';++const HOST_SECRET = `${SECRET}-host`;++const INC = 'inc_01JAB2C3D4E5F6G7H8J9K0MNPQ';++++function withNetwork() {++ const host = scenario();++ host.put('/etc/openvibe/host.env', `OV_OAUTH_CLIENT_ID=host\nOV_OAUTH_CLIENT_SECRET="${HOST_SECRET}"\nEVENTS_URL=http://127.0.0.1:4300\nOV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000\n`, { mode: 0o600, owner: 'root' });++ host.tokenRequests = [];++ host.posts = [];++ const store = [];++ host.http.set('http://127.0.0.1:4000/oauth/token', ({ body }) => {++ const form = Object.fromEntries(new URLSearchParams(body));++ host.tokenRequests.push(form);++ if (form.client_secret !== HOST_SECRET) return { status: 401, body: { error: 'invalid_client' } };++ return { status: 200, body: { access_token: TOKEN, token_type: 'Bearer', expires_in: 300 } };++ });++ const now = '2026-09-26T16:00:00.000Z';++ host.http.set('http://127.0.0.1:4000/api/v1/status/incidents', ({ method, headers, body }) => {++ if (method === 'GET') return { status: 200, body: { active: store.filter((i) => !['resolved', 'completed'].includes(i.state)), recent: store.filter((i) => ['resolved', 'completed'].includes(i.state)) } };++ assert.strictEqual(headers.Authorization, `Bearer ${TOKEN}`);++ const b = JSON.parse(body);++ assert.ok(contracts.validate('network.status-incident-request@1', b).valid, JSON.stringify(b));++ host.posts.push(b);++ const i = { id: INC, kind: b.kind, title: b.title, ...(b.kind === 'incident' ? { severity: b.severity } : {}), state: b.kind === 'maintenance' ? 'scheduled' : 'investigating', services: b.services, starts_at: b.starts_at || now, ends_at: b.ends_at || null, updates: [{ at: now, state: 'investigating', message: b.message }], created_at: now, updated_at: now };++ store.push(i);++ return { status: 201, body: i };++ });++ host.http.set(`http://127.0.0.1:4000/api/v1/status/incidents/${INC}/updates`, ({ headers, body }) => {++ assert.strictEqual(headers.Authorization, `Bearer ${TOKEN}`);++ const b = JSON.parse(body);++ assert.ok(contracts.validate('network.status-incident-request@1', b).valid);++ host.posts.push(b);++ const i = store.find((x) => x.id === INC);++ if (['resolved', 'completed'].includes(i.state)) return { status: 409, body: { detail: `this ${i.kind} is ${i.state}` } };++ i.state = b.state; i.updates.push({ at: now, state: b.state, message: b.message });++ return { status: 200, body: i };++ });++ return host;++}++++runTests([++ test('incident open / update / list: Host\'s token for network.status.incident, contract bodies, no secret shown', async () => {++ const host = withNetwork();++ let r = await host.cli('incident', 'open', '--title', 'Live streams stall on start', '--services', 'live', '--severity', 'major', '--message', 'New streams take a minute to start.');++ assert.strictEqual(r.code, 0, r.out);++ assert.match(r.out, new RegExp(`${INC} major investigating live Live streams stall on start`));++ assert.deepStrictEqual(host.tokenRequests.map((t) => [t.audience, t.scope]), [['openvibe.network', 'network.status.incident']]);++ r = await host.cli('incident', 'update', INC, '--state', 'resolved', '--message', 'Rebound.');++ assert.strictEqual(r.code, 0, r.out);++ assert.match(r.out, /resolved/);++ r = await host.cli('incident', 'update', INC, '--state', 'monitoring', '--message', 'again');++ assert.strictEqual(r.code, 2);++ assert.match(r.out, /Network answered 409: this incident is resolved/);++ r = await host.cli('incident', 'list');++ assert.match(r.out, /active: 0/);++ assert.match(r.out, /closed in the last 30 days: 1/);++ for (const s of [HOST_SECRET, TOKEN]) assert.ok(!r.out.includes(s));++ }),++ test('bad input stops before Network: services, ids, missing text', async () => {++ const host = withNetwork();++ assert.strictEqual((await host.cli('incident', 'open', '--title', 'x', '--services', 'Bad Id', '--message', 'm')).code, 1);++ assert.strictEqual((await host.cli('incident', 'open', '--title', 'x', '--services', 'live')).code, 1, '--message is required');++ assert.strictEqual((await host.cli('incident', 'update', 'nope', '--state', 'resolved', '--message', 'm')).code, 1);++ assert.strictEqual(host.posts.length, 0);++ }),++ test('freeze holds deploys (exit 6), never rollbacks; --force goes through loudly; unfreeze lifts it', async () => {++ const host = withNetwork();++ push(host, 'live', { 'server/index.js': 'console.log(2);' }, 'server change');++ let r = await host.cli('freeze', 'live', '--reason', 'incident: streams stall', '--incident', INC);++ assert.strictEqual(r.code, 0, r.out);++ r = await host.cli('deploy', 'live');++ assert.strictEqual(r.code, 6, r.out);++ assert.match(r.out, /live is frozen since .*: incident: streams stall \(inc_/);++ assert.deepStrictEqual(host.restarts(), [], 'nothing restarted');++ r = await host.cli('freeze');++ assert.match(r.out, /^live {2}since .* {2}incident: streams stall/m);++ r = await host.cli('deploy', 'live', '--force');++ assert.strictEqual(r.code, 0, r.out);++ assert.match(r.out, /--force: deploying live through the freeze/);++ await host.cli('unfreeze', 'live');++ await host.cli('freeze', 'all', '--reason', 'network-wide change window');++ push(host, 'live', { 'server/index.js': 'console.log(3);' }, 'another');++ r = await host.cli('deploy', 'live');++ assert.strictEqual(r.code, 6);++ assert.match(r.out, /\(all services\)/);++ r = await host.cli('rollback', 'live');++ assert.notStrictEqual(r.code, 6, 'a rollback is never frozen');++ assert.match((await host.cli('unfreeze', 'all')).out, /unfrozen: all/);++ assert.match((await host.cli('unfreeze', 'all')).out, /was not frozen/);++ }),++ test('maintenance schedule --freeze freezes its services; complete lifts them', async () => {++ const host = withNetwork();++ let r = await host.cli('maintenance', 'schedule', '--title', 'Media storage move', '--services', 'media,live', '--from', '2026-09-28T03:00:00Z', '--until', '2026-09-28T04:00:00Z', '--message', 'Uploads pause for up to an hour.', '--freeze');++ assert.strictEqual(r.code, 0, r.out);++ assert.deepStrictEqual(host.posts[0], { kind: 'maintenance', title: 'Media storage move', services: ['media', 'live'], message: 'Uploads pause for up to an hour.', starts_at: '2026-09-28T03:00:00.000Z', ends_at: '2026-09-28T04:00:00.000Z' });++ r = await host.cli('freeze');++ assert.match(r.out, /live .*maintenance: Media storage move/);++ assert.match(r.out, /media .*maintenance: Media storage move/);++ r = await host.cli('maintenance', 'start', INC, '--message', 'Moving now.');++ assert.strictEqual(r.code, 0);++ r = await host.cli('maintenance', 'complete', INC, '--message', 'Done.');++ assert.strictEqual(r.code, 0, r.out);++ assert.match((await host.cli('freeze')).out, /nothing is frozen/);++ assert.strictEqual((await host.cli('maintenance', 'schedule', '--title', 'x', '--services', 'live', '--from', 'soon', '--until', 'later', '--message', 'm')).code, 1);++ }),++ test('not configured: a clear refusal', async () => {++ const host = scenario();++ const r = await host.cli('incident', 'open', '--title', 'x', '--services', 'live', '--message', 'm');++ assert.strictEqual(r.code, 2);++ assert.match(r.out, /not configured/);++ }),++]);
new file mode 100644
new file mode 100644
@@ -0,0 +1,60 @@
@@ -0,0 +1,60 @@
+# Incidents, maintenance, freezes and DNS
+
+Roadmap WS-N task 12. Containers are decided in [ADR-032](https://github.com/OpenVibers/OpenVibe.Contracts/blob/main/docs/adr/ADR-032-containers.md): platform services stay systemd units.
+
+## Incidents and maintenance (the public status page)
+
+Every command here posts to openvibe.network/status (Contracts 0.66.0 `network.status-incident-request@1`).
+It uses Host's service token (`network.status.incident`) from `/etc/openvibe/host.env`, the same credentials as
+release notifications.
+
+```bash
+sudo ovhost incident open --title "Live streams stall on start" --services live --severity major \
+ --message "New streams take a minute to start. We are on it."
+sudo ovhost incident update inc_… --state identified --message "A restart left the ingest socket unbound."
+sudo ovhost incident update inc_… --state resolved --message "Rebound; streams start at once again."
+sudo ovhost incident list
+
+sudo ovhost maintenance schedule --title "Media storage move" --services media \
+ --from 2026-09-28T03:00:00Z --until 2026-09-28T04:00:00Z --message "Uploads pause for up to an hour." --freeze
+sudo ovhost maintenance start inc_… --message "Moving now."
+sudo ovhost maintenance complete inc_… --message "Done." # also lifts the window's freezes
+```
+
+- **Incident states:** investigating, identified, monitoring, resolved.
+- **Maintenance states:** scheduled, in_progress, completed.
+- **Closed:** a resolved incident or completed window refuses further updates.
+- **Messages are public:** no internal hostnames, secrets or personal data.
+- **Staff admins** can post the same through `POST /api/v1/status/incidents` with their session.
+
+## Freezes (deploy holds)
+
+```bash
+sudo ovhost freeze live --reason "incident: streams stall" --incident inc_…
+sudo ovhost freeze all --reason "network-wide change window"
+sudo ovhost freeze # what is frozen
+sudo ovhost unfreeze live
+```
+
+- **A frozen service's `ovhost deploy`** refuses with exit 6. `--force` deploys through and says so in the log.
+- **Rollbacks are never frozen:** they are how an incident ends.
+- **Storage:** freezes live in `/var/lib/openvibe-host/freeze/`.
+
+## DNS (Cloudflare)
+
+These run on the operator's workstation, where the token is (`~/.config/cloudflare-token`, or `--token-file`).
+The production host holds no Cloudflare token. No inventory is needed.
+
+```bash
+node lib/cli.js dns list openvibe.live
+node lib/cli.js dns ensure beta.openvibe.live CNAME openvibe.live --proxied # dry run
+node lib/cli.js dns ensure beta.openvibe.live CNAME openvibe.live --proxied --apply
+node lib/cli.js dns delete beta.openvibe.live CNAME --apply
+```
+
+- **Proxy mode:** web records name theirs (`--proxied` or `--dns-only`).
+- **Never proxied:** names in the inventory's `dns.dnsOnly`. The default list is `ingest.openvibe.live`,
+ `relay.openvibe.live`, `turn.openvibe.live`, `cname.openvibe.host` and `ingest.openre.stream`, as of
+ 2026-09-26. Service records (`_dmarc`, `*._domainkey`) are never proxied either.
+- **One record per name and type:** duplicates of a name and type are refused and must be resolved by hand.
+- **Dry run by default:** nothing changes without `--apply`. The token is never printed or written.
@@ -62,6 +62,17 @@ Usage: ovhost <command> [args] [--inventory <file>] [--json]
@@ -62,6 +62,17 @@ Usage: ovhost <command> [args] [--inventory <file>] [--json]
declared by the inventory) to <prefix>-archive/; never deletes them
declared by the inventory) to <prefix>-archive/; never deletes them
archive list the archives off-host
archive list the archives off-host
archive restore <stamp> <path> --out <dir> one archived file back, checked against the manifest
archive restore <stamp> <path> --out <dir> one archived file back, checked against the manifest
+ dns list <zone> Cloudflare records (run where the token is: --token-file, or
+ ~/.config/cloudflare-token; the host holds none)
+ dns ensure <name> <type> <content> (--proxied|--dns-only) [--ttl <s>] [--apply] dry run by default
+ dns delete <name> <type> [--apply]
+ incident open --title <t> --services a,b [--severity minor|major|critical] --message <m>
+ incident update <inc_…> --state identified|monitoring|resolved --message <m>
+ incident list incidents and maintenance on openvibe.network/status
+ maintenance schedule --title <t> --services a,b --from <iso> --until <iso> --message <m> [--freeze]
+ maintenance start|complete <inc_…> --message <m> (complete lifts the window's freezes)
+ freeze <service|all> --reason <text> [--incident <inc_…>] deploys refuse it (exit 6) unless --force
+ unfreeze <service|all> lift a freeze (freeze with no argument lists them)
browser-watch [--sites live,...] [--force]
browser-watch [--sites live,...] [--force]
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
@@ -87,7 +98,7 @@ drill: 0 passed · 1 precondition (not root, port in use, no backup, unsupported
@@ -87,7 +98,7 @@ drill: 0 passed · 1 precondition (not root, port in use, no backup, unsupported
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', 'inventory', 'to', 'limit', 'warn-days', 'variant', 'manifest', 'out', 'ready-timeout', 'wildcard-cert', 'backup', 'run', 'keep-daily', 'keep-weekly', 'from-host', 'backup-env', 'release', 'commit', 'origin', 'events-env', 'note']);
+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', 'wildcard-cert', 'backup', 'run', 'keep-daily', 'keep-weekly', 'from-host', 'backup-env', 'release', 'commit', 'origin', 'events-env', 'note']);
function parseArgs(argv) {
function parseArgs(argv) {
const pos = [];
const pos = [];
@@ -148,7 +159,12 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc
@@ -148,7 +159,12 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc
const needService = () => { if (!pos[1]) throw new UsageError(`${cmd} needs a <service>`); return pos[1]; };
const needService = () => { if (!pos[1]) throw new UsageError(`${cmd} needs a <service>`); return pos[1]; };
try {
try {
- const inv = await lib.inventory.load(exec, { file: opts.inventory, env });
+ // The DNS adapter runs on the operator's workstation (where the Cloudflare token is), which has no inventory.
+ let inv;
+ try { inv = await lib.inventory.load(exec, { file: opts.inventory, env }); } catch (err) {
+ if (cmd !== 'dns' || opts.inventory || !(err instanceof InventoryError)) throw err;
+ inv = { dns: { dnsOnly: lib.dns.DEFAULT_DNS_ONLY }, services: {} };
+ }
const ctx = { exec, inv, log };
const ctx = { exec, inv, log };
switch (cmd) {
switch (cmd) {
case 'validate': {
case 'validate': {
@@ -374,6 +390,84 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc
@@ -374,6 +390,84 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc
else out(`OFFSITE ${o.ok ? 'ok' : 'FAILED'}: ${o.uploaded.length} file(s) to s3://${o.bucket}/${o.location}${o.failures.length ? `; failures: ${o.failures.map((f) => `${f.service ? `${f.service}/` : ''}${f.file}: ${f.error}`).join('; ')}` : ''}`);
else out(`OFFSITE ${o.ok ? 'ok' : 'FAILED'}: ${o.uploaded.length} file(s) to s3://${o.bucket}/${o.location}${o.failures.length ? `; failures: ${o.failures.map((f) => `${f.service ? `${f.service}/` : ''}${f.file}: ${f.error}`).join('; ')}` : ''}`);
return o.ok ? 0 : 2;
return o.ok ? 0 : 2;
}
}
+ case 'dns': {
+ // WS-N task 12: the DNS adapter (lib/dns.js; Cloudflare API).
+ const dns = lib.dns;
+ const conn = { tokenFile: opts['token-file'], env };
+ const row = (r) => `${r.name} ${r.type} ${r.content}${r.proxied ? ' proxied' : ''} ttl ${r.ttl === 1 ? 'auto' : r.ttl}`;
+ try {
+ const sub = pos[1];
+ if (sub === 'list') {
+ if (!pos[2]) throw new UsageError('usage: ovhost dns list <zone>');
+ const r = await dns.list(ctx, pos[2], conn);
+ if (json) emit(r); else { out(`zone ${r.zone}: ${r.records.length} record(s)`); for (const x of r.records) out(` ${row(x)}`); }
+ return 0;
+ }
+ if (sub === 'ensure' || sub === 'delete') {
+ if (!pos[2] || !pos[3] || (sub === 'ensure' && !pos[4])) throw new UsageError(sub === 'ensure' ? 'usage: ovhost dns ensure <name> <type> <content> (--proxied|--dns-only) [--ttl <s>] [--apply]' : 'usage: ovhost dns delete <name> <type> [--apply]');
+ if (opts.proxied && opts['dns-only']) throw new UsageError('--proxied or --dns-only, not both');
+ const r = sub === 'ensure'
+ ? await dns.ensure(ctx, { name: pos[2], type: pos[3], content: pos[4], proxied: opts.proxied ? true : opts['dns-only'] ? false : undefined, ttl: opts.ttl != null ? Number(opts.ttl) : undefined }, { apply: !!opts.apply, ...conn })
+ : await dns.remove(ctx, { name: pos[2], type: pos[3] }, { apply: !!opts.apply, ...conn });
+ if (json) emit(r);
+ else {
+ out(`${r.dryRun ? 'would ' : ''}${r.action} in ${r.zone}${r.dryRun ? ' (dry run; --apply to change)' : ''}`);
+ if (r.before) out(` before: ${row(r.before)}`);
+ if (r.after) out(` after: ${row(r.after)}`);
+ }
+ return 0;
+ }
+ throw new UsageError('usage: ovhost dns list|ensure|delete …');
+ } catch (err) {
+ if (err instanceof dns.DnsError) { (json ? emit({ ok: false, error: err.message }) : out(`[ovhost] ✗ ${err.message}`)); return err.exitCode; }
+ throw err;
+ }
+ }
+ case 'incident':
+ case 'maintenance':
+ case 'freeze':
+ case 'unfreeze': {
+ // WS-N task 12: incident and maintenance controls (lib/incidents.js).
+ const inc = lib.incidents;
+ const conn = { envFile: opts['events-env'], env };
+ const show = (i) => `${i.id} ${i.kind === 'maintenance' ? 'maintenance' : (i.severity || 'incident')} ${i.state} ${i.services.join(',')} ${i.title}${i.ends_at ? ` (until ${i.ends_at})` : ''}`;
+ try {
+ if (cmd === 'freeze') {
+ if (!pos[1]) { const f = await inc.freezes(ctx); if (json) emit(f); else if (!f.length) out('nothing is frozen'); else for (const x of f) out(`${x.service} since ${x.at} ${x.reason}${x.incident ? ` (${x.incident})` : ''}`); return 0; }
+ const r = await inc.freeze(ctx, pos[1], { reason: opts.reason, incident: opts.incident, by: env.SUDO_USER || env.USER || null });
+ if (json) emit(r); else out(`frozen: ${r.service} (${r.reason}); deploys refuse it until ovhost unfreeze ${r.service}`);
+ return 0;
+ }
+ if (cmd === 'unfreeze') {
+ if (!pos[1]) throw new UsageError('usage: ovhost unfreeze <service|all>');
+ const had = await inc.unfreeze(ctx, pos[1]);
+ out(had ? `unfrozen: ${pos[1]}` : `${pos[1]} was not frozen`);
+ return 0;
+ }
+ const sub = pos[1];
+ let r;
+ if (cmd === 'incident') {
+ if (sub === 'open') r = await inc.open(ctx, opts, conn);
+ else if (sub === 'update') r = await inc.update(ctx, pos[2], opts, conn);
+ else if (sub === 'list') { const l = await inc.list(ctx, conn); if (json) emit(l); else { out(`active: ${l.active.length}`); for (const i of l.active) out(` ${show(i)}`); out(`closed in the last 30 days: ${l.recent.length}`); for (const i of l.recent) out(` ${show(i)}`); } return 0; }
+ else throw new UsageError('usage: ovhost incident open|update|list …');
+ } else {
+ if (sub === 'schedule') {
+ r = await inc.schedule(ctx, opts, conn);
+ if (opts.freeze) for (const s of r.services) await inc.freeze(ctx, s, { reason: `maintenance: ${r.title}`, incident: r.id, by: env.SUDO_USER || env.USER || null });
+ } else if (sub === 'start') r = await inc.update(ctx, pos[2], { state: 'in_progress', message: opts.message }, conn);
+ else if (sub === 'complete') {
+ r = await inc.update(ctx, pos[2], { state: 'completed', message: opts.message }, conn);
+ for (const f of await inc.freezes(ctx)) if (f.incident === r.id) await inc.unfreeze(ctx, f.service);
+ } else throw new UsageError('usage: ovhost maintenance schedule|start|complete …');
+ }
+ if (json) emit(r); else out(show(r));
+ return 0;
+ } catch (err) {
+ if (err instanceof inc.IncidentsError) { (json ? emit({ ok: false, error: err.message }) : out(`[ovhost] ✗ ${err.message}`)); return err.exitCode; }
+ throw err;
+ }
+ }
case 'browser-watch': {
case 'browser-watch': {
// WS-H task 11: the release UX check. A failure pages through the alerts relay.
// WS-H task 11: the release UX check. A failure pages through the alerts relay.
const sites = String(opts.sites || 'live').split(',').map((x) => x.trim()).filter(Boolean);
const sites = String(opts.sites || 'live').split(',').map((x) => x.trim()).filter(Boolean);
new file mode 100644
new file mode 100644
@@ -0,0 +1,130 @@
@@ -0,0 +1,130 @@
+'use strict';
+/**
+ * The DNS adapter (roadmap WS-N task 12): Cloudflare's API, behind ovhost.
+ *
+ * ovhost dns list <zone>
+ * ovhost dns ensure <name> <type> <content> (--proxied | --dns-only) [--ttl <s>] [--apply]
+ * ovhost dns delete <name> <type> [--apply]
+ *
+ * The token is the operator's: --token-file <path>, else $CLOUDFLARE_API_TOKEN_FILE, else
+ * ~/.config/cloudflare-token, else $CLOUDFLARE_API_TOKEN. It never leaves the machine that runs the command,
+ * is never printed or written, and the production host holds none (so these commands run from the
+ * operator's workstation). Changes are a dry run unless --apply.
+ *
+ * The zone is the longest suffix of <name> among the zones the token can see. `ensure` makes exactly one
+ * record of that name and type with that content and proxy mode: it creates, updates in place, or reports
+ * `unchanged`; several records of the same name and type are refused (resolve by hand). A/AAAA/CNAME need an
+ * explicit --proxied or --dns-only. Names the inventory lists as DNS-only (`dns.dnsOnly`: RTMP, TURN, JSMPEG,
+ * the custom-domain CNAME target; see the Cloudflare layout) are never proxied.
+ */
+const path = require('path');
+
+const API = 'https://api.cloudflare.com/client/v4';
+const TYPES = ['A', 'AAAA', 'CNAME', 'TXT', 'MX', 'CAA'];
+const PROXYABLE = new Set(['A', 'AAAA', 'CNAME']);
+// DNS-only on 2026-09-26 (Cloudflare layout): RTMP/SRT ingest, the TURN relay, the custom-domain CNAME target.
+// Mail records (_dmarc, *._domainkey) are DNS-only by their nature and are refused by the underscore rule.
+const DEFAULT_DNS_ONLY = Object.freeze(['ingest.openvibe.live', 'relay.openvibe.live', 'turn.openvibe.live', 'cname.openvibe.host', 'ingest.openre.stream']);
+const NAME_RE = /^(?=.{1,253}$)(\*\.)?([a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
+
+class DnsError extends Error {
+ constructor(message, exitCode = 2) { super(message); this.name = 'DnsError'; this.exitCode = exitCode; }
+}
+
+async function loadToken(exec, { tokenFile = null, env = {} } = {}) {
+ const candidates = [tokenFile, env.CLOUDFLARE_API_TOKEN_FILE, env.HOME ? path.join(env.HOME, '.config', 'cloudflare-token') : null].filter(Boolean);
+ for (const f of candidates) {
+ let text = null;
+ try { text = await exec.readFile(f); } catch { text = null; }
+ if (text && text.trim()) return text.trim();
+ if (f === tokenFile) throw new DnsError(`--token-file ${f} is not readable`, 1);
+ }
+ if (env.CLOUDFLARE_API_TOKEN) return String(env.CLOUDFLARE_API_TOKEN).trim();
+ throw new DnsError('no Cloudflare token: pass --token-file, or run this from the operator\'s workstation (~/.config/cloudflare-token); the host holds none', 1);
+}
+
+function client(exec, token) {
+ return async function cf(method, route, body = null) {
+ const r = await exec.request(`${API}${route}`, {
+ method, headers: { Authorization: `Bearer ${token}`, Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}) },
+ body: body ? JSON.stringify(body) : null, timeoutMs: 15000,
+ });
+ let json = null;
+ try { json = JSON.parse(r.body); } catch { json = null; }
+ if (!json || json.success !== true) {
+ const msg = json && Array.isArray(json.errors) && json.errors.length ? json.errors.map((e) => `${e.code}: ${e.message}`).join('; ') : (r.status ? `HTTP ${r.status}` : (r.error || 'no answer'));
+ throw new DnsError(`Cloudflare refused ${method} ${route.split('?')[0]}: ${msg}`);
+ }
+ return json.result;
+ };
+}
+
+async function zoneFor(cf, name) {
+ const labels = name.replace(/^\*\./, '').split('.');
+ for (let i = 0; i < labels.length - 1; i++) {
+ const candidate = labels.slice(i).join('.');
+ const zones = await cf('GET', `/zones?name=${encodeURIComponent(candidate)}&per_page=5`);
+ if (Array.isArray(zones) && zones.length) return zones[0];
+ }
+ throw new DnsError(`no zone for ${name} is visible to this token`, 1);
+}
+
+const show = (r) => ({ name: r.name, type: r.type, content: r.content, proxied: !!r.proxied, ttl: r.ttl });
+
+async function list(ctx, zoneName, opts = {}) {
+ const cf = client(ctx.exec, await loadToken(ctx.exec, opts));
+ const zone = await zoneFor(cf, zoneName);
+ const out = [];
+ for (let page = 1; page <= 20; page++) {
+ const rows = await cf('GET', `/zones/${zone.id}/dns_records?per_page=100&page=${page}`);
+ out.push(...rows.map(show));
+ if (rows.length < 100) break;
+ }
+ return { zone: zone.name, records: out.sort((a, b) => a.name.localeCompare(b.name) || a.type.localeCompare(b.type)) };
+}
+
+function checkRecord(ctx, { name, type, content, proxied, ttl }) {
+ if (!NAME_RE.test(String(name || '').toLowerCase())) throw new DnsError(`not a DNS name: ${name}`, 1);
+ if (!TYPES.includes(type)) throw new DnsError(`type is one of ${TYPES.join(', ')}`, 1);
+ if (!content) throw new DnsError('content is required', 1);
+ if (PROXYABLE.has(type) && proxied == null) throw new DnsError(`${type} records need --proxied or --dns-only (Cloudflare layout: web traffic proxied; RTMP, TURN, JSMPEG and the CNAME target DNS-only)`, 1);
+ if (!PROXYABLE.has(type) && proxied) throw new DnsError(`${type} records cannot be proxied`, 1);
+ const dnsOnly = new Set(((ctx.inv && ctx.inv.dns && ctx.inv.dns.dnsOnly) || []).map((n) => String(n).toLowerCase()));
+ if (proxied && dnsOnly.has(String(name).toLowerCase())) throw new DnsError(`${name} is DNS-only by the inventory (dns.dnsOnly): it carries non-HTTP traffic or is the CNAME target`, 1);
+ if (proxied && /(^|\.)_/.test(String(name))) throw new DnsError(`${name} is a service record (_dmarc, _domainkey…): never proxied`, 1);
+ if (ttl != null && !(Number.isInteger(ttl) && (ttl === 1 || (ttl >= 60 && ttl <= 86400)))) throw new DnsError('ttl is 1 (automatic) or 60–86400 seconds', 1);
+}
+
+/** Make exactly one <name> <type> record with this content and proxy mode. → { action, zone, before?, after } */
+async function ensure(ctx, rec, { apply = false, ...opts } = {}) {
+ const want = { name: String(rec.name).toLowerCase(), type: String(rec.type).toUpperCase(), content: String(rec.content), proxied: rec.proxied, ttl: rec.ttl == null ? 1 : Number(rec.ttl) };
+ checkRecord(ctx, want);
+ const cf = client(ctx.exec, await loadToken(ctx.exec, opts));
+ const zone = await zoneFor(cf, want.name);
+ const existing = await cf('GET', `/zones/${zone.id}/dns_records?type=${want.type}&name=${encodeURIComponent(want.name)}`);
+ if (existing.length > 1) throw new DnsError(`${existing.length} ${want.type} records named ${want.name}: resolve them by hand first`);
+ const body = { type: want.type, name: want.name, content: want.content, ttl: want.ttl, ...(PROXYABLE.has(want.type) ? { proxied: !!want.proxied } : {}) };
+ const cur = existing[0] || null;
+ const same = cur && cur.content === body.content && (!PROXYABLE.has(want.type) || !!cur.proxied === !!body.proxied) && (cur.ttl === body.ttl || (body.proxied && cur.ttl === 1));
+ if (same) return { action: 'unchanged', zone: zone.name, after: show(cur) };
+ const action = cur ? 'update' : 'create';
+ if (!apply) return { action, dryRun: true, zone: zone.name, ...(cur ? { before: show(cur) } : {}), after: show({ ...body, proxied: !!body.proxied }) };
+ const done = cur ? await cf('PUT', `/zones/${zone.id}/dns_records/${cur.id}`, body) : await cf('POST', `/zones/${zone.id}/dns_records`, body);
+ return { action, zone: zone.name, ...(cur ? { before: show(cur) } : {}), after: show(done) };
+}
+
+async function remove(ctx, { name, type }, { apply = false, ...opts } = {}) {
+ const n = String(name).toLowerCase(); const t = String(type).toUpperCase();
+ if (!NAME_RE.test(n)) throw new DnsError(`not a DNS name: ${name}`, 1);
+ if (!TYPES.includes(t)) throw new DnsError(`type is one of ${TYPES.join(', ')}`, 1);
+ const cf = client(ctx.exec, await loadToken(ctx.exec, opts));
+ const zone = await zoneFor(cf, n);
+ const existing = await cf('GET', `/zones/${zone.id}/dns_records?type=${t}&name=${encodeURIComponent(n)}`);
+ if (!existing.length) return { action: 'absent', zone: zone.name };
+ if (existing.length > 1) throw new DnsError(`${existing.length} ${t} records named ${n}: resolve them by hand`);
+ if (!apply) return { action: 'delete', dryRun: true, zone: zone.name, before: show(existing[0]) };
+ await cf('DELETE', `/zones/${zone.id}/dns_records/${existing[0].id}`);
+ return { action: 'delete', zone: zone.name, before: show(existing[0]) };
+}
+
+module.exports = { list, ensure, remove, loadToken, DnsError, TYPES, DEFAULT_DNS_ONLY };
new file mode 100644
new file mode 100644
@@ -0,0 +1,108 @@
@@ -0,0 +1,108 @@
+'use strict';
+/**
+ * Incident and maintenance controls (roadmap WS-N task 12).
+ *
+ * ovhost incident open --title <t> --services a,b [--severity minor|major|critical] --message <m>
+ * ovhost incident update <inc_…> --state identified|monitoring|resolved --message <m>
+ * ovhost incident list
+ * ovhost maintenance schedule --title <t> --services a,b --from <iso> --until <iso> --message <m> [--freeze]
+ * ovhost maintenance start|complete <inc_…> --message <m>
+ * ovhost freeze <service|all> --reason <text> [--incident <inc_…>]
+ * ovhost unfreeze <service|all>
+ *
+ * Incidents and windows are posted to OpenVibe.Network's status page (POST /api/v1/status/incidents,
+ * Contracts 0.66.0 network.status-incident-request@1) with Host's service token (network.status.incident),
+ * from the same credentials as release notifications (/etc/openvibe/host.env; the secret and token are never
+ * printed). Messages are for the public: no internal hostnames, secrets or personal data.
+ *
+ * A freeze is local: <stateDir>/freeze/<service>.json (or all.json). `ovhost deploy` refuses a frozen service
+ * (exit 6) unless --force; rollbacks are never frozen, since they are how an incident ends. A maintenance window
+ * scheduled with --freeze freezes its services until `maintenance complete`.
+ */
+const path = require('path');
+const { loadCredentials } = require('./announce');
+
+const SERVICE_RE = /^[a-z][a-z0-9-]{1,39}$/;
+const INCIDENT_RE = /^inc_[0-9A-HJKMNP-TV-Z]{26}$/;
+
+class IncidentsError extends Error {
+ constructor(message, exitCode = 2) { super(message); this.name = 'IncidentsError'; this.exitCode = exitCode; }
+}
+
+async function call(ctx, method, route, body, { envFile = null, env = {} } = {}) {
+ const creds = await loadCredentials(ctx.exec, ctx.inv, { file: envFile, env });
+ if (creds.error) throw new IncidentsError(`not configured: ${creds.error}`);
+ const net = creds.networkUrl;
+ let token = null;
+ if (method !== 'GET') {
+ const form = new URLSearchParams({ grant_type: 'client_credentials', client_id: creds.clientId, client_secret: creds.secret, audience: 'openvibe.network', scope: 'network.status.incident' });
+ const t = await ctx.exec.request(`${net}/oauth/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }, body: form.toString(), timeoutMs: 5000 });
+ try { token = t.status === 200 ? JSON.parse(t.body).access_token : null; } catch { token = null; }
+ if (!token) throw new IncidentsError(t.status ? `the token endpoint answered ${t.status}` : `Network unreachable (${t.error || 'no answer'})`);
+ }
+ const r = await ctx.exec.request(`${net}/api/v1/status/incidents${route}`, {
+ method, headers: { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}) },
+ body: body ? JSON.stringify(body) : null, timeoutMs: 10000,
+ });
+ let json = null;
+ try { json = JSON.parse(r.body); } catch { json = null; }
+ if (r.status < 200 || r.status >= 300) throw new IncidentsError(`Network answered ${r.status || 'nothing'}${json && (json.detail || json.error) ? `: ${json.detail || json.error}` : ''}`);
+ return json;
+}
+
+function services(list) {
+ const s = String(list || '').split(',').map((x) => x.trim()).filter(Boolean);
+ if (!s.length) throw new IncidentsError('--services names at least one service id', 1);
+ for (const x of s) if (!SERVICE_RE.test(x)) throw new IncidentsError(`not a service id: ${x}`, 1);
+ return s;
+}
+const needId = (id) => { if (!INCIDENT_RE.test(String(id || ''))) throw new IncidentsError('name the incident: inc_…', 1); return id; };
+const needText = (v, flag) => { if (!v || v === true || !String(v).trim()) throw new IncidentsError(`${flag} is required`, 1); return String(v).trim(); };
+
+const open = (ctx, o, conn) => call(ctx, 'POST', '', {
+ kind: 'incident', title: needText(o.title, '--title'), severity: o.severity || 'minor', services: services(o.services), message: needText(o.message, '--message'),
+}, conn);
+const update = (ctx, id, o, conn) => call(ctx, 'POST', `/${needId(id)}/updates`, { state: needText(o.state, '--state'), message: needText(o.message, '--message'), ...(o.until ? { ends_at: new Date(o.until).toISOString() } : {}) }, conn);
+const list = (ctx, conn) => call(ctx, 'GET', '', null, conn);
+function schedule(ctx, o, conn) {
+ const from = Date.parse(o.from); const until = Date.parse(o.until);
+ if (!Number.isFinite(from) || !Number.isFinite(until)) throw new IncidentsError('--from and --until are ISO 8601 times', 1);
+ return call(ctx, 'POST', '', { kind: 'maintenance', title: needText(o.title, '--title'), services: services(o.services), message: needText(o.message, '--message'), starts_at: new Date(from).toISOString(), ends_at: new Date(until).toISOString() }, conn);
+}
+
+// ── Freezes ──
+const freezeDir = (inv) => path.join(inv.stateDir, 'freeze');
+const freezeFile = (inv, id) => path.join(freezeDir(inv), `${id}.json`);
+
+async function freeze(ctx, id, { reason, incident = null, by = null, now = new Date().toISOString() } = {}) {
+ if (id !== 'all' && !SERVICE_RE.test(String(id))) throw new IncidentsError(`not a service id: ${id}`, 1);
+ const rec = { service: id, reason: needText(reason, '--reason'), incident: incident || null, by: by || null, at: now };
+ await ctx.exec.mkdir(freezeDir(ctx.inv), { mode: 0o750 });
+ await ctx.exec.writeFile(freezeFile(ctx.inv, id), `${JSON.stringify(rec, null, 2)}\n`, { mode: 0o640 });
+ return rec;
+}
+async function unfreeze(ctx, id) {
+ if (id !== 'all' && !SERVICE_RE.test(String(id))) throw new IncidentsError(`not a service id: ${id}`, 1);
+ const had = await frozen(ctx, id, { exact: true });
+ if (had) await ctx.exec.removeFile(freezeFile(ctx.inv, id));
+ return !!had;
+}
+/** The freeze that holds a service (its own, else 'all'), or null. exact: only its own file. */
+async function frozen(ctx, id, { exact = false } = {}) {
+ for (const f of exact ? [id] : [id, 'all']) {
+ let rec = null;
+ try { rec = JSON.parse((await ctx.exec.readFile(freezeFile(ctx.inv, f))) || 'null'); } catch { rec = null; }
+ if (rec) return rec;
+ }
+ return null;
+}
+async function freezes(ctx) {
+ const out = [];
+ for (const e of (await ctx.exec.readdir(freezeDir(ctx.inv))) || []) {
+ if (!e.name.endsWith('.json')) continue;
+ try { const r = JSON.parse((await ctx.exec.readFile(path.join(freezeDir(ctx.inv), e.name))) || 'null'); if (r) out.push(r); } catch { /* skip */ }
+ }
+ return out.sort((a, b) => a.service.localeCompare(b.service));
+}
+
+module.exports = { open, update, list, schedule, freeze, unfreeze, frozen, freezes, IncidentsError, INCIDENT_RE };
@@ -36,6 +36,8 @@ module.exports = {
@@ -36,6 +36,8 @@ module.exports = {
archive: require('./archive'),
archive: require('./archive'),
alerts: require('./alerts'),
alerts: require('./alerts'),
browserWatch: require('./browser-watch'),
browserWatch: require('./browser-watch'),
+ incidents: require('./incidents'),
+ dns: require('./dns'),
snapshot,
snapshot,
drill,
drill,
DrillError,
DrillError,
@@ -333,6 +333,8 @@ function normalise(raw) {
@@ -333,6 +333,8 @@ function normalise(raw) {
},
},
// Release notifications (lib/announce.js): the env file with Host's service credentials.
// Release notifications (lib/announce.js): the env file with Host's service credentials.
events: { envFile: raw.events && raw.events.envFile ? abs(raw.events.envFile, 'events.envFile') : null },
events: { envFile: raw.events && raw.events.envFile ? abs(raw.events.envFile, 'events.envFile') : null },
+ // The DNS adapter (lib/dns.js): names never proxied by Cloudflare (non-HTTP traffic, the CNAME target).
+ dns: { dnsOnly: Array.isArray(raw.dns && raw.dns.dnsOnly) ? raw.dns.dnsOnly.map(String) : require('./dns').DEFAULT_DNS_ONLY },
services: {},
services: {},
};
};
for (const [id, svc] of Object.entries(raw.services)) {
for (const [id, svc] of Object.entries(raw.services)) {
@@ -19,7 +19,7 @@
@@ -19,7 +19,7 @@
*
*
* Exit codes: 0 ok · 1 usage/precondition · 2 validation failed, nothing restarted ·
* Exit codes: 0 ok · 1 usage/precondition · 2 validation failed, nothing restarted ·
* 3 not ready, rolled back and serving · 4 rollback failed — MANUAL INTERVENTION ·
* 3 not ready, rolled back and serving · 4 rollback failed — MANUAL INTERVENTION ·
- * 5 protected sessions active (refused, or --wait-idle gave up)
+ * 5 protected sessions active (refused, or --wait-idle gave up) · 6 frozen (ovhost freeze)
*/
*/
const path = require('path');
const path = require('path');
const { git } = require('./git');
const { git } = require('./git');
@@ -31,7 +31,7 @@ const releases = require('./releases');
@@ -31,7 +31,7 @@ const releases = require('./releases');
const lock = require('./lock');
const lock = require('./lock');
const nginx = require('./nginx');
const nginx = require('./nginx');
-const EXIT = { OK: 0, USAGE: 1, VALIDATION: 2, ROLLED_BACK: 3, ROLLBACK_FAILED: 4, PROTECTED: 5 };
+const EXIT = { OK: 0, USAGE: 1, VALIDATION: 2, ROLLED_BACK: 3, ROLLBACK_FAILED: 4, PROTECTED: 5, FROZEN: 6 };
class OpError extends Error {
class OpError extends Error {
constructor(message, exitCode, extra = {}) {
constructor(message, exitCode, extra = {}) {
@@ -415,6 +415,12 @@ async function apply(ctx, svc, mode, opts = {}) {
@@ -415,6 +415,12 @@ async function apply(ctx, svc, mode, opts = {}) {
async function deploy(ctx, id, opts = {}) {
async function deploy(ctx, id, opts = {}) {
const svc = require('./inventory').service(ctx.inv, id);
const svc = require('./inventory').service(ctx.inv, id);
+ // A freeze (ovhost freeze, or maintenance schedule --freeze; lib/incidents.js) holds deploys, never rollbacks.
+ const hold = await require('./incidents').frozen(ctx, svc.id);
+ if (hold) {
+ if (!opts.force) throw new OpError(`${svc.id} is frozen${hold.service === 'all' ? ' (all services)' : ''} since ${hold.at}: ${hold.reason}${hold.incident ? ` (${hold.incident})` : ''}. ovhost unfreeze ${hold.service}, or --force`, EXIT.FROZEN);
+ ctx.log(`!!! --force: deploying ${svc.id} through the freeze (${hold.reason})`);
+ }
return apply(ctx, svc, 'deploy', opts);
return apply(ctx, svc, 'deploy', opts);
}
}
@@ -16,7 +16,7 @@
@@ -16,7 +16,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.62.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.66.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
},
},
@@ -1412,9 +1412,9 @@
@@ -1412,9 +1412,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.62.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.62.0",
- "integrity": "sha512-mmVQToHBY77NrZEHqOBoPuRdZ/bnAzp11E7VkBrN9maPdGCoG8YIJqIq/Hi/dFiDrLJtAcdXRKl/tahdWrkkdQ==",
+ "version": "0.66.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.66.0",
+ "integrity": "sha512-iArup+k2iUI4ddMDVgVrf8xTCbEB5LaV0Da/1Bt5k3eH2TJpJ7yIEtBgBee0UZ6taNd2bZsZ6zJOkamqCAa4nA==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -31,7 +31,7 @@
@@ -31,7 +31,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.62.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.66.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
},
},
new file mode 100644
new file mode 100644
@@ -0,0 +1,90 @@
@@ -0,0 +1,90 @@
+'use strict';
+/**
+ * The DNS adapter (lib/dns.js, WS-N task 12): Cloudflare's API with the operator's token (never printed);
+ * dry run unless --apply; ensure creates, updates or leaves alone; DNS-only names and service records are
+ * never proxied; web records name their proxy mode; duplicate records are refused; runs without an inventory.
+ */
+const assert = require('assert');
+const { scenario, test, runTests } = require('./helpers');
+const { createFakeHost } = require('./fake-host');
+const { main } = require('../lib/cli');
+
+const TOKEN = 'cf_TOKEN_MUST_NEVER_APPEAR_91b0';
+const API = 'https://api.cloudflare.com/client/v4';
+
+function cloudflare(host, records = []) {
+ host.cf = { records: records.map((r, i) => ({ id: `rec${i}`, ttl: 1, proxied: false, ...r })), writes: [] };
+ const ok = (result) => ({ status: 200, body: { success: true, errors: [], result } });
+ host.http.set(`${API}/zones?name=openvibe.live&per_page=5`, ({ headers }) => { assert.strictEqual(headers.Authorization, `Bearer ${TOKEN}`); return ok([{ id: 'z1', name: 'openvibe.live' }]); });
+ for (const n of ['www.openvibe.live', 'ingest.openvibe.live', '_dmarc.openvibe.live', 'new.openvibe.live']) host.http.set(`${API}/zones?name=${n}&per_page=5`, () => ok([]));
+ host.http.set(`${API}/zones/z1/dns_records?per_page=100&page=1`, () => ok(host.cf.records));
+ for (const [name, type] of [['www.openvibe.live', 'A'], ['new.openvibe.live', 'CNAME'], ['ingest.openvibe.live', 'A'], ['_dmarc.openvibe.live', 'CNAME'], ['dup.openvibe.live', 'A']]) {
+ host.http.set(`${API}/zones/z1/dns_records?type=${type}&name=${encodeURIComponent(name)}`, () => ok(host.cf.records.filter((r) => r.name === name && r.type === type)));
+ }
+ host.http.set(`${API}/zones?name=dup.openvibe.live&per_page=5`, () => ok([]));
+ host.http.set(`${API}/zones/z1/dns_records`, ({ method, body }) => { const b = JSON.parse(body); host.cf.writes.push([method, b]); const rec = { id: `rec${host.cf.records.length}`, ...b }; host.cf.records.push(rec); return ok(rec); });
+ for (let i = 0; i < 10; i++) {
+ host.http.set(`${API}/zones/z1/dns_records/rec${i}`, ({ method, body }) => {
+ host.cf.writes.push([method, body ? JSON.parse(body) : null]);
+ const k = host.cf.records.findIndex((r) => r.id === `rec${i}`);
+ if (method === 'DELETE') { host.cf.records.splice(k, 1); return ok({ id: `rec${i}` }); }
+ host.cf.records[k] = { ...host.cf.records[k], ...JSON.parse(body) };
+ return ok(host.cf.records[k]);
+ });
+ }
+ return host;
+}
+
+runTests([
+ test('list, then ensure: dry run by default, create / update / unchanged with --apply; the token never shows', async () => {
+ const host = cloudflare(scenario(), [{ name: 'www.openvibe.live', type: 'A', content: '203.0.113.5', proxied: true }]);
+ const env = { CLOUDFLARE_API_TOKEN: TOKEN };
+ const cli = (...a) => main(a, { exec: host.exec, out: (s) => (host.lines = (host.lines || []).concat(s)), env });
+ const run = async (...a) => { host.lines = []; const code = await cli(...a); return { code, out: host.lines.join('\n') }; };
+ let r = await run('dns', 'list', 'openvibe.live');
+ assert.strictEqual(r.code, 0, r.out);
+ assert.match(r.out, /zone openvibe\.live: 1 record\(s\)/);
+ assert.match(r.out, /www\.openvibe\.live {2}A {2}203\.0\.113\.5 {2}proxied/);
+ r = await run('dns', 'ensure', 'www.openvibe.live', 'A', '203.0.113.5', '--proxied');
+ assert.match(r.out, /unchanged in openvibe\.live/);
+ r = await run('dns', 'ensure', 'www.openvibe.live', 'A', '203.0.113.9', '--proxied');
+ assert.match(r.out, /would update in openvibe\.live \(dry run; --apply to change\)/);
+ assert.strictEqual(host.cf.writes.length, 0, 'a dry run writes nothing');
+ r = await run('dns', 'ensure', 'www.openvibe.live', 'A', '203.0.113.9', '--proxied', '--apply');
+ assert.match(r.out, /^update in openvibe\.live/m);
+ assert.deepStrictEqual(host.cf.writes[0], ['PUT', { type: 'A', name: 'www.openvibe.live', content: '203.0.113.9', ttl: 1, proxied: true }]);
+ r = await run('dns', 'ensure', 'new.openvibe.live', 'CNAME', 'openvibe.live', '--proxied', '--apply');
+ assert.match(r.out, /^create in openvibe\.live/m);
+ r = await run('dns', 'delete', 'new.openvibe.live', 'CNAME');
+ assert.match(r.out, /would delete/);
+ r = await run('dns', 'delete', 'new.openvibe.live', 'CNAME', '--apply');
+ assert.match(r.out, /^delete in openvibe\.live/m);
+ assert.ok(!JSON.stringify(host.calls).includes(TOKEN) && !r.out.includes(TOKEN), 'the token appears in no call record or output');
+ }),
+ test('guards: proxy mode required, DNS-only names and service records never proxied, duplicates refused, bad input', async () => {
+ const host = cloudflare(scenario(), [{ name: 'dup.openvibe.live', type: 'A', content: '1.1.1.1' }, { name: 'dup.openvibe.live', type: 'A', content: '1.1.1.2' }]);
+ const env = { CLOUDFLARE_API_TOKEN: TOKEN };
+ const run = async (...a) => { const lines = []; const code = await main(a, { exec: host.exec, out: (s) => lines.push(s), env }); return { code, out: lines.join('\n') }; };
+ let r = await run('dns', 'ensure', 'www.openvibe.live', 'A', '203.0.113.5');
+ assert.deepStrictEqual([r.code, /need --proxied or --dns-only/.test(r.out)], [1, true]);
+ r = await run('dns', 'ensure', 'ingest.openvibe.live', 'A', '203.0.113.5', '--proxied');
+ assert.deepStrictEqual([r.code, /DNS-only by the inventory/.test(r.out)], [1, true]);
+ r = await run('dns', 'ensure', '_dmarc.openvibe.live', 'CNAME', 'x.example', '--proxied');
+ assert.deepStrictEqual([r.code, /never proxied/.test(r.out)], [1, true]);
+ r = await run('dns', 'ensure', 'dup.openvibe.live', 'A', '1.1.1.1', '--dns-only');
+ assert.deepStrictEqual([r.code, /2 A records named dup\.openvibe\.live/.test(r.out)], [2, true]);
+ assert.strictEqual((await run('dns', 'ensure', 'Not A Name', 'A', '1.1.1.1', '--dns-only')).code, 1);
+ assert.strictEqual((await run('dns', 'ensure', 'www.openvibe.live', 'SRV', 'x', '--dns-only')).code, 1);
+ assert.strictEqual((await run('dns', 'ensure', 'www.openvibe.live', 'A', '1.1.1.1', '--proxied', '--dns-only')).code, 1);
+ assert.strictEqual(host.cf.writes.length, 0);
+ }),
+ test('no token: a clear refusal; no inventory needed on the workstation', async () => {
+ const bare = createFakeHost(); // no /etc/openvibe/host.json
+ const lines = [];
+ const code = await main(['dns', 'list', 'openvibe.live'], { exec: bare.exec, out: (s) => lines.push(s), env: {} });
+ assert.strictEqual(code, 1);
+ assert.match(lines.join('\n'), /no Cloudflare token/);
+ const other = await main(['status'], { exec: bare.exec, out: () => {}, env: {} });
+ assert.strictEqual(other, 1, 'other commands still need the inventory');
+ }),
+]);
new file mode 100644
new file mode 100644
@@ -0,0 +1,122 @@
@@ -0,0 +1,122 @@
+'use strict';
+/**
+ * Incident and maintenance controls (lib/incidents.js, WS-N task 12): ovhost incident / maintenance post to
+ * Network's status page with Host's token (network.status.incident); freezes hold deploys (exit 6) but never
+ * rollbacks; --force goes through and says so; a window scheduled with --freeze is lifted by complete.
+ */
+const assert = require('assert');
+const contracts = require('openvibe-contracts');
+const { scenario, push, test, runTests, SECRET } = require('./helpers');
+
+const TOKEN = 'tok_INCIDENT_TOKEN_MUST_NEVER_APPEAR_7a2c';
+const HOST_SECRET = `${SECRET}-host`;
+const INC = 'inc_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+
+function withNetwork() {
+ const host = scenario();
+ host.put('/etc/openvibe/host.env', `OV_OAUTH_CLIENT_ID=host\nOV_OAUTH_CLIENT_SECRET="${HOST_SECRET}"\nEVENTS_URL=http://127.0.0.1:4300\nOV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000\n`, { mode: 0o600, owner: 'root' });
+ host.tokenRequests = [];
+ host.posts = [];
+ const store = [];
+ host.http.set('http://127.0.0.1:4000/oauth/token', ({ body }) => {
+ const form = Object.fromEntries(new URLSearchParams(body));
+ host.tokenRequests.push(form);
+ if (form.client_secret !== HOST_SECRET) return { status: 401, body: { error: 'invalid_client' } };
+ return { status: 200, body: { access_token: TOKEN, token_type: 'Bearer', expires_in: 300 } };
+ });
+ const now = '2026-09-26T16:00:00.000Z';
+ host.http.set('http://127.0.0.1:4000/api/v1/status/incidents', ({ method, headers, body }) => {
+ if (method === 'GET') return { status: 200, body: { active: store.filter((i) => !['resolved', 'completed'].includes(i.state)), recent: store.filter((i) => ['resolved', 'completed'].includes(i.state)) } };
+ assert.strictEqual(headers.Authorization, `Bearer ${TOKEN}`);
+ const b = JSON.parse(body);
+ assert.ok(contracts.validate('network.status-incident-request@1', b).valid, JSON.stringify(b));
+ host.posts.push(b);
+ const i = { id: INC, kind: b.kind, title: b.title, ...(b.kind === 'incident' ? { severity: b.severity } : {}), state: b.kind === 'maintenance' ? 'scheduled' : 'investigating', services: b.services, starts_at: b.starts_at || now, ends_at: b.ends_at || null, updates: [{ at: now, state: 'investigating', message: b.message }], created_at: now, updated_at: now };
+ store.push(i);
+ return { status: 201, body: i };
+ });
+ host.http.set(`http://127.0.0.1:4000/api/v1/status/incidents/${INC}/updates`, ({ headers, body }) => {
+ assert.strictEqual(headers.Authorization, `Bearer ${TOKEN}`);
+ const b = JSON.parse(body);
+ assert.ok(contracts.validate('network.status-incident-request@1', b).valid);
+ host.posts.push(b);
+ const i = store.find((x) => x.id === INC);
+ if (['resolved', 'completed'].includes(i.state)) return { status: 409, body: { detail: `this ${i.kind} is ${i.state}` } };
+ i.state = b.state; i.updates.push({ at: now, state: b.state, message: b.message });
+ return { status: 200, body: i };
+ });
+ return host;
+}
+
+runTests([
+ test('incident open / update / list: Host\'s token for network.status.incident, contract bodies, no secret shown', async () => {
+ const host = withNetwork();
+ let r = await host.cli('incident', 'open', '--title', 'Live streams stall on start', '--services', 'live', '--severity', 'major', '--message', 'New streams take a minute to start.');
+ assert.strictEqual(r.code, 0, r.out);
+ assert.match(r.out, new RegExp(`${INC} major investigating live Live streams stall on start`));
+ assert.deepStrictEqual(host.tokenRequests.map((t) => [t.audience, t.scope]), [['openvibe.network', 'network.status.incident']]);
+ r = await host.cli('incident', 'update', INC, '--state', 'resolved', '--message', 'Rebound.');
+ assert.strictEqual(r.code, 0, r.out);
+ assert.match(r.out, /resolved/);
+ r = await host.cli('incident', 'update', INC, '--state', 'monitoring', '--message', 'again');
+ assert.strictEqual(r.code, 2);
+ assert.match(r.out, /Network answered 409: this incident is resolved/);
+ r = await host.cli('incident', 'list');
+ assert.match(r.out, /active: 0/);
+ assert.match(r.out, /closed in the last 30 days: 1/);
+ for (const s of [HOST_SECRET, TOKEN]) assert.ok(!r.out.includes(s));
+ }),
+ test('bad input stops before Network: services, ids, missing text', async () => {
+ const host = withNetwork();
+ assert.strictEqual((await host.cli('incident', 'open', '--title', 'x', '--services', 'Bad Id', '--message', 'm')).code, 1);
+ assert.strictEqual((await host.cli('incident', 'open', '--title', 'x', '--services', 'live')).code, 1, '--message is required');
+ assert.strictEqual((await host.cli('incident', 'update', 'nope', '--state', 'resolved', '--message', 'm')).code, 1);
+ assert.strictEqual(host.posts.length, 0);
+ }),
+ test('freeze holds deploys (exit 6), never rollbacks; --force goes through loudly; unfreeze lifts it', async () => {
+ const host = withNetwork();
+ push(host, 'live', { 'server/index.js': 'console.log(2);' }, 'server change');
+ let r = await host.cli('freeze', 'live', '--reason', 'incident: streams stall', '--incident', INC);
+ assert.strictEqual(r.code, 0, r.out);
+ r = await host.cli('deploy', 'live');
+ assert.strictEqual(r.code, 6, r.out);
+ assert.match(r.out, /live is frozen since .*: incident: streams stall \(inc_/);
+ assert.deepStrictEqual(host.restarts(), [], 'nothing restarted');
+ r = await host.cli('freeze');
+ assert.match(r.out, /^live {2}since .* {2}incident: streams stall/m);
+ r = await host.cli('deploy', 'live', '--force');
+ assert.strictEqual(r.code, 0, r.out);
+ assert.match(r.out, /--force: deploying live through the freeze/);
+ await host.cli('unfreeze', 'live');
+ await host.cli('freeze', 'all', '--reason', 'network-wide change window');
+ push(host, 'live', { 'server/index.js': 'console.log(3);' }, 'another');
+ r = await host.cli('deploy', 'live');
+ assert.strictEqual(r.code, 6);
+ assert.match(r.out, /\(all services\)/);
+ r = await host.cli('rollback', 'live');
+ assert.notStrictEqual(r.code, 6, 'a rollback is never frozen');
+ assert.match((await host.cli('unfreeze', 'all')).out, /unfrozen: all/);
+ assert.match((await host.cli('unfreeze', 'all')).out, /was not frozen/);
+ }),
+ test('maintenance schedule --freeze freezes its services; complete lifts them', async () => {
+ const host = withNetwork();
+ let r = await host.cli('maintenance', 'schedule', '--title', 'Media storage move', '--services', 'media,live', '--from', '2026-09-28T03:00:00Z', '--until', '2026-09-28T04:00:00Z', '--message', 'Uploads pause for up to an hour.', '--freeze');
+ assert.strictEqual(r.code, 0, r.out);
+ assert.deepStrictEqual(host.posts[0], { kind: 'maintenance', title: 'Media storage move', services: ['media', 'live'], message: 'Uploads pause for up to an hour.', starts_at: '2026-09-28T03:00:00.000Z', ends_at: '2026-09-28T04:00:00.000Z' });
+ r = await host.cli('freeze');
+ assert.match(r.out, /live .*maintenance: Media storage move/);
+ assert.match(r.out, /media .*maintenance: Media storage move/);
+ r = await host.cli('maintenance', 'start', INC, '--message', 'Moving now.');
+ assert.strictEqual(r.code, 0);
+ r = await host.cli('maintenance', 'complete', INC, '--message', 'Done.');
+ assert.strictEqual(r.code, 0, r.out);
+ assert.match((await host.cli('freeze')).out, /nothing is frozen/);
+ assert.strictEqual((await host.cli('maintenance', 'schedule', '--title', 'x', '--services', 'live', '--from', 'soon', '--until', 'later', '--message', 'm')).code, 1);
+ }),
+ test('not configured: a clear refusal', async () => {
+ const host = scenario();
+ const r = await host.cli('incident', 'open', '--title', 'x', '--services', 'live', '--message', 'm');
+ assert.strictEqual(r.code, 2);
+ assert.match(r.out, /not configured/);
+ }),
+]);