Unify trunk on main: initialization → main #3
@@ -1,8 +1,10 @@
|
|||||||
# One machine, raising a substrate from the bundle its host carries.
|
# 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
|
# This is the bootstrap class (novox/hq ADR 0009): no forge, no control plane to talk to, no
|
||||||
# exists to develop the first three steps of raising a mesh — a container runtime, a store, and
|
# delivery. It exists to develop the steps of raising a mesh on a machine with no route out —
|
||||||
# a database inside it — which is as far as the bootstrap can go until a control plane exists.
|
# 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
|
scenario: first-node
|
||||||
|
|
||||||
segments:
|
segments:
|
||||||
@@ -15,8 +17,13 @@ machines:
|
|||||||
at: { segment: hosting, address: [192.0.2.10] }
|
at: { segment: hosting, address: [192.0.2.10] }
|
||||||
inbound: allow
|
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:
|
images:
|
||||||
- postgres:17-alpine
|
- postgres:17-alpine
|
||||||
|
- cloudamqp/lavinmq:latest
|
||||||
|
- mesh-control:development
|
||||||
|
|
||||||
place:
|
place:
|
||||||
all: [host, runtime]
|
all: [host, runtime]
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
* by the running machine now — nothing is inferred from a file on disk.
|
* 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";
|
import { depthOf, type Diagram, type DiagramMachine, type DiagramSegment } from "./model.ts";
|
||||||
|
|
||||||
interface RawInstance {
|
interface RawInstance {
|
||||||
@@ -28,7 +28,10 @@ interface RawInstance {
|
|||||||
export async function diagramFromLive(instanceId: string): Promise<Diagram> {
|
export async function diagramFromLive(instanceId: string): Promise<Diagram> {
|
||||||
const networks = (await taggedNetworks()).filter((n) => n.instanceId === instanceId);
|
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 parsed = JSON.parse(json) as RawInstance[];
|
||||||
const mine = parsed.filter((i) => i.config?.["user.mesh-lab.instance"] === instanceId);
|
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}'`);
|
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. */
|
/** Did the command work? For commands whose output is not the point. */
|
||||||
export async function succeeds(args: string[], timeoutMs = 60_000): Promise<boolean> {
|
export async function succeeds(args: string[], timeoutMs = 60_000): Promise<boolean> {
|
||||||
return (await incusOk(args, timeoutMs)) !== null;
|
return (await incusOk(args, timeoutMs)) !== null;
|
||||||
@@ -168,14 +184,18 @@ export interface TaggedInstance {
|
|||||||
* nothing. Metadata is what the instance actually knows about itself.
|
* nothing. Metadata is what the instance actually knows about itself.
|
||||||
*/
|
*/
|
||||||
export async function taggedInstances(): Promise<TaggedInstance[]> {
|
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;
|
let parsed: unknown;
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(json);
|
parsed = JSON.parse(json);
|
||||||
} catch {
|
} 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[] = [];
|
const tagged: TaggedInstance[] = [];
|
||||||
for (const entry of parsed) {
|
for (const entry of parsed) {
|
||||||
@@ -199,14 +219,17 @@ export interface TaggedNetwork {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function taggedNetworks(): Promise<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;
|
let parsed: unknown;
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(json);
|
parsed = JSON.parse(json);
|
||||||
} catch {
|
} 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[] = [];
|
const tagged: TaggedNetwork[] = [];
|
||||||
for (const entry of parsed) {
|
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