openvibers/openvibe.host Commits

Release notifications (roadmap WS-P task 9): ovhost publishes host.deploy.activated to OpenVibe.Events

OP OpenVibers committed 26 Sept 2026 · 62fad64f80 · 10 files, +848 −6

After a deploy or rollback that went live (result deployed or rolled-back, exit 0), ovhost announces the release: one events.event-envelope@1 with event_type host.deploy.activated, source host, actor service:host, visibility public, priority low, subject { type: release, id: <service>:<release> } and payload { service, release, commit, origin, deployed_at, components?, rollback? } (contract host.deploy.activated@1, openvibe-contracts 0.58.0). Open tabs (openvibe-shared release-watch 1.17.0) listen for it on the Events realtime stream and check /release.json within seconds instead of at their next poll.

`ovhost announce <service> [--release <id>] [--commit <sha>] [--origin <url>] [--force] [--dry-run] [--events-env <file>]` does the same for services deployed by their own scripts (Live, Tools, Sites, OpenRe, Games). The release is --release, else what the service's loopback /release.json reports (the origin of its ready URL, with the ready headers: that is what open tabs compare with), else the checkout's HEAD (12 hex). The commit is included when it is known and the release is its prefix. The origin is --origin, the inventory entry's new optional `origin`, or the manifest's publicOrigin in the installed openvibe-contracts. A service the inventory does not know (a Sites placeholder) needs --release. <stateDir>/announced/<service>.json keeps the last release sent; the same release is not sent twice (--force sends it again as a new event). --dry-run prints the envelope and needs no credentials. Exit 0 sent, already sent or dry run; 1 usage or not configured; 2 publishing failed.

Best effort: the token request (Network client_credentials, audience openvibe.events, scope events.event.publish) and the publish (POST /api/v1/events) time out after 2.5 s each, a failed publish is retried once with the same event_id (Events stores it once), 6 s at most in all. A failure is one log line; the deploy's exit code never depends on it (--no-announce skips it). Credentials: Host's service principal `host` (the grant is already a Network default) from --events-env, $OVHOST_EVENTS_ENV, the inventory's new events.envFile, else /etc/openvibe/host.env: OV_OAUTH_CLIENT_ID (default host), OV_OAUTH_CLIENT_SECRET, EVENTS_URL, OV_NETWORK_INTERNAL_URL (default http://127.0.0.1:4000); plain http only on loopback. The secret and the token are never printed, logged, stored or returned (a non-enumerable property; failures name a status and a problem code, never a body). Not configured: skipped with one line, and no HTTP request at all.

The executor gains request() (any method, http or https; the fake host answers it from the same URL map), so the suite still touches nothing real. test/announce.test.js (9 tests): the token form and the envelope (validated as events.event-envelope@1, and as host.deploy.activated@1 once the installed contracts have it), the release from /release.json with components, from git HEAD, or given for an unknown service; once per release and --force; Tools' Host header; retry with the same event_id, no retry on 403, a refused token named; not configured means no HTTP; the three ways to choose the env file; --dry-run; deploy and rollback announce (rollback: true) and never change their exit code; nothing is announced for a deploy that did not go live or did not change anything; no secret in any output, recorded call or written file. Checked by hand against a real OpenVibe.Events (a temp database, a mock Network token endpoint): the event was stored and reached an anonymous subscriber of /realtime/stream?topics=host.deploy.activated.

docs/release-notifications.md: the payload, who announces, the credentials, the provisioning steps for the lead (the host client, its secret in host.env, the URLs, the grant, Events' source and anonymous settings, updating ovhost) and an end-to-end check with curl. README and host.example.json (events.envFile) updated.

Changes

ddiff --git a/README.md b/README.md
iindex f5fc551..241feae 100644
---- a/README.md
++++ b/README.md
@@@ -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 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 notification
ovhost 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`), 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.
++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
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'`.
ddiff --git a/docs/release-notifications.md b/docs/release-notifications.md
nnew file mode 100644
iindex 0000000..f6c0388
---- /dev/null
++++ b/docs/release-notifications.md
@@@ -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).
ddiff --git a/host.example.json b/host.example.json
iindex 3ccc100..768f65c 100644
---- a/host.example.json
++++ b/host.example.json
@@@ -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",
ddiff --git a/lib/announce.js b/lib/announce.js
nnew file mode 100644
iindex 0000000..900b3ea
---- /dev/null
++++ b/lib/announce.js
@@@ -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 };
ddiff --git a/lib/cli.js b/lib/cli.js
iindex ed002d4..ebee390 100644
---- a/lib/cli.js
++++ b/lib/cli.js
@@@ -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 log
certs [--warn-days <n>] certificates referenced by enabled nginx vhosts, with expiry
nginx 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) => proc
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 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) => proc
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.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;
}
ddiff --git a/lib/executor.js b/lib/executor.js
iindex 4a98839..bc1dcb9 100644
---- a/lib/executor.js
++++ b/lib/executor.js
@@@ -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,
ddiff --git a/lib/index.js b/lib/index.js
iindex 95dbf76..35b498e 100644
---- a/lib/index.js
++++ b/lib/index.js
@@@ -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,
};
ddiff --git a/lib/inventory.js b/lib/inventory.js
iindex 38ba6ca..0b5744a 100644
---- a/lib/inventory.js
++++ b/lib/inventory.js
@@@ -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)) {
ddiff --git a/test/announce.test.js b/test/announce.test.js
nnew file mode 100644
iindex 0000000..b0bec9e
---- /dev/null
++++ b/test/announce.test.js
@@@ -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');
++ }),
++]);
ddiff --git a/test/fake-host.js b/test/fake-host.js
iindex 9329bfa..65bae8e 100644
---- a/test/fake-host.js
++++ b/test/fake-host.js
@@@ -14,7 +14,7 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
const calls = [];
const repos = new Map(); // repoPath -> repo
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 listeners = new Map(); // port -> [{ pid, process }]
let clock = start;
@@@ -354,6 +354,14 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
const 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 });