openvibers/openvibe.network Commits

Production drift (WS-S task 7): hourly, each running service's deployed commit is compared with its repository's main on GitHub (current, behind by N commits since the oldest undeployed one, diverged, unknown); shown under each release on /status, in /api/v1/registry/releases (main, and undeployed: services behind main for over a day), and as openvibe_deploy_drift_seconds{service} for the 24-hour alert

OP OpenVibers committed 25 Sept 2026 · 4a9f88901e · 7 files, +182 −3


Changes

ddiff --git a/package.json b/package.json
iindex 62000b46..bda1ea33 100644
---- a/package.json
++++ b/package.json
@@@ -6,7 +6,7 @@
"scripts": {
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
-- "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/usernames.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-manifest-fixture.test.js && node test/staff-claims.test.js && node test/profile-events.test.js && node test/grants-admin.test.js && node test/events-ops.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js",
++ "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/usernames.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-manifest-fixture.test.js && node test/staff-claims.test.js && node test/profile-events.test.js && node test/grants-admin.test.js && node test/events-ops.test.js && node test/deploy-drift.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
},
ddiff --git a/server/index.js b/server/index.js
iindex beec0791..689096ec 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -408,6 +408,11 @@ app.get('/internal/integrations/github-token', require('./identity/principals').
const ecosystemInternal = require('./registry/ecosystem').internalFromEnv(process.env);
if (ecosystemInternal.ignored.length) console.warn(`[Registry] ignored (not a loopback URL): ${ecosystemInternal.ignored.join(', ')}`);
const ecosystem = require('./registry/ecosystem').createEcosystemRegistry({ issuer: config.jwt.issuer, internalOverrides: ecosystemInternal.overrides });
++// Production drift: each running service's deployed commit against its repository's main (WS-S task 7).
++require('./registry/deploy-drift').createDeployDrift({
++ services: () => ecosystem.releases().services.filter((r) => r.release).map((r) => ({ id: r.id, release: r.release, repository: ((require('openvibe-contracts').services.manifests.find((m) => m.id === r.id)) || {}).repository })),
++ token: () => process.env.GITHUB_TOKEN || require('./integrations/github').tokenOf(db) || '',
++}).start();
app.use(ecosystem.router());
ecosystem.start();
// The released libraries' latest published tags (not the versions Network installs) for the registry.
ddiff --git a/server/observability.js b/server/observability.js
iindex 88ebc763..4516bad2 100644
---- a/server/observability.js
++++ b/server/observability.js
@@@ -21,6 +21,9 @@ const grantLabel = (g) => (g === 'urn:ietf:params:oauth:grant-type:jwt-bearer' ?
const tokensIssued = registry.counter({ name: 'network_tokens_issued_total', help: 'Tokens issued by POST /oauth/token, by grant type', labelNames: ['grant_type'] });
const tokenFailures = registry.counter({ name: 'network_token_failures_total', help: 'POST /oauth/token requests refused, by grant type and OAuth error code', labelNames: ['grant_type', 'error'] });
++// Seconds each service's main has been ahead of what runs (registry/deploy-drift.js); alert at 24 h.
++registry.gauge({ name: 'openvibe_deploy_drift_seconds', help: 'Seconds since the oldest commit on main that the running service does not have (0 when current)', labelNames: ['service'], maxSeries: 60,
++ collect: () => require('./registry/deploy-drift').driftSeconds() });
const principalFailures = registry.counter({ name: 'network_principal_token_failures_total', help: 'Service (principal) tokens refused at capability-guarded Network routes, by problem code', labelNames: ['code'], maxSeries: 50 });
/** Middleware for POST /oauth/token: counts the outcome without reading or logging any secret. */
ddiff --git a/server/registry/deploy-drift.js b/server/registry/deploy-drift.js
nnew file mode 100644
iindex 00000000..69c33d65
---- /dev/null
++++ b/server/registry/deploy-drift.js
@@@ -0,0 +1,93 @@
++'use strict';
++/**
++ * Production drift (roadmap WS-S task 7): how far each running service's deployed commit (its
++ * /release.json `release`) is behind its repository's main branch on GitHub, and since when.
++ *
++ * state current the deployed commit is main's head (or main is not ahead of it)
++ * behind main has commits that are not deployed: behind_by, since (the first of them)
++ * diverged the deployed commit is not an ancestor of main (a local or force-pushed build)
++ * unknown GitHub did not answer, or the commit is not on GitHub
++ *
++ * Read hourly with GitHub's compare API (GET /repos/{repo}/compare/{deployed}...main, one call per
++ * running service; GITHUB_TOKEN or the admin GitHub token raises the rate limit). A failure keeps the
++ * last answer. `since` is the committer date of the oldest undeployed commit, so the drift age is how
++ * long main has had something production does not run. Shown on /status and in
++ * /api/v1/registry/releases; openvibe_deploy_drift_seconds{service} feeds the Prometheus alert
++ * OpenVibeDeployDrift (24 hours; OpenVibe.Host deploy/prometheus/openvibe-rules.yml).
++ */
++const SHA_RE = /^[0-9a-f]{7,40}$/;
++const REPO_RE = /^OpenVibers\/[A-Za-z0-9._-]{1,100}$/;
++
++const state = new Map(); // service id -> { repo, deployed, state, behind_by, since, main, checked_at, error }
++let timer = null;
++
++function current(id) { return state.get(id) || null; }
++function all() { return Object.fromEntries(state); }
++
++/** Seconds main has been ahead of production, per service (0 when current; nothing when unknown). */
++function driftSeconds(now = Date.now()) {
++ const out = [];
++ for (const [id, d] of state) {
++ if (d.state === 'current') out.push({ labels: { service: id }, value: 0 });
++ else if (d.state === 'behind' && d.since) out.push({ labels: { service: id }, value: Math.max(0, Math.round((now - Date.parse(d.since)) / 1000)) });
++ }
++ return out;
++}
++
++/**
++ * services(): [{ id, release, repository }] for what runs now (the registry's releases view).
++ */
++function createDeployDrift({ services, fetchImpl = globalThis.fetch, token = () => process.env.GITHUB_TOKEN || '', intervalMs = 3600e3, now = () => Date.now(), log = console } = {}) {
++ const tokenNow = () => (typeof token === 'function' ? token() : token) || '';
++ async function checkOne({ id, release, repository }) {
++ const at = new Date(now()).toISOString();
++ if (!SHA_RE.test(String(release || '')) || !REPO_RE.test(String(repository || ''))) { state.delete(id); return null; }
++ const prev = state.get(id);
++ try {
++ const res = await fetchImpl(`https://api.github.com/repos/${repository}/compare/${release}...main`, {
++ headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'OpenVibe.Network registry', ...(tokenNow() ? { Authorization: `Bearer ${tokenNow()}` } : {}) },
++ signal: AbortSignal.timeout(10000),
++ });
++ if (res.status === 404) { state.set(id, { repo: repository, deployed: release, state: 'unknown', error: 'commit not on GitHub', checked_at: at }); return state.get(id); }
++ if (!res.ok) throw new Error(`GitHub answered ${res.status}`);
++ const body = await res.json();
++ const ahead = Number(body.ahead_by) || 0;
++ const behind = Number(body.behind_by) || 0;
++ const commits = Array.isArray(body.commits) ? body.commits : [];
++ const main = commits.length ? String(commits[commits.length - 1].sha || '').slice(0, 12) : String(release).slice(0, 12);
++ let row;
++ if (behind > 0 && ahead === 0) row = { state: 'diverged', behind_by: 0 };
++ else if (ahead === 0) row = { state: 'current', behind_by: 0 };
++ else {
++ const first = commits[0] && commits[0].commit && (commits[0].commit.committer || commits[0].commit.author);
++ row = { state: 'behind', behind_by: ahead, since: first && first.date ? new Date(first.date).toISOString() : null };
++ }
++ state.set(id, { repo: repository, deployed: release, main, checked_at: at, ...row });
++ } catch (err) {
++ // Keep the last answer; say it is stale.
++ state.set(id, prev ? { ...prev, stale: true, error: err.message } : { repo: repository, deployed: release, state: 'unknown', error: err.message, checked_at: at });
++ if (log && log.warn) log.warn(`[drift] ${id}: ${err.message}`);
++ }
++ return state.get(id);
++ }
++ async function refresh() {
++ const list = (typeof services === 'function' ? services() : services) || [];
++ const seen = new Set();
++ for (const s of list) { seen.add(s.id); await checkOne(s); }
++ for (const id of [...state.keys()]) if (!seen.has(id)) state.delete(id);
++ return all();
++ }
++ function start() {
++ if (timer) return;
++ const first = setTimeout(() => { refresh().catch(() => {}); }, 60 * 1000);
++ if (first.unref) first.unref();
++ timer = setInterval(() => { refresh().catch(() => {}); }, intervalMs);
++ if (timer.unref) timer.unref();
++ }
++ function stop() { if (timer) clearInterval(timer); timer = null; }
++ return { refresh, checkOne, start, stop };
++}
++
++function _reset() { state.clear(); if (timer) clearInterval(timer); timer = null; }
++
++module.exports = { createDeployDrift, current, all, driftSeconds, _reset };
ddiff --git a/server/registry/ecosystem.js b/server/registry/ecosystem.js
iindex eeef1d6e..82949735 100644
---- a/server/registry/ecosystem.js
++++ b/server/registry/ecosystem.js
@@@ -218,6 +218,9 @@ function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = g
const row = { id: m.id, state: e.state, status: h.status, checked_at: h.checked_at || null, release: r ? r.release : null };
if (!r || !r.release) { row.error = (r && r.error) || h.reason || null; return row; }
Object.assign(row, { released_at: r.released_at, booted_at: r.booted_at, contracts_version: r.contracts_version || null, packages: r.packages || {} });
++ // How far the deployed commit is behind the repository's main branch (./deploy-drift.js).
++ const dd = require('./deploy-drift').current(m.id);
++ if (dd) row.main = { state: dd.state, behind_by: dd.behind_by ?? null, since: dd.since || null, head: dd.main || null, checked_at: dd.checked_at, ...(dd.error ? { error: dd.error } : {}) };
row.drift = {};
for (const l of libs) {
const installed = l.package === 'openvibe-contracts' ? r.contracts_version : (r.packages || {})[l.package];
@@@ -226,7 +229,10 @@ function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = g
return row;
}).filter(Boolean);
const behind = services.filter(s => s.drift && Object.values(s.drift).some(d => d.state === 'behind')).map(s => s.id);
-- return { checked_at: lastPollAt ? new Date(lastPollAt).toISOString() : null, poll_interval_s: Math.round(pollMs / 1000), libraries: libs, services, behind };
++ // Deployed behind main for more than a day (WS-S task 7).
++ const dayAgo = now() - 24 * 3600e3;
++ const undeployed = services.filter(s => s.main && s.main.state === 'behind' && s.main.since && Date.parse(s.main.since) < dayAgo).map(s => s.id);
++ return { checked_at: lastPollAt ? new Date(lastPollAt).toISOString() : null, poll_interval_s: Math.round(pollMs / 1000), libraries: libs, services, behind, undeployed };
}
/** Services grouped by category, each with its maturity, exposure and last check. */
ddiff --git a/server/status/routes.js b/server/status/routes.js
iindex d7c45fe3..c23a7f12 100644
---- a/server/status/routes.js
++++ b/server/status/routes.js
@@@ -28,6 +28,15 @@ const esc = seo.esc;
const STATES = ['up', 'degraded', 'down', 'not-running', 'unknown'];
const LABEL = { up: 'Up', degraded: 'Degraded', down: 'Down', 'not-running': 'Not running', unknown: 'Unknown' };
++/** The deployed commit against main (registry/deploy-drift.js), as a short note under the release. */
++function mainNote(d) {
++ if (!d) return '';
++ if (d.state === 'current') return '<small>main: current</small>';
++ if (d.state === 'behind') return `<small>main: ${esc(String(d.behind_by))} commit${d.behind_by === 1 ? '' : 's'} not deployed${d.since ? ` since <time datetime="${esc(d.since)}">${esc(d.since.slice(0, 16).replace('T', ' '))} UTC</time>` : ''}</small>`;
++ if (d.state === 'diverged') return '<small>main: not on main (a local build)</small>';
++ return '';
++}
++
function loadSlo() { return JSON.parse(fs.readFileSync(SLO_FILE, 'utf8')); }
function rows(ecosystem) {
@@@ -52,6 +61,7 @@ function rows(ecosystem) {
http_status: r.http_status ?? null,
latency_ms: r.latency_ms ?? null,
release: r.release && r.release.release ? { release: r.release.release, released_at: r.release.released_at || null, booted_at: r.release.booted_at || null } : null,
++ main: (() => { const d = require('../registry/deploy-drift').current(m.id); return d ? { state: d.state, behind_by: d.behind_by ?? null, since: d.since || null } : null; })(),
release_error: r.release && !r.release.release ? r.release.error || 'unknown' : null,
ready: r.ready || null,
...(r.stale ? { stale: true, last_status: r.last_status } : {}),
@@@ -103,7 +113,7 @@ function renderPage(list, slo, generatedAt) {
<td><b>${esc(r.name)}</b><small>${esc(r.id)} · manifest: ${esc(r.manifest_status)}</small></td>
<td>${exposureCell(r)}</td>
<td><span class="st-b st-${esc(r.status)}">${esc(r.status === 'not-running' ? r.label : LABEL[r.status] + (r.label.endsWith('(loopback only)') ? ' · loopback only' : ''))}</span>${r.stale ? `<small>last seen ${esc(r.last_status)}</small>` : ''}${r.basis === 'health' ? '<small>liveness only (no readiness endpoint)</small>' : ''}${r.reason && r.status !== 'not-running' ? `<small>${esc(r.reason)}</small>` : ''}${checksList(r.ready)}</td>
--<td>${r.release ? `<code>${esc(r.release.release)}</code>${r.release.booted_at ? `<small>booted ${esc(r.release.booted_at)}</small>` : ''}` : `<small>${r.status === 'not-running' ? '—' : esc(r.release_error ? `unknown (${r.release_error})` : 'unknown')}</small>`}</td>
++<td>${r.release ? `<code>${esc(r.release.release)}</code>${r.release.booted_at ? `<small>booted ${esc(r.release.booted_at)}</small>` : ''}${mainNote(r.main)}` : `<small>${r.status === 'not-running' ? '—' : esc(r.release_error ? `unknown (${r.release_error})` : 'unknown')}</small>`}</td>
<td>${r.checked_at ? `<time datetime="${esc(r.checked_at)}">${esc(r.checked_at)}</time>` : '<small>not checked yet</small>'}${r.latency_ms != null ? `<small>${esc(r.latency_ms)} ms</small>` : ''}</td>
</tr>`).join('\n');
const sloRows = slo.categories.map((c) => `<dt>${esc(c.name)}</dt><dd>${esc(c.sli)} <br><small>Proposed: ${esc(Object.entries(c.proposed_target).map(([k, v]) => `${k.replace(/_/g, ' ')} ${v}`).join('; '))} · priority: ${esc(c.priority)} · ${esc(c.measurement_status || (c.measured_by.length ? 'measured' : 'not instrumented'))}</small></dd>`).join('\n');
ddiff --git a/test/deploy-drift.test.js b/test/deploy-drift.test.js
nnew file mode 100644
iindex 00000000..cb324bd8
---- /dev/null
++++ b/test/deploy-drift.test.js
@@@ -0,0 +1,62 @@
++'use strict';
++// Production drift (WS-S task 7): the deployed commit against main through GitHub's compare API —
++// current, behind (how many commits and since the oldest undeployed one), diverged, unknown; a failed
++// lookup keeps the last answer; the gauge gives seconds behind (0 when current); services that stop
++// running are dropped; a non-commit release is skipped.
++const assert = require('assert');
++const drift = require('../server/registry/deploy-drift');
++
++const NOW = Date.parse('2026-09-26T12:00:00Z');
++const answers = {
++ 'OpenVibers/OpenVibe.Live/compare/aaaaaaa1...main': { status: 200, body: { ahead_by: 0, behind_by: 0, commits: [] } },
++ 'OpenVibers/OpenVibe.Chat/compare/bbbbbbb2...main': { status: 200, body: { ahead_by: 3, behind_by: 0, commits: [
++ { sha: 'c1', commit: { committer: { date: '2026-09-25T10:00:00Z' } } },
++ { sha: 'c2', commit: { committer: { date: '2026-09-25T11:00:00Z' } } },
++ { sha: 'c3ffffffffffff', commit: { committer: { date: '2026-09-26T09:00:00Z' } } },
++ ] } },
++ 'OpenVibers/OpenVibe.Media/compare/ccccccc3...main': { status: 200, body: { ahead_by: 0, behind_by: 2, commits: [] } },
++ 'OpenVibers/OpenVibe.Tools/compare/ddddddd4...main': { status: 404, body: { message: 'Not Found' } },
++};
++let failing = false;
++const asked = [];
++const fetchImpl = async (url, opts) => {
++ const key = url.replace('https://api.github.com/repos/', '');
++ asked.push({ key, auth: opts.headers.Authorization });
++ if (failing) throw new Error('network down');
++ const a = answers[key];
++ return { ok: a.status === 200, status: a.status, json: async () => a.body };
++};
++
++(async () => {
++ let running = [
++ { id: 'live', release: 'aaaaaaa1', repository: 'OpenVibers/OpenVibe.Live' },
++ { id: 'chat', release: 'bbbbbbb2', repository: 'OpenVibers/OpenVibe.Chat' },
++ { id: 'media', release: 'ccccccc3', repository: 'OpenVibers/OpenVibe.Media' },
++ { id: 'tools', release: 'ddddddd4', repository: 'OpenVibers/OpenVibe.Tools' },
++ { id: 'sites', release: 'not-a-sha', repository: 'OpenVibers/OpenVibe.Sites' },
++ ];
++ const d = drift.createDeployDrift({ services: () => running, fetchImpl, token: 'ghp_test', now: () => NOW, log: null });
++ await d.refresh();
++ assert.strictEqual(asked.length, 4, 'a release that is not a commit is not looked up');
++ assert.strictEqual(asked[0].auth, 'Bearer ghp_test');
++ assert.strictEqual(drift.current('live').state, 'current');
++ const chat = drift.current('chat');
++ assert.deepStrictEqual([chat.state, chat.behind_by, chat.since, chat.main], ['behind', 3, '2026-09-25T10:00:00.000Z', 'c3ffffffffff']);
++ assert.strictEqual(drift.current('media').state, 'diverged');
++ assert.deepStrictEqual([drift.current('tools').state, drift.current('tools').error], ['unknown', 'commit not on GitHub']);
++ assert.strictEqual(drift.current('sites'), null);
++ const g = Object.fromEntries(drift.driftSeconds(NOW).map((x) => [x.labels.service, x.value]));
++ assert.deepStrictEqual(g, { live: 0, chat: 26 * 3600 }, 'seconds since the oldest undeployed commit; unknown and diverged are not measured');
++
++ failing = true;
++ await d.refresh();
++ assert.strictEqual(drift.current('chat').state, 'behind', 'a failed lookup keeps the last answer');
++ assert.strictEqual(drift.current('chat').stale, true);
++ failing = false;
++
++ running = running.filter((r) => r.id !== 'media');
++ await d.refresh();
++ assert.strictEqual(drift.current('media'), null, 'a service that stopped running is dropped');
++ drift._reset();
++ console.log('deploy drift: all checks passed');
++})().catch((err) => { console.error(err); process.exit(1); });