249 lines
15 KiB
JavaScript
249 lines
15 KiB
JavaScript
// `mosaic business instances` and `mosaic business instance add|set` (row 165, #1662, Q171,
|
|
// REQ-ROLE-3): the human reads, adds and changes role instances through the broker's instances
|
|
// and instances.set verbs, and with --tester the tester does (row 175, #1673, Q173). The broker
|
|
// refuses an agent and a reader; the business package's instance writer refuses a stale revision
|
|
// and every document `mosaic business validate` would refuse, and logs the change with its actor.
|
|
// Row 194 (#1692, D1): `business instance launch|terminate|regenerate|delete` and `business veto`.
|
|
// Launch, regenerate and delete are requests the launcher's pass carries out. The command polls
|
|
// instance.request until the request closes or --wait runs out (D1.1 item 5).
|
|
import { readFileSync } from "node:fs";
|
|
import { CliError } from "./errors.mjs";
|
|
|
|
export const INSTANCES_USAGE = "mosaic business instances BUSINESS [--json] [--tester] | " +
|
|
"business instance add BUSINESS INSTANCE --definition DEF [--vars FILE] [--launch] --revision REV [--tester] | " +
|
|
"business instance set BUSINESS INSTANCE [--definition DEF] [--vars FILE] [--launch | --no-launch] --revision REV [--tester] | " +
|
|
"business instance launch|regenerate BUSINESS INSTANCE [--wait SECONDS] [--tester] | " +
|
|
"business instance terminate BUSINESS INSTANCE [--reason TEXT] [--tester] | " +
|
|
"business instance delete BUSINESS INSTANCE --revision REV [--wait SECONDS] | " +
|
|
"business veto BUSINESS keep|unkeep INSTANCE --revision REV | business veto BUSINESS nohire|hire DEFINITION --revision REV";
|
|
|
|
// The bus answers a refusal with its code alone, so the reason a human reads is here.
|
|
export const REASONS = {
|
|
"organization-credentials-out-of-scope": "this command binds no credentials, so it refuses a definition that declares them " +
|
|
"and a definition change on an instance that holds them; that stays a hand edit of the business file",
|
|
"organization-instance-exists": "the business already has that instance; change it with `mosaic business instance set`",
|
|
"organization-unknown-instance": "the business has no such instance; add it with `mosaic business instance add`",
|
|
"organization-definition-refused": "this command neither adds nor changes an assistant, and moves no instance to the assistant " +
|
|
"definition; an assistant changes only through `mosaic limits set` with an assistants patch",
|
|
"organization-launch-not-configured": "the business file has no launch block, so launch membership can't change",
|
|
"organization-no-change": "the business file already says that",
|
|
"organization-project-mismatch": "a project file's id differs from its key in the business file; `mosaic business validate` shows it",
|
|
"organization-unresolvable-instance": "an instance wouldn't resolve against a project file; `mosaic business validate` shows why",
|
|
"revision-conflict": "the business file changed since that revision; read it again with `mosaic business instances`",
|
|
// Row 194 (#1692): the lifecycle and veto refusals.
|
|
"organization-instance-referenced": "the instance is launch.by or an arbiter; change that reference first",
|
|
"organization-instance-kept": "the instance is kept; run `mosaic business veto BUSINESS unkeep INSTANCE` first",
|
|
"organization-instance-retired": "that name was deleted and stays retired; pick another name",
|
|
"instance-running": "the instance has a run; end it with `mosaic business instance terminate` first",
|
|
"instance-starting": "a request for the instance has not closed yet; wait for it and try again",
|
|
"instance-retired": "that name was deleted and launches no more",
|
|
"not-thread-launch": "a thread instance starts on a thread message, not by launch",
|
|
"regenerate-thread": "a thread instance isn't regenerated; its context is the thread's",
|
|
"not-held": "the instance has no run and no open request",
|
|
"launch-revoked": "launches are revoked for the business; `mosaic launches on --business BUSINESS` lifts it",
|
|
"launches-disabled": "the bus host was started with --no-launch",
|
|
"request-expired": "the launcher didn't take the request within its 60 s",
|
|
"regenerate-timeout": "the old run didn't exit within the request's 60 s, so no new run started; it still runs",
|
|
"workspace-rename-failed": "the business file is changed and the name retired, but the workspace stayed where it was; " +
|
|
"run the same delete again to retry the rename",
|
|
"capacity-full": "a launch limit is reached; `mosaic limits --business BUSINESS` shows them",
|
|
"limit-reached": "a launch limit is reached; `mosaic limits --business BUSINESS` shows them",
|
|
"unknown-role": "the business has no such instance",
|
|
};
|
|
|
|
const LIFECYCLE_OPS = ["launch", "terminate", "regenerate", "delete"];
|
|
const VETO_OPS = ["keep", "unkeep", "nohire", "hire"];
|
|
const WAIT_SECONDS = 30;
|
|
const POLL_MS = 200;
|
|
|
|
export const RESTART_NEEDED = "restart needed: the running bus keeps the roles it loaded at start; this command does not restart it";
|
|
const NO_RESTART = "no restart: the launcher reads the change at its next launch check";
|
|
|
|
function parse(args) {
|
|
const out = { positional: [], flags: new Set(), values: {} };
|
|
for (let i = 0; i < args.length; i++) {
|
|
const arg = args[i];
|
|
if (["--json", "--launch", "--no-launch", "--tester"].includes(arg)) {
|
|
if (out.flags.has(arg)) throw new CliError(INSTANCES_USAGE, 4);
|
|
out.flags.add(arg);
|
|
} else if (["--definition", "--vars", "--revision", "--wait", "--reason"].includes(arg)) {
|
|
if (!args[i + 1] || Object.hasOwn(out.values, arg)) throw new CliError(INSTANCES_USAGE, 4);
|
|
out.values[arg] = args[++i];
|
|
} else if (arg.startsWith("--")) throw new CliError(INSTANCES_USAGE, 4);
|
|
else out.positional.push(arg);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// The args instances.set takes, from `instance add|set`. A usage error is exit 4 before any call.
|
|
function setArgs(parsed) {
|
|
const [, op, , instance] = parsed.positional;
|
|
const { values, flags } = parsed;
|
|
if (values["--wait"] !== undefined || values["--reason"] !== undefined) throw new CliError(INSTANCES_USAGE, 4);
|
|
const launch = flags.has("--launch") ? true : flags.has("--no-launch") ? false : undefined;
|
|
if (parsed.positional.length !== 4 || !["add", "set"].includes(op) || !values["--revision"] || flags.has("--json") ||
|
|
(flags.has("--launch") && flags.has("--no-launch")) || (op === "add" && (!values["--definition"] || launch === false)) ||
|
|
(op === "set" && !values["--definition"] && !values["--vars"] && launch === undefined)) {
|
|
throw new CliError(INSTANCES_USAGE, 4);
|
|
}
|
|
const args = { revision: values["--revision"], instance, op: op === "add" ? "add" : "change" };
|
|
if (values["--definition"]) args.definition = values["--definition"];
|
|
if (values["--vars"]) {
|
|
try {
|
|
args.vars = JSON.parse(readFileSync(values["--vars"], "utf8"));
|
|
} catch (error) {
|
|
throw new CliError(`--vars ${values["--vars"]}: ${error.message}`, 4);
|
|
}
|
|
}
|
|
if (launch !== undefined) args.launch = launch;
|
|
return args;
|
|
}
|
|
|
|
// Row 194: the lifecycle and veto args, or null when `instance OP` is add or set. Usage errors exit 4.
|
|
function lifecycleArgs(parsed) {
|
|
const { positional, values, flags } = parsed;
|
|
const [sub, op, , name] = positional;
|
|
const only = (allowed) => Object.keys(values).every((k) => allowed.includes(k)) && [...flags].every((f) => allowed.includes(f));
|
|
let wait = WAIT_SECONDS;
|
|
if (values["--wait"] !== undefined) {
|
|
if (!/^[0-9]{1,3}$/.test(values["--wait"]) || Number(values["--wait"]) > 600) throw new CliError(INSTANCES_USAGE, 4);
|
|
wait = Number(values["--wait"]);
|
|
}
|
|
if (sub === "veto") {
|
|
const [, , vop, target] = positional;
|
|
if (positional.length !== 4 || !VETO_OPS.includes(vop) || !values["--revision"] || !only(["--revision"])) throw new CliError(INSTANCES_USAGE, 4);
|
|
return { verb: "veto.set", args: { revision: values["--revision"], op: vop, name: target } };
|
|
}
|
|
if (!LIFECYCLE_OPS.includes(op)) return null;
|
|
if (positional.length !== 4) throw new CliError(INSTANCES_USAGE, 4);
|
|
if (op === "terminate") {
|
|
if (!only(["--reason", "--tester"])) throw new CliError(INSTANCES_USAGE, 4);
|
|
return { verb: "instance.end", args: { instance: name, ...(values["--reason"] !== undefined ? { reason: values["--reason"] } : {}) } };
|
|
}
|
|
if (op === "delete") {
|
|
if (!values["--revision"] || !only(["--revision", "--wait"])) throw new CliError(INSTANCES_USAGE, 4);
|
|
return { verb: "instances.set", args: { revision: values["--revision"], instance: name, op: "delete" }, wait };
|
|
}
|
|
if (!only(["--wait", "--tester"])) throw new CliError(INSTANCES_USAGE, 4);
|
|
return { verb: `instance.${op}`, args: { instance: name }, wait };
|
|
}
|
|
|
|
// D1.1 item 5: polls instance.request until the request closes or `wait` seconds pass. A closed
|
|
// request with a refusal or a cancel is exit 3 (refused) with its code. Still open or taken at the
|
|
// end is exit 3 with the request id, as the design's timeout is: a taken request with no done is
|
|
// outcome-unknown.
|
|
async function follow(call, request, wait) {
|
|
const end = Date.now() + wait * 1000;
|
|
for (;;) {
|
|
const r = await call("instance.request", { request });
|
|
if (r.state === "closed") {
|
|
if (r.cancelled) throw refusal("request-cancelled", `cancelled by ${r.cancelled.by} (request ${request})`);
|
|
if (r.done?.refusal) {
|
|
const kept = r.done.transaction ? `, transaction ${r.done.transaction}` : "";
|
|
throw refusal(r.done.refusal, `${REASONS[r.done.refusal] ?? "the launcher refused the request"} (request ${request}${kept})`);
|
|
}
|
|
return r;
|
|
}
|
|
if (Date.now() >= end) {
|
|
const error = r.state === "taken"
|
|
? new CliError(`outcome-unknown: request ${request} was taken and has not closed after ${wait} s; the launch log names any run it started`, 3)
|
|
: new CliError(`request ${request} is still open after ${wait} s; the launcher takes it within its 60 s or closes it request-expired`, 3);
|
|
error.code = r.state === "taken" ? "outcome-unknown" : "request-open";
|
|
error.final = true;
|
|
throw error;
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, Math.min(POLL_MS, Math.max(0, end - Date.now()))));
|
|
}
|
|
}
|
|
|
|
// `final`: the catch below passes it on as it is, with its request id.
|
|
function refusal(code, why) {
|
|
const error = new CliError(`${code}: ${why}`, 3);
|
|
error.code = code;
|
|
error.final = true;
|
|
return error;
|
|
}
|
|
|
|
function lifecycleLine(business, op, instance, r) {
|
|
if (op === "delete") return `business ${business}: deleted ${instance}, now retired\ntransaction ${r.done.transaction}\n`;
|
|
return `business ${business}: ${op === "launch" ? "launched" : "regenerated"} ${instance}, run ${r.done.run} (request ${r.request})\n`;
|
|
}
|
|
|
|
function terminateLines(business, instance, out) {
|
|
const lines = [`business ${business}: ended ${instance} (request ${out.request})`];
|
|
lines.push(out.runs.length ? `runs ${out.runs.join(", ")}: claims revoked, the launcher signals each` : "no run held a claim");
|
|
if (out.cancelled.length) lines.push(`cancelled ${out.cancelled.join(", ")}`);
|
|
if (out.taken.length) lines.push(`taken ${out.taken.join(", ")}: past cancelling; a run it starts is ended by the launcher's pass, or by a second terminate`);
|
|
return `${lines.join("\n")}\n`;
|
|
}
|
|
|
|
export function formatInstances(view) {
|
|
const lines = [`business ${view.business}, revision ${view.revision}`];
|
|
for (const [name, entry] of Object.entries(view.instances)) {
|
|
const marks = [entry.launcher && "launcher", entry.launched && "launched", entry.protected && "protected", entry.kept && "kept",
|
|
entry.running?.length && `running ${entry.running.join(", ")}`].filter(Boolean);
|
|
const vars = Object.keys(entry.vars).length ? ` vars ${JSON.stringify(entry.vars)}` : "";
|
|
lines.push(` ${name}: ${entry.definition}${marks.length ? ` (${marks.join(", ")})` : ""}${vars}`);
|
|
}
|
|
const definitions = Object.entries(view.definitions);
|
|
const accepted = definitions.filter(([, d]) => d.accepted).map(([name]) => name);
|
|
const bound = definitions.filter(([, d]) => d.services.length).map(([name, d]) => `${name} (${d.services.join(", ")})`);
|
|
lines.push(`definitions add and set accept: ${accepted.join(", ") || "none"}`);
|
|
if (bound.length) lines.push(`definitions that declare credentials, a hand edit: ${bound.join(", ")}`);
|
|
if (view.retired?.length) lines.push(`retired: ${view.retired.join(", ")}`);
|
|
if (view.noHire?.length) lines.push(`the chief may not hire: ${view.noHire.join(", ")}`);
|
|
return `${lines.join("\n")}\n`;
|
|
}
|
|
|
|
// args are the words after `mosaic business`. callFor(parsed) returns the human's call(verb, args).
|
|
export async function instancesCommand(args, io, callFor) {
|
|
const parsed = parse(args);
|
|
const sub = parsed.positional[0];
|
|
const business = parsed.positional[sub === "instance" ? 2 : 1];
|
|
if (!["instances", "instance", "veto"].includes(sub) || !business) throw new CliError(INSTANCES_USAGE, 4);
|
|
let request;
|
|
const lifecycle = sub === "instances" ? null : lifecycleArgs(parsed);
|
|
if (lifecycle) {
|
|
// veto and the lifecycle verbs, below.
|
|
} else if (sub === "instances") {
|
|
if (parsed.positional.length !== 2 || Object.keys(parsed.values).length || parsed.flags.has("--launch") || parsed.flags.has("--no-launch")) {
|
|
throw new CliError(INSTANCES_USAGE, 4);
|
|
}
|
|
} else request = setArgs(parsed);
|
|
try {
|
|
const call = callFor({ positional: parsed.positional, flags: parsed.flags, values: { "--business": business } });
|
|
if (lifecycle) {
|
|
const out = await call(lifecycle.verb, lifecycle.args);
|
|
if (sub === "veto") {
|
|
return io.stdout.write(`business ${out.business}: veto ${lifecycle.args.op} ${lifecycle.args.name}\nrevision ${out.revision}\ntransaction ${out.transaction}\n`);
|
|
}
|
|
const op = parsed.positional[1];
|
|
if (op === "terminate") return io.stdout.write(terminateLines(business, lifecycle.args.instance, out));
|
|
io.stderr.write(`mosaic: request ${out.request}, waiting up to ${lifecycle.wait} s\n`);
|
|
return io.stdout.write(lifecycleLine(business, op, lifecycle.args.instance, await follow(call, out.request, lifecycle.wait)));
|
|
}
|
|
if (!request) {
|
|
const view = await call("instances", {});
|
|
return io.stdout.write(parsed.flags.has("--json") ? `${JSON.stringify(view, null, 2)}\n` : formatInstances(view));
|
|
}
|
|
const result = await call("instances.set", request);
|
|
const entry = result.instances[request.instance];
|
|
io.stdout.write([
|
|
`business ${result.business}: ${request.op === "add" ? "added" : "changed"} ${request.instance} (${entry.definition}${entry.launched ? ", launched" : ""})`,
|
|
`revision ${result.revision}`,
|
|
`transaction ${result.transaction}`,
|
|
result.restart ? RESTART_NEEDED : NO_RESTART,
|
|
].join("\n") + "\n");
|
|
} catch (error) {
|
|
if (error.final) throw error;
|
|
const code = error.code;
|
|
if (Object.hasOwn(REASONS, code)) {
|
|
const refused = new CliError(`${code}: ${REASONS[code]}`, error.exitCode ?? 2);
|
|
refused.code = code;
|
|
throw refused;
|
|
}
|
|
if (error instanceof CliError) throw error;
|
|
throw new CliError(error.message, 2);
|
|
}
|
|
}
|