Files
stack/packages/cli/tests/stack-instances.test.mjs
T
jason.woltje 6096b051b1 row 243: tests that depend on the gate umask set their file modes; stack-instances bootstraps under an explicit umask with a 077 subtest (#1754)
process.test, proof-cli.test and sso.test set the too-open mode with chmod
instead of taking it from the umask. stack-instances.test bootstraps under an
explicit umask and asserts the 0700 config dir under umask 077.
Candidate morgana/row243 6581f750e (code 137bda3af), Webby APPROVE rev 2939.
2026-10-21 14:12:03 -05:00

483 lines
26 KiB
JavaScript

// Row 190 (#1688): several stack instances on one host. The config and bootstrap cases (T-I1, T-I2,
// the config part of T-I7), two real bus hosts side by side (T-I5) and the guard (T-I10). Design and
// T table: agents/darkwing/work/queue-190/DESIGN.md D7. The unit, port and release cases are T56 in
// stack-release.test.mjs. HOME is a scratch directory in every test, so ~/.config/mosaic-* and
// ~/.mosaic-* resolve inside it; nothing here reads or writes ~/.mosaic-dev or ~/.config/mosaic-dev.
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawn, spawnSync } from "node:child_process";
import { once } from "node:events";
import { chmodSync, cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { join, relative } from "node:path";
import { fileURLToPath } from "node:url";
import { LiveSessionGuard } from "../../conversation/src/guard.mjs";
import { businessDoc, rolesCopy, writeJson } from "../../business/tests/helpers.mjs";
import { REPO_SKILLS } from "../../harness/tests/helpers.mjs";
import { humanEnv, notifyConfig, tmp } from "./helpers.mjs";
const SCRIPTS = fileURLToPath(new URL("../../../scripts/", import.meta.url));
const CONFIG = join(SCRIPTS, "mosaic-config.mjs");
const BOOTSTRAP = join(SCRIPTS, "bootstrap.sh");
const BUS_SERVICE = join(SCRIPTS, "bus-service.sh");
const WEBUI_SERVICE = join(SCRIPTS, "webui-service.sh");
const STACK_COPY = join(SCRIPTS, "stack-copy.sh");
const MOSAIC = join(SCRIPTS, "mosaic");
// The environment for a scratch home: no MOSAIC_CONFIG unless a test sets one.
function homeEnv(home, extra = {}) {
const env = humanEnv({ HOME: home, ...extra });
if (!("MOSAIC_CONFIG" in extra)) delete env.MOSAIC_CONFIG;
delete env.XDG_CONFIG_HOME;
return env;
}
function run(cmd, args, env) {
const r = spawnSync(cmd, args, { env, encoding: "utf8", timeout: 30000 });
return { code: r.status, out: r.stdout, err: r.stderr };
}
// The child runs under an explicit umask, so the mode assertions hold under any gate umask (row 243,
// sage-1918). mosaic-config.mjs creates with 0644 and lets the umask narrow it.
const underUmask = (mask, home, args) => run("/bin/bash", ["-c", `umask ${mask}; exec "$@"`, "bash", BOOTSTRAP, ...args], homeEnv(home));
const bootstrap = (home, ...args) => underUmask("022", home, args);
const config = (home, extra, ...args) => run(process.execPath, [CONFIG, ...args], homeEnv(home, extra));
const cfgOf = (home, name) => join(home, ".config", `mosaic-${name}`, "config.json");
// The instance op's exports as an object.
function exportsOf(out) {
return Object.fromEntries(out.trim().split("\n").map((line) => {
const m = line.match(/^(\w+)='(.*)'$/);
assert.ok(m, `not a shell-safe export: ${line}`);
return [m[1], m[2]];
}));
}
// Every entry under `dir`: relative path, type, mode, size, mtime and content hash.
function snapshot(dir) {
const out = {};
const walk = (d) => {
for (const name of readdirSync(d).sort()) {
const p = join(d, name);
const s = lstatSync(p);
const key = relative(dir, p);
if (s.isDirectory()) {
out[key] = { dir: true, mode: s.mode, mtime: s.mtimeMs };
walk(p);
} else {
out[key] = { mode: s.mode, size: s.size, mtime: s.mtimeMs, sha: createHash("sha256").update(readFileSync(p)).digest("hex") };
}
}
};
if (existsSync(dir)) walk(dir);
return out;
}
const mode = (p) => statSync(p).mode & 0o777;
test("T-I1 bootstrap --instance creates each instance's config, root and marker once, and refuses what it must not claim", async (t) => {
await t.test("alpha and beta get a 0700 config dir, a config naming ~/.mosaic-NAME, a 0700 root and a 0644 marker", (t) => {
const home = tmp(t, "si-");
for (const name of ["alpha", "beta"]) {
const r = bootstrap(home, "--instance", name);
assert.equal(r.code, 0, r.err);
const cfg = cfgOf(home, name), root = join(home, `.mosaic-${name}`);
assert.equal(r.err, `mosaic-config: created configuration for instance ${name}: ${cfg}, data root ${root}\n`);
assert.equal(mode(join(home, ".config", `mosaic-${name}`)), 0o700);
assert.equal(mode(cfg), 0o644);
assert.equal(JSON.parse(readFileSync(cfg, "utf8")).dataRoot, root);
assert.equal(mode(root), 0o700);
const marker = join(root, "instance.json");
assert.equal(readFileSync(marker, "utf8"), `{"instanceVersion":1,"name":"${name}"}\n`);
assert.equal(mode(marker), 0o644);
assert.deepEqual(readdirSync(root), ["instance.json"]);
assert.deepEqual(readdirSync(join(home, ".config", `mosaic-${name}`)), ["config.json"]);
}
assert.equal(existsSync(join(home, ".config", "mosaic-dev")), false);
assert.equal(existsSync(join(home, ".mosaic-dev")), false);
});
await t.test("under umask 077 the config and marker are 0600: the source honors the operator's umask", (t) => {
const home = tmp(t, "si-");
const r = underUmask("077", home, ["--instance", "alpha"]);
assert.equal(r.code, 0, r.err);
assert.equal(mode(join(home, ".config", "mosaic-alpha")), 0o700);
assert.equal(mode(cfgOf(home, "alpha")), 0o600);
assert.equal(mode(join(home, ".mosaic-alpha")), 0o700);
assert.equal(mode(join(home, ".mosaic-alpha", "instance.json")), 0o600);
});
await t.test("a second bootstrap of each changes no byte, mode or mtime", (t) => {
const home = tmp(t, "si-");
for (const name of ["alpha", "beta"]) assert.equal(bootstrap(home, "--instance", name).code, 0);
const before = snapshot(home);
for (const name of ["alpha", "beta"]) {
const r = bootstrap(home, "--instance", name);
assert.equal(r.code, 0, r.err);
assert.equal(r.err, `mosaic-config: configuration already present, validated without changes: ${cfgOf(home, name)}\n`);
}
assert.deepEqual(snapshot(home), before);
});
await t.test("a non-empty root without its own marker refuses before anything is written, worktrees included", (t) => {
const home = tmp(t, "si-");
mkdirSync(join(home, ".mosaic-gamma"));
writeFileSync(join(home, ".mosaic-gamma", "notes.txt"), "someone's data\n");
mkdirSync(join(home, ".mosaic-worktrees", "row190"), { recursive: true });
mkdirSync(join(home, ".mosaic-delta"));
writeFileSync(join(home, ".mosaic-delta", "instance.json"), `{"instanceVersion":1,"name":"alpha"}\n`);
writeFileSync(join(home, ".mosaic-delta", "other"), "x");
const before = snapshot(home);
for (const name of ["gamma", "worktrees", "delta"]) {
const r = bootstrap(home, "--instance", name);
assert.equal(r.code, 3, r.err);
assert.match(r.err, new RegExp(`instance-root-unclaimed: ${join(home, `.mosaic-${name}`)} exists, is not empty and holds no instance.json naming ${name}; nothing was written`));
assert.equal(existsSync(join(home, ".config", `mosaic-${name}`)), false);
}
assert.deepEqual(snapshot(home), before);
// A root that is a file refuses the same way.
writeFileSync(join(home, ".mosaic-file"), "x");
const r = bootstrap(home, "--instance", "file");
assert.equal(r.code, 3);
assert.match(r.err, /instance-root-unclaimed: .* exists and is not a directory; nothing was written/);
assert.equal(existsSync(join(home, ".config", "mosaic-file")), false);
});
await t.test("an empty existing root is claimed, and an existing config whose root lost its marker refuses, not repairs", (t) => {
const home = tmp(t, "si-");
mkdirSync(join(home, ".mosaic-alpha"), { mode: 0o700 });
assert.equal(bootstrap(home, "--instance", "alpha").code, 0);
assert.equal(readFileSync(join(home, ".mosaic-alpha", "instance.json"), "utf8"), `{"instanceVersion":1,"name":"alpha"}\n`);
rmSync(join(home, ".mosaic-alpha", "instance.json"));
const before = snapshot(home);
const r = bootstrap(home, "--instance", "alpha");
assert.equal(r.code, 3);
assert.match(r.err, /instance-root-unclaimed: .*\.mosaic-alpha holds no instance\.json naming alpha/);
assert.deepEqual(snapshot(home), before);
});
await t.test("a dangling symlink at the config path is never written through, named or default", (t) => {
const home = tmp(t, "si-");
for (const [name, args] of [["alpha", ["--instance", "alpha"]], ["dev", []]]) {
const dir = join(home, ".config", `mosaic-${name}`), target = join(home, `elsewhere-${name}.json`);
mkdirSync(dir, { recursive: true, mode: 0o700 });
symlinkSync(target, join(dir, "config.json"));
const r = bootstrap(home, ...args);
assert.equal(r.code, 2, `${name}: ${r.err}`);
assert.match(r.err, /configuration path must be a regular, non-symbolic-link file/);
assert.equal(existsSync(target), false, `${name}: the link's target was created`);
assert.deepEqual(readdirSync(dir), ["config.json"]);
assert.ok(lstatSync(join(dir, "config.json")).isSymbolicLink());
}
assert.deepEqual(readdirSync(home).sort(), [".config"]);
});
await t.test("bad names and stray arguments exit 4 and write nothing", (t) => {
const home = tmp(t, "si-");
for (const name of ["A", "a-b", "x", "a", "abcdefghijklmnopq", "../x", "1ab", "a.b", ""]) {
const r = bootstrap(home, "--instance", name);
assert.equal(r.code, 4, `${name}: ${r.err}`);
assert.match(r.err, /instance-name-invalid/);
}
assert.equal(bootstrap(home, "--instance", "abcdefghijklmnop").code, 0, "16 characters pass");
rmSync(join(home, ".config"), { recursive: true });
rmSync(join(home, ".mosaic-abcdefghijklmnop"), { recursive: true });
for (const args of [["--instance"], ["--instance", "alpha", "extra"], ["extra"], ["--name", "alpha"]]) {
const r = bootstrap(home, ...args);
assert.equal(r.code, 4, `${args.join(" ")}: ${r.err}`);
assert.match(r.err, /usage: mosaic-config\.mjs bootstrap \[--instance NAME\]/);
}
assert.deepEqual(readdirSync(home), []);
});
await t.test("--instance dev writes exactly what no flag writes", (t) => {
const plain = tmp(t, "si-"), dev = tmp(t, "si-");
const a = bootstrap(plain), b = bootstrap(dev, "--instance", "dev");
assert.equal(a.code, 0, a.err);
assert.equal(b.code, 0, b.err);
assert.equal(b.err.replaceAll(dev, "HOME"), a.err.replaceAll(plain, "HOME"));
const shape = (home) => Object.fromEntries(Object.entries(snapshot(home)).map(([k, v]) => [k, { ...v, mtime: 0, sha: v.sha && createHash("sha256").update(readFileSync(join(home, k), "utf8").replaceAll(home, "HOME")).digest("hex") }]));
assert.deepEqual(shape(dev), shape(plain));
assert.deepEqual(Object.keys(snapshot(plain)), [".config", ".config/mosaic-dev", ".config/mosaic-dev/config.json"]);
});
});
test("T-I2 selection: the four rows of D1 through the instance op and through an entry point", async (t) => {
const home = tmp(t, "si-");
for (const name of ["alpha", "beta"]) assert.equal(bootstrap(home, "--instance", name).code, 0);
const scratch = join(home, "scratch-config.json");
writeJson(scratch, { configVersion: 1, environment: "development", dataRoot: join(home, "scratch-data"), execution: { backend: "docker", provider: "zai", model: "glm-5.3-flash" } });
await t.test("row 1: no flag and no MOSAIC_CONFIG is the default; a missing default config leaves the data root empty", () => {
const r = config(home, {}, "instance");
assert.equal(r.code, 0, r.err);
assert.deepEqual(exportsOf(r.out), {
MOSAIC_INSTANCE: "dev",
MOSAIC_INSTANCE_CONFIG: cfgOf(home, "dev"),
MOSAIC_INSTANCE_CONFIG_DIR: join(home, ".config", "mosaic-dev"),
MOSAIC_INSTANCE_DATA_ROOT: "",
MOSAIC_UNIT_SUFFIX: "",
});
assert.deepEqual(exportsOf(config(home, {}, "instance", "--instance", "dev").out), exportsOf(r.out));
});
await t.test("row 2: MOSAIC_CONFIG alone is that config with the default names", () => {
const r = config(home, { MOSAIC_CONFIG: scratch }, "instance");
assert.equal(r.code, 0, r.err);
assert.deepEqual(exportsOf(r.out), {
MOSAIC_INSTANCE: "dev",
MOSAIC_INSTANCE_CONFIG: scratch,
MOSAIC_INSTANCE_CONFIG_DIR: home,
MOSAIC_INSTANCE_DATA_ROOT: join(home, "scratch-data"),
MOSAIC_UNIT_SUFFIX: "",
});
});
await t.test("row 3: --instance NAME alone is NAME's config, root and suffix", () => {
for (const name of ["alpha", "beta"]) {
const r = config(home, {}, "instance", "--instance", name);
assert.equal(r.code, 0, r.err);
assert.deepEqual(exportsOf(r.out), {
MOSAIC_INSTANCE: name,
MOSAIC_INSTANCE_CONFIG: cfgOf(home, name),
MOSAIC_INSTANCE_CONFIG_DIR: join(home, ".config", `mosaic-${name}`),
MOSAIC_INSTANCE_DATA_ROOT: join(home, `.mosaic-${name}`),
MOSAIC_UNIT_SUFFIX: `-${name}`,
});
}
});
await t.test("row 4: both must name one file; a symlink to it agrees, any other file exits 4", () => {
const link = join(home, "alpha-link.json");
symlinkSync(cfgOf(home, "alpha"), link);
for (const given of [cfgOf(home, "alpha"), link]) {
const r = config(home, { MOSAIC_CONFIG: given }, "instance", "--instance", "alpha");
assert.equal(r.code, 0, r.err);
assert.equal(exportsOf(r.out).MOSAIC_INSTANCE_CONFIG, cfgOf(home, "alpha"));
}
for (const given of [cfgOf(home, "beta"), scratch, join(home, "missing.json")]) {
for (const op of ["instance", "validate", "env", "bootstrap"]) {
const r = config(home, { MOSAIC_CONFIG: given }, op, "--instance", "alpha");
assert.equal(r.code, 4, `${op} ${given}: ${r.err}`);
assert.equal(r.out, "");
assert.match(r.err, new RegExp(`instance-config-mismatch: --instance alpha names ${cfgOf(home, "alpha")}, but MOSAIC_CONFIG names ${given}`));
}
}
});
await t.test("a named instance with no config exits 3 with its own bootstrap hint", () => {
const r = config(home, {}, "instance", "--instance", "gamma");
assert.equal(r.code, 3);
assert.match(r.err, /configuration not found: .*mosaic-gamma\/config\.json \(run scripts\/bootstrap\.sh --instance gamma to create it\)/);
});
await t.test("an entry point takes --instance in any position, once, and refuses a mismatch before it prints", () => {
const first = run(BUS_SERVICE, ["--instance", "alpha", "render"], homeEnv(home));
assert.equal(first.code, 0, first.err);
assert.match(first.out, new RegExp(`^Environment=MOSAIC_CONFIG=${cfgOf(home, "alpha")}$`, "m"));
assert.equal(run(BUS_SERVICE, ["render", "--instance", "alpha"], homeEnv(home)).out, first.out);
assert.equal(run(BUS_SERVICE, ["render", "--instance", "alpha"], homeEnv(home, { MOSAIC_CONFIG: cfgOf(home, "alpha") })).out, first.out);
const mismatch = run(BUS_SERVICE, ["--instance", "alpha", "render"], homeEnv(home, { MOSAIC_CONFIG: cfgOf(home, "beta") }));
assert.equal(mismatch.code, 4);
assert.equal(mismatch.out, "");
assert.match(mismatch.err, /instance-config-mismatch/);
for (const args of [["--instance", "alpha", "render", "--instance", "alpha"], ["render", "--instance"]]) {
const r = run(BUS_SERVICE, args, homeEnv(home));
assert.equal(r.code, 4, args.join(" "));
assert.equal(r.out, "");
assert.match(r.err, /instance: usage: --instance NAME, given once/);
}
const bad = run(BUS_SERVICE, ["--instance", "a-b", "render"], homeEnv(home));
assert.equal(bad.code, 4);
assert.match(bad.err, /instance-name-invalid/);
});
await t.test("copy takes no --instance in either script: every instance shares the copies", () => {
const copies = join(home, "copies-probe");
const before = snapshot(home);
for (const [cmd, args, err] of [
[STACK_COPY, ["--instance", "alpha", "copy", "HEAD", "--copies", copies], /^usage: stack-copy\.sh copy COMMIT/],
[STACK_COPY, ["copy", "HEAD", "--copies", copies, "--instance", "alpha"], /^usage: stack-copy\.sh copy COMMIT/],
[WEBUI_SERVICE, ["--instance", "alpha", "copy", "HEAD", "--copies", copies], /^webui-service: copy takes no --instance; every instance shares the copies\n$/],
[WEBUI_SERVICE, ["copy", "HEAD", "--copies", copies, "--instance", "alpha"], /^webui-service: copy takes no --instance; every instance shares the copies\n$/],
]) {
const r = run(cmd, args, homeEnv(home));
assert.equal(r.code, 4, `${args.join(" ")}: ${r.err}`);
assert.equal(r.out, "");
assert.match(r.err, err, args.join(" "));
}
assert.deepEqual(snapshot(home), before);
});
});
test("T-I7 claims: the instance op refuses a root another instance claims, and a named root with no marker", async (t) => {
const home = tmp(t, "si-");
for (const name of ["alpha", "beta"]) assert.equal(bootstrap(home, "--instance", name).code, 0);
const edit = (name, dataRoot) => writeJson(cfgOf(home, name), { ...JSON.parse(readFileSync(cfgOf(home, name), "utf8")), dataRoot });
const scratch = join(home, "scratch-config.json");
const scratchRoot = join(home, "scratch-data");
writeJson(scratch, { configVersion: 1, environment: "development", dataRoot: scratchRoot, execution: { backend: "docker", provider: "zai", model: "glm-5.3-flash" } });
await t.test("alpha's config pointing at beta's root refuses instance-root-mismatch in every op", () => {
edit("alpha", join(home, ".mosaic-beta"));
for (const op of ["instance", "validate", "env", "bootstrap"]) {
const r = config(home, {}, op, "--instance", "alpha");
assert.equal(r.code, 3, `${op}: ${r.err}`);
assert.equal(r.out, "");
assert.match(r.err, new RegExp(`instance-root-mismatch: ${join(home, ".mosaic-beta")} is claimed by instance beta, not alpha`));
}
edit("alpha", join(home, ".mosaic-alpha"));
assert.equal(config(home, {}, "instance", "--instance", "alpha").code, 0);
});
await t.test("MOSAIC_CONFIG naming alpha's config with no flag refuses: the root is alpha's", () => {
for (const op of ["instance"]) {
const r = config(home, { MOSAIC_CONFIG: cfgOf(home, "alpha") }, op);
assert.equal(r.code, 3, r.err);
assert.match(r.err, new RegExp(`instance-root-mismatch: ${join(home, ".mosaic-alpha")} is claimed by instance alpha; select it with --instance alpha`));
}
});
await t.test("a default config whose root carries alpha's marker refuses; one naming dev, or none, passes", () => {
mkdirSync(scratchRoot, { recursive: true });
assert.equal(config(home, { MOSAIC_CONFIG: scratch }, "instance").code, 0, "no marker passes as today");
writeFileSync(join(scratchRoot, "instance.json"), `{"instanceVersion":1,"name":"alpha"}\n`);
const r = config(home, { MOSAIC_CONFIG: scratch }, "instance");
assert.equal(r.code, 3);
assert.match(r.err, /claimed by instance alpha; select it with --instance alpha/);
writeFileSync(join(scratchRoot, "instance.json"), `{"instanceVersion":1,"name":"dev"}\n`);
assert.equal(config(home, { MOSAIC_CONFIG: scratch }, "instance").code, 0);
rmSync(join(scratchRoot, "instance.json"));
});
await t.test("a named root whose marker is missing, a symlink, unparsable or the wrong shape refuses instance-root-unclaimed", () => {
const marker = join(home, ".mosaic-alpha", "instance.json");
const good = readFileSync(marker, "utf8");
const cases = [
["missing", () => {}, /holds no instance\.json naming alpha/],
["a symlink", () => { writeFileSync(join(home, "real-marker.json"), good); symlinkSync(join(home, "real-marker.json"), marker); }, /must be a regular, non-symbolic-link file/],
["a directory", () => mkdirSync(marker), /must be a regular, non-symbolic-link file/],
["unparsable", () => writeFileSync(marker, "{"), /is not a valid marker/],
["an extra key", () => writeFileSync(marker, `{"instanceVersion":1,"name":"alpha","x":1}\n`), /is not a valid marker/],
["version 2", () => writeFileSync(marker, `{"instanceVersion":2,"name":"alpha"}\n`), /is not a valid marker/],
["a bad name", () => writeFileSync(marker, `{"instanceVersion":1,"name":"Alpha"}\n`), /is not a valid marker/],
];
for (const [name, change, why] of cases) {
rmSync(marker, { recursive: true, force: true });
change();
const r = config(home, {}, "instance", "--instance", "alpha");
assert.equal(r.code, 3, `${name}: ${r.err}`);
assert.match(r.err, /instance-root-unclaimed/, name);
assert.match(r.err, why, name);
rmSync(marker, { recursive: true, force: true });
writeFileSync(marker, good);
}
assert.equal(config(home, {}, "instance", "--instance", "alpha").code, 0);
});
});
async function until(check, ms) {
const end = Date.now() + ms;
while (!check()) {
if (Date.now() > end) throw new Error("timed out");
await new Promise((r) => setTimeout(r, 50));
}
}
// One named instance in `home`, ready for `bus start acme --no-launch`: bootstrap, a business file in
// its config dir, its own roles copy, the skills its roles name, and a notifier with no binding.
function hostInstance(home, root, name) {
assert.equal(bootstrap(home, "--instance", name).code, 0);
const cfg = cfgOf(home, name), dataRoot = join(home, `.mosaic-${name}`);
const files = join(root, `${name}-files`);
const roles = rolesCopy(files);
const doc = businessDoc(files, "acme");
for (const r of Object.values(doc.roles)) r.vars = { harness: "pi", model: "glm-5.3-flash", thinking: "high", ...r.vars };
writeJson(join(home, ".config", `mosaic-${name}`, "businesses", "acme.json"), { ...doc, plugins: { ...doc.plugins, harness: ["pi", "claude-code"] } });
cpSync(REPO_SKILLS, join(dataRoot, "skills-enabled"), { recursive: true });
notifyConfig(dataRoot, "acme", null);
return { name, cfg, dataRoot, env: homeEnv(home, { MOSAIC_CONFIG: cfg, MOSAIC_ROLES_DIR: roles }) };
}
test("T-I5 two real bus hosts, one per instance, run at once and each answers from its own socket under its own root", async (t) => {
const root = tmp(t, "si-");
const home = join(root, "h");
mkdirSync(home);
const both = ["alpha", "beta"].map((name) => hostInstance(home, root, name));
for (const inst of both) {
const sock = join(inst.dataRoot, "bus", "broker.sock");
// The kernel's limit for a Unix socket path is 107 bytes (the row 177 lesson).
assert.ok(Buffer.byteLength(sock) <= 107, `socket path is ${Buffer.byteLength(sock)} bytes: ${sock}`);
inst.sock = sock;
inst.child = spawn(MOSAIC, ["bus", "start", "acme", "--no-launch"], { env: inst.env, stdio: ["ignore", "pipe", "pipe"] });
t.after(() => inst.child.exitCode === null && inst.child.kill("SIGKILL"));
inst.out = "";
inst.child.stdout.on("data", (b) => (inst.out += b));
inst.child.stderr.on("data", (b) => (inst.out += b));
}
for (const inst of both) await until(() => /bus host up: business acme.*\n/.test(inst.out) || inst.child.exitCode !== null, 30000);
for (const inst of both) {
assert.match(inst.out, new RegExp(`bus host up: business acme, socket ${inst.sock}, notifier off, launches disabled\n`), inst.out);
assert.ok(lstatSync(inst.sock).isSocket());
}
for (const inst of both) {
const r = run(MOSAIC, ["bus", "status", "--json"], inst.env);
assert.equal(r.code, 0, r.err);
const s = JSON.parse(r.out);
assert.equal(s.host.business, "acme");
assert.equal(s.host.pid, inst.child.pid);
assert.equal(s.host.live, true);
assert.equal(s.socket, true);
// The broker process under the host holds the writer lock.
assert.equal(s.writerLock.live, true);
inst.lockPid = s.writerLock.pid;
}
assert.notEqual(both[0].child.pid, both[1].child.pid);
assert.notEqual(both[0].lockPid, both[1].lockPid);
// Stopping alpha leaves beta up.
const [alpha, beta] = both;
const exitA = once(alpha.child, "exit");
const stopA = run(MOSAIC, ["bus", "stop"], alpha.env);
assert.equal(stopA.code, 0, stopA.err);
assert.match(stopA.out, new RegExp(`stopped bus host for acme \\(pid ${alpha.child.pid}\\)`));
assert.equal((await exitA)[0], 0, alpha.out);
assert.equal(beta.child.exitCode, null);
const still = JSON.parse(run(MOSAIC, ["bus", "status", "--json"], beta.env).out);
assert.equal(still.host.pid, beta.child.pid);
assert.equal(still.writerLock.pid, beta.lockPid);
assert.equal(still.writerLock.live, true);
assert.equal(existsSync(join(alpha.dataRoot, "bus", "writer.lock")), false);
const exitB = once(beta.child, "exit");
assert.equal(run(MOSAIC, ["bus", "stop"], beta.env).code, 0);
assert.equal((await exitB)[0], 0, beta.out);
});
test("T-I10 the guard protects every instance's config dir and data roots, even under a corrupt config", (t) => {
const root = tmp(t, "si-");
const home = join(root, "h");
mkdirSync(home);
for (const name of ["alpha", "beta"]) assert.equal(bootstrap(home, "--instance", name).code, 0);
// alpha's root moved by its operator; beta's config is broken.
const moved = join(root, "alpha-data");
mkdirSync(moved);
writeJson(cfgOf(home, "alpha"), { ...JSON.parse(readFileSync(cfgOf(home, "alpha"), "utf8")), dataRoot: moved });
writeFileSync(cfgOf(home, "beta"), "{ not json");
for (const dir of ["mosaic.bak", "mosaic", "mosaic-a-b", "mosaic-Alpha", "mosaic-x"]) mkdirSync(join(home, ".config", dir));
const guard = new LiveSessionGuard({ fixtureRoot: root, homes: [home] });
const refuses = [
[join(home, ".config", "mosaic-alpha", "x"), "stack instance alpha's config dir"],
[join(home, ".mosaic-alpha", "x"), "stack instance alpha's default data root"],
[join(moved, "sessions", "x"), "stack instance alpha's configured data root"],
[join(home, ".config", "mosaic-beta", "x"), "stack instance beta's config dir"],
[join(home, ".mosaic-beta", "x"), "stack instance beta's default data root"],
[join(home, ".config"), "stack instance alpha's config dir"],
];
for (const [p, why] of refuses) {
assert.throws(() => guard.check({ workspace: p }, "bind"), (e) => e.message === `workspace overlaps ${why} (bind)`, p);
}
for (const dir of ["mosaic.bak", "mosaic", "mosaic-a-b", "mosaic-Alpha", "mosaic-x"]) {
assert.equal(guard.check({ workspace: join(home, ".config", dir, "x") }, "bind"), true, dir);
}
assert.equal(guard.check({ workspace: join(root, "work") }, "bind"), true);
});