Lands Gizmoduck's round 1 candidate,ed4193316on gizmoduck/row235-e (one commit on62ddc96cc), approved by Duckworth (duckworth-0089, REVIEW-r1d9ecec514on duckworth/reviews, #1741 comment 30022). 13 paths, manifest dad377ca8fe4. No 235 path changed between62ddc96ccand this commit's parent. Patch-id c355d2fc457d, the same as 62ddc96cc..ed4193316. broker.mjs keeps row 213's import and decision.raise creative refusal as landed. Creative roles (mos-1163, mos-1164, sage-1908): #hostDefinition refuses host-creative-role at the raise and at the step 1 recheck, and #hostAccess refuses it at host.run before reading the grant record, which stays for hosts.revoke. Duckworth checked from the code that a creative role can't raise, approve or spend a host grant, and ruled the upsert mutant equivalent. Evidence: Gizmoduck's runs on my slot. B1-B37 on 5df4b52c5, and run 2 on 534f2254a (10:12:52-10:14:51Z): grant-host 9/9, grant-executor 37/37 and grant-enroll 24/24, each run whole, with B16, B23, B27, B38 and B39 killed. B1-B39: 38 killed, and B17 is equivalent (sage-1904). Gate on my slot, 10:30:55-10:42:43Z on29ce1e360plus the 13 paths: bus 596/0 (its first whole run on this head, duckworth-0089 N1), business 200/0, cli 666/0, and every scripts/test-*.sh green (auth 15, conductor 17, config 31, discord 78, extension-package 18, foundation 44, land-work 86, queue 156 and 28, release 14, task 98, all 0 failed).
153 lines
6.9 KiB
JavaScript
153 lines
6.9 KiB
JavaScript
// Row 235 (#1741, #436 row b, r6 X4, X5): the grant executor's Credentials calls. register and
|
|
// unregister add and drop one store reference in the running bus, and claim, release and abortClaim
|
|
// hold the (agent, host.<id>) slot that host.run and revoke share. Keys are made by ssh-keygen in a
|
|
// scratch root, and nothing connects to any host.
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { Credentials } from '../src/credentials.mjs';
|
|
|
|
const AGENT = 'demo/ops';
|
|
const SERVICE = 'host.web1';
|
|
|
|
function setup(t) {
|
|
const root = mkdtempSync(join(tmpdir(), 'bus-claim-'));
|
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
const storeRoot = join(root, 'secrets', 'agents');
|
|
mkdirSync(storeRoot, { recursive: true, mode: 0o700 });
|
|
chmodSync(join(root, 'secrets'), 0o700);
|
|
const file = join(storeRoot, 'demo', 'ops', SERVICE);
|
|
const ref = { service: SERVICE, store: true, file, rotateBy: '2099-01-01' };
|
|
const key = (name) => {
|
|
execFileSync('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-C', 'test', '-f', join(root, name)]);
|
|
return readFileSync(join(root, name), 'utf8');
|
|
};
|
|
const c = new Credentials({ references: {}, storeRoot, repoRoots: [], dataRoot: '/nonexistent-data' });
|
|
t.after(() => c.close());
|
|
const state = () => c.status().find((e) => e.instance === AGENT && e.service === SERVICE)?.state;
|
|
const run = () => c.use(AGENT, SERVICE, async () => ({ ok: true }));
|
|
return { root, storeRoot, file, ref, key, c, state, run };
|
|
}
|
|
|
|
test('X4: register adds a store reference to the running bus, and issue then writes the key', async (t) => {
|
|
const s = setup(t);
|
|
assert.throws(() => s.c.issue(AGENT, SERVICE, s.key('k1'), '2099-01-01'), /credential-unknown/);
|
|
s.c.register(AGENT, SERVICE, s.ref);
|
|
assert.equal(s.state(), 'not-issued');
|
|
await assert.rejects(s.run(), /credential-unavailable/);
|
|
assert.equal(s.c.issue(AGENT, SERVICE, s.key('k2'), '2099-01-01').state, 'valid');
|
|
assert.deepEqual(await s.run(), { ok: true });
|
|
});
|
|
|
|
test('X4: register runs the constructor\'s reference checks', (t) => {
|
|
const s = setup(t);
|
|
const other = join(s.storeRoot, 'demo', 'pm', SERVICE);
|
|
for (const [ref, code] of [
|
|
[null, /credential-reference/],
|
|
[{ ...s.ref, rotateBy: '2099-02-30' }, /credential-date/],
|
|
[{ ...s.ref, file: other }, /credential-reference/],
|
|
[{ ...s.ref, extra: 1 }, /credential-reference/],
|
|
[{ ...s.ref, service: 'host.web2' }, /credential-reference/],
|
|
[{ ...s.ref, env: 'X' }, /credential-reference/],
|
|
])
|
|
assert.throws(() => s.c.register(AGENT, SERVICE, ref), code);
|
|
assert.throws(() => s.c.register(AGENT, 'nope', s.ref), /credential-reference/);
|
|
assert.throws(() => s.c.register('demo/@tester', SERVICE, { ...s.ref, file: join(s.storeRoot, 'demo', '@tester', SERVICE) }), /credential-reference/);
|
|
assert.equal(s.state(), undefined);
|
|
});
|
|
|
|
test('X4: register reads no file, so a key file left at the path stays unused, and a live token refuses', async (t) => {
|
|
const s = setup(t);
|
|
mkdirSync(join(s.storeRoot, 'demo', 'ops'), { recursive: true, mode: 0o700 });
|
|
writeFileSync(s.file, s.key('left'), { mode: 0o600 });
|
|
s.c.register(AGENT, SERVICE, s.ref);
|
|
assert.equal(s.state(), 'not-issued');
|
|
await assert.rejects(s.run(), /credential-unavailable/);
|
|
s.c.issue(AGENT, SERVICE, s.key('k1'), '2099-01-01');
|
|
assert.throws(() => s.c.register(AGENT, SERVICE, s.ref), /credential-reference/);
|
|
assert.equal(s.state(), 'valid');
|
|
// A revoked entry has no token, so a new grant registers again.
|
|
s.c.revoke(AGENT, SERVICE);
|
|
assert.equal(s.state(), 'revoked');
|
|
s.c.register(AGENT, SERVICE, s.ref);
|
|
assert.equal(s.state(), 'not-issued');
|
|
});
|
|
|
|
test('X3, X4: unregister drops an entry with no token and refuses one with a live token', (t) => {
|
|
const s = setup(t);
|
|
s.c.unregister(AGENT, SERVICE);
|
|
s.c.register(AGENT, SERVICE, s.ref);
|
|
s.c.unregister(AGENT, SERVICE);
|
|
assert.equal(s.state(), undefined);
|
|
assert.throws(() => s.c.issue(AGENT, SERVICE, s.key('k1'), '2099-01-01'), /credential-unknown/);
|
|
s.c.register(AGENT, SERVICE, s.ref);
|
|
s.c.issue(AGENT, SERVICE, s.key('k2'), '2099-01-01');
|
|
assert.throws(() => s.c.unregister(AGENT, SERVICE), /credential-reference/);
|
|
assert.equal(s.state(), 'valid');
|
|
s.c.revoke(AGENT, SERVICE);
|
|
assert.equal(existsSync(s.file), false);
|
|
s.c.unregister(AGENT, SERVICE);
|
|
assert.equal(s.state(), undefined);
|
|
});
|
|
|
|
test('X5: a claim holds the slot, so a second claim and a host.run refuse host-busy until release', async (t) => {
|
|
const s = setup(t);
|
|
s.c.register(AGENT, SERVICE, s.ref);
|
|
s.c.issue(AGENT, SERVICE, s.key('k1'), '2099-01-01');
|
|
const signal = s.c.claim(AGENT, SERVICE);
|
|
assert.equal(signal.aborted, false);
|
|
assert.equal(s.c.busy(AGENT, SERVICE), true);
|
|
assert.throws(() => s.c.claim(AGENT, SERVICE), /host-busy/);
|
|
await assert.rejects(s.run(), /host-busy/);
|
|
// A claim needs no reference: the executor claims before it registers a new key.
|
|
const other = s.c.claim('demo/pm', SERVICE);
|
|
s.c.release('demo/pm', SERVICE, other);
|
|
s.c.release(AGENT, SERVICE, signal);
|
|
assert.equal(s.c.busy(AGENT, SERVICE), false);
|
|
assert.deepEqual(await s.run(), { ok: true });
|
|
});
|
|
|
|
test('X5: a claim made while a host.run is in flight refuses host-busy', async (t) => {
|
|
const s = setup(t);
|
|
s.c.register(AGENT, SERVICE, s.ref);
|
|
s.c.issue(AGENT, SERVICE, s.key('k1'), '2099-01-01');
|
|
let finish;
|
|
const running = s.c.use(AGENT, SERVICE, () => new Promise((r) => (finish = r)));
|
|
assert.throws(() => s.c.claim(AGENT, SERVICE), /host-busy/);
|
|
finish({ ok: true });
|
|
assert.deepEqual(await running, { ok: true });
|
|
s.c.release(AGENT, SERVICE, s.c.claim(AGENT, SERVICE));
|
|
});
|
|
|
|
test('X5: a stale release leaves a later claim in place', (t) => {
|
|
const s = setup(t);
|
|
const first = s.c.claim(AGENT, SERVICE);
|
|
s.c.release(AGENT, SERVICE, first);
|
|
const second = s.c.claim(AGENT, SERVICE);
|
|
s.c.release(AGENT, SERVICE, first);
|
|
s.c.release(AGENT, SERVICE);
|
|
assert.equal(s.c.busy(AGENT, SERVICE), true);
|
|
s.c.release(AGENT, SERVICE, second);
|
|
assert.equal(s.c.busy(AGENT, SERVICE), false);
|
|
});
|
|
|
|
test('X5: abortClaim aborts a live claim and says pending, and revoke\'s abort reaches a claim too', (t) => {
|
|
const s = setup(t);
|
|
assert.deepEqual(s.c.abortClaim(AGENT, SERVICE), { pending: false });
|
|
const signal = s.c.claim(AGENT, SERVICE);
|
|
assert.deepEqual(s.c.abortClaim(AGENT, SERVICE), { pending: true });
|
|
assert.equal(signal.aborted, true);
|
|
// The slot stays held until the executor releases it.
|
|
assert.equal(s.c.busy(AGENT, SERVICE), true);
|
|
s.c.release(AGENT, SERVICE, signal);
|
|
s.c.register(AGENT, SERVICE, s.ref);
|
|
s.c.issue(AGENT, SERVICE, s.key('k1'), '2099-01-01');
|
|
const live = s.c.claim(AGENT, SERVICE);
|
|
s.c.revoke(AGENT, SERVICE);
|
|
assert.equal(live.aborted, true);
|
|
s.c.release(AGENT, SERVICE, live);
|
|
});
|