openvibers/openvibe.host Commits
Release notifications (roadmap WS-P task 9): ovhost publishes host.deploy.activated to OpenVibe.Events
Changes
@@@ -95,8 +95,9 @@ ovhost validate <service> [--manifest <file>] env NAMES, unit files, port, vhosovhost env-names <service> names declared in the checkout's .env.exampleovhost show [<service>...] the inventory as ovhost reads it (repo, owner, units, socket, workers, probes)ovhost plan <service> [--to <sha>] [--no-fetch]--ovhost deploy <service> [--wait-idle] [--force] [--restart] [--to <sha>] [--install-units] [--ready-timeout <s>] [--browser-check]--ovhost rollback <service> [--to <sha>] [--wait-idle] [--force]++ovhost deploy <service> [--wait-idle] [--force] [--restart] [--to <sha>] [--install-units] [--ready-timeout <s>] [--browser-check] [--no-announce]++ovhost rollback <service> [--to <sha>] [--wait-idle] [--force] [--no-announce]++ovhost announce <service> [--release <id>] [--commit <sha>] [--origin <url>] [--force] [--dry-run] release notificationovhost releases <service> [--limit <n>]ovhost certs [--warn-days <n>]ovhost nginx render <service> [--variant http|sse|websocket] [--manifest <file>] [--install]@@@ -113,6 +114,8 @@ Every command accepts `--json` and `--inventory <file>`. `drill` exits `0` passe`deploy --browser-check` checks the service's public site in headless Chrome after a deploy that went through. It runs [`scripts/browser-check.js`](docs/browser-check.md) for that one site. The check is report only: it never changes the exit code or the release record. It needs Chrome where ovhost runs.++**Release notifications** (WS-P task 9). A deploy or rollback that went live is announced to OpenVibe.Events as `host.deploy.activated`. The event is public, has subject `release` and carries the service, its `/release.json` release id, commit and origin. Open tabs (openvibe-shared release-watch 1.17.0 and later) then check `/release.json` within seconds instead of at their next poll. `ovhost announce <service>` does the same for services deployed by their own scripts (Live, Tools and Sites call it at the end of theirs). It is best effort: a few seconds at most, never a failed deploy, one event per release. The credentials are Host's service principal from `/etc/openvibe/host.env`. [docs/release-notifications.md](docs/release-notifications.md) covers the payload, the credentials, provisioning and an end-to-end check.++### Browser check (all public sites)`node scripts/browser-check.js [--sites a,b] [--out docs/browser-check.md] [--remote <ssh-host>]` loads every public product in headless Chrome with `openvibe-shared/browser-harness`. Per route it checks status, console errors, overflow at 390/768/1280 px, duplicate scripts, no-JS text, canonical, JSON-LD against the visible text and axe-core. Per site it checks repeated-navigation growth and idle work. See [docs/browser-check.md](docs/browser-check.md) for what it checks, the remote runner and the recorded runs.@@@ -272,6 +275,8 @@ Through the `openvibe-sdk` v0.5.0 transactional outbox (`event_outbox`), insideThe relay publishes with Host's service token (`events.event.publish`, audience `openvibe.events`) only when `EVENTS_URL` and `OV_OAUTH_CLIENT_SECRET` are set. Otherwise rows wait and `/api/ready` says the relay is off.++These tenant events have subject `deploy` and visibility `internal`. The operator plane (`ovhost`) publishes `host.deploy.activated` with subject `release` and visibility `public` for network service releases ([docs/release-notifications.md](docs/release-notifications.md)). Both shapes are the one contract `host.deploy.activated@1` (openvibe-contracts 0.58.0).++### DashboardServer-rendered pages with the shared chrome (`openvibe-shared` v1.5.1: navbar and theme loader from the Network, `<noscript>` navigation, SSR footer, app icon, legal pages). Every action is a plain form, so it works without JavaScript: projects, quotas and usage, members, sites, folder or archive upload, deploys with activate/rollback/delete, activation history, upload logs and file lists, domains with their DNS records and a "check DNS now" button. Pages are `private, no-store`, `noindex`, `frame-ancestors 'none'`.nnew file mode 100644@@@ -0,0 +1,150 @@++# Release notifications (`host.deploy.activated`)++++Roadmap WS-P task 9, ADR-016 (active client updates). When a network service's release goes live,++Host publishes one `host.deploy.activated` event to OpenVibe.Events. The event has visibility++**public** and subject `{ type: "release", id: "<service>:<release>" }`. Open tabs run++`openvibe-shared/release-watch.js` (1.17.0 and later) and listen for it on the Events realtime stream.++When the event names their service, they check `/release.json` within about 20 seconds instead of++at their next 10-minute poll. The event carries identifiers only.++++| Payload field | Value |++|---|---|++| `service` | The service id the pages' `/release.json` names (`live`, `tools`, a Sites placeholder's id such as `news`) |++| `release` | The release id `/release.json` reports, 7–40 hex characters |++| `commit` | The deployed commit when known and the release is its prefix, else `null` |++| `origin` | The public origin (`https://openvibe.live`), or `null` |++| `deployed_at` | When Host announced it |++| `components` | Optional: the manifest's `{ name: { kind, version } }` |++| `rollback` | Optional: `true` after `ovhost rollback` |++++The payload contract is `host.deploy.activated@1` in openvibe-contracts 0.58.0. The same event type also++covers Stage B tenant activations, which use subject `deploy`, a different payload and visibility++`internal`, so browsers never receive them.++++## Who announces++++- `ovhost deploy <service>` and `ovhost rollback <service>` announce after a deploy that went live++ (result `deployed` or `rolled-back`, exit 0). `--no-announce` skips it.++- Services deployed by their own scripts call `ovhost announce <service>` at the end:++ - Live: `deploy/scripts/deploy.sh`.++ - Tools: `deploy/scripts/deploy.sh`.++ - Sites: `deploy/scripts/deploy.sh`, one call per placeholder with `--release` and `--origin`.++++ OpenRe and Games can do the same by hand or in their scripts. The scripts do nothing if `ovhost` is++ missing or has no `announce`, and a failed announcement never fails the deploy.++++`ovhost announce <service> [--release <id>] [--commit <sha>] [--origin <url>] [--force] [--dry-run] [--events-env <file>] [--json]`++++- **Release:** `--release`, else what the service's loopback `/release.json` reports (at the origin of its++ inventory ready URL, with the ready headers), else the checkout's HEAD (12 hex). A service that++ is not in the inventory needs `--release`.++- **Origin:** `--origin`, else the inventory entry's `origin`, else the service manifest's++ `publicOrigin` in the installed openvibe-contracts.++- **Once per release.** `<stateDir>/announced/<service>.json` remembers the last release sent, and the++ same release is not sent again. `--force` sends it again as a new event.++- **Best effort.** The token request and the publish time out after 2.5 s each. A failed publish is++ retried once with the same `event_id`, and Events stores it once. The whole announcement takes 6 s at++ most.++- **Exit codes:** `0` sent, already sent, or dry run. `1` usage error, or credentials not configured.++ `2` publishing failed. `--dry-run` prints the envelope and needs no credentials.++++## Credentials++++Host publishes as its Network service principal: OAuth client **`host`** (`svc:host`), grant++`events.event.publish` on audience `openvibe.events`. This is the client the Stage B Host API already++uses, and Network seeds the grant from `DEFAULT_GRANTS`++(`server/identity/principals.js`: `['host', 'events.event.publish', 'openvibe.events', []]`).++++ovhost reads these names (never printed, logged or stored) from the first file that is set:++`--events-env`, then `$OVHOST_EVENTS_ENV`, then the inventory's `events.envFile`, then++**`/etc/openvibe/host.env`** (the Host API's env file).++++| Name | |++|---|---|++| `OV_OAUTH_CLIENT_ID` | default `host` |++| `OV_OAUTH_CLIENT_SECRET` | required |++| `EVENTS_URL` | required, e.g. `http://127.0.0.1:4300` (plain http only on loopback) |++| `OV_NETWORK_INTERNAL_URL` | default `http://127.0.0.1:4000`. The token endpoint is `<it>/oauth/token` |++++If the file or a required name is missing, the announcement is skipped. An `ovhost deploy` logs one line++and carries on, and `ovhost announce` exits 1.++++## Provisioning on the host (for the lead)++++All commands run on the production host. None of them prints a secret.++++1. **Is the `host` client there?** Network seeds it at boot, with a secret that only its database holds.++ ```++ cd /opt/openvibe.network && sudo node server/setup/service-principal.js list | grep -E '^host\s'++ ```++ Expect `host site OpenVibe.Host`. It is listed as `site` because it has the dashboard's redirect++ URI; client_credentials works for it all the same. If the line is missing, create the client instead of rotating it in step 2:++ `sudo node server/setup/service-principal.js create host --env-file /etc/openvibe/host.env`.++2. **Does Host's env file already hold its secret?** It does if the Host API was set up with one. The++ first command counts matching lines and never prints a value.++ ```++ sudo grep -c '^OV_OAUTH_CLIENT_SECRET=.' /etc/openvibe/host.env++ ```++ If it prints `0`, or the file does not exist, write a fresh secret into it:++ ```++ cd /opt/openvibe.network && sudo node server/setup/service-principal.js rotate host --env-file /etc/openvibe/host.env++ sudo systemctl restart openvibe-host++ ```++ Rotating changes the secret the running Host API uses, so restart it afterwards. The script writes++ `OV_OAUTH_CLIENT_ID=host` and `OV_OAUTH_CLIENT_SECRET=…` with mode 0600.++3. **Events and Network URLs** in the same file, if they are absent (again, a count only):++ ```++ sudo grep -cE '^(EVENTS_URL|OV_NETWORK_INTERNAL_URL)=.' /etc/openvibe/host.env++ ```++ If the count is below 2, add the missing lines with `sudoedit /etc/openvibe/host.env`:++ `EVENTS_URL=http://127.0.0.1:4300` and `OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000`.++4. **The grant.** It is a default grant, so a Network boot since 2026-09-23 has it. Check as the owner in++ the Network admin, `GET https://openvibe.network/api/admin/grants?client=host`, where it shows as++ active. Or check the database directly:++ ```++ cd /opt/openvibe.network && sudo sqlite3 data/network.db "SELECT capability, audience, revoked_at FROM principal_grants WHERE client_id='host'"++ ```++ Expect `events.event.publish|openvibe.events|` (not revoked). If it is missing, grant it in the++ admin: `POST /api/admin/grants { client_id: "host", capability: "events.event.publish", reason: "release notifications (WS-P 9)" }`.++5. **Events accepts `host` as a source and serves public events to signed-out browsers.** This is++ the default: `host` is in `DEFAULT_SOURCES`, and `REALTIME_ALLOW_ANONYMOUS` is not `false`. Check++ that `/etc/openvibe/events.env` does not override either one. Each count below should be 0:++ ```++ sudo grep -c '^EVENTS_SOURCE_PREFIXES=' /etc/openvibe/events.env++ sudo grep -c '^REALTIME_ALLOW_ANONYMOUS=false' /etc/openvibe/events.env++ ```++ A site outside `https://*.openvibe.*` (for example openre.stream) only gets the realtime stream if++ its origin is in Events' `REALTIME_CORS_ORIGINS`. Otherwise its tabs back off and keep polling.++6. **Update ovhost** to this release. The CLI is the checkout `/usr/local/bin/ovhost` points into++ (`readlink -f /usr/local/bin/ovhost`):++ ```++ cd "$(dirname "$(dirname "$(readlink -f /usr/local/bin/ovhost)")")" && sudo git pull --ff-only++ ovhost --help | grep -q 'announce <service>' && echo "announce available"++ ```++ No dependency changed.++++## Verifying end to end++++1. Subscribe the way a signed-out browser does, from any machine:++ ```++ curl -N 'https://events.openvibe.network/realtime/stream?topics=host.deploy.activated'++ ```++ The stream starts with `retry: 3000` and `: connected anonymous`, then sends a `: hb` comment every++ 25 s.++2. On the host, see what would be sent:++ `sudo ovhost announce live --dry-run`. It prints the release from Live's `/release.json` and the++ envelope. Then send it:++ `sudo ovhost announce live --force`++ ```++ [ovhost] release notification sent: live 1a2b3c4d (from /release.json) as evt_01… (seq 12345)++ ```++3. The subscriber prints++ `id: 12345` / `data: {"seq":12345,"event":{"event_type":"host.deploy.activated",…,"visibility":"public","subject":{"type":"release","id":"live:1a2b3c4d"},"payload":{"service":"live",…}}}`.++4. In a browser on a site that pins openvibe-shared 1.17.0 or later, run `OVRelease.state().realtime` in++ the console. It shows `{ state: 'open', service: 'live', events: 1, … }` after the announcement.++ An `--force` of the release the tab already runs is ignored (`ignored: 1`).++++On failure: `token: HTTP 401 invalid_client` means the secret in the file does not match Network++(step 2). `token: HTTP 400 invalid_scope` means the grant is missing (step 4). `publish: HTTP 403 …`++means Events refused the source or the type (step 5). `ECONNREFUSED` means a wrong URL in the file++(step 3).@@@ -18,6 +18,10 @@"remote": "origin","branch": "main"},++ "_events": "Release notifications (ovhost deploy/rollback/announce, docs/release-notifications.md): the env file with Host's service principal credentials (OV_OAUTH_CLIENT_ID, OV_OAUTH_CLIENT_SECRET, EVENTS_URL, OV_NETWORK_INTERNAL_URL). A service entry may set \"origin\" (https) to override the manifest's publicOrigin.",++ "events": {++ "envFile": "/etc/openvibe/host.env"++ },"services": {"network": {"repo": "/opt/openvibe.network",nnew file mode 100644@@@ -0,0 +1,300 @@++'use strict';++/**++ * Release notifications (roadmap WS-P task 9; ADR-016). When a network service's release goes live,++ * Host publishes `host.deploy.activated` to OpenVibe.Events with visibility public and subject++ * { type: release, id: <service>:<release> }. Open tabs (openvibe-shared release-watch, subscribed to++ * that topic over Events realtime) then check /release.json within seconds instead of at their next++ * poll.++ *++ * ovhost deploy|rollback <service> announces after a deploy or rollback that went live++ * ovhost announce <service> for services deployed by their own scripts (Live, Tools, Sites,++ * OpenRe, Games)++ *++ * Best effort: it never fails or holds up a deploy. The token request and the publish each have a++ * short timeout and the whole announcement a budget of a few seconds; a failure is logged and the++ * deploy's exit code stays what it was. A retry sends the same event_id, which Events stores once.++ *++ * The release is what the service's own /release.json reports now (on loopback, at the origin of its++ * ready URL), because that is what open tabs compare with; else the checkout's HEAD (12 hex); or++ * --release. The commit is included when it is known and the release is its prefix (or --commit).++ * The origin is --origin, the inventory entry's `origin`, or the service manifest's publicOrigin in++ * the installed openvibe-contracts. The last release announced for each service is kept in++ * <stateDir>/announced/<service>.json and the same release is not announced twice (--force does).++ *++ * Credentials: Host's Network service principal, client `host` (grant events.event.publish, audience++ * openvibe.events), from the Host API's env file. Variable NAMES:++ * OV_OAUTH_CLIENT_ID default host++ * OV_OAUTH_CLIENT_SECRET required++ * EVENTS_URL required, e.g. http://127.0.0.1:4300++ * OV_NETWORK_INTERNAL_URL default http://127.0.0.1:4000 (the token endpoint is <it>/oauth/token)++ * File: --events-env, else $OVHOST_EVENTS_ENV, else the inventory's events.envFile, else++ * /etc/openvibe/host.env. The secret and the token are never printed, logged, stored or returned.++ */++const path = require('path');++const { git } = require('./git');++++const EVENT_TYPE = 'host.deploy.activated';++const DEFAULT_ENV_FILE = '/etc/openvibe/host.env';++const SERVICE_RE = /^[a-z][a-z0-9-]{1,39}$/;++const HEX_RE = /^[0-9a-f]{7,40}$/;++const COMPONENT_RE = /^[a-z][a-z0-9-]{0,39}$/;++const VERSION_RE = /^[0-9A-Za-z._+-]{1,64}$/;++const KINDS = ['style', 'content', 'script', 'server'];++const MAX_COMPONENTS = 32;++const REQUEST_MS = 2500; // each HTTP request++const BUDGET_MS = 6000; // the token request, the publish and its one retry together++const LINE_RE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/;++const WANTED = ['OV_OAUTH_CLIENT_ID', 'OV_OAUTH_CLIENT_SECRET', 'EVENTS_URL', 'OV_NETWORK_INTERNAL_URL'];++++class AnnounceError extends Error {++ constructor(message, exitCode = 1) { super(message); this.exitCode = exitCode; }++}++++const loopback = (hostname) => ['127.0.0.1', 'localhost', '[::1]', '::1'].includes(hostname);++++/** KEY=value lines -> { KEY: value } for the names announce reads; nothing else is kept. */++function parseEnv(text) {++ const out = {};++ for (const line of String(text || '').split('\n')) {++ const m = line.match(LINE_RE);++ if (!m || !WANTED.includes(m[1])) continue;++ let v = m[2].trim();++ if ((v.startsWith('"') && v.endsWith('"') && v.length >= 2) || (v.startsWith("'") && v.endsWith("'") && v.length >= 2)) v = v.slice(1, -1);++ else v = v.replace(/\s+#.*$/, '');++ out[m[1]] = v;++ }++ return out;++}++++function baseUrl(raw, name) {++ let u;++ try { u = new URL(raw); } catch { return { error: `${name} is not a URL` }; }++ if (!['http:', 'https:'].includes(u.protocol) || u.username || u.password) return { error: `${name} must be an http(s) URL without credentials` };++ if (u.protocol === 'http:' && !loopback(u.hostname)) return { error: `${name} must be https:// (plain http only on loopback)` };++ return { url: `${u.origin}${u.pathname.replace(/\/+$/, '')}` };++}++++/**++ * -> { file, clientId, eventsUrl, networkUrl } with the secret as a non-enumerable property (so++ * JSON.stringify and util.inspect never show it), or { file, error, notConfigured }.++ */++async function loadCredentials(exec, inv, { file = null, env = {} } = {}) {++ const f = file || env.OVHOST_EVENTS_ENV || (inv.events && inv.events.envFile) || DEFAULT_ENV_FILE;++ if (!path.isAbsolute(f)) return { file: f, error: 'the events env file must be an absolute path', notConfigured: true };++ let text;++ try { text = await exec.readFile(f, { privileged: true }); } catch (err) { return { file: f, error: `${f} is not readable (${err.code || 'error'})`, notConfigured: true }; }++ if (text == null) return { file: f, error: `${f} not found`, notConfigured: true };++ const v = parseEnv(text);++ const missing = ['OV_OAUTH_CLIENT_SECRET', 'EVENTS_URL'].filter((n) => !v[n]);++ if (missing.length) return { file: f, error: `${f} sets no ${missing.join(' and no ')}`, notConfigured: true };++ const events = baseUrl(v.EVENTS_URL, 'EVENTS_URL');++ if (events.error) return { file: f, error: events.error, notConfigured: true };++ const network = baseUrl(v.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000', 'OV_NETWORK_INTERNAL_URL');++ if (network.error) return { file: f, error: network.error, notConfigured: true };++ const clientId = v.OV_OAUTH_CLIENT_ID || 'host';++ if (!SERVICE_RE.test(clientId)) return { file: f, error: 'OV_OAUTH_CLIENT_ID is not a service principal id', notConfigured: true };++ const cfg = { file: f, clientId, eventsUrl: events.url, networkUrl: network.url };++ Object.defineProperty(cfg, 'secret', { value: v.OV_OAUTH_CLIENT_SECRET, enumerable: false });++ return cfg;++}++++/** The service's public origin from the installed openvibe-contracts manifest, or null. */++function manifestOrigin(id, contracts) {++ let lib = contracts;++ if (!lib) { try { lib = require('openvibe-contracts'); } catch { return null; } }++ const m = lib.services && typeof lib.services.get === 'function' ? lib.services.get(id) : null;++ return m && m.publicOrigin ? checkOrigin(m.publicOrigin, { quiet: true }) : null;++}++++function checkOrigin(raw, { quiet = false } = {}) {++ let u;++ try { u = new URL(String(raw)); } catch { if (quiet) return null; throw new AnnounceError('--origin must be a URL such as https://openvibe.live'); }++ const ok = (u.protocol === 'https:' || (u.protocol === 'http:' && loopback(u.hostname))) && !u.username && !u.password;++ if (!ok) { if (quiet) return null; throw new AnnounceError('--origin must be an https:// origin'); }++ return u.origin;++}++++/** Only what the event schema allows: { <name>: { kind, version } }, at most 32; else null. */++function components(raw) {++ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;++ const out = {};++ const names = Object.keys(raw);++ if (!names.length || names.length > MAX_COMPONENTS) return null;++ for (const name of names) {++ const c = raw[name];++ if (!COMPONENT_RE.test(name) || !c || !KINDS.includes(c.kind) || !VERSION_RE.test(String(c.version || ''))) return null;++ out[name] = { kind: c.kind, version: String(c.version) };++ }++ return out;++}++++/** GET <ready origin>/release.json on loopback -> { release, components } | { note }. */++async function servedManifest(exec, svc) {++ let url;++ try { url = new URL('/release.json', svc.ready.url).href; } catch { return { note: 'no ready URL to find /release.json at' }; }++ const r = await exec.http(url, { timeoutMs: 3000, headers: svc.ready.headers || {} });++ if (r.status !== 200) return { note: `${url} answered ${r.status || r.error}; using the checkout's HEAD` };++ let m;++ try { m = JSON.parse(r.body); } catch { return { note: `${url} is not JSON; using the checkout's HEAD` }; }++ if (!m || typeof m.release !== 'string' || !HEX_RE.test(m.release)) return { note: `${url} names no release; using the checkout's HEAD` };++ if (m.service !== svc.id) return { note: `${url} is service "${m.service}", not "${svc.id}"; using the checkout's HEAD` };++ return { release: m.release, components: components(m.components) };++}++++/**++ * What to announce for `id`: { service, release, commit, origin, components, from, notes }.++ * `head` (a full sha) saves the git call when the caller knows it (ovhost deploy).++ */++async function describeRelease(ctx, id, { release = null, commit = null, origin = null, head = null, contracts = null } = {}) {++ const { exec, inv } = ctx;++ if (!SERVICE_RE.test(String(id || ''))) throw new AnnounceError(`"${id}" is not a service id (the "service" a /release.json names)`);++ const svc = inv.services[id] || null;++ const notes = [];++ let rel = release == null ? null : String(release).toLowerCase();++ if (rel !== null && !HEX_RE.test(rel)) throw new AnnounceError('--release must be 7 to 40 hex characters: the release id the service\'s /release.json reports');++ let cm = commit == null ? null : String(commit).toLowerCase();++ if (cm !== null && !HEX_RE.test(cm)) throw new AnnounceError('--commit must be 7 to 40 hex characters');++ let comps = null;++ let from = rel ? '--release' : null;++ if (!rel && svc && svc.ready) {++ const m = await servedManifest(exec, svc);++ if (m.release) { rel = m.release; comps = m.components; from = '/release.json'; } else notes.push(m.note);++ }++ let sha = head && /^[0-9a-f]{40}$/.test(head) ? head : null;++ if (!sha && svc && (!rel || !cm)) {++ try {++ const h = await git(exec, svc).head();++ if (/^[0-9a-f]{40}$/.test(h)) sha = h;++ } catch (err) { notes.push(`the checkout's HEAD is unknown (${err.message.split('\n')[0].slice(0, 120)})`); }++ }++ if (!rel && sha) { rel = sha.slice(0, 12); from = 'git HEAD'; }++ if (!rel) {++ throw new AnnounceError(svc ? `cannot tell ${id}'s release (no /release.json answer, no git HEAD): pass --release <id>`++ : `${id} is not in the inventory: pass --release <id> (the release its /release.json reports) and --origin`);++ }++ if (!cm && sha && sha.startsWith(rel)) cm = sha;++ let o = null;++ if (origin != null) o = checkOrigin(origin);++ else if (svc && svc.origin) o = svc.origin;++ else o = manifestOrigin(svc ? svc.manifest : id, contracts);++ return { service: id, release: rel, commit: cm, origin: o, components: comps, from, notes };++}++++function newEventId(now) {++ return require('openvibe-contracts').ids.newId('event', now);++}++++/** The events.event-envelope@1 for a description (payload: contract host.deploy.activated@1). */++function envelope(d, { now, rollback = false, eventId = newEventId(now) }) {++ const at = new Date(now).toISOString();++ const payload = { service: d.service, release: d.release, commit: d.commit || null, origin: d.origin || null, deployed_at: at };++ if (d.components) payload.components = d.components;++ if (rollback) payload.rollback = true;++ return {++ event_id: eventId,++ event_type: EVENT_TYPE,++ version: 1,++ source: 'host',++ actor: { type: 'service', id: 'host' },++ timestamp: at,++ priority: 'low',++ visibility: 'public',++ subject: { type: 'release', id: `${d.service}:${d.release}` },++ payload,++ };++}++++/** A short reason for a failed request: the status and a problem/OAuth code, never the body. */++function reason(r) {++ if (!r) return 'no answer';++ if (r.status === 0) return r.error || 'unreachable';++ let code = null;++ try { const j = JSON.parse(r.body); code = j && (j.code || j.error); } catch { /* not JSON */ }++ return `HTTP ${r.status}${typeof code === 'string' && /^[a-z0-9._-]{1,60}$/i.test(code) ? ` ${code}` : ''}`;++}++++/** Token, then POST /api/v1/events (one retry, same event_id). -> { ok, seq, duplicate } | { ok: false, stage, error }. */++async function publish(exec, creds, env) {++ const start = exec.now();++ const left = () => BUDGET_MS - (exec.now() - start);++ const form = new URLSearchParams({ grant_type: 'client_credentials', client_id: creds.clientId, client_secret: creds.secret, audience: 'openvibe.events', scope: 'events.event.publish' });++ const t = await exec.request(`${creds.networkUrl}/oauth/token`, {++ method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }, body: form.toString(), timeoutMs: REQUEST_MS,++ });++ if (t.status !== 200) return { ok: false, stage: 'token', error: reason(t) };++ let token = null;++ try { token = JSON.parse(t.body).access_token; } catch { token = null; }++ if (typeof token !== 'string' || !token) return { ok: false, stage: 'token', error: 'no access_token in the answer' };++ const body = JSON.stringify(env);++ let last = null;++ let attempts = 0;++ while (attempts < 2) {++ attempts += 1;++ last = await exec.request(`${creds.eventsUrl}/api/v1/events`, {++ method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: `Bearer ${token}` }, body, timeoutMs: Math.max(500, Math.min(REQUEST_MS, left())),++ });++ if (last.status === 200 || last.status === 201) {++ let j = {};++ try { j = JSON.parse(last.body) || {}; } catch { j = {}; }++ return { ok: true, seq: Number.isInteger(j.seq) ? j.seq : null, duplicate: j.duplicate === true || last.status === 200, attempts };++ }++ const retryable = last.status === 0 || last.status === 429 || last.status >= 500;++ if (!retryable || left() < 1000) break;++ await exec.sleep(300);++ }++ return { ok: false, stage: 'publish', error: reason(last), attempts };++}++++const stateFile = (inv, service) => path.join(inv.stateDir, 'announced', `${service}.json`);++++async function readState(exec, inv, service) {++ try {++ const text = await exec.readFile(stateFile(inv, service));++ return text ? JSON.parse(text) : null;++ } catch { return null; }++}++++/**++ * Announce `id`'s current release. Never throws for a publishing problem: the result says what++ * happened. Throws AnnounceError only for bad arguments (exit 1).++ * -> { service, release, commit, origin, from, event_id, published, skipped, error, notConfigured, seq, duplicate, envelope? }++ */++async function announce(ctx, id, { release = null, commit = null, origin = null, head = null, rollback = false, force = false, dryRun = false, envFile = null, env = {}, contracts = null } = {}) {++ const { exec, inv, log = () => {} } = ctx;++ let creds = null;++ if (!dryRun) {++ creds = await loadCredentials(exec, inv, { file: envFile, env });++ if (creds.error) {++ // Nothing is described or sent: an unconfigured host announces nothing, quietly and cheaply.++ if (!SERVICE_RE.test(String(id || ''))) throw new AnnounceError(`"${id}" is not a service id`);++ return { service: id, release: release || null, published: false, skipped: 'not configured', error: creds.error, notConfigured: true };++ }++ }++ const d = await describeRelease(ctx, id, { release, commit, origin, head, contracts });++ for (const n of d.notes) log(n);++ const env_ = envelope(d, { now: exec.now(), rollback });++ const out = { service: d.service, release: d.release, commit: d.commit, origin: d.origin, from: d.from, event_id: env_.event_id, published: false, skipped: null, error: null, notConfigured: false };++ if (dryRun) return { ...out, skipped: 'dry run', envelope: env_ };++ const prev = await readState(exec, inv, d.service);++ if (!force && prev && prev.release === d.release) return { ...out, event_id: prev.event_id || null, skipped: `already announced at ${prev.at}` };++ const p = await publish(exec, creds, env_);++ if (!p.ok) return { ...out, error: `${p.stage}: ${p.error}` };++ const rec = { service: d.service, release: d.release, commit: d.commit, event_id: env_.event_id, seq: p.seq, at: env_.timestamp };++ try {++ await exec.mkdir(path.dirname(stateFile(inv, d.service)), { mode: 0o750 });++ await exec.writeFile(stateFile(inv, d.service), `${JSON.stringify(rec)}\n`, { mode: 0o640 });++ } catch (err) { log(`note: could not record the announcement in ${stateFile(inv, d.service)} (${err.code || err.message})`); }++ return { ...out, published: true, seq: p.seq, duplicate: p.duplicate };++}++++/** One line for the operator. */++function summary(r) {++ const what = `${r.service}${r.release ? ` ${r.release}` : ''}`;++ if (r.published) return `release notification sent: ${what}${r.from ? ` (from ${r.from})` : ''} as ${r.event_id}${r.seq != null ? ` (seq ${r.seq}${r.duplicate ? ', already stored' : ''})` : ''}`;++ if (r.notConfigured) return `release notification not sent: ${r.error} (see docs/release-notifications.md)`;++ if (r.error) return `release notification FAILED for ${what}: ${r.error} (the deploy stands; ovhost announce ${r.service} retries)`;++ if (r.skipped === 'dry run') return `dry run: ${what}${r.from ? ` (from ${r.from})` : ''}; nothing sent`;++ return `release notification not sent: ${what} was ${r.skipped}${r.skipped && r.skipped.startsWith('already') ? ' (--force sends it again)' : ''}`;++}++++module.exports = { announce, describeRelease, envelope, publish, loadCredentials, summary, stateFile, AnnounceError, EVENT_TYPE, DEFAULT_ENV_FILE };@@@ -26,7 +26,16 @@ Usage: ovhost <command> [args] [--inventory <file>] [--json]deploy <service> [--wait-idle] [--force] [--restart] [--to <sha>] [--install-units] [--ready-timeout <s>][--browser-check] then check the service's public site in headless Chrome(scripts/browser-check.js; report only, never changes the exit code)-- rollback <service> [--to <sha>] [--wait-idle] [--force]++ [--no-announce] a deploy that went live is announced (see announce) unless this is set++ rollback <service> [--to <sha>] [--wait-idle] [--force] [--no-announce]++ announce <service> [--release <id>] [--commit <sha>] [--origin <url>] [--force] [--dry-run]++ publish host.deploy.activated to OpenVibe.Events (public), so open++ tabs check /release.json now: for services deployed by their own++ scripts. Release: --release, else the service's loopback++ /release.json, else the checkout's HEAD; the same release is sent++ once (--force again). Credentials: --events-env <file>, else++ $OVHOST_EVENTS_ENV, else inventory events.envFile, else++ /etc/openvibe/host.env (docs/release-notifications.md)releases <service> [--limit <n>] the release logcerts [--warn-days <n>] certificates referenced by enabled nginx vhosts, with expirynginx render <service> [--variant http|sse|websocket] [--manifest <file>] [--install]@@@ -62,11 +71,12 @@ Usage: ovhost <command> [args] [--inventory <file>] [--json]Inventory: --inventory, else $OVHOST_INVENTORY, else /etc/openvibe/host.json.Exit codes: 0 ok · 1 usage/precondition · 2 validation failed (nothing restarted) · 3 not ready,rolled back · 4 rollback failed (manual intervention) · 5 protected sessions active ·++announce: 0 sent, already sent or dry run · 1 usage or not configured · 2 publishing failed ·drill: 0 passed · 1 precondition (not root, port in use, no backup, unsupported) · 2 drill failed ·backup --all / offsite: 0 everything succeeded · 1 precondition (config, key, lock) · 2 something failed`;--const FLAGS_WITH_VALUE = new Set(['inventory', 'to', 'limit', 'warn-days', 'variant', 'manifest', 'out', 'ready-timeout', 'wildcard-cert', 'backup', 'run', 'keep-daily', 'keep-weekly', 'from-host', 'backup-env']);++const FLAGS_WITH_VALUE = new Set(['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']);function parseArgs(argv) {const pos = [];@@@ -109,6 +119,12 @@ function bytes(n) {return `${i ? v.toFixed(1) : v} ${u[i]}`;}++/** An announce result without the envelope, for --json. */++function announcedSummary(a) {++ const { envelope, ...rest } = a;++ return rest;++}++async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => process.stdout.write(`${s}\n`), env = process.env, s3 = lib.offsite.createClient, browserCheck = runBrowserCheck } = {}) {let parsed;try { parsed = parseArgs(argv); } catch (err) { out(err.message); out(USAGE); return 1; }@@@ -177,7 +193,15 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => procconst id = needService();const o = { waitIdle: !!opts['wait-idle'], force: !!opts.force, restart: !!opts.restart, to: opts.to, installUnits: !!opts['install-units'], readyTimeout: opts['ready-timeout'] ? Number(opts['ready-timeout']) : undefined };const r = cmd === 'deploy' ? await lib.deploy(ctx, id, o) : await lib.rollback(ctx, id, o);-- if (json) emit(r.record);++ // Release notification (lib/announce.js): best effort, never changes the exit code.++ let announced = null;++ if (r.exitCode === 0 && ['deployed', 'rolled-back'].includes(r.record.result) && !opts['no-announce']) {++ try {++ announced = await lib.announce(ctx, r.record.service, { head: r.record.to, rollback: cmd === 'rollback', envFile: opts['events-env'], env });++ } catch (e) { announced = { service: r.record.service, published: false, error: e.message }; }++ log(lib.announceSummary(announced));++ }++ if (json) emit(announced ? { ...r.record, announce: announcedSummary(announced) } : r.record);else if (r.record.result !== 'unchanged') out(`[ovhost] result: ${r.record.result} (release ${r.record.id})`);if (cmd === 'deploy' && opts['browser-check'] && !json && r.exitCode === 0 && r.record.result === 'deployed') {log(`browser check of ${id}'s public site (report only)…`);@@@ -188,6 +212,20 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc}return r.exitCode;}++ case 'announce': {++ const id = needService();++ const a = await lib.announce(ctx, id, {++ release: opts.release, commit: opts.commit, origin: opts.origin,++ force: !!opts.force, dryRun: !!opts['dry-run'], envFile: opts['events-env'], env,++ });++ if (json) emit(a.envelope ? { ...announcedSummary(a), envelope: a.envelope } : announcedSummary(a));++ else {++ log(lib.announceSummary(a));++ if (a.envelope) out(JSON.stringify(a.envelope, null, 2));++ }++ if (a.published || (a.skipped && !a.notConfigured)) return 0;++ return a.notConfigured ? 1 : 2;++ }case 'status': {const rows = await lib.status(ctx, pos.slice(1));if (json) { emit(rows); return 0; }@@@ -356,6 +394,7 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => procif (err instanceof lib.DrillError) { out(`[ovhost] ${err.message}`); return err.exitCode; }if (err instanceof lib.offsite.OffsiteError) { out(`[ovhost] ✗ ${err.message}`); return err.exitCode; }if (err instanceof lib.OpError) { out(`[ovhost] ✗ ${err.message}`); return err.exitCode; }++ if (err instanceof lib.AnnounceError) { out(`[ovhost] ${err.message}`); return err.exitCode; }out(`[ovhost] ✗ ${err.message}`);return 2;}@@@ -19,6 +19,9 @@* readStream(path) -> Readable ; writeStream(path, { mode }) -> Writable (creates the file* exclusively: it fails if the path exists). Off-host backups only, which run as root.* http(url, { timeoutMs, headers }) -> { status, body } | { status: 0, error }++ * request(url, { method, headers, body, timeoutMs }) -> { status, body } | { status: 0, error }++ * any method, http:// or https:// (release notifications: Network's token endpoint and Events'++ * publish API). Headers and body may carry a secret: nothing here logs them.* sqlite(db, sql, { as }) -> rows ; sqliteBackup(db, dest, { as })* listeners(port) -> [{ pid, process }]* kill(pid, signal) -> bool (signal one pid; used only for a restore drill's own instance)@@@ -27,6 +30,7 @@const { spawn } = require('child_process');const fs = require('fs');const httpLib = require('http');++const httpsLib = require('https');const os = require('os');const path = require('path');@@@ -192,6 +196,34 @@ function createSystemExecutor({ env = process.env } = {}) {});}++ function request(url, { method = 'GET', headers = {}, body = null, timeoutMs = 5000 } = {}) {++ return new Promise((resolve) => {++ let u;++ try { u = new URL(url); } catch { resolve({ status: 0, error: 'bad URL' }); return; }++ const mod = u.protocol === 'https:' ? httpsLib : u.protocol === 'http:' ? httpLib : null;++ if (!mod) { resolve({ status: 0, error: 'unsupported protocol' }); return; }++ let req = null;++ let settled = false;++ const finish = (r) => { if (settled) return; settled = true; clearTimeout(timer); resolve(r); };++ const timer = setTimeout(() => { if (req) req.destroy(); finish({ status: 0, error: 'timeout' }); }, timeoutMs);++ const payload = body == null ? null : Buffer.from(String(body));++ try {++ req = mod.request(u, { method, headers: { ...headers, ...(payload ? { 'Content-Length': payload.length } : {}) } }, (res) => {++ let text = '';++ res.setEncoding('utf8');++ res.on('data', (c) => { if (text.length < 1024 * 1024) text += c; });++ res.on('end', () => finish({ status: res.statusCode, body: text }));++ res.on('error', (err) => finish({ status: 0, error: err.code || 'response error' }));++ });++ req.on('error', (err) => finish({ status: 0, error: err.code || 'request error' }));++ if (payload) req.write(payload);++ req.end();++ } catch (err) {++ finish({ status: 0, error: err.code || 'request error' });++ }++ });++ }++async function sqliteCall(payload, as) {const r = await run(process.execPath, [WORKER, JSON.stringify(payload)], { as, timeoutMs: 6 * 3600 * 1000 });let out;@@@ -231,6 +263,7 @@ function createSystemExecutor({ env = process.env } = {}) {createExclusive,pidAlive: async (pid) => pidAlive(pid),http,++ request,sqlite: async (db, sql, { as } = {}) => (await sqliteCall({ op: 'query', db, sql }, as)).rows,sqliteBackup: async (db, dest, { as } = {}) => { await sqliteCall({ op: 'backup', db, dest }, as); },listeners,@@@ -14,6 +14,7 @@ const { drill, DrillError } = require('./commands/drill');const releases = require('./releases');const certs = require('./certs');const nginx = require('./nginx');++const announce = require('./announce');const { createSystemExecutor } = require('./executor');module.exports = {@@@ -38,6 +39,9 @@ module.exports = {releases,certs,nginx,++ announce: announce.announce,++ announceSummary: announce.summary,++ AnnounceError: announce.AnnounceError,EXIT: ops.EXIT,OpError: ops.OpError,};@@@ -32,6 +32,13 @@ function rel(value, where) {return path.normalize(value);}++function httpsOrigin(value, where) {++ let u;++ try { u = new URL(String(value)); } catch { throw new InventoryError(`${where} must be an https:// origin`); }++ if (u.protocol !== 'https:' || u.username || u.password || u.origin !== String(value).replace(/\/+$/, '')) throw new InventoryError(`${where} must be an https:// origin such as https://openvibe.live`);++ return u.origin;++}++function normaliseService(id, raw, defaults) {if (!ID_RE.test(id)) throw new InventoryError(`service id "${id}" must match ${ID_RE}`);const where = `services.${id}`;@@@ -79,6 +86,8 @@ function normaliseService(id, raw, defaults) {}),backupOnChange: s.backupOnChange || [],nginx: s.nginx || null,++ // The public origin release notifications name (lib/announce.js); default: the manifest's publicOrigin.++ origin: s.origin == null ? null : httpsOrigin(s.origin, `${where}.origin`),// Lifecycle (WS-P task 1): normally the service manifest's `lifecycle` block; an inventory// `lifecycle` replaces it for this host (lib/lifecycle.js resolve(), checked by validate).lifecycle: s.lifecycle == null ? null : s.lifecycle,@@@ -322,6 +331,8 @@ function normalise(raw) {sitesEnabled: abs((raw.nginx && raw.nginx.sitesEnabled) || '/etc/nginx/sites-enabled', 'nginx.sitesEnabled'),bin: (raw.nginx && raw.nginx.bin) || 'nginx',},++ // 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 },services: {},};for (const [id, svc] of Object.entries(raw.services)) {nnew file mode 100644@@@ -0,0 +1,288 @@++'use strict';++/**++ * Release notifications (lib/announce.js, WS-P task 9): `ovhost announce` and the announcement after a++ * deploy publish host.deploy.activated to OpenVibe.Events with Host's service token, never fail or++ * change a deploy, send one release once, and never show the client secret or the token.++ */++const assert = require('assert');++const contracts = require('openvibe-contracts');++const { scenario, push, test, runTests, SECRET } = require('./helpers');++const { main } = require('../lib/cli');++++const TOKEN = 'tok_SERVICE_TOKEN_MUST_NEVER_APPEAR_91c2';++const ENV_FILE = '/etc/openvibe/host.env';++const HOST_SECRET = `${SECRET}-host`;++++/** The installed openvibe-contracts has host.deploy.activated@1 from 0.58.0; older pins skip this check. */++function payloadSchemaCheck(payload) {++ let known = true;++ try { contracts.schema('host.deploy.activated'); } catch { known = false; }++ if (!known) return;++ const v = contracts.validate('host.deploy.activated@1', payload);++ assert.ok(v.valid, JSON.stringify(v.errors));++}++++/** A scenario with Host's credentials, Network's token endpoint and Events' publish API. */++function withEvents({ events = 'ok', token = 'ok', envFile = ENV_FILE, envText } = {}) {++ const host = scenario();++ host.put(envFile, envText != null ? envText : `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\nHOST_FORM_SECRET=${SECRET}-form\n`, { mode: 0o600, owner: 'root' });++ host.tokenRequests = [];++ host.published = [];++ host.publishAttempts = 0;++ host.http.set('http://127.0.0.1:4000/oauth/token', ({ method, headers, body }) => {++ const form = Object.fromEntries(new URLSearchParams(body));++ host.tokenRequests.push({ method, contentType: headers['Content-Type'], form });++ if (token !== 'ok') return token;++ 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, scope: 'events.event.publish' } };++ });++ host.http.set('http://127.0.0.1:4300/api/v1/events', ({ method, headers, body }) => {++ host.publishAttempts += 1;++ assert.strictEqual(method, 'POST');++ assert.strictEqual(headers.Authorization, `Bearer ${TOKEN}`);++ const env = JSON.parse(body);++ const v = contracts.validate('events.event-envelope@1', env);++ assert.ok(v.valid, JSON.stringify(v.errors));++ if (typeof events === 'function') return events(env);++ if (events !== 'ok') return events;++ const dup = host.published.some((e) => e.event_id === env.event_id);++ if (!dup) host.published.push(env);++ return { status: dup ? 200 : 201, body: { event_id: env.event_id, seq: host.published.length + 100, duplicate: dup } };++ });++ return host;++}++++function servesRelease(host, url, manifest) {++ host.releaseJsonHeaders = [];++ host.http.set(url, ({ headers }) => { host.releaseJsonHeaders.push(headers); return { status: 200, body: manifest }; });++}++++function noSecrets(host, out) {++ for (const s of [HOST_SECRET, TOKEN, `${SECRET}-form`]) {++ assert.ok(!out.includes(s), 'the output shows no secret');++ assert.ok(!JSON.stringify(host.calls).includes(s), 'no recorded call carries a secret');++ for (const [p, f] of host.files) if (p !== ENV_FILE && f.type === 'file') assert.ok(!String(f.content).includes(s), `${p} holds no secret`);++ }++}++++runTests([++ test('announce live: the release /release.json reports, a service token for events.event.publish, one public event', async () => {++ const host = withEvents();++ const head = host.repo('live').head;++ servesRelease(host, 'http://127.0.0.1:3000/release.json', { service: 'live', release: head.slice(0, 8), components: { shell: { kind: 'script', version: 'sh1' }, server: { kind: 'server', version: 'sv1' } } });++ const r = await host.cli('announce', 'live');++ assert.strictEqual(r.code, 0, r.out);++ assert.match(r.out, new RegExp(`release notification sent: live ${head.slice(0, 8)} \\(from /release\\.json\\) as evt_[0-9A-Z]{26} \\(seq 101\\)`));++ assert.deepStrictEqual(host.tokenRequests.map((t) => [t.method, t.contentType, t.form.grant_type, t.form.client_id, t.form.audience, t.form.scope]),++ [['POST', 'application/x-www-form-urlencoded', 'client_credentials', 'host', 'openvibe.events', 'events.event.publish']]);++ assert.strictEqual(host.published.length, 1);++ const e = host.published[0];++ assert.strictEqual(e.event_type, 'host.deploy.activated');++ assert.strictEqual(e.version, 1);++ assert.strictEqual(e.source, 'host');++ assert.deepStrictEqual(e.actor, { type: 'service', id: 'host' });++ assert.strictEqual(e.visibility, 'public');++ assert.strictEqual(e.priority, 'low');++ assert.deepStrictEqual(e.subject, { type: 'release', id: `live:${head.slice(0, 8)}` });++ assert.deepStrictEqual(e.payload, {++ service: 'live', release: head.slice(0, 8), commit: head, origin: 'https://openvibe.live', deployed_at: e.timestamp,++ components: { shell: { kind: 'script', version: 'sh1' }, server: { kind: 'server', version: 'sv1' } },++ });++ payloadSchemaCheck(e.payload);++ const state = JSON.parse(host.read('/var/lib/openvibe-host/announced/live.json'));++ assert.deepStrictEqual([state.release, state.event_id, state.seq], [head.slice(0, 8), e.event_id, 101]);++ noSecrets(host, r.out);++ }),++++ test('the same release is announced once; --force sends it again as a new event', async () => {++ const host = withEvents();++ const r1 = await host.cli('announce', 'tools');++ assert.strictEqual(r1.code, 0, r1.out);++ const r2 = await host.cli('announce', 'tools');++ assert.strictEqual(r2.code, 0, r2.out);++ assert.match(r2.out, /tools [0-9a-f]{12} was already announced at .*--force sends it again/);++ assert.strictEqual(host.published.length, 1);++ const r3 = await host.cli('announce', 'tools', '--force');++ assert.strictEqual(r3.code, 0, r3.out);++ assert.strictEqual(host.published.length, 2);++ assert.notStrictEqual(host.published[0].event_id, host.published[1].event_id);++ push(host, 'tools', { 'apps/gateway/server.js': 'gw(2);' });++ await host.exec.run('git', ['-C', '/opt/openvibe.tools', 'merge', '--ff-only', host.repo('tools').remoteRefs['origin/main']], { as: 'ubuntu' });++ const r4 = await host.cli('announce', 'tools');++ assert.strictEqual(r4.code, 0, r4.out);++ assert.strictEqual(host.published.length, 3, 'a new release is announced');++ }),++++ test('without /release.json: the checkout HEAD (12 hex) and its commit; the ready URL\'s Host header is sent', async () => {++ const host = withEvents();++ const head = host.repo('tools').head;++ const r = await host.cli('announce', 'tools');++ assert.strictEqual(r.code, 0, r.out);++ assert.match(r.out, /answered ECONNREFUSED; using the checkout's HEAD/);++ assert.match(r.out, /\(from git HEAD\)/);++ const p = host.published[0].payload;++ assert.deepStrictEqual([p.service, p.release, p.commit, p.origin], ['tools', head.slice(0, 12), head, 'https://openvibe.tools']);++ assert.ok(!('components' in p));++ payloadSchemaCheck(p);++ host.published.length = 0;++ servesRelease(host, 'http://127.0.0.1:4001/release.json', { service: 'tools', release: 'abcdef123456' });++ const r2 = await host.cli('announce', 'tools', '--force');++ assert.strictEqual(r2.code, 0, r2.out);++ assert.strictEqual(host.releaseJsonHeaders[0].Host, 'openvibe.tools');++ assert.deepStrictEqual([host.published[0].payload.release, host.published[0].payload.commit], ['abcdef123456', null], 'a commit that does not start with the release is left out');++ }),++++ test('a service the inventory does not know (a Sites placeholder): --release and --origin, no commit', async () => {++ const host = withEvents();++ const bad = await host.cli('announce', 'news');++ assert.strictEqual(bad.code, 1, bad.out);++ assert.match(bad.out, /news is not in the inventory: pass --release/);++ const r = await host.cli('announce', 'news', '--release', 'DF0D28A88E98', '--origin', 'https://openvibe.news');++ assert.strictEqual(r.code, 0, r.out);++ assert.deepStrictEqual(host.published[0].payload, { service: 'news', release: 'df0d28a88e98', commit: null, origin: 'https://openvibe.news', deployed_at: host.published[0].timestamp });++ assert.deepStrictEqual(host.published[0].subject, { type: 'release', id: 'news:df0d28a88e98' });++ payloadSchemaCheck(host.published[0].payload);++ const noOrigin = await host.cli('announce', 'stream', '--release', '2a3844407d96');++ assert.strictEqual(noOrigin.code, 0, noOrigin.out);++ assert.strictEqual(host.published[1].payload.origin, null, 'no manifest, no --origin: origin null');++ for (const argv of [['--release', 'not-hex'], ['--release', 'abcdef1', '--origin', 'http://example.com'], ['--release', 'abcdef1', '--commit', 'xyz']]) {++ const x = await host.cli('announce', 'news', ...argv);++ assert.strictEqual(x.code, 1, `${argv.join(' ')}: ${x.out}`);++ }++ const badId = await host.cli('announce', 'Bad_Id', '--release', 'abcdef1');++ assert.strictEqual(badId.code, 1, badId.out);++ }),++++ test('publishing failures: one retry with the same event_id, exit 2, nothing recorded; a refused token is named', async () => {++ const host = withEvents({ events: { status: 503, body: { code: 'events.unavailable' } } });++ const r = await host.cli('announce', 'tools');++ assert.strictEqual(r.code, 2, r.out);++ assert.match(r.out, /release notification FAILED for tools [0-9a-f]{12}: publish: HTTP 503 events\.unavailable/);++ assert.strictEqual(host.publishAttempts, 2, 'retried once');++ assert.strictEqual(host.read('/var/lib/openvibe-host/announced/tools.json'), null);++ noSecrets(host, r.out);++++ const ids = [];++ const flaky = withEvents({ events: (env) => { ids.push(env.event_id); return ids.length === 1 ? { error: 'ECONNRESET' } : { status: 201, body: { event_id: env.event_id, seq: 5, duplicate: false } }; } });++ const r2 = await flaky.cli('announce', 'tools');++ assert.strictEqual(r2.code, 0, r2.out);++ assert.strictEqual(ids.length, 2);++ assert.strictEqual(ids[0], ids[1], 'the retry is the same event (Events stores it once)');++++ const refused = withEvents({ events: { status: 403, body: { code: 'events.type_not_allowed' } } });++ const r3 = await refused.cli('announce', 'tools');++ assert.strictEqual(r3.code, 2, r3.out);++ assert.strictEqual(refused.publishAttempts, 1, 'a 403 is not retried');++ assert.match(r3.out, /HTTP 403 events\.type_not_allowed/);++++ const badToken = withEvents({ envText: `OV_OAUTH_CLIENT_SECRET=wrong-${SECRET}\nEVENTS_URL=http://127.0.0.1:4300\n` });++ const r4 = await badToken.cli('announce', 'tools');++ assert.strictEqual(r4.code, 2, r4.out);++ assert.match(r4.out, /token: HTTP 401 invalid_client/);++ assert.strictEqual(badToken.publishAttempts, 0);++ assert.ok(!r4.out.includes(`wrong-${SECRET}`));++++ const down = withEvents();++ down.http.delete('http://127.0.0.1:4300/api/v1/events');++ const r5 = await down.cli('announce', 'tools');++ assert.strictEqual(r5.code, 2, r5.out);++ assert.match(r5.out, /publish: ECONNREFUSED/);++ }),++++ test('not configured: exit 1, no HTTP at all; --events-env, $OVHOST_EVENTS_ENV and the inventory choose the file', async () => {++ const host = scenario();++ const r = await host.cli('announce', 'tools');++ assert.strictEqual(r.code, 1, r.out);++ assert.match(r.out, /release notification not sent: \/etc\/openvibe\/host\.env not found/);++ assert.ok(!host.calls.some((c) => c.cmd === 'curl'), 'nothing was requested');++++ const partial = withEvents({ envText: 'OV_OAUTH_CLIENT_ID=host\nEVENTS_URL=http://127.0.0.1:4300\n' });++ const p = await partial.cli('announce', 'tools');++ assert.strictEqual(p.code, 1, p.out);++ assert.match(p.out, /sets no OV_OAUTH_CLIENT_SECRET/);++++ const plainHttp = withEvents({ envText: `OV_OAUTH_CLIENT_SECRET=${SECRET}\nEVENTS_URL=http://events.example.com\n` });++ const ph = await plainHttp.cli('announce', 'tools');++ assert.strictEqual(ph.code, 1, ph.out);++ assert.match(ph.out, /EVENTS_URL must be https:\/\/ \(plain http only on loopback\)/);++++ const other = withEvents({ envFile: '/etc/openvibe/announce.env' });++ const lines = [];++ const code = await main(['announce', 'tools'], { exec: other.exec, out: (s) => lines.push(s), env: { OVHOST_EVENTS_ENV: '/etc/openvibe/announce.env' } });++ assert.strictEqual(code, 0, lines.join('\n'));++ other.published.length = 0;++ const viaFlag = await other.cli('announce', 'tools', '--force', '--events-env', '/etc/openvibe/announce.env');++ assert.strictEqual(viaFlag.code, 0, viaFlag.out);++ const inv = JSON.parse(other.read('/etc/openvibe/host.json'));++ inv.events = { envFile: '/etc/openvibe/announce.env' };++ other.put('/etc/openvibe/host.json', JSON.stringify(inv), { mode: 0o640, owner: 'root' });++ const viaInventory = await other.cli('announce', 'tools', '--force');++ assert.strictEqual(viaInventory.code, 0, viaInventory.out);++ assert.strictEqual(other.published.length, 2);++ }),++++ test('--dry-run prints the envelope and sends nothing (no credentials needed)', async () => {++ const host = scenario();++ const r = await host.cli('announce', 'tools', '--dry-run');++ assert.strictEqual(r.code, 0, r.out);++ assert.match(r.out, /dry run: tools [0-9a-f]{12} \(from git HEAD\); nothing sent/);++ const env = JSON.parse(r.out.slice(r.out.indexOf('{')));++ assert.strictEqual(env.event_type, 'host.deploy.activated');++ assert.ok(contracts.validate('events.event-envelope@1', env).valid);++ assert.ok(!host.reads.includes(ENV_FILE), 'the credentials were not read');++ const j = await host.cli('announce', 'tools', '--dry-run', '--json');++ assert.strictEqual(JSON.parse(j.out).envelope.visibility, 'public');++ }),++++ test('ovhost deploy announces the release that went live; the deploy exit code never depends on it', async () => {++ const host = withEvents();++ const to = push(host, 'live', { 'server/index.js': 'console.log(2);' }, 'server change');++ host.http.set('http://127.0.0.1:3000/release.json', () => ({ status: 200, body: { service: 'live', release: host.units.get('openvibe-live.service').runningSha.slice(0, 12) } }));++ const r = await host.cli('deploy', 'live');++ assert.strictEqual(r.code, 0, r.out);++ assert.match(r.out, new RegExp(`release notification sent: live ${to.slice(0, 12)} \\(from /release\\.json\\)`));++ assert.deepStrictEqual([host.published[0].payload.release, host.published[0].payload.commit], [to.slice(0, 12), to]);++ assert.ok(!('rollback' in host.published[0].payload));++++ const rb = await host.cli('rollback', 'live', '--json');++ assert.strictEqual(rb.code, 0, rb.out);++ const rec = JSON.parse(rb.out.slice(rb.out.indexOf('{')));++ assert.strictEqual(rec.result, 'rolled-back');++ assert.strictEqual(rec.announce.published, true);++ assert.ok(!('envelope' in rec.announce));++ assert.strictEqual(host.published[1].payload.rollback, true);++ payloadSchemaCheck(host.published[1].payload);++++ const quiet = withEvents();++ push(quiet, 'live', { 'server/index.js': 'console.log(3);' });++ const q = await quiet.cli('deploy', 'live', '--no-announce');++ assert.strictEqual(q.code, 0, q.out);++ assert.strictEqual(quiet.tokenRequests.length, 0);++++ const down = withEvents({ events: { status: 500, body: {} } });++ push(down, 'live', { 'server/index.js': 'console.log(4);' });++ const d = await down.cli('deploy', 'live');++ assert.strictEqual(d.code, 0, d.out);++ assert.match(d.out, /release notification FAILED for live/);++ assert.match(d.out, /result: deployed/);++++ const unconfigured = scenario();++ push(unconfigured, 'live', { 'server/index.js': 'console.log(5);' });++ const u = await unconfigured.cli('deploy', 'live');++ assert.strictEqual(u.code, 0, u.out);++ assert.match(u.out, /release notification not sent: \/etc\/openvibe\/host\.env not found/);++ }),++++ test('a deploy that did not go live announces nothing', async () => {++ const host = withEvents();++ const to = push(host, 'live', { 'server/index.js': 'broken();' });++ host.badShas.add(to);++ const r = await host.cli('deploy', 'live');++ assert.strictEqual(r.code, 3, r.out);++ assert.strictEqual(host.tokenRequests.length, 0);++ const same = await host.cli('deploy', 'media');++ assert.strictEqual(same.code, 0, same.out);++ assert.match(same.out, /nothing to do/);++ assert.strictEqual(host.tokenRequests.length, 0, 'an unchanged deploy is not announced');++ }),++]);@@@ -14,7 +14,7 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', stconst calls = [];const repos = new Map(); // repoPath -> repoconst units = new Map(); // unit -> state-- const http = new Map(); // url -> fn({ headers }) -> { status, body }++ const http = new Map(); // url -> fn({ headers, method, body }) -> { status, body } (request() also passes method and body)const alivePids = new Set();const listeners = new Map(); // port -> [{ pid, process }]let clock = start;@@@ -354,6 +354,14 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', stconst r = h({ headers });return { status: r.status, body: typeof r.body === 'string' ? r.body : JSON.stringify(r.body || {}) };},++ async request(url, { method = 'GET', headers = {}, body = null } = {}) {++ calls.push({ cmd: 'curl', args: ['-X', method, url], as: null, privileged: false, cwd: null });++ const h = http.get(url);++ if (!h) return { status: 0, error: 'ECONNREFUSED' };++ const r = h({ method, headers, body });++ if (r && r.error) return { status: 0, error: r.error };++ return { status: r.status, body: typeof r.body === 'string' ? r.body : JSON.stringify(r.body || {}) };++ },async sqlite(db, sql, { as } = {}) {host.sqliteCalls.push({ op: 'query', db, sql, as });calls.push({ cmd: 'sqlite', args: [db, sql], as: as || null, privileged: false, cwd: null });
@@ -95,8 +95,9 @@ ovhost validate <service> [--manifest <file>] env NAMES, unit files, port, vhos
@@ -95,8 +95,9 @@ ovhost validate <service> [--manifest <file>] env NAMES, unit files, port, vhos
ovhost env-names <service> names declared in the checkout's .env.example
ovhost env-names <service> names declared in the checkout's .env.example
ovhost show [<service>...] the inventory as ovhost reads it (repo, owner, units, socket, workers, probes)
ovhost show [<service>...] the inventory as ovhost reads it (repo, owner, units, socket, workers, probes)
ovhost plan <service> [--to <sha>] [--no-fetch]
ovhost plan <service> [--to <sha>] [--no-fetch]
-ovhost deploy <service> [--wait-idle] [--force] [--restart] [--to <sha>] [--install-units] [--ready-timeout <s>] [--browser-check]
-ovhost rollback <service> [--to <sha>] [--wait-idle] [--force]
+ovhost deploy <service> [--wait-idle] [--force] [--restart] [--to <sha>] [--install-units] [--ready-timeout <s>] [--browser-check] [--no-announce]
+ovhost rollback <service> [--to <sha>] [--wait-idle] [--force] [--no-announce]
+ovhost announce <service> [--release <id>] [--commit <sha>] [--origin <url>] [--force] [--dry-run] release notification
ovhost releases <service> [--limit <n>]
ovhost releases <service> [--limit <n>]
ovhost certs [--warn-days <n>]
ovhost certs [--warn-days <n>]
ovhost nginx render <service> [--variant http|sse|websocket] [--manifest <file>] [--install]
ovhost nginx render <service> [--variant http|sse|websocket] [--manifest <file>] [--install]
@@ -113,6 +114,8 @@ Every command accepts `--json` and `--inventory <file>`. `drill` exits `0` passe
@@ -113,6 +114,8 @@ Every command accepts `--json` and `--inventory <file>`. `drill` exits `0` passe
`deploy --browser-check` checks the service's public site in headless Chrome after a deploy that went through. It runs [`scripts/browser-check.js`](docs/browser-check.md) for that one site. The check is report only: it never changes the exit code or the release record. It needs Chrome where ovhost runs.
`deploy --browser-check` checks the service's public site in headless Chrome after a deploy that went through. It runs [`scripts/browser-check.js`](docs/browser-check.md) for that one site. The check is report only: it never changes the exit code or the release record. It needs Chrome where ovhost runs.
+**Release notifications** (WS-P task 9). A deploy or rollback that went live is announced to OpenVibe.Events as `host.deploy.activated`. The event is public, has subject `release` and carries the service, its `/release.json` release id, commit and origin. Open tabs (openvibe-shared release-watch 1.17.0 and later) then check `/release.json` within seconds instead of at their next poll. `ovhost announce <service>` does the same for services deployed by their own scripts (Live, Tools and Sites call it at the end of theirs). It is best effort: a few seconds at most, never a failed deploy, one event per release. The credentials are Host's service principal from `/etc/openvibe/host.env`. [docs/release-notifications.md](docs/release-notifications.md) covers the payload, the credentials, provisioning and an end-to-end check.
+
### Browser check (all public sites)
### Browser check (all public sites)
`node scripts/browser-check.js [--sites a,b] [--out docs/browser-check.md] [--remote <ssh-host>]` loads every public product in headless Chrome with `openvibe-shared/browser-harness`. Per route it checks status, console errors, overflow at 390/768/1280 px, duplicate scripts, no-JS text, canonical, JSON-LD against the visible text and axe-core. Per site it checks repeated-navigation growth and idle work. See [docs/browser-check.md](docs/browser-check.md) for what it checks, the remote runner and the recorded runs.
`node scripts/browser-check.js [--sites a,b] [--out docs/browser-check.md] [--remote <ssh-host>]` loads every public product in headless Chrome with `openvibe-shared/browser-harness`. Per route it checks status, console errors, overflow at 390/768/1280 px, duplicate scripts, no-JS text, canonical, JSON-LD against the visible text and axe-core. Per site it checks repeated-navigation growth and idle work. See [docs/browser-check.md](docs/browser-check.md) for what it checks, the remote runner and the recorded runs.
@@ -272,6 +275,8 @@ Through the `openvibe-sdk` v0.5.0 transactional outbox (`event_outbox`), inside
@@ -272,6 +275,8 @@ Through the `openvibe-sdk` v0.5.0 transactional outbox (`event_outbox`), inside
The relay publishes with Host's service token (`events.event.publish`, audience `openvibe.events`) only when `EVENTS_URL` and `OV_OAUTH_CLIENT_SECRET` are set. Otherwise rows wait and `/api/ready` says the relay is off.
The relay publishes with Host's service token (`events.event.publish`, audience `openvibe.events`) only when `EVENTS_URL` and `OV_OAUTH_CLIENT_SECRET` are set. Otherwise rows wait and `/api/ready` says the relay is off.
+These tenant events have subject `deploy` and visibility `internal`. The operator plane (`ovhost`) publishes `host.deploy.activated` with subject `release` and visibility `public` for network service releases ([docs/release-notifications.md](docs/release-notifications.md)). Both shapes are the one contract `host.deploy.activated@1` (openvibe-contracts 0.58.0).
+
### Dashboard
### Dashboard
Server-rendered pages with the shared chrome (`openvibe-shared` v1.5.1: navbar and theme loader from the Network, `<noscript>` navigation, SSR footer, app icon, legal pages). Every action is a plain form, so it works without JavaScript: projects, quotas and usage, members, sites, folder or archive upload, deploys with activate/rollback/delete, activation history, upload logs and file lists, domains with their DNS records and a "check DNS now" button. Pages are `private, no-store`, `noindex`, `frame-ancestors 'none'`.
Server-rendered pages with the shared chrome (`openvibe-shared` v1.5.1: navbar and theme loader from the Network, `<noscript>` navigation, SSR footer, app icon, legal pages). Every action is a plain form, so it works without JavaScript: projects, quotas and usage, members, sites, folder or archive upload, deploys with activate/rollback/delete, activation history, upload logs and file lists, domains with their DNS records and a "check DNS now" button. Pages are `private, no-store`, `noindex`, `frame-ancestors 'none'`.
new file mode 100644
new file mode 100644
@@ -0,0 +1,150 @@
@@ -0,0 +1,150 @@
+# Release notifications (`host.deploy.activated`)
+
+Roadmap WS-P task 9, ADR-016 (active client updates). When a network service's release goes live,
+Host publishes one `host.deploy.activated` event to OpenVibe.Events. The event has visibility
+**public** and subject `{ type: "release", id: "<service>:<release>" }`. Open tabs run
+`openvibe-shared/release-watch.js` (1.17.0 and later) and listen for it on the Events realtime stream.
+When the event names their service, they check `/release.json` within about 20 seconds instead of
+at their next 10-minute poll. The event carries identifiers only.
+
+| Payload field | Value |
+|---|---|
+| `service` | The service id the pages' `/release.json` names (`live`, `tools`, a Sites placeholder's id such as `news`) |
+| `release` | The release id `/release.json` reports, 7–40 hex characters |
+| `commit` | The deployed commit when known and the release is its prefix, else `null` |
+| `origin` | The public origin (`https://openvibe.live`), or `null` |
+| `deployed_at` | When Host announced it |
+| `components` | Optional: the manifest's `{ name: { kind, version } }` |
+| `rollback` | Optional: `true` after `ovhost rollback` |
+
+The payload contract is `host.deploy.activated@1` in openvibe-contracts 0.58.0. The same event type also
+covers Stage B tenant activations, which use subject `deploy`, a different payload and visibility
+`internal`, so browsers never receive them.
+
+## Who announces
+
+- `ovhost deploy <service>` and `ovhost rollback <service>` announce after a deploy that went live
+ (result `deployed` or `rolled-back`, exit 0). `--no-announce` skips it.
+- Services deployed by their own scripts call `ovhost announce <service>` at the end:
+ - Live: `deploy/scripts/deploy.sh`.
+ - Tools: `deploy/scripts/deploy.sh`.
+ - Sites: `deploy/scripts/deploy.sh`, one call per placeholder with `--release` and `--origin`.
+
+ OpenRe and Games can do the same by hand or in their scripts. The scripts do nothing if `ovhost` is
+ missing or has no `announce`, and a failed announcement never fails the deploy.
+
+`ovhost announce <service> [--release <id>] [--commit <sha>] [--origin <url>] [--force] [--dry-run] [--events-env <file>] [--json]`
+
+- **Release:** `--release`, else what the service's loopback `/release.json` reports (at the origin of its
+ inventory ready URL, with the ready headers), else the checkout's HEAD (12 hex). A service that
+ is not in the inventory needs `--release`.
+- **Origin:** `--origin`, else the inventory entry's `origin`, else the service manifest's
+ `publicOrigin` in the installed openvibe-contracts.
+- **Once per release.** `<stateDir>/announced/<service>.json` remembers the last release sent, and the
+ same release is not sent again. `--force` sends it again as a new event.
+- **Best effort.** The token request and the publish time out after 2.5 s each. A failed publish is
+ retried once with the same `event_id`, and Events stores it once. The whole announcement takes 6 s at
+ most.
+- **Exit codes:** `0` sent, already sent, or dry run. `1` usage error, or credentials not configured.
+ `2` publishing failed. `--dry-run` prints the envelope and needs no credentials.
+
+## Credentials
+
+Host publishes as its Network service principal: OAuth client **`host`** (`svc:host`), grant
+`events.event.publish` on audience `openvibe.events`. This is the client the Stage B Host API already
+uses, and Network seeds the grant from `DEFAULT_GRANTS`
+(`server/identity/principals.js`: `['host', 'events.event.publish', 'openvibe.events', []]`).
+
+ovhost reads these names (never printed, logged or stored) from the first file that is set:
+`--events-env`, then `$OVHOST_EVENTS_ENV`, then the inventory's `events.envFile`, then
+**`/etc/openvibe/host.env`** (the Host API's env file).
+
+| Name | |
+|---|---|
+| `OV_OAUTH_CLIENT_ID` | default `host` |
+| `OV_OAUTH_CLIENT_SECRET` | required |
+| `EVENTS_URL` | required, e.g. `http://127.0.0.1:4300` (plain http only on loopback) |
+| `OV_NETWORK_INTERNAL_URL` | default `http://127.0.0.1:4000`. The token endpoint is `<it>/oauth/token` |
+
+If the file or a required name is missing, the announcement is skipped. An `ovhost deploy` logs one line
+and carries on, and `ovhost announce` exits 1.
+
+## Provisioning on the host (for the lead)
+
+All commands run on the production host. None of them prints a secret.
+
+1. **Is the `host` client there?** Network seeds it at boot, with a secret that only its database holds.
+ ```
+ cd /opt/openvibe.network && sudo node server/setup/service-principal.js list | grep -E '^host\s'
+ ```
+ Expect `host site OpenVibe.Host`. It is listed as `site` because it has the dashboard's redirect
+ URI; client_credentials works for it all the same. If the line is missing, create the client instead of rotating it in step 2:
+ `sudo node server/setup/service-principal.js create host --env-file /etc/openvibe/host.env`.
+2. **Does Host's env file already hold its secret?** It does if the Host API was set up with one. The
+ first command counts matching lines and never prints a value.
+ ```
+ sudo grep -c '^OV_OAUTH_CLIENT_SECRET=.' /etc/openvibe/host.env
+ ```
+ If it prints `0`, or the file does not exist, write a fresh secret into it:
+ ```
+ cd /opt/openvibe.network && sudo node server/setup/service-principal.js rotate host --env-file /etc/openvibe/host.env
+ sudo systemctl restart openvibe-host
+ ```
+ Rotating changes the secret the running Host API uses, so restart it afterwards. The script writes
+ `OV_OAUTH_CLIENT_ID=host` and `OV_OAUTH_CLIENT_SECRET=…` with mode 0600.
+3. **Events and Network URLs** in the same file, if they are absent (again, a count only):
+ ```
+ sudo grep -cE '^(EVENTS_URL|OV_NETWORK_INTERNAL_URL)=.' /etc/openvibe/host.env
+ ```
+ If the count is below 2, add the missing lines with `sudoedit /etc/openvibe/host.env`:
+ `EVENTS_URL=http://127.0.0.1:4300` and `OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000`.
+4. **The grant.** It is a default grant, so a Network boot since 2026-09-23 has it. Check as the owner in
+ the Network admin, `GET https://openvibe.network/api/admin/grants?client=host`, where it shows as
+ active. Or check the database directly:
+ ```
+ cd /opt/openvibe.network && sudo sqlite3 data/network.db "SELECT capability, audience, revoked_at FROM principal_grants WHERE client_id='host'"
+ ```
+ Expect `events.event.publish|openvibe.events|` (not revoked). If it is missing, grant it in the
+ admin: `POST /api/admin/grants { client_id: "host", capability: "events.event.publish", reason: "release notifications (WS-P 9)" }`.
+5. **Events accepts `host` as a source and serves public events to signed-out browsers.** This is
+ the default: `host` is in `DEFAULT_SOURCES`, and `REALTIME_ALLOW_ANONYMOUS` is not `false`. Check
+ that `/etc/openvibe/events.env` does not override either one. Each count below should be 0:
+ ```
+ sudo grep -c '^EVENTS_SOURCE_PREFIXES=' /etc/openvibe/events.env
+ sudo grep -c '^REALTIME_ALLOW_ANONYMOUS=false' /etc/openvibe/events.env
+ ```
+ A site outside `https://*.openvibe.*` (for example openre.stream) only gets the realtime stream if
+ its origin is in Events' `REALTIME_CORS_ORIGINS`. Otherwise its tabs back off and keep polling.
+6. **Update ovhost** to this release. The CLI is the checkout `/usr/local/bin/ovhost` points into
+ (`readlink -f /usr/local/bin/ovhost`):
+ ```
+ cd "$(dirname "$(dirname "$(readlink -f /usr/local/bin/ovhost)")")" && sudo git pull --ff-only
+ ovhost --help | grep -q 'announce <service>' && echo "announce available"
+ ```
+ No dependency changed.
+
+## Verifying end to end
+
+1. Subscribe the way a signed-out browser does, from any machine:
+ ```
+ curl -N 'https://events.openvibe.network/realtime/stream?topics=host.deploy.activated'
+ ```
+ The stream starts with `retry: 3000` and `: connected anonymous`, then sends a `: hb` comment every
+ 25 s.
+2. On the host, see what would be sent:
+ `sudo ovhost announce live --dry-run`. It prints the release from Live's `/release.json` and the
+ envelope. Then send it:
+ `sudo ovhost announce live --force`
+ ```
+ [ovhost] release notification sent: live 1a2b3c4d (from /release.json) as evt_01… (seq 12345)
+ ```
+3. The subscriber prints
+ `id: 12345` / `data: {"seq":12345,"event":{"event_type":"host.deploy.activated",…,"visibility":"public","subject":{"type":"release","id":"live:1a2b3c4d"},"payload":{"service":"live",…}}}`.
+4. In a browser on a site that pins openvibe-shared 1.17.0 or later, run `OVRelease.state().realtime` in
+ the console. It shows `{ state: 'open', service: 'live', events: 1, … }` after the announcement.
+ An `--force` of the release the tab already runs is ignored (`ignored: 1`).
+
+On failure: `token: HTTP 401 invalid_client` means the secret in the file does not match Network
+(step 2). `token: HTTP 400 invalid_scope` means the grant is missing (step 4). `publish: HTTP 403 …`
+means Events refused the source or the type (step 5). `ECONNREFUSED` means a wrong URL in the file
+(step 3).
@@ -18,6 +18,10 @@
@@ -18,6 +18,10 @@
"remote": "origin",
"remote": "origin",
"branch": "main"
"branch": "main"
},
},
+ "_events": "Release notifications (ovhost deploy/rollback/announce, docs/release-notifications.md): the env file with Host's service principal credentials (OV_OAUTH_CLIENT_ID, OV_OAUTH_CLIENT_SECRET, EVENTS_URL, OV_NETWORK_INTERNAL_URL). A service entry may set \"origin\" (https) to override the manifest's publicOrigin.",
+ "events": {
+ "envFile": "/etc/openvibe/host.env"
+ },
"services": {
"services": {
"network": {
"network": {
"repo": "/opt/openvibe.network",
"repo": "/opt/openvibe.network",
new file mode 100644
new file mode 100644
@@ -0,0 +1,300 @@
@@ -0,0 +1,300 @@
+'use strict';
+/**
+ * Release notifications (roadmap WS-P task 9; ADR-016). When a network service's release goes live,
+ * Host publishes `host.deploy.activated` to OpenVibe.Events with visibility public and subject
+ * { type: release, id: <service>:<release> }. Open tabs (openvibe-shared release-watch, subscribed to
+ * that topic over Events realtime) then check /release.json within seconds instead of at their next
+ * poll.
+ *
+ * ovhost deploy|rollback <service> announces after a deploy or rollback that went live
+ * ovhost announce <service> for services deployed by their own scripts (Live, Tools, Sites,
+ * OpenRe, Games)
+ *
+ * Best effort: it never fails or holds up a deploy. The token request and the publish each have a
+ * short timeout and the whole announcement a budget of a few seconds; a failure is logged and the
+ * deploy's exit code stays what it was. A retry sends the same event_id, which Events stores once.
+ *
+ * The release is what the service's own /release.json reports now (on loopback, at the origin of its
+ * ready URL), because that is what open tabs compare with; else the checkout's HEAD (12 hex); or
+ * --release. The commit is included when it is known and the release is its prefix (or --commit).
+ * The origin is --origin, the inventory entry's `origin`, or the service manifest's publicOrigin in
+ * the installed openvibe-contracts. The last release announced for each service is kept in
+ * <stateDir>/announced/<service>.json and the same release is not announced twice (--force does).
+ *
+ * Credentials: Host's Network service principal, client `host` (grant events.event.publish, audience
+ * openvibe.events), from the Host API's env file. Variable NAMES:
+ * OV_OAUTH_CLIENT_ID default host
+ * OV_OAUTH_CLIENT_SECRET required
+ * EVENTS_URL required, e.g. http://127.0.0.1:4300
+ * OV_NETWORK_INTERNAL_URL default http://127.0.0.1:4000 (the token endpoint is <it>/oauth/token)
+ * File: --events-env, else $OVHOST_EVENTS_ENV, else the inventory's events.envFile, else
+ * /etc/openvibe/host.env. The secret and the token are never printed, logged, stored or returned.
+ */
+const path = require('path');
+const { git } = require('./git');
+
+const EVENT_TYPE = 'host.deploy.activated';
+const DEFAULT_ENV_FILE = '/etc/openvibe/host.env';
+const SERVICE_RE = /^[a-z][a-z0-9-]{1,39}$/;
+const HEX_RE = /^[0-9a-f]{7,40}$/;
+const COMPONENT_RE = /^[a-z][a-z0-9-]{0,39}$/;
+const VERSION_RE = /^[0-9A-Za-z._+-]{1,64}$/;
+const KINDS = ['style', 'content', 'script', 'server'];
+const MAX_COMPONENTS = 32;
+const REQUEST_MS = 2500; // each HTTP request
+const BUDGET_MS = 6000; // the token request, the publish and its one retry together
+const LINE_RE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/;
+const WANTED = ['OV_OAUTH_CLIENT_ID', 'OV_OAUTH_CLIENT_SECRET', 'EVENTS_URL', 'OV_NETWORK_INTERNAL_URL'];
+
+class AnnounceError extends Error {
+ constructor(message, exitCode = 1) { super(message); this.exitCode = exitCode; }
+}
+
+const loopback = (hostname) => ['127.0.0.1', 'localhost', '[::1]', '::1'].includes(hostname);
+
+/** KEY=value lines -> { KEY: value } for the names announce reads; nothing else is kept. */
+function parseEnv(text) {
+ const out = {};
+ for (const line of String(text || '').split('\n')) {
+ const m = line.match(LINE_RE);
+ if (!m || !WANTED.includes(m[1])) continue;
+ let v = m[2].trim();
+ if ((v.startsWith('"') && v.endsWith('"') && v.length >= 2) || (v.startsWith("'") && v.endsWith("'") && v.length >= 2)) v = v.slice(1, -1);
+ else v = v.replace(/\s+#.*$/, '');
+ out[m[1]] = v;
+ }
+ return out;
+}
+
+function baseUrl(raw, name) {
+ let u;
+ try { u = new URL(raw); } catch { return { error: `${name} is not a URL` }; }
+ if (!['http:', 'https:'].includes(u.protocol) || u.username || u.password) return { error: `${name} must be an http(s) URL without credentials` };
+ if (u.protocol === 'http:' && !loopback(u.hostname)) return { error: `${name} must be https:// (plain http only on loopback)` };
+ return { url: `${u.origin}${u.pathname.replace(/\/+$/, '')}` };
+}
+
+/**
+ * -> { file, clientId, eventsUrl, networkUrl } with the secret as a non-enumerable property (so
+ * JSON.stringify and util.inspect never show it), or { file, error, notConfigured }.
+ */
+async function loadCredentials(exec, inv, { file = null, env = {} } = {}) {
+ const f = file || env.OVHOST_EVENTS_ENV || (inv.events && inv.events.envFile) || DEFAULT_ENV_FILE;
+ if (!path.isAbsolute(f)) return { file: f, error: 'the events env file must be an absolute path', notConfigured: true };
+ let text;
+ try { text = await exec.readFile(f, { privileged: true }); } catch (err) { return { file: f, error: `${f} is not readable (${err.code || 'error'})`, notConfigured: true }; }
+ if (text == null) return { file: f, error: `${f} not found`, notConfigured: true };
+ const v = parseEnv(text);
+ const missing = ['OV_OAUTH_CLIENT_SECRET', 'EVENTS_URL'].filter((n) => !v[n]);
+ if (missing.length) return { file: f, error: `${f} sets no ${missing.join(' and no ')}`, notConfigured: true };
+ const events = baseUrl(v.EVENTS_URL, 'EVENTS_URL');
+ if (events.error) return { file: f, error: events.error, notConfigured: true };
+ const network = baseUrl(v.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000', 'OV_NETWORK_INTERNAL_URL');
+ if (network.error) return { file: f, error: network.error, notConfigured: true };
+ const clientId = v.OV_OAUTH_CLIENT_ID || 'host';
+ if (!SERVICE_RE.test(clientId)) return { file: f, error: 'OV_OAUTH_CLIENT_ID is not a service principal id', notConfigured: true };
+ const cfg = { file: f, clientId, eventsUrl: events.url, networkUrl: network.url };
+ Object.defineProperty(cfg, 'secret', { value: v.OV_OAUTH_CLIENT_SECRET, enumerable: false });
+ return cfg;
+}
+
+/** The service's public origin from the installed openvibe-contracts manifest, or null. */
+function manifestOrigin(id, contracts) {
+ let lib = contracts;
+ if (!lib) { try { lib = require('openvibe-contracts'); } catch { return null; } }
+ const m = lib.services && typeof lib.services.get === 'function' ? lib.services.get(id) : null;
+ return m && m.publicOrigin ? checkOrigin(m.publicOrigin, { quiet: true }) : null;
+}
+
+function checkOrigin(raw, { quiet = false } = {}) {
+ let u;
+ try { u = new URL(String(raw)); } catch { if (quiet) return null; throw new AnnounceError('--origin must be a URL such as https://openvibe.live'); }
+ const ok = (u.protocol === 'https:' || (u.protocol === 'http:' && loopback(u.hostname))) && !u.username && !u.password;
+ if (!ok) { if (quiet) return null; throw new AnnounceError('--origin must be an https:// origin'); }
+ return u.origin;
+}
+
+/** Only what the event schema allows: { <name>: { kind, version } }, at most 32; else null. */
+function components(raw) {
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
+ const out = {};
+ const names = Object.keys(raw);
+ if (!names.length || names.length > MAX_COMPONENTS) return null;
+ for (const name of names) {
+ const c = raw[name];
+ if (!COMPONENT_RE.test(name) || !c || !KINDS.includes(c.kind) || !VERSION_RE.test(String(c.version || ''))) return null;
+ out[name] = { kind: c.kind, version: String(c.version) };
+ }
+ return out;
+}
+
+/** GET <ready origin>/release.json on loopback -> { release, components } | { note }. */
+async function servedManifest(exec, svc) {
+ let url;
+ try { url = new URL('/release.json', svc.ready.url).href; } catch { return { note: 'no ready URL to find /release.json at' }; }
+ const r = await exec.http(url, { timeoutMs: 3000, headers: svc.ready.headers || {} });
+ if (r.status !== 200) return { note: `${url} answered ${r.status || r.error}; using the checkout's HEAD` };
+ let m;
+ try { m = JSON.parse(r.body); } catch { return { note: `${url} is not JSON; using the checkout's HEAD` }; }
+ if (!m || typeof m.release !== 'string' || !HEX_RE.test(m.release)) return { note: `${url} names no release; using the checkout's HEAD` };
+ if (m.service !== svc.id) return { note: `${url} is service "${m.service}", not "${svc.id}"; using the checkout's HEAD` };
+ return { release: m.release, components: components(m.components) };
+}
+
+/**
+ * What to announce for `id`: { service, release, commit, origin, components, from, notes }.
+ * `head` (a full sha) saves the git call when the caller knows it (ovhost deploy).
+ */
+async function describeRelease(ctx, id, { release = null, commit = null, origin = null, head = null, contracts = null } = {}) {
+ const { exec, inv } = ctx;
+ if (!SERVICE_RE.test(String(id || ''))) throw new AnnounceError(`"${id}" is not a service id (the "service" a /release.json names)`);
+ const svc = inv.services[id] || null;
+ const notes = [];
+ let rel = release == null ? null : String(release).toLowerCase();
+ if (rel !== null && !HEX_RE.test(rel)) throw new AnnounceError('--release must be 7 to 40 hex characters: the release id the service\'s /release.json reports');
+ let cm = commit == null ? null : String(commit).toLowerCase();
+ if (cm !== null && !HEX_RE.test(cm)) throw new AnnounceError('--commit must be 7 to 40 hex characters');
+ let comps = null;
+ let from = rel ? '--release' : null;
+ if (!rel && svc && svc.ready) {
+ const m = await servedManifest(exec, svc);
+ if (m.release) { rel = m.release; comps = m.components; from = '/release.json'; } else notes.push(m.note);
+ }
+ let sha = head && /^[0-9a-f]{40}$/.test(head) ? head : null;
+ if (!sha && svc && (!rel || !cm)) {
+ try {
+ const h = await git(exec, svc).head();
+ if (/^[0-9a-f]{40}$/.test(h)) sha = h;
+ } catch (err) { notes.push(`the checkout's HEAD is unknown (${err.message.split('\n')[0].slice(0, 120)})`); }
+ }
+ if (!rel && sha) { rel = sha.slice(0, 12); from = 'git HEAD'; }
+ if (!rel) {
+ throw new AnnounceError(svc ? `cannot tell ${id}'s release (no /release.json answer, no git HEAD): pass --release <id>`
+ : `${id} is not in the inventory: pass --release <id> (the release its /release.json reports) and --origin`);
+ }
+ if (!cm && sha && sha.startsWith(rel)) cm = sha;
+ let o = null;
+ if (origin != null) o = checkOrigin(origin);
+ else if (svc && svc.origin) o = svc.origin;
+ else o = manifestOrigin(svc ? svc.manifest : id, contracts);
+ return { service: id, release: rel, commit: cm, origin: o, components: comps, from, notes };
+}
+
+function newEventId(now) {
+ return require('openvibe-contracts').ids.newId('event', now);
+}
+
+/** The events.event-envelope@1 for a description (payload: contract host.deploy.activated@1). */
+function envelope(d, { now, rollback = false, eventId = newEventId(now) }) {
+ const at = new Date(now).toISOString();
+ const payload = { service: d.service, release: d.release, commit: d.commit || null, origin: d.origin || null, deployed_at: at };
+ if (d.components) payload.components = d.components;
+ if (rollback) payload.rollback = true;
+ return {
+ event_id: eventId,
+ event_type: EVENT_TYPE,
+ version: 1,
+ source: 'host',
+ actor: { type: 'service', id: 'host' },
+ timestamp: at,
+ priority: 'low',
+ visibility: 'public',
+ subject: { type: 'release', id: `${d.service}:${d.release}` },
+ payload,
+ };
+}
+
+/** A short reason for a failed request: the status and a problem/OAuth code, never the body. */
+function reason(r) {
+ if (!r) return 'no answer';
+ if (r.status === 0) return r.error || 'unreachable';
+ let code = null;
+ try { const j = JSON.parse(r.body); code = j && (j.code || j.error); } catch { /* not JSON */ }
+ return `HTTP ${r.status}${typeof code === 'string' && /^[a-z0-9._-]{1,60}$/i.test(code) ? ` ${code}` : ''}`;
+}
+
+/** Token, then POST /api/v1/events (one retry, same event_id). -> { ok, seq, duplicate } | { ok: false, stage, error }. */
+async function publish(exec, creds, env) {
+ const start = exec.now();
+ const left = () => BUDGET_MS - (exec.now() - start);
+ const form = new URLSearchParams({ grant_type: 'client_credentials', client_id: creds.clientId, client_secret: creds.secret, audience: 'openvibe.events', scope: 'events.event.publish' });
+ const t = await exec.request(`${creds.networkUrl}/oauth/token`, {
+ method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }, body: form.toString(), timeoutMs: REQUEST_MS,
+ });
+ if (t.status !== 200) return { ok: false, stage: 'token', error: reason(t) };
+ let token = null;
+ try { token = JSON.parse(t.body).access_token; } catch { token = null; }
+ if (typeof token !== 'string' || !token) return { ok: false, stage: 'token', error: 'no access_token in the answer' };
+ const body = JSON.stringify(env);
+ let last = null;
+ let attempts = 0;
+ while (attempts < 2) {
+ attempts += 1;
+ last = await exec.request(`${creds.eventsUrl}/api/v1/events`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: `Bearer ${token}` }, body, timeoutMs: Math.max(500, Math.min(REQUEST_MS, left())),
+ });
+ if (last.status === 200 || last.status === 201) {
+ let j = {};
+ try { j = JSON.parse(last.body) || {}; } catch { j = {}; }
+ return { ok: true, seq: Number.isInteger(j.seq) ? j.seq : null, duplicate: j.duplicate === true || last.status === 200, attempts };
+ }
+ const retryable = last.status === 0 || last.status === 429 || last.status >= 500;
+ if (!retryable || left() < 1000) break;
+ await exec.sleep(300);
+ }
+ return { ok: false, stage: 'publish', error: reason(last), attempts };
+}
+
+const stateFile = (inv, service) => path.join(inv.stateDir, 'announced', `${service}.json`);
+
+async function readState(exec, inv, service) {
+ try {
+ const text = await exec.readFile(stateFile(inv, service));
+ return text ? JSON.parse(text) : null;
+ } catch { return null; }
+}
+
+/**
+ * Announce `id`'s current release. Never throws for a publishing problem: the result says what
+ * happened. Throws AnnounceError only for bad arguments (exit 1).
+ * -> { service, release, commit, origin, from, event_id, published, skipped, error, notConfigured, seq, duplicate, envelope? }
+ */
+async function announce(ctx, id, { release = null, commit = null, origin = null, head = null, rollback = false, force = false, dryRun = false, envFile = null, env = {}, contracts = null } = {}) {
+ const { exec, inv, log = () => {} } = ctx;
+ let creds = null;
+ if (!dryRun) {
+ creds = await loadCredentials(exec, inv, { file: envFile, env });
+ if (creds.error) {
+ // Nothing is described or sent: an unconfigured host announces nothing, quietly and cheaply.
+ if (!SERVICE_RE.test(String(id || ''))) throw new AnnounceError(`"${id}" is not a service id`);
+ return { service: id, release: release || null, published: false, skipped: 'not configured', error: creds.error, notConfigured: true };
+ }
+ }
+ const d = await describeRelease(ctx, id, { release, commit, origin, head, contracts });
+ for (const n of d.notes) log(n);
+ const env_ = envelope(d, { now: exec.now(), rollback });
+ const out = { service: d.service, release: d.release, commit: d.commit, origin: d.origin, from: d.from, event_id: env_.event_id, published: false, skipped: null, error: null, notConfigured: false };
+ if (dryRun) return { ...out, skipped: 'dry run', envelope: env_ };
+ const prev = await readState(exec, inv, d.service);
+ if (!force && prev && prev.release === d.release) return { ...out, event_id: prev.event_id || null, skipped: `already announced at ${prev.at}` };
+ const p = await publish(exec, creds, env_);
+ if (!p.ok) return { ...out, error: `${p.stage}: ${p.error}` };
+ const rec = { service: d.service, release: d.release, commit: d.commit, event_id: env_.event_id, seq: p.seq, at: env_.timestamp };
+ try {
+ await exec.mkdir(path.dirname(stateFile(inv, d.service)), { mode: 0o750 });
+ await exec.writeFile(stateFile(inv, d.service), `${JSON.stringify(rec)}\n`, { mode: 0o640 });
+ } catch (err) { log(`note: could not record the announcement in ${stateFile(inv, d.service)} (${err.code || err.message})`); }
+ return { ...out, published: true, seq: p.seq, duplicate: p.duplicate };
+}
+
+/** One line for the operator. */
+function summary(r) {
+ const what = `${r.service}${r.release ? ` ${r.release}` : ''}`;
+ if (r.published) return `release notification sent: ${what}${r.from ? ` (from ${r.from})` : ''} as ${r.event_id}${r.seq != null ? ` (seq ${r.seq}${r.duplicate ? ', already stored' : ''})` : ''}`;
+ if (r.notConfigured) return `release notification not sent: ${r.error} (see docs/release-notifications.md)`;
+ if (r.error) return `release notification FAILED for ${what}: ${r.error} (the deploy stands; ovhost announce ${r.service} retries)`;
+ if (r.skipped === 'dry run') return `dry run: ${what}${r.from ? ` (from ${r.from})` : ''}; nothing sent`;
+ return `release notification not sent: ${what} was ${r.skipped}${r.skipped && r.skipped.startsWith('already') ? ' (--force sends it again)' : ''}`;
+}
+
+module.exports = { announce, describeRelease, envelope, publish, loadCredentials, summary, stateFile, AnnounceError, EVENT_TYPE, DEFAULT_ENV_FILE };
@@ -26,7 +26,16 @@ Usage: ovhost <command> [args] [--inventory <file>] [--json]
@@ -26,7 +26,16 @@ Usage: ovhost <command> [args] [--inventory <file>] [--json]
deploy <service> [--wait-idle] [--force] [--restart] [--to <sha>] [--install-units] [--ready-timeout <s>]
deploy <service> [--wait-idle] [--force] [--restart] [--to <sha>] [--install-units] [--ready-timeout <s>]
[--browser-check] then check the service's public site in headless Chrome
[--browser-check] then check the service's public site in headless Chrome
(scripts/browser-check.js; report only, never changes the exit code)
(scripts/browser-check.js; report only, never changes the exit code)
- rollback <service> [--to <sha>] [--wait-idle] [--force]
+ [--no-announce] a deploy that went live is announced (see announce) unless this is set
+ rollback <service> [--to <sha>] [--wait-idle] [--force] [--no-announce]
+ announce <service> [--release <id>] [--commit <sha>] [--origin <url>] [--force] [--dry-run]
+ publish host.deploy.activated to OpenVibe.Events (public), so open
+ tabs check /release.json now: for services deployed by their own
+ scripts. Release: --release, else the service's loopback
+ /release.json, else the checkout's HEAD; the same release is sent
+ once (--force again). Credentials: --events-env <file>, else
+ $OVHOST_EVENTS_ENV, else inventory events.envFile, else
+ /etc/openvibe/host.env (docs/release-notifications.md)
releases <service> [--limit <n>] the release log
releases <service> [--limit <n>] the release log
certs [--warn-days <n>] certificates referenced by enabled nginx vhosts, with expiry
certs [--warn-days <n>] certificates referenced by enabled nginx vhosts, with expiry
nginx render <service> [--variant http|sse|websocket] [--manifest <file>] [--install]
nginx render <service> [--variant http|sse|websocket] [--manifest <file>] [--install]
@@ -62,11 +71,12 @@ Usage: ovhost <command> [args] [--inventory <file>] [--json]
@@ -62,11 +71,12 @@ Usage: ovhost <command> [args] [--inventory <file>] [--json]
Inventory: --inventory, else $OVHOST_INVENTORY, else /etc/openvibe/host.json.
Inventory: --inventory, else $OVHOST_INVENTORY, else /etc/openvibe/host.json.
Exit codes: 0 ok · 1 usage/precondition · 2 validation failed (nothing restarted) · 3 not ready,
Exit codes: 0 ok · 1 usage/precondition · 2 validation failed (nothing restarted) · 3 not ready,
rolled back · 4 rollback failed (manual intervention) · 5 protected sessions active ·
rolled back · 4 rollback failed (manual intervention) · 5 protected sessions active ·
+announce: 0 sent, already sent or dry run · 1 usage or not configured · 2 publishing failed ·
drill: 0 passed · 1 precondition (not root, port in use, no backup, unsupported) · 2 drill failed ·
drill: 0 passed · 1 precondition (not root, port in use, no backup, unsupported) · 2 drill failed ·
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(['inventory', 'to', 'limit', 'warn-days', 'variant', 'manifest', 'out', 'ready-timeout', 'wildcard-cert', 'backup', 'run', 'keep-daily', 'keep-weekly', 'from-host', 'backup-env']);
+const FLAGS_WITH_VALUE = new Set(['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']);
function parseArgs(argv) {
function parseArgs(argv) {
const pos = [];
const pos = [];
@@ -109,6 +119,12 @@ function bytes(n) {
@@ -109,6 +119,12 @@ function bytes(n) {
return `${i ? v.toFixed(1) : v} ${u[i]}`;
return `${i ? v.toFixed(1) : v} ${u[i]}`;
}
}
+/** An announce result without the envelope, for --json. */
+function announcedSummary(a) {
+ const { envelope, ...rest } = a;
+ return rest;
+}
+
async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => process.stdout.write(`${s}\n`), env = process.env, s3 = lib.offsite.createClient, browserCheck = runBrowserCheck } = {}) {
async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => process.stdout.write(`${s}\n`), env = process.env, s3 = lib.offsite.createClient, browserCheck = runBrowserCheck } = {}) {
let parsed;
let parsed;
try { parsed = parseArgs(argv); } catch (err) { out(err.message); out(USAGE); return 1; }
try { parsed = parseArgs(argv); } catch (err) { out(err.message); out(USAGE); return 1; }
@@ -177,7 +193,15 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc
@@ -177,7 +193,15 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc
const id = needService();
const id = needService();
const o = { waitIdle: !!opts['wait-idle'], force: !!opts.force, restart: !!opts.restart, to: opts.to, installUnits: !!opts['install-units'], readyTimeout: opts['ready-timeout'] ? Number(opts['ready-timeout']) : undefined };
const o = { waitIdle: !!opts['wait-idle'], force: !!opts.force, restart: !!opts.restart, to: opts.to, installUnits: !!opts['install-units'], readyTimeout: opts['ready-timeout'] ? Number(opts['ready-timeout']) : undefined };
const r = cmd === 'deploy' ? await lib.deploy(ctx, id, o) : await lib.rollback(ctx, id, o);
const r = cmd === 'deploy' ? await lib.deploy(ctx, id, o) : await lib.rollback(ctx, id, o);
- if (json) emit(r.record);
+ // Release notification (lib/announce.js): best effort, never changes the exit code.
+ let announced = null;
+ if (r.exitCode === 0 && ['deployed', 'rolled-back'].includes(r.record.result) && !opts['no-announce']) {
+ try {
+ announced = await lib.announce(ctx, r.record.service, { head: r.record.to, rollback: cmd === 'rollback', envFile: opts['events-env'], env });
+ } catch (e) { announced = { service: r.record.service, published: false, error: e.message }; }
+ log(lib.announceSummary(announced));
+ }
+ if (json) emit(announced ? { ...r.record, announce: announcedSummary(announced) } : r.record);
else if (r.record.result !== 'unchanged') out(`[ovhost] result: ${r.record.result} (release ${r.record.id})`);
else if (r.record.result !== 'unchanged') out(`[ovhost] result: ${r.record.result} (release ${r.record.id})`);
if (cmd === 'deploy' && opts['browser-check'] && !json && r.exitCode === 0 && r.record.result === 'deployed') {
if (cmd === 'deploy' && opts['browser-check'] && !json && r.exitCode === 0 && r.record.result === 'deployed') {
log(`browser check of ${id}'s public site (report only)…`);
log(`browser check of ${id}'s public site (report only)…`);
@@ -188,6 +212,20 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc
@@ -188,6 +212,20 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc
}
}
return r.exitCode;
return r.exitCode;
}
}
+ case 'announce': {
+ const id = needService();
+ const a = await lib.announce(ctx, id, {
+ release: opts.release, commit: opts.commit, origin: opts.origin,
+ force: !!opts.force, dryRun: !!opts['dry-run'], envFile: opts['events-env'], env,
+ });
+ if (json) emit(a.envelope ? { ...announcedSummary(a), envelope: a.envelope } : announcedSummary(a));
+ else {
+ log(lib.announceSummary(a));
+ if (a.envelope) out(JSON.stringify(a.envelope, null, 2));
+ }
+ if (a.published || (a.skipped && !a.notConfigured)) return 0;
+ return a.notConfigured ? 1 : 2;
+ }
case 'status': {
case 'status': {
const rows = await lib.status(ctx, pos.slice(1));
const rows = await lib.status(ctx, pos.slice(1));
if (json) { emit(rows); return 0; }
if (json) { emit(rows); return 0; }
@@ -356,6 +394,7 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc
@@ -356,6 +394,7 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc
if (err instanceof lib.DrillError) { out(`[ovhost] ${err.message}`); return err.exitCode; }
if (err instanceof lib.DrillError) { out(`[ovhost] ${err.message}`); return err.exitCode; }
if (err instanceof lib.offsite.OffsiteError) { out(`[ovhost] ✗ ${err.message}`); return err.exitCode; }
if (err instanceof lib.offsite.OffsiteError) { out(`[ovhost] ✗ ${err.message}`); return err.exitCode; }
if (err instanceof lib.OpError) { out(`[ovhost] ✗ ${err.message}`); return err.exitCode; }
if (err instanceof lib.OpError) { out(`[ovhost] ✗ ${err.message}`); return err.exitCode; }
+ if (err instanceof lib.AnnounceError) { out(`[ovhost] ${err.message}`); return err.exitCode; }
out(`[ovhost] ✗ ${err.message}`);
out(`[ovhost] ✗ ${err.message}`);
return 2;
return 2;
}
}
@@ -19,6 +19,9 @@
@@ -19,6 +19,9 @@
* readStream(path) -> Readable ; writeStream(path, { mode }) -> Writable (creates the file
* readStream(path) -> Readable ; writeStream(path, { mode }) -> Writable (creates the file
* exclusively: it fails if the path exists). Off-host backups only, which run as root.
* exclusively: it fails if the path exists). Off-host backups only, which run as root.
* http(url, { timeoutMs, headers }) -> { status, body } | { status: 0, error }
* http(url, { timeoutMs, headers }) -> { status, body } | { status: 0, error }
+ * request(url, { method, headers, body, timeoutMs }) -> { status, body } | { status: 0, error }
+ * any method, http:// or https:// (release notifications: Network's token endpoint and Events'
+ * publish API). Headers and body may carry a secret: nothing here logs them.
* sqlite(db, sql, { as }) -> rows ; sqliteBackup(db, dest, { as })
* sqlite(db, sql, { as }) -> rows ; sqliteBackup(db, dest, { as })
* listeners(port) -> [{ pid, process }]
* listeners(port) -> [{ pid, process }]
* kill(pid, signal) -> bool (signal one pid; used only for a restore drill's own instance)
* kill(pid, signal) -> bool (signal one pid; used only for a restore drill's own instance)
@@ -27,6 +30,7 @@
@@ -27,6 +30,7 @@
const { spawn } = require('child_process');
const { spawn } = require('child_process');
const fs = require('fs');
const fs = require('fs');
const httpLib = require('http');
const httpLib = require('http');
+const httpsLib = require('https');
const os = require('os');
const os = require('os');
const path = require('path');
const path = require('path');
@@ -192,6 +196,34 @@ function createSystemExecutor({ env = process.env } = {}) {
@@ -192,6 +196,34 @@ function createSystemExecutor({ env = process.env } = {}) {
});
});
}
}
+ function request(url, { method = 'GET', headers = {}, body = null, timeoutMs = 5000 } = {}) {
+ return new Promise((resolve) => {
+ let u;
+ try { u = new URL(url); } catch { resolve({ status: 0, error: 'bad URL' }); return; }
+ const mod = u.protocol === 'https:' ? httpsLib : u.protocol === 'http:' ? httpLib : null;
+ if (!mod) { resolve({ status: 0, error: 'unsupported protocol' }); return; }
+ let req = null;
+ let settled = false;
+ const finish = (r) => { if (settled) return; settled = true; clearTimeout(timer); resolve(r); };
+ const timer = setTimeout(() => { if (req) req.destroy(); finish({ status: 0, error: 'timeout' }); }, timeoutMs);
+ const payload = body == null ? null : Buffer.from(String(body));
+ try {
+ req = mod.request(u, { method, headers: { ...headers, ...(payload ? { 'Content-Length': payload.length } : {}) } }, (res) => {
+ let text = '';
+ res.setEncoding('utf8');
+ res.on('data', (c) => { if (text.length < 1024 * 1024) text += c; });
+ res.on('end', () => finish({ status: res.statusCode, body: text }));
+ res.on('error', (err) => finish({ status: 0, error: err.code || 'response error' }));
+ });
+ req.on('error', (err) => finish({ status: 0, error: err.code || 'request error' }));
+ if (payload) req.write(payload);
+ req.end();
+ } catch (err) {
+ finish({ status: 0, error: err.code || 'request error' });
+ }
+ });
+ }
+
async function sqliteCall(payload, as) {
async function sqliteCall(payload, as) {
const r = await run(process.execPath, [WORKER, JSON.stringify(payload)], { as, timeoutMs: 6 * 3600 * 1000 });
const r = await run(process.execPath, [WORKER, JSON.stringify(payload)], { as, timeoutMs: 6 * 3600 * 1000 });
let out;
let out;
@@ -231,6 +263,7 @@ function createSystemExecutor({ env = process.env } = {}) {
@@ -231,6 +263,7 @@ function createSystemExecutor({ env = process.env } = {}) {
createExclusive,
createExclusive,
pidAlive: async (pid) => pidAlive(pid),
pidAlive: async (pid) => pidAlive(pid),
http,
http,
+ request,
sqlite: async (db, sql, { as } = {}) => (await sqliteCall({ op: 'query', db, sql }, as)).rows,
sqlite: async (db, sql, { as } = {}) => (await sqliteCall({ op: 'query', db, sql }, as)).rows,
sqliteBackup: async (db, dest, { as } = {}) => { await sqliteCall({ op: 'backup', db, dest }, as); },
sqliteBackup: async (db, dest, { as } = {}) => { await sqliteCall({ op: 'backup', db, dest }, as); },
listeners,
listeners,
@@ -14,6 +14,7 @@ const { drill, DrillError } = require('./commands/drill');
@@ -14,6 +14,7 @@ const { drill, DrillError } = require('./commands/drill');
const releases = require('./releases');
const releases = require('./releases');
const certs = require('./certs');
const certs = require('./certs');
const nginx = require('./nginx');
const nginx = require('./nginx');
+const announce = require('./announce');
const { createSystemExecutor } = require('./executor');
const { createSystemExecutor } = require('./executor');
module.exports = {
module.exports = {
@@ -38,6 +39,9 @@ module.exports = {
@@ -38,6 +39,9 @@ module.exports = {
releases,
releases,
certs,
certs,
nginx,
nginx,
+ announce: announce.announce,
+ announceSummary: announce.summary,
+ AnnounceError: announce.AnnounceError,
EXIT: ops.EXIT,
EXIT: ops.EXIT,
OpError: ops.OpError,
OpError: ops.OpError,
};
};
@@ -32,6 +32,13 @@ function rel(value, where) {
@@ -32,6 +32,13 @@ function rel(value, where) {
return path.normalize(value);
return path.normalize(value);
}
}
+function httpsOrigin(value, where) {
+ let u;
+ try { u = new URL(String(value)); } catch { throw new InventoryError(`${where} must be an https:// origin`); }
+ if (u.protocol !== 'https:' || u.username || u.password || u.origin !== String(value).replace(/\/+$/, '')) throw new InventoryError(`${where} must be an https:// origin such as https://openvibe.live`);
+ return u.origin;
+}
+
function normaliseService(id, raw, defaults) {
function normaliseService(id, raw, defaults) {
if (!ID_RE.test(id)) throw new InventoryError(`service id "${id}" must match ${ID_RE}`);
if (!ID_RE.test(id)) throw new InventoryError(`service id "${id}" must match ${ID_RE}`);
const where = `services.${id}`;
const where = `services.${id}`;
@@ -79,6 +86,8 @@ function normaliseService(id, raw, defaults) {
@@ -79,6 +86,8 @@ function normaliseService(id, raw, defaults) {
}),
}),
backupOnChange: s.backupOnChange || [],
backupOnChange: s.backupOnChange || [],
nginx: s.nginx || null,
nginx: s.nginx || null,
+ // The public origin release notifications name (lib/announce.js); default: the manifest's publicOrigin.
+ origin: s.origin == null ? null : httpsOrigin(s.origin, `${where}.origin`),
// Lifecycle (WS-P task 1): normally the service manifest's `lifecycle` block; an inventory
// Lifecycle (WS-P task 1): normally the service manifest's `lifecycle` block; an inventory
// `lifecycle` replaces it for this host (lib/lifecycle.js resolve(), checked by validate).
// `lifecycle` replaces it for this host (lib/lifecycle.js resolve(), checked by validate).
lifecycle: s.lifecycle == null ? null : s.lifecycle,
lifecycle: s.lifecycle == null ? null : s.lifecycle,
@@ -322,6 +331,8 @@ function normalise(raw) {
@@ -322,6 +331,8 @@ function normalise(raw) {
sitesEnabled: abs((raw.nginx && raw.nginx.sitesEnabled) || '/etc/nginx/sites-enabled', 'nginx.sitesEnabled'),
sitesEnabled: abs((raw.nginx && raw.nginx.sitesEnabled) || '/etc/nginx/sites-enabled', 'nginx.sitesEnabled'),
bin: (raw.nginx && raw.nginx.bin) || 'nginx',
bin: (raw.nginx && raw.nginx.bin) || 'nginx',
},
},
+ // 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 },
services: {},
services: {},
};
};
for (const [id, svc] of Object.entries(raw.services)) {
for (const [id, svc] of Object.entries(raw.services)) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,288 @@
@@ -0,0 +1,288 @@
+'use strict';
+/**
+ * Release notifications (lib/announce.js, WS-P task 9): `ovhost announce` and the announcement after a
+ * deploy publish host.deploy.activated to OpenVibe.Events with Host's service token, never fail or
+ * change a deploy, send one release once, and never show the client secret or the token.
+ */
+const assert = require('assert');
+const contracts = require('openvibe-contracts');
+const { scenario, push, test, runTests, SECRET } = require('./helpers');
+const { main } = require('../lib/cli');
+
+const TOKEN = 'tok_SERVICE_TOKEN_MUST_NEVER_APPEAR_91c2';
+const ENV_FILE = '/etc/openvibe/host.env';
+const HOST_SECRET = `${SECRET}-host`;
+
+/** The installed openvibe-contracts has host.deploy.activated@1 from 0.58.0; older pins skip this check. */
+function payloadSchemaCheck(payload) {
+ let known = true;
+ try { contracts.schema('host.deploy.activated'); } catch { known = false; }
+ if (!known) return;
+ const v = contracts.validate('host.deploy.activated@1', payload);
+ assert.ok(v.valid, JSON.stringify(v.errors));
+}
+
+/** A scenario with Host's credentials, Network's token endpoint and Events' publish API. */
+function withEvents({ events = 'ok', token = 'ok', envFile = ENV_FILE, envText } = {}) {
+ const host = scenario();
+ host.put(envFile, envText != null ? envText : `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\nHOST_FORM_SECRET=${SECRET}-form\n`, { mode: 0o600, owner: 'root' });
+ host.tokenRequests = [];
+ host.published = [];
+ host.publishAttempts = 0;
+ host.http.set('http://127.0.0.1:4000/oauth/token', ({ method, headers, body }) => {
+ const form = Object.fromEntries(new URLSearchParams(body));
+ host.tokenRequests.push({ method, contentType: headers['Content-Type'], form });
+ if (token !== 'ok') return token;
+ 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, scope: 'events.event.publish' } };
+ });
+ host.http.set('http://127.0.0.1:4300/api/v1/events', ({ method, headers, body }) => {
+ host.publishAttempts += 1;
+ assert.strictEqual(method, 'POST');
+ assert.strictEqual(headers.Authorization, `Bearer ${TOKEN}`);
+ const env = JSON.parse(body);
+ const v = contracts.validate('events.event-envelope@1', env);
+ assert.ok(v.valid, JSON.stringify(v.errors));
+ if (typeof events === 'function') return events(env);
+ if (events !== 'ok') return events;
+ const dup = host.published.some((e) => e.event_id === env.event_id);
+ if (!dup) host.published.push(env);
+ return { status: dup ? 200 : 201, body: { event_id: env.event_id, seq: host.published.length + 100, duplicate: dup } };
+ });
+ return host;
+}
+
+function servesRelease(host, url, manifest) {
+ host.releaseJsonHeaders = [];
+ host.http.set(url, ({ headers }) => { host.releaseJsonHeaders.push(headers); return { status: 200, body: manifest }; });
+}
+
+function noSecrets(host, out) {
+ for (const s of [HOST_SECRET, TOKEN, `${SECRET}-form`]) {
+ assert.ok(!out.includes(s), 'the output shows no secret');
+ assert.ok(!JSON.stringify(host.calls).includes(s), 'no recorded call carries a secret');
+ for (const [p, f] of host.files) if (p !== ENV_FILE && f.type === 'file') assert.ok(!String(f.content).includes(s), `${p} holds no secret`);
+ }
+}
+
+runTests([
+ test('announce live: the release /release.json reports, a service token for events.event.publish, one public event', async () => {
+ const host = withEvents();
+ const head = host.repo('live').head;
+ servesRelease(host, 'http://127.0.0.1:3000/release.json', { service: 'live', release: head.slice(0, 8), components: { shell: { kind: 'script', version: 'sh1' }, server: { kind: 'server', version: 'sv1' } } });
+ const r = await host.cli('announce', 'live');
+ assert.strictEqual(r.code, 0, r.out);
+ assert.match(r.out, new RegExp(`release notification sent: live ${head.slice(0, 8)} \\(from /release\\.json\\) as evt_[0-9A-Z]{26} \\(seq 101\\)`));
+ assert.deepStrictEqual(host.tokenRequests.map((t) => [t.method, t.contentType, t.form.grant_type, t.form.client_id, t.form.audience, t.form.scope]),
+ [['POST', 'application/x-www-form-urlencoded', 'client_credentials', 'host', 'openvibe.events', 'events.event.publish']]);
+ assert.strictEqual(host.published.length, 1);
+ const e = host.published[0];
+ assert.strictEqual(e.event_type, 'host.deploy.activated');
+ assert.strictEqual(e.version, 1);
+ assert.strictEqual(e.source, 'host');
+ assert.deepStrictEqual(e.actor, { type: 'service', id: 'host' });
+ assert.strictEqual(e.visibility, 'public');
+ assert.strictEqual(e.priority, 'low');
+ assert.deepStrictEqual(e.subject, { type: 'release', id: `live:${head.slice(0, 8)}` });
+ assert.deepStrictEqual(e.payload, {
+ service: 'live', release: head.slice(0, 8), commit: head, origin: 'https://openvibe.live', deployed_at: e.timestamp,
+ components: { shell: { kind: 'script', version: 'sh1' }, server: { kind: 'server', version: 'sv1' } },
+ });
+ payloadSchemaCheck(e.payload);
+ const state = JSON.parse(host.read('/var/lib/openvibe-host/announced/live.json'));
+ assert.deepStrictEqual([state.release, state.event_id, state.seq], [head.slice(0, 8), e.event_id, 101]);
+ noSecrets(host, r.out);
+ }),
+
+ test('the same release is announced once; --force sends it again as a new event', async () => {
+ const host = withEvents();
+ const r1 = await host.cli('announce', 'tools');
+ assert.strictEqual(r1.code, 0, r1.out);
+ const r2 = await host.cli('announce', 'tools');
+ assert.strictEqual(r2.code, 0, r2.out);
+ assert.match(r2.out, /tools [0-9a-f]{12} was already announced at .*--force sends it again/);
+ assert.strictEqual(host.published.length, 1);
+ const r3 = await host.cli('announce', 'tools', '--force');
+ assert.strictEqual(r3.code, 0, r3.out);
+ assert.strictEqual(host.published.length, 2);
+ assert.notStrictEqual(host.published[0].event_id, host.published[1].event_id);
+ push(host, 'tools', { 'apps/gateway/server.js': 'gw(2);' });
+ await host.exec.run('git', ['-C', '/opt/openvibe.tools', 'merge', '--ff-only', host.repo('tools').remoteRefs['origin/main']], { as: 'ubuntu' });
+ const r4 = await host.cli('announce', 'tools');
+ assert.strictEqual(r4.code, 0, r4.out);
+ assert.strictEqual(host.published.length, 3, 'a new release is announced');
+ }),
+
+ test('without /release.json: the checkout HEAD (12 hex) and its commit; the ready URL\'s Host header is sent', async () => {
+ const host = withEvents();
+ const head = host.repo('tools').head;
+ const r = await host.cli('announce', 'tools');
+ assert.strictEqual(r.code, 0, r.out);
+ assert.match(r.out, /answered ECONNREFUSED; using the checkout's HEAD/);
+ assert.match(r.out, /\(from git HEAD\)/);
+ const p = host.published[0].payload;
+ assert.deepStrictEqual([p.service, p.release, p.commit, p.origin], ['tools', head.slice(0, 12), head, 'https://openvibe.tools']);
+ assert.ok(!('components' in p));
+ payloadSchemaCheck(p);
+ host.published.length = 0;
+ servesRelease(host, 'http://127.0.0.1:4001/release.json', { service: 'tools', release: 'abcdef123456' });
+ const r2 = await host.cli('announce', 'tools', '--force');
+ assert.strictEqual(r2.code, 0, r2.out);
+ assert.strictEqual(host.releaseJsonHeaders[0].Host, 'openvibe.tools');
+ assert.deepStrictEqual([host.published[0].payload.release, host.published[0].payload.commit], ['abcdef123456', null], 'a commit that does not start with the release is left out');
+ }),
+
+ test('a service the inventory does not know (a Sites placeholder): --release and --origin, no commit', async () => {
+ const host = withEvents();
+ const bad = await host.cli('announce', 'news');
+ assert.strictEqual(bad.code, 1, bad.out);
+ assert.match(bad.out, /news is not in the inventory: pass --release/);
+ const r = await host.cli('announce', 'news', '--release', 'DF0D28A88E98', '--origin', 'https://openvibe.news');
+ assert.strictEqual(r.code, 0, r.out);
+ assert.deepStrictEqual(host.published[0].payload, { service: 'news', release: 'df0d28a88e98', commit: null, origin: 'https://openvibe.news', deployed_at: host.published[0].timestamp });
+ assert.deepStrictEqual(host.published[0].subject, { type: 'release', id: 'news:df0d28a88e98' });
+ payloadSchemaCheck(host.published[0].payload);
+ const noOrigin = await host.cli('announce', 'stream', '--release', '2a3844407d96');
+ assert.strictEqual(noOrigin.code, 0, noOrigin.out);
+ assert.strictEqual(host.published[1].payload.origin, null, 'no manifest, no --origin: origin null');
+ for (const argv of [['--release', 'not-hex'], ['--release', 'abcdef1', '--origin', 'http://example.com'], ['--release', 'abcdef1', '--commit', 'xyz']]) {
+ const x = await host.cli('announce', 'news', ...argv);
+ assert.strictEqual(x.code, 1, `${argv.join(' ')}: ${x.out}`);
+ }
+ const badId = await host.cli('announce', 'Bad_Id', '--release', 'abcdef1');
+ assert.strictEqual(badId.code, 1, badId.out);
+ }),
+
+ test('publishing failures: one retry with the same event_id, exit 2, nothing recorded; a refused token is named', async () => {
+ const host = withEvents({ events: { status: 503, body: { code: 'events.unavailable' } } });
+ const r = await host.cli('announce', 'tools');
+ assert.strictEqual(r.code, 2, r.out);
+ assert.match(r.out, /release notification FAILED for tools [0-9a-f]{12}: publish: HTTP 503 events\.unavailable/);
+ assert.strictEqual(host.publishAttempts, 2, 'retried once');
+ assert.strictEqual(host.read('/var/lib/openvibe-host/announced/tools.json'), null);
+ noSecrets(host, r.out);
+
+ const ids = [];
+ const flaky = withEvents({ events: (env) => { ids.push(env.event_id); return ids.length === 1 ? { error: 'ECONNRESET' } : { status: 201, body: { event_id: env.event_id, seq: 5, duplicate: false } }; } });
+ const r2 = await flaky.cli('announce', 'tools');
+ assert.strictEqual(r2.code, 0, r2.out);
+ assert.strictEqual(ids.length, 2);
+ assert.strictEqual(ids[0], ids[1], 'the retry is the same event (Events stores it once)');
+
+ const refused = withEvents({ events: { status: 403, body: { code: 'events.type_not_allowed' } } });
+ const r3 = await refused.cli('announce', 'tools');
+ assert.strictEqual(r3.code, 2, r3.out);
+ assert.strictEqual(refused.publishAttempts, 1, 'a 403 is not retried');
+ assert.match(r3.out, /HTTP 403 events\.type_not_allowed/);
+
+ const badToken = withEvents({ envText: `OV_OAUTH_CLIENT_SECRET=wrong-${SECRET}\nEVENTS_URL=http://127.0.0.1:4300\n` });
+ const r4 = await badToken.cli('announce', 'tools');
+ assert.strictEqual(r4.code, 2, r4.out);
+ assert.match(r4.out, /token: HTTP 401 invalid_client/);
+ assert.strictEqual(badToken.publishAttempts, 0);
+ assert.ok(!r4.out.includes(`wrong-${SECRET}`));
+
+ const down = withEvents();
+ down.http.delete('http://127.0.0.1:4300/api/v1/events');
+ const r5 = await down.cli('announce', 'tools');
+ assert.strictEqual(r5.code, 2, r5.out);
+ assert.match(r5.out, /publish: ECONNREFUSED/);
+ }),
+
+ test('not configured: exit 1, no HTTP at all; --events-env, $OVHOST_EVENTS_ENV and the inventory choose the file', async () => {
+ const host = scenario();
+ const r = await host.cli('announce', 'tools');
+ assert.strictEqual(r.code, 1, r.out);
+ assert.match(r.out, /release notification not sent: \/etc\/openvibe\/host\.env not found/);
+ assert.ok(!host.calls.some((c) => c.cmd === 'curl'), 'nothing was requested');
+
+ const partial = withEvents({ envText: 'OV_OAUTH_CLIENT_ID=host\nEVENTS_URL=http://127.0.0.1:4300\n' });
+ const p = await partial.cli('announce', 'tools');
+ assert.strictEqual(p.code, 1, p.out);
+ assert.match(p.out, /sets no OV_OAUTH_CLIENT_SECRET/);
+
+ const plainHttp = withEvents({ envText: `OV_OAUTH_CLIENT_SECRET=${SECRET}\nEVENTS_URL=http://events.example.com\n` });
+ const ph = await plainHttp.cli('announce', 'tools');
+ assert.strictEqual(ph.code, 1, ph.out);
+ assert.match(ph.out, /EVENTS_URL must be https:\/\/ \(plain http only on loopback\)/);
+
+ const other = withEvents({ envFile: '/etc/openvibe/announce.env' });
+ const lines = [];
+ const code = await main(['announce', 'tools'], { exec: other.exec, out: (s) => lines.push(s), env: { OVHOST_EVENTS_ENV: '/etc/openvibe/announce.env' } });
+ assert.strictEqual(code, 0, lines.join('\n'));
+ other.published.length = 0;
+ const viaFlag = await other.cli('announce', 'tools', '--force', '--events-env', '/etc/openvibe/announce.env');
+ assert.strictEqual(viaFlag.code, 0, viaFlag.out);
+ const inv = JSON.parse(other.read('/etc/openvibe/host.json'));
+ inv.events = { envFile: '/etc/openvibe/announce.env' };
+ other.put('/etc/openvibe/host.json', JSON.stringify(inv), { mode: 0o640, owner: 'root' });
+ const viaInventory = await other.cli('announce', 'tools', '--force');
+ assert.strictEqual(viaInventory.code, 0, viaInventory.out);
+ assert.strictEqual(other.published.length, 2);
+ }),
+
+ test('--dry-run prints the envelope and sends nothing (no credentials needed)', async () => {
+ const host = scenario();
+ const r = await host.cli('announce', 'tools', '--dry-run');
+ assert.strictEqual(r.code, 0, r.out);
+ assert.match(r.out, /dry run: tools [0-9a-f]{12} \(from git HEAD\); nothing sent/);
+ const env = JSON.parse(r.out.slice(r.out.indexOf('{')));
+ assert.strictEqual(env.event_type, 'host.deploy.activated');
+ assert.ok(contracts.validate('events.event-envelope@1', env).valid);
+ assert.ok(!host.reads.includes(ENV_FILE), 'the credentials were not read');
+ const j = await host.cli('announce', 'tools', '--dry-run', '--json');
+ assert.strictEqual(JSON.parse(j.out).envelope.visibility, 'public');
+ }),
+
+ test('ovhost deploy announces the release that went live; the deploy exit code never depends on it', async () => {
+ const host = withEvents();
+ const to = push(host, 'live', { 'server/index.js': 'console.log(2);' }, 'server change');
+ host.http.set('http://127.0.0.1:3000/release.json', () => ({ status: 200, body: { service: 'live', release: host.units.get('openvibe-live.service').runningSha.slice(0, 12) } }));
+ const r = await host.cli('deploy', 'live');
+ assert.strictEqual(r.code, 0, r.out);
+ assert.match(r.out, new RegExp(`release notification sent: live ${to.slice(0, 12)} \\(from /release\\.json\\)`));
+ assert.deepStrictEqual([host.published[0].payload.release, host.published[0].payload.commit], [to.slice(0, 12), to]);
+ assert.ok(!('rollback' in host.published[0].payload));
+
+ const rb = await host.cli('rollback', 'live', '--json');
+ assert.strictEqual(rb.code, 0, rb.out);
+ const rec = JSON.parse(rb.out.slice(rb.out.indexOf('{')));
+ assert.strictEqual(rec.result, 'rolled-back');
+ assert.strictEqual(rec.announce.published, true);
+ assert.ok(!('envelope' in rec.announce));
+ assert.strictEqual(host.published[1].payload.rollback, true);
+ payloadSchemaCheck(host.published[1].payload);
+
+ const quiet = withEvents();
+ push(quiet, 'live', { 'server/index.js': 'console.log(3);' });
+ const q = await quiet.cli('deploy', 'live', '--no-announce');
+ assert.strictEqual(q.code, 0, q.out);
+ assert.strictEqual(quiet.tokenRequests.length, 0);
+
+ const down = withEvents({ events: { status: 500, body: {} } });
+ push(down, 'live', { 'server/index.js': 'console.log(4);' });
+ const d = await down.cli('deploy', 'live');
+ assert.strictEqual(d.code, 0, d.out);
+ assert.match(d.out, /release notification FAILED for live/);
+ assert.match(d.out, /result: deployed/);
+
+ const unconfigured = scenario();
+ push(unconfigured, 'live', { 'server/index.js': 'console.log(5);' });
+ const u = await unconfigured.cli('deploy', 'live');
+ assert.strictEqual(u.code, 0, u.out);
+ assert.match(u.out, /release notification not sent: \/etc\/openvibe\/host\.env not found/);
+ }),
+
+ test('a deploy that did not go live announces nothing', async () => {
+ const host = withEvents();
+ const to = push(host, 'live', { 'server/index.js': 'broken();' });
+ host.badShas.add(to);
+ const r = await host.cli('deploy', 'live');
+ assert.strictEqual(r.code, 3, r.out);
+ assert.strictEqual(host.tokenRequests.length, 0);
+ const same = await host.cli('deploy', 'media');
+ assert.strictEqual(same.code, 0, same.out);
+ assert.match(same.out, /nothing to do/);
+ assert.strictEqual(host.tokenRequests.length, 0, 'an unchanged deploy is not announced');
+ }),
+]);
@@ -14,7 +14,7 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
@@ -14,7 +14,7 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
const calls = [];
const calls = [];
const repos = new Map(); // repoPath -> repo
const repos = new Map(); // repoPath -> repo
const units = new Map(); // unit -> state
const units = new Map(); // unit -> state
- const http = new Map(); // url -> fn({ headers }) -> { status, body }
+ const http = new Map(); // url -> fn({ headers, method, body }) -> { status, body } (request() also passes method and body)
const alivePids = new Set();
const alivePids = new Set();
const listeners = new Map(); // port -> [{ pid, process }]
const listeners = new Map(); // port -> [{ pid, process }]
let clock = start;
let clock = start;
@@ -354,6 +354,14 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
@@ -354,6 +354,14 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
const r = h({ headers });
const r = h({ headers });
return { status: r.status, body: typeof r.body === 'string' ? r.body : JSON.stringify(r.body || {}) };
return { status: r.status, body: typeof r.body === 'string' ? r.body : JSON.stringify(r.body || {}) };
},
},
+ async request(url, { method = 'GET', headers = {}, body = null } = {}) {
+ calls.push({ cmd: 'curl', args: ['-X', method, url], as: null, privileged: false, cwd: null });
+ const h = http.get(url);
+ if (!h) return { status: 0, error: 'ECONNREFUSED' };
+ const r = h({ method, headers, body });
+ if (r && r.error) return { status: 0, error: r.error };
+ return { status: r.status, body: typeof r.body === 'string' ? r.body : JSON.stringify(r.body || {}) };
+ },
async sqlite(db, sql, { as } = {}) {
async sqlite(db, sql, { as } = {}) {
host.sqliteCalls.push({ op: 'query', db, sql, as });
host.sqliteCalls.push({ op: 'query', db, sql, as });
calls.push({ cmd: 'sqlite', args: [db, sql], as: as || null, privileged: false, cwd: null });
calls.push({ cmd: 'sqlite', args: [db, sql], as: as || null, privileged: false, cwd: null });