Unify trunk on main: initialization → main #3
@@ -1,8 +1,10 @@
|
||||
# One machine, raising a substrate from the bundle its host carries.
|
||||
#
|
||||
# This is the bootstrap class (novox/hq ADR 0009): no forge, no control plane, no delivery. It
|
||||
# exists to develop the first three steps of raising a mesh — a container runtime, a store, and
|
||||
# a database inside it — which is as far as the bootstrap can go until a control plane exists.
|
||||
# This is the bootstrap class (novox/hq ADR 0009): no forge, no control plane to talk to, no
|
||||
# delivery. It exists to develop the steps of raising a mesh on a machine with no route out —
|
||||
# a container runtime, a store, the control plane's schema in it, and the broker.
|
||||
#
|
||||
# It stops before the control plane *runs*, because there is nothing for it to serve yet.
|
||||
scenario: first-node
|
||||
|
||||
segments:
|
||||
@@ -15,8 +17,13 @@ machines:
|
||||
at: { segment: hosting, address: [192.0.2.10] }
|
||||
inbound: allow
|
||||
|
||||
# Placed into a registry the scenario raises, which is what a real node pulls from anyway. The
|
||||
# digests below are the ones that registry assigns, and that satisfies pinning: what is required
|
||||
# is a reference that is exact and cannot move (novox/hq ADR 0006).
|
||||
images:
|
||||
- postgres:17-alpine
|
||||
- cloudamqp/lavinmq:latest
|
||||
- mesh-control:development
|
||||
|
||||
place:
|
||||
all: [host, runtime]
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* by the running machine now — nothing is inferred from a file on disk.
|
||||
*/
|
||||
|
||||
import { incusOk, taggedNetworks } from "../incus/client.ts";
|
||||
import { incus, incusOk, taggedNetworks } from "../incus/client.ts";
|
||||
import { depthOf, type Diagram, type DiagramMachine, type DiagramSegment } from "./model.ts";
|
||||
|
||||
interface RawInstance {
|
||||
@@ -28,7 +28,10 @@ interface RawInstance {
|
||||
export async function diagramFromLive(instanceId: string): Promise<Diagram> {
|
||||
const networks = (await taggedNetworks()).filter((n) => n.instanceId === instanceId);
|
||||
|
||||
const json = (await incusOk(["list", "--format", "json"], 30_000)) ?? "[]";
|
||||
// Not `incusOk(...) ?? "[]"`. A picture is read from what runs (novox/hq ADR 0018), and a read
|
||||
// that failed and became an empty list would draw an empty scenario rather than fail — a
|
||||
// diagram that is confidently wrong, which is worse than no diagram.
|
||||
const json = (await incus(["list", "--format", "json"], 30_000)).stdout.trim() || "[]";
|
||||
const parsed = JSON.parse(json) as RawInstance[];
|
||||
const mine = parsed.filter((i) => i.config?.["user.mesh-lab.instance"] === instanceId);
|
||||
if (mine.length === 0 && networks.length === 0) throw new Error(`no scenario instance '${instanceId}'`);
|
||||
|
||||
+29
-6
@@ -111,6 +111,22 @@ export async function incusOk(args: string[], timeoutMs = 60_000): Promise<strin
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask incus what exists, where answering "none" without having looked would be a lie.
|
||||
*
|
||||
* The comment on `incusOk` above warns that absence and success must not be made
|
||||
* indistinguishable. Three of its callers then wrote `?? "[]"` and did exactly that, and it cost
|
||||
* a session: `mesh-lab list` reported *no scenario instances standing* while two were standing,
|
||||
* because this shell had no permission to reach the daemon. The lab was not wrong about the
|
||||
* instances — it had never managed to ask.
|
||||
*
|
||||
* So anything enumerating what exists comes through here and throws. `incusOk` remains right for
|
||||
* questions where failure genuinely means no, like `instanceExists`.
|
||||
*/
|
||||
async function enumerate(args: string[], timeoutMs = 30_000): Promise<string> {
|
||||
return (await incus(args, timeoutMs)).stdout;
|
||||
}
|
||||
|
||||
/** Did the command work? For commands whose output is not the point. */
|
||||
export async function succeeds(args: string[], timeoutMs = 60_000): Promise<boolean> {
|
||||
return (await incusOk(args, timeoutMs)) !== null;
|
||||
@@ -168,14 +184,18 @@ export interface TaggedInstance {
|
||||
* nothing. Metadata is what the instance actually knows about itself.
|
||||
*/
|
||||
export async function taggedInstances(): Promise<TaggedInstance[]> {
|
||||
const json = (await incusOk(["list", "--format", "json"], 30_000)) ?? "[]";
|
||||
const json = (await enumerate(["list", "--format", "json"])).trim() || "[]";
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(json);
|
||||
} catch {
|
||||
return [];
|
||||
throw new IncusError(["list", "--format", "json"],
|
||||
`incus answered something that is not JSON: ${json.slice(0, 200)}`, null);
|
||||
}
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new IncusError(["list", "--format", "json"],
|
||||
"incus answered JSON that is not a list of instances", null);
|
||||
}
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
|
||||
const tagged: TaggedInstance[] = [];
|
||||
for (const entry of parsed) {
|
||||
@@ -199,14 +219,17 @@ export interface TaggedNetwork {
|
||||
}
|
||||
|
||||
export async function taggedNetworks(): Promise<TaggedNetwork[]> {
|
||||
const json = (await incusOk(["network", "list", "--format", "json"], 30_000)) ?? "[]";
|
||||
const args = ["network", "list", "--format", "json"];
|
||||
const json = (await enumerate(args)).trim() || "[]";
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(json);
|
||||
} catch {
|
||||
return [];
|
||||
throw new IncusError(args, `incus answered something that is not JSON: ${json.slice(0, 200)}`, null);
|
||||
}
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new IncusError(args, "incus answered JSON that is not a list of networks", null);
|
||||
}
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
|
||||
const tagged: TaggedNetwork[] = [];
|
||||
for (const entry of parsed) {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// Set before importing: the client reads MESH_LAB_INCUS once, at module load.
|
||||
// `false` is a real program that exits non-zero and prints nothing — which is also the worst
|
||||
// case, because an empty stderr is how a failure arrives with no explanation.
|
||||
process.env["MESH_LAB_INCUS"] = "false";
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { instanceExists, taggedInstances, taggedNetworks } from "../src/incus/client.ts";
|
||||
|
||||
/**
|
||||
* "I cannot see" must never be answered as "there is nothing there."
|
||||
*
|
||||
* This is the fault the comment on `incusOk` warns about, committed by three of its own callers
|
||||
* writing `?? "[]"`. It cost a session: `mesh-lab list` printed *no scenario instances standing*
|
||||
* while two were standing, because the shell had no permission to reach the daemon. Nothing was
|
||||
* wrong with the lab's knowledge of the instances — it had never managed to ask.
|
||||
*
|
||||
* The same shape as the fault the node host exists to prevent, in the tool that tests the host:
|
||||
* a service that does not exist reported as `stopped`.
|
||||
*/
|
||||
|
||||
test("listing instances fails rather than reporting none", async () => {
|
||||
await assert.rejects(
|
||||
() => taggedInstances(),
|
||||
"a failed `incus list` came back as an empty list; every caller would report nothing running",
|
||||
);
|
||||
});
|
||||
|
||||
test("listing networks fails rather than reporting none", async () => {
|
||||
await assert.rejects(
|
||||
() => taggedNetworks(),
|
||||
"a failed `incus network list` came back as an empty list",
|
||||
);
|
||||
});
|
||||
|
||||
test("but a question whose failure genuinely means no still answers no", async () => {
|
||||
// The distinction worth keeping. `instanceExists` asks about one named thing, and a daemon
|
||||
// that will not answer is not evidence the instance exists — so false is honest here, and
|
||||
// making this throw too would be over-correcting until nothing can be asked at all.
|
||||
assert.equal(await instanceExists("anything"), false);
|
||||
});
|
||||
Reference in New Issue
Block a user