openvibers/openvibe.host Commits
Drills run an acceptance subset (WS-S task 2): drill.acceptance lists GETs the restored instance alone must answer with a status and top-level JSON keys — for services whose answers differ from production by nature or that have nothing public to compare; failures are the 'acceptance' stage in the log and the Markdown row
Changes
@@@ -250,6 +250,7 @@ async function drill(ctx, id, { backup: backupArg = null, keep = false } = {}) {ready: null,compare: [],counts: [],++ acceptance: [],stop: null,kept: false,result: 'failed',@@@ -381,6 +382,23 @@ async function drill(ctx, id, { backup: backupArg = null, keep = false } = {}) {}const bad = [...record.compare.filter((r) => !r.match).map((r) => `${r.path} (${r.detail})`), ...record.counts.filter((r) => !r.match).map((r) => `${r.table} ${r.production} ≠ ${r.restored}`)];if (bad.length) throw fail('compare', `differs from production: ${bad.join('; ')}`);++ // The acceptance subset (WS-S task 2): the restored instance alone answers each declared GET with++ // the declared status and, for JSON, the declared top-level keys.++ for (const a of (d.acceptance || [])) {++ const r = await exec.http(`http://127.0.0.1:${d.port}${a.path}`, { timeoutMs: 10000, headers: { ...headers, ...a.headers } });++ let detail = null;++ if (r.status !== a.status) detail = `status ${r.status || r.error || 0}, expected ${a.status}`;++ else if (a.keys.length) {++ let body = null;++ try { body = JSON.parse(r.body || ''); } catch { body = null; }++ const missing = body && typeof body === 'object' ? a.keys.filter((k) => !(k in body)) : a.keys;++ if (missing.length) detail = `missing ${missing.join(', ')}`;++ }++ record.acceptance.push({ path: a.path, ok: !detail, ...(detail ? { detail } : {}) });++ log(`${detail ? 'FAILED' : 'ok'} ${a.path}${detail ? `: ${detail}` : ''}`);++ }++ const unaccepted = record.acceptance.filter((r) => !r.ok).map((r) => `${r.path} (${r.detail})`);++ if (unaccepted.length) throw fail('acceptance', unaccepted.join('; '));} catch (err) {if (!(err instanceof Abort)) fail('error', err.message);}@@@ -443,7 +461,10 @@ function markdownRow(r) {if (same.length) checks.push(`${same.join(', ')} identical to production${r.compare.some((c) => c.match && c.mode && c.mode !== 'bytes') ? ' (JSON without volatile keys where declared)' : ''}`);checks.push(...diff);for (const c of r.counts) checks.push(`${c.table} ${c.production == null ? '?' : c.production} ${c.match ? '=' : '≠'} ${c.restored}`);-- if (r.failure && r.failure.stage !== 'compare') checks.push(`${r.failure.stage}: ${cell(r.failure.message)}`);++ const accepted = (r.acceptance || []).filter((a) => a.ok).map((a) => `\`${a.path}\``);++ if (accepted.length) checks.push(`acceptance ${accepted.join(', ')}`);++ if (r.failure && r.failure.stage !== 'compare' && r.failure.stage !== 'acceptance') checks.push(`${r.failure.stage}: ${cell(r.failure.message)}`);++ if (r.failure && r.failure.stage === 'acceptance') checks.push(`acceptance failed: ${cell(r.failure.message)}`);const when = r.startedAt.replace('T', ' ').slice(0, 16);return `| ${when} | ${r.service} | \`${r.backup}/\` (\`ovhost drill ${r.service}\`) | ${checks.join('; ')} | ${r.result} |`;}@@@ -165,6 +165,9 @@ const PLACEHOLDER_RE = /\{(tmp|port|db:[A-Za-z0-9._-]+)\}/g;* ready readiness path; default: the path of the service's ready URL* compare [{ path, ignore: [volatile keys], headers }] read-only GETs compared with production* counts [{ db, table }] row counts compared between production and the restored copy++ * acceptance [{ path, status: 200, keys: [top-level JSON keys] }] GETs the restored instance alone must++ * answer (roadmap WS-S task 2): the acceptance subset for services whose answers differ from++ * production by nature (queues, clocks) or that have no public read to compare* outbound true lets the instance reach non-loopback addresses (default: loopback only)*/function normaliseDrill(raw, svc, where) {@@@ -259,7 +262,16 @@ function normaliseDrill(raw, svc, where) {if (!TABLE_RE.test(c.table || '')) throw new InventoryError(`${w}.counts[${i}].table must be a table name`);return { db: c.db, table: c.table };});-- if (!compare.length && !counts.length) throw new InventoryError(`${w} needs at least one compare path or row count`);++ const acceptance = (raw.acceptance || []).map((a, i) => {++ const aa = typeof a === 'string' ? { path: a } : a;++ if (!aa || typeof aa.path !== 'string' || !/^\/[^/]/.test(aa.path) || /\s/.test(aa.path)) throw new InventoryError(`${w}.acceptance[${i}].path must be a path such as /api/health`);++ const status = aa.status == null ? 200 : Number(aa.status);++ if (!Number.isInteger(status) || status < 100 || status > 599) throw new InventoryError(`${w}.acceptance[${i}].status must be an HTTP status`);++ const keys = aa.keys || [];++ if (!Array.isArray(keys) || keys.some((k) => typeof k !== 'string' || !k)) throw new InventoryError(`${w}.acceptance[${i}].keys must be a list of key names`);++ return { path: aa.path, status, keys, headers: aa.headers || {} };++ });++ if (!compare.length && !counts.length && !acceptance.length) throw new InventoryError(`${w} needs at least one compare path, row count or acceptance check`);const readyTimeoutSeconds = Number(raw.readyTimeoutSeconds || (svc.ready && svc.ready.timeoutSeconds) || 90);return {supported: true,@@@ -276,6 +288,7 @@ function normaliseDrill(raw, svc, where) {readyTimeoutSeconds,compare,counts,++ acceptance,outbound: raw.outbound === true,outboundReason: raw.outboundReason || null,runtimeMaxSeconds: Number(raw.runtimeMaxSeconds || 1800),@@@ -368,6 +368,32 @@ runTests([assert.ok(!host.files.has(rec.dir));}),++ test('acceptance: the restored instance alone answers each declared GET with its status and JSON keys (WS-S task 2)', async () => {++ const accept = [{ path: '/api/ready', keys: ['status'] }, { path: '/api/v1/pulse?limit=5', keys: ['items', 'generated_at'] }];++ let host = await drillScenario({ drill: { acceptance: accept } });++ let r = await host.cli('drill', 'community');++ assert.strictEqual(r.code, 0, r.out);++ let rec = lastLog(host);++ assert.deepStrictEqual(rec.acceptance, [{ path: '/api/ready', ok: true }, { path: '/api/v1/pulse?limit=5', ok: true }]);++ assert.match(rec.markdown, /acceptance `\/api\/ready`, `\/api\/v1\/pulse\?limit=5`/);++++ host = await drillScenario({ drill: { acceptance: [{ path: '/api/v1/pulse?limit=5', keys: ['items', 'cursor'] }, { path: '/api/ready', status: 204 }] } });++ r = await host.cli('drill', 'community');++ assert.strictEqual(r.code, 2, r.out);++ rec = lastLog(host);++ assert.strictEqual(rec.failure.stage, 'acceptance');++ assert.deepStrictEqual(rec.acceptance.map((a) => [a.ok, a.detail || null]), [[false, 'missing cursor'], [false, 'status 200, expected 204']]);++ assert.match(rec.markdown, /acceptance failed: /);++ assert.ok(!host.files.has(rec.dir), 'still cleaned up');++ }),++++ test('acceptance entries are validated in the inventory', async () => {++ const host = await drillScenario({ drill: { acceptance: [{ path: 'no-slash' }] }, backup: false });++ const r = await host.cli('drill', 'community');++ assert.notStrictEqual(r.code, 0);++ assert.match(r.out, /acceptance\[0\]\.path must be a path/);++ }),++test('compare: a status mismatch or a failing production answer is never a match', () => {const e = { path: '/x', ignore: [] };assert.strictEqual(compareBodies(e, { status: 500, body: 'e' }, { status: 500, body: 'e' }).match, false);
@@ -250,6 +250,7 @@ async function drill(ctx, id, { backup: backupArg = null, keep = false } = {}) {
@@ -250,6 +250,7 @@ async function drill(ctx, id, { backup: backupArg = null, keep = false } = {}) {
ready: null,
ready: null,
compare: [],
compare: [],
counts: [],
counts: [],
+ acceptance: [],
stop: null,
stop: null,
kept: false,
kept: false,
result: 'failed',
result: 'failed',
@@ -381,6 +382,23 @@ async function drill(ctx, id, { backup: backupArg = null, keep = false } = {}) {
@@ -381,6 +382,23 @@ async function drill(ctx, id, { backup: backupArg = null, keep = false } = {}) {
}
}
const bad = [...record.compare.filter((r) => !r.match).map((r) => `${r.path} (${r.detail})`), ...record.counts.filter((r) => !r.match).map((r) => `${r.table} ${r.production} ≠ ${r.restored}`)];
const bad = [...record.compare.filter((r) => !r.match).map((r) => `${r.path} (${r.detail})`), ...record.counts.filter((r) => !r.match).map((r) => `${r.table} ${r.production} ≠ ${r.restored}`)];
if (bad.length) throw fail('compare', `differs from production: ${bad.join('; ')}`);
if (bad.length) throw fail('compare', `differs from production: ${bad.join('; ')}`);
+ // The acceptance subset (WS-S task 2): the restored instance alone answers each declared GET with
+ // the declared status and, for JSON, the declared top-level keys.
+ for (const a of (d.acceptance || [])) {
+ const r = await exec.http(`http://127.0.0.1:${d.port}${a.path}`, { timeoutMs: 10000, headers: { ...headers, ...a.headers } });
+ let detail = null;
+ if (r.status !== a.status) detail = `status ${r.status || r.error || 0}, expected ${a.status}`;
+ else if (a.keys.length) {
+ let body = null;
+ try { body = JSON.parse(r.body || ''); } catch { body = null; }
+ const missing = body && typeof body === 'object' ? a.keys.filter((k) => !(k in body)) : a.keys;
+ if (missing.length) detail = `missing ${missing.join(', ')}`;
+ }
+ record.acceptance.push({ path: a.path, ok: !detail, ...(detail ? { detail } : {}) });
+ log(`${detail ? 'FAILED' : 'ok'} ${a.path}${detail ? `: ${detail}` : ''}`);
+ }
+ const unaccepted = record.acceptance.filter((r) => !r.ok).map((r) => `${r.path} (${r.detail})`);
+ if (unaccepted.length) throw fail('acceptance', unaccepted.join('; '));
} catch (err) {
} catch (err) {
if (!(err instanceof Abort)) fail('error', err.message);
if (!(err instanceof Abort)) fail('error', err.message);
}
}
@@ -443,7 +461,10 @@ function markdownRow(r) {
@@ -443,7 +461,10 @@ function markdownRow(r) {
if (same.length) checks.push(`${same.join(', ')} identical to production${r.compare.some((c) => c.match && c.mode && c.mode !== 'bytes') ? ' (JSON without volatile keys where declared)' : ''}`);
if (same.length) checks.push(`${same.join(', ')} identical to production${r.compare.some((c) => c.match && c.mode && c.mode !== 'bytes') ? ' (JSON without volatile keys where declared)' : ''}`);
checks.push(...diff);
checks.push(...diff);
for (const c of r.counts) checks.push(`${c.table} ${c.production == null ? '?' : c.production} ${c.match ? '=' : '≠'} ${c.restored}`);
for (const c of r.counts) checks.push(`${c.table} ${c.production == null ? '?' : c.production} ${c.match ? '=' : '≠'} ${c.restored}`);
- if (r.failure && r.failure.stage !== 'compare') checks.push(`${r.failure.stage}: ${cell(r.failure.message)}`);
+ const accepted = (r.acceptance || []).filter((a) => a.ok).map((a) => `\`${a.path}\``);
+ if (accepted.length) checks.push(`acceptance ${accepted.join(', ')}`);
+ if (r.failure && r.failure.stage !== 'compare' && r.failure.stage !== 'acceptance') checks.push(`${r.failure.stage}: ${cell(r.failure.message)}`);
+ if (r.failure && r.failure.stage === 'acceptance') checks.push(`acceptance failed: ${cell(r.failure.message)}`);
const when = r.startedAt.replace('T', ' ').slice(0, 16);
const when = r.startedAt.replace('T', ' ').slice(0, 16);
return `| ${when} | ${r.service} | \`${r.backup}/\` (\`ovhost drill ${r.service}\`) | ${checks.join('; ')} | ${r.result} |`;
return `| ${when} | ${r.service} | \`${r.backup}/\` (\`ovhost drill ${r.service}\`) | ${checks.join('; ')} | ${r.result} |`;
}
}
@@ -165,6 +165,9 @@ const PLACEHOLDER_RE = /\{(tmp|port|db:[A-Za-z0-9._-]+)\}/g;
@@ -165,6 +165,9 @@ const PLACEHOLDER_RE = /\{(tmp|port|db:[A-Za-z0-9._-]+)\}/g;
* ready readiness path; default: the path of the service's ready URL
* ready readiness path; default: the path of the service's ready URL
* compare [{ path, ignore: [volatile keys], headers }] read-only GETs compared with production
* compare [{ path, ignore: [volatile keys], headers }] read-only GETs compared with production
* counts [{ db, table }] row counts compared between production and the restored copy
* counts [{ db, table }] row counts compared between production and the restored copy
+ * acceptance [{ path, status: 200, keys: [top-level JSON keys] }] GETs the restored instance alone must
+ * answer (roadmap WS-S task 2): the acceptance subset for services whose answers differ from
+ * production by nature (queues, clocks) or that have no public read to compare
* outbound true lets the instance reach non-loopback addresses (default: loopback only)
* outbound true lets the instance reach non-loopback addresses (default: loopback only)
*/
*/
function normaliseDrill(raw, svc, where) {
function normaliseDrill(raw, svc, where) {
@@ -259,7 +262,16 @@ function normaliseDrill(raw, svc, where) {
@@ -259,7 +262,16 @@ function normaliseDrill(raw, svc, where) {
if (!TABLE_RE.test(c.table || '')) throw new InventoryError(`${w}.counts[${i}].table must be a table name`);
if (!TABLE_RE.test(c.table || '')) throw new InventoryError(`${w}.counts[${i}].table must be a table name`);
return { db: c.db, table: c.table };
return { db: c.db, table: c.table };
});
});
- if (!compare.length && !counts.length) throw new InventoryError(`${w} needs at least one compare path or row count`);
+ const acceptance = (raw.acceptance || []).map((a, i) => {
+ const aa = typeof a === 'string' ? { path: a } : a;
+ if (!aa || typeof aa.path !== 'string' || !/^\/[^/]/.test(aa.path) || /\s/.test(aa.path)) throw new InventoryError(`${w}.acceptance[${i}].path must be a path such as /api/health`);
+ const status = aa.status == null ? 200 : Number(aa.status);
+ if (!Number.isInteger(status) || status < 100 || status > 599) throw new InventoryError(`${w}.acceptance[${i}].status must be an HTTP status`);
+ const keys = aa.keys || [];
+ if (!Array.isArray(keys) || keys.some((k) => typeof k !== 'string' || !k)) throw new InventoryError(`${w}.acceptance[${i}].keys must be a list of key names`);
+ return { path: aa.path, status, keys, headers: aa.headers || {} };
+ });
+ if (!compare.length && !counts.length && !acceptance.length) throw new InventoryError(`${w} needs at least one compare path, row count or acceptance check`);
const readyTimeoutSeconds = Number(raw.readyTimeoutSeconds || (svc.ready && svc.ready.timeoutSeconds) || 90);
const readyTimeoutSeconds = Number(raw.readyTimeoutSeconds || (svc.ready && svc.ready.timeoutSeconds) || 90);
return {
return {
supported: true,
supported: true,
@@ -276,6 +288,7 @@ function normaliseDrill(raw, svc, where) {
@@ -276,6 +288,7 @@ function normaliseDrill(raw, svc, where) {
readyTimeoutSeconds,
readyTimeoutSeconds,
compare,
compare,
counts,
counts,
+ acceptance,
outbound: raw.outbound === true,
outbound: raw.outbound === true,
outboundReason: raw.outboundReason || null,
outboundReason: raw.outboundReason || null,
runtimeMaxSeconds: Number(raw.runtimeMaxSeconds || 1800),
runtimeMaxSeconds: Number(raw.runtimeMaxSeconds || 1800),
@@ -368,6 +368,32 @@ runTests([
@@ -368,6 +368,32 @@ runTests([
assert.ok(!host.files.has(rec.dir));
assert.ok(!host.files.has(rec.dir));
}),
}),
+ test('acceptance: the restored instance alone answers each declared GET with its status and JSON keys (WS-S task 2)', async () => {
+ const accept = [{ path: '/api/ready', keys: ['status'] }, { path: '/api/v1/pulse?limit=5', keys: ['items', 'generated_at'] }];
+ let host = await drillScenario({ drill: { acceptance: accept } });
+ let r = await host.cli('drill', 'community');
+ assert.strictEqual(r.code, 0, r.out);
+ let rec = lastLog(host);
+ assert.deepStrictEqual(rec.acceptance, [{ path: '/api/ready', ok: true }, { path: '/api/v1/pulse?limit=5', ok: true }]);
+ assert.match(rec.markdown, /acceptance `\/api\/ready`, `\/api\/v1\/pulse\?limit=5`/);
+
+ host = await drillScenario({ drill: { acceptance: [{ path: '/api/v1/pulse?limit=5', keys: ['items', 'cursor'] }, { path: '/api/ready', status: 204 }] } });
+ r = await host.cli('drill', 'community');
+ assert.strictEqual(r.code, 2, r.out);
+ rec = lastLog(host);
+ assert.strictEqual(rec.failure.stage, 'acceptance');
+ assert.deepStrictEqual(rec.acceptance.map((a) => [a.ok, a.detail || null]), [[false, 'missing cursor'], [false, 'status 200, expected 204']]);
+ assert.match(rec.markdown, /acceptance failed: /);
+ assert.ok(!host.files.has(rec.dir), 'still cleaned up');
+ }),
+
+ test('acceptance entries are validated in the inventory', async () => {
+ const host = await drillScenario({ drill: { acceptance: [{ path: 'no-slash' }] }, backup: false });
+ const r = await host.cli('drill', 'community');
+ assert.notStrictEqual(r.code, 0);
+ assert.match(r.out, /acceptance\[0\]\.path must be a path/);
+ }),
+
test('compare: a status mismatch or a failing production answer is never a match', () => {
test('compare: a status mismatch or a failing production answer is never a match', () => {
const e = { path: '/x', ignore: [] };
const e = { path: '/x', ignore: [] };
assert.strictEqual(compareBodies(e, { status: 500, body: 'e' }, { status: 500, body: 'e' }).match, false);
assert.strictEqual(compareBodies(e, { status: 500, body: 'e' }, { status: 500, body: 'e' }).match, false);