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

OP OpenVibers committed 26 Sept 2026 · 59c31fe7f3 · 11 files, +623 −9


Changes

ddiff --git a/docs/incidents-and-dns.md b/docs/incidents-and-dns.md
nnew file mode 100644
iindex 0000000..cd2beff
---- /dev/null
++++ b/docs/incidents-and-dns.md
@@@ -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.
ddiff --git a/lib/cli.js b/lib/cli.js
iindex 66b4f7d..74b45f4 100644
---- a/lib/cli.js
++++ b/lib/cli.js
@@@ -62,6 +62,17 @@ Usage: ovhost <command> [args] [--inventory <file>] [--json]
declared by the inventory) to <prefix>-archive/; never deletes them
archive list the archives off-host
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]
check each site in a real browser after every release (and
daily) as ovcheck; metrics for OpenVibeBrowserCheckFailed
@@@ -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
`;
--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) => proc
const 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) => 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('; ')}` : ''}`);
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);
ddiff --git a/lib/dns.js b/lib/dns.js
nnew file mode 100644
iindex 0000000..09d443f
---- /dev/null
++++ b/lib/dns.js
@@@ -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 };
ddiff --git a/lib/incidents.js b/lib/incidents.js
nnew file mode 100644
iindex 0000000..f5ff6f2
---- /dev/null
++++ b/lib/incidents.js
@@@ -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 };
ddiff --git a/lib/index.js b/lib/index.js
iindex de756d4..edc64b9 100644
---- a/lib/index.js
++++ b/lib/index.js
@@@ -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,
ddiff --git a/lib/inventory.js b/lib/inventory.js
iindex 0b5744a..6ba8773 100644
---- a/lib/inventory.js
++++ b/lib/inventory.js
@@@ -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)) {
ddiff --git a/lib/release-ops.js b/lib/release-ops.js
iindex a042687..49c20cb 100644
---- a/lib/release-ops.js
++++ b/lib/release-ops.js
@@@ -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);
}
ddiff --git a/package-lock.json b/package-lock.json
iindex 22f4c16..8179f6b 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 400b0f2..7b0f54f 100644
---- a/package.json
++++ b/package.json
@@@ -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"
},
ddiff --git a/test/dns.test.js b/test/dns.test.js
nnew file mode 100644
iindex 0000000..6743701
---- /dev/null
++++ b/test/dns.test.js
@@@ -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');
++ }),
++]);
ddiff --git a/test/incidents.test.js b/test/incidents.test.js
nnew file mode 100644
iindex 0000000..938db31
---- /dev/null
++++ b/test/incidents.test.js
@@@ -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/);
++ }),
++]);