// E1 (P1-4): `mosaic org restore` brings the organization back running. It starts the bus as // `mosaic bus start ID --dev-local-owner --detach` would; a bus that does not start is said so with // that verb, and the restore stands. On an instance where that start is refused, the restore is // refused first and changes nothing. `--no-bus` keeps the old restore. The bus start itself is a // stand-in here; agent-guard.test.mjs and org.test.mjs start a real one. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import * as service from '../../organization/src/org-service.mjs'; import { listOrgs } from '../../organization/src/org-registry.mjs'; import { DEV_OWNER } from '../src/dev-local-owner.mjs'; import { orgCommand } from '../src/org.mjs'; import { io, humanEnv } from './helpers.mjs'; // A named instance under a temporary HOME (the dev-owner checks read the instance from HOME), with // one archived organization. async function archived(t) { const home = mkdtempSync(join(tmpdir(), 'e1-restore-')), saved = process.env.HOME; process.env.HOME = home; t.after(() => { process.env.HOME = saved; rmSync(home, { recursive: true, force: true }); }); const configDir = join(home, '.config', 'mosaic-e1test'), dataRoot = join(home, 'data'); mkdirSync(configDir, { recursive: true, mode: 0o700 }); mkdirSync(dataRoot, { mode: 0o700 }); const ctx = { configDir, dataRoot, instance: 'e1test', principal: DEV_OWNER }; const created = await service.createOrg(ctx, 'alpha', await service.templateRender('alpha')); const gone = await service.archiveOrg(ctx, 'alpha', { ifRevision: created.revision, busStop: async () => ({ stopped: false }) }); const env = humanEnv({ HOME: home, MOSAIC_CONFIG: join(configDir, 'config.json') }); const state = async () => (await listOrgs(ctx)).find(e => e.id === 'alpha').state; return { ctx, env, revision: gone.revision, state, system: { dataRoot } }; } const restore = (f, deps, ...extra) => { const x = io(f.env); return orgCommand(['restore', 'alpha', '--if-revision', String(f.revision), '--json', ...extra], x, { system: f.system, ...deps }).then(() => x, e => { e.io = x; throw e; }); }; test('E1: restore starts the bus as the dev owner and its receipt names the bus', async t => { const f = await archived(t), calls = []; const x = await restore(f, { busStart: async (ctx, id, opts) => { calls.push([ctx.dataRoot, ctx.env === f.env, id, opts]); return { org: id, pid: 4242, socket: '/s', root: '/r' }; } }); assert.deepEqual(calls, [[f.ctx.dataRoot, true, 'alpha', { detach: true, devLocalOwner: true, pm: false }]]); const receipt = JSON.parse(x.out.text); assert.equal(receipt.revision, f.revision + 1); assert.deepEqual(receipt.bus, { outcome: 'started', pid: 4242 }); assert.equal(await f.state(), 'active'); }); test('E1: a bus that does not start after a good restore is said so, with the verb, and the restore stands', async t => { const f = await archived(t); const { CliError } = await import('../src/errors.mjs'); const busStart = async () => { throw new CliError('bus failed to start; see the bus log', 3); }; await assert.rejects(restore(f, { busStart }), e => { assert.equal(e.exitCode, 3); assert.equal(e.message, 'alpha was restored, but its bus did not start: bus failed to start; see the bus log; start it with mosaic bus start alpha --dev-local-owner --detach'); // The restore receipt is printed before the refusal. const receipt = JSON.parse(e.io.out.text); assert.equal(receipt.revision, f.revision + 1); assert.deepEqual(receipt.bus, { outcome: 'failed', message: 'bus failed to start; see the bus log', next: 'mosaic bus start alpha --dev-local-owner --detach' }); return true; }); assert.equal(await f.state(), 'active'); }); test('E1: a bus host that died leaves a stale lock; the receipt names bus recover before the start', async t => { const f = await archived(t); const { CliError } = await import('../src/errors.mjs'); const busStart = async () => { throw new CliError('stale bus host lock', 3); }; await assert.rejects(restore(f, { busStart }), e => { assert.equal(e.exitCode, 3); assert.equal(e.message, 'alpha was restored, but its bus did not start: stale bus host lock; clear the dead host with mosaic bus recover alpha, then start it with mosaic bus start alpha --dev-local-owner --detach'); assert.deepEqual(JSON.parse(e.io.out.text).bus, { outcome: 'failed', message: 'stale bus host lock', next: 'mosaic bus recover alpha; mosaic bus start alpha --dev-local-owner --detach' }); return true; }); assert.equal(await f.state(), 'active'); }); test('E1: where a dev-owner bus start is refused, restore is refused before it changes anything, and --no-bus restores', async t => { const f = await archived(t); let starts = 0; const busStart = async () => { starts++; return { pid: 1 }; }; writeFileSync(join(f.ctx.dataRoot, '.deployed'), '{}\n'); await assert.rejects(restore(f, { busStart }), e => e.exitCode === 3 && /^restore starts the bus as the local dev owner, and --dev-local-owner refuses \.deployed; restore with --no-bus, then start the bus with mosaic bus start$/.test(e.message) && e.io.out.text === ''); assert.equal(await f.state(), 'archived'); const x = await restore(f, { busStart }, '--no-bus'); assert.equal(JSON.parse(x.out.text).bus, null); assert.equal(starts, 0); assert.equal(await f.state(), 'active'); }); test('E1: an agent run may restore only with --no-bus', async t => { const f = await archived(t); let starts = 0; const busStart = async () => { starts++; return { pid: 1 }; }; f.env = { ...f.env, MOSAIC_AGENT_RUN: '1' }; await assert.rejects(restore(f, { busStart }), e => e.exitCode === 3 && /MOSAIC_AGENT_RUN/.test(e.message)); assert.equal(await f.state(), 'archived'); await restore(f, { busStart }, '--no-bus'); assert.equal(starts, 0); assert.equal(await f.state(), 'active'); }); test('CB4 archive and delete from the CLI hand the managed copy remote to the organization service', async t => { const home = mkdtempSync(join(tmpdir(), 'cb4-cli-')), saved = process.env.HOME; process.env.HOME = home; t.after(() => { process.env.HOME = saved; rmSync(home, { recursive: true, force: true }); }); const configDir = join(home, '.config', 'mosaic-e1test'), dataRoot = join(home, 'data'); mkdirSync(configDir, { recursive: true, mode: 0o700 }); mkdirSync(dataRoot, { mode: 0o700 }); const ctx = { configDir, dataRoot, instance: 'e1test', principal: DEV_OWNER }; await service.createOrg(ctx, 'alpha', await service.templateRender('alpha')); const env = humanEnv({ HOME: home, MOSAIC_CONFIG: join(configDir, 'config.json') }); const asked = []; const copyRemoteFor = async c => { asked.push([c.configDir, c.dataRoot]); return async () => 'revoked'; }; const run = words => orgCommand(words, io(env), { system: { dataRoot }, copyRemoteFor }); const revision = (await listOrgs(ctx)).find(e => e.id === 'alpha').revision; await run(['archive', 'alpha', '--if-revision', String(revision), '--json']); assert.deepEqual(asked, [[configDir, dataRoot]]); const archived = (await listOrgs(ctx)).find(e => e.id === 'alpha').revision; await run(['delete', 'alpha', '--confirm', 'alpha', '--if-revision', String(archived), '--json']); assert.deepEqual(asked, [[configDir, dataRoot], [configDir, dataRoot]]); assert.equal((await listOrgs(ctx)).find(e => e.id === 'alpha')?.state ?? 'deleted', 'deleted'); });