Files
mesh-lab/src/lifecycle/place.ts
T
jschoubben 5d6e8fbe7a Rename mesh-control -> mesh-controller, substrate -> foundation
One name per thing, per the HQ glossary: the module/container/image/binary/repo
becomes mesh-controller, the seat the-controller, and the store+broker pair the
foundation (embedded base bundles, default template and example lock renamed with
their go:embed directives). No behaviour change — a pure vocabulary rename.

Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
2026-09-16 18:40:40 +02:00

652 lines
28 KiB
TypeScript

/**
* `place:` — putting something inside the machines.
*
* Until now the lab raised an underlay and put nothing on it: correct, and useless, because
* the thing it exists to test did not exist (novox/hq 03-DESIGN/00-as-is/11-the-lab.md). Tier
* 0 now does, so this is the seam where the lab acquires a consumer.
*
* Only `host` is placeable. Everything else in the placement vocabulary — the foundation, a
* control plane, a forge — is still refused by name rather than ignored, because a scenario
* that declares something and raises without it is the fault this lab was built to catch
* (novox/hq 04-ISSUES/003).
*/
import type { Scenario } from "../declaration/types.ts";
import { spawn } from "node:child_process";
import { unlink } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { incus, incusOk, succeeds } from "../incus/client.ts";
import { around, log, shorten } from "../log.ts";
import { mustBeHandedOver, repositoryOf, type HeldImage } from "../pinning.ts";
/**
* What this stage can put inside a machine.
*
* `image:<reference>` is the one that takes an argument — `image:alpine@sha256:...` — because
* WHICH image is the point of placing one.
*/
export const PLACEABLE = ["host", "runtime"] as const;
export type Placeable = (typeof PLACEABLE)[number];
/** The prefix that marks a container image, and what follows it is passed through unchanged. */
export const IMAGE_PREFIX = "image:";
/** Whether an artifact names something this stage can place. */
export function isPlaceable(artifact: string): boolean {
return (PLACEABLE as readonly string[]).includes(artifact) ||
(artifact.startsWith(IMAGE_PREFIX) && artifact.slice(IMAGE_PREFIX.length).trim() !== "");
}
/** Where the host binary lives on a machine once placed. */
export const HOST_PATH = "/usr/local/bin/mesh-host";
/**
* Where the installer lives on a machine once placed.
*
* **Beside the host, because it is the same tier and the same delivery** (novox/hq ADR 0067):
* bootstrapping is done by hand and it changes a machine, which is what tier 0 is — but `mesh-host`
* says of itself that it connects to nothing and listens on nothing, and an installer that loads
* images and interrogates a control plane cannot be folded into it without making that sentence
* false. Two programs, one shelf.
*/
export const BOOTSTRAP_PATH = "/usr/local/bin/mesh-bootstrap";
export interface Placement {
machine: string;
artifacts: string[];
}
/**
* Resolve `place:` to one list per machine.
*
* `all:` applies to every machine; a per-machine entry **overrides** it rather than adding to
* it, which is what the declaration design says and is worth being exact about — a scenario
* naming one artifact for one machine gets that artifact and not that artifact plus the rest.
*/
export function planPlacements(scenario: Scenario): Placement[] {
const place = scenario.place;
if (!place) return [];
const all = place.all ?? [];
return Object.keys(scenario.machines).map((machine) => {
const own = place[machine];
return { machine, artifacts: own ?? all };
}).filter((p) => p.artifacts.length > 0);
}
/**
* The host binary to place, from the environment.
*
* Deliberately an explicit path rather than a search. The declaration design leaves *where
* `place:` gets its artifacts from* open — before the mesh is self-hosting they come from
* outside, afterwards from the mesh — and it suggests a named source rather than a path. This
* is neither: it is the smallest thing that works while that stays undecided, and it refuses
* loudly rather than guessing, so nothing here hardens into the answer by accident.
*/
export function hostBinaryPath(): string | null {
return process.env["MESH_LAB_HOST_BINARY"] ?? null;
}
/**
* The installer to place, from the environment. Same rule as the host binary: an explicit path,
* and nothing is guessed.
*/
export function bootstrapBinaryPath(): string | null {
return process.env["MESH_LAB_BOOTSTRAP_BINARY"] ?? null;
}
export class PlacementError extends Error {
readonly machine: string;
constructor(machine: string, message: string) {
super(message);
this.name = "PlacementError";
this.machine = machine;
}
}
export interface PlacedHost {
machine: string;
/** What the host reported about the machine, read back from it. */
profile: unknown;
version: string;
}
/**
* Put the host on a machine and ask it what the machine is.
*
* The result is read back from the running binary, never assumed from the fact that the copy
* succeeded (novox/hq ADR 0018). A file arriving is not a host working, which is the same
* distinction the host itself makes about installed packages.
*/
export async function placeHost(
instanceName: string,
machine: string,
binary: string,
log: (message: string) => void = () => {},
): Promise<PlacedHost> {
await incus(["file", "push", binary, `${instanceName}${HOST_PATH}`, "--mode", "0755"], 180_000);
// Read back that it is there and executable before trusting it to answer questions.
const version = (await incusOk(["exec", instanceName, "--", HOST_PATH, "version"], 60_000))?.trim();
if (!version) {
throw new PlacementError(
machine,
`the host binary was copied to ${machine} but does not run there. A file arriving is ` +
`not a host working.`,
);
}
const reported = await incusOk(
["exec", instanceName, "--", HOST_PATH, "profile", "--json"],
120_000,
);
if (!reported) {
throw new PlacementError(
machine,
`the host runs on ${machine} (${version}) but reported no profile. A host that cannot ` +
`say what a machine is cannot be asked to change it.`,
);
}
let profile: unknown;
try {
profile = JSON.parse(reported);
} catch (err) {
throw new PlacementError(
machine,
`the host on ${machine} reported something that is not a profile: ${(err as Error).message}`,
);
}
log(` placed the host on ${machine} (${version})`);
return { machine, profile, version };
}
/**
* Put the installer on a machine and ask it what it is.
*
* The same shape as {@link placeHost} and for the same reason: the version is read back from the
* running binary, because a file arriving is not a program working (novox/hq ADR 0018). The
* installer is the larger of the two by an order of magnitude — it carries a saved container image
* — so a copy that half-arrived is a real possibility rather than a theoretical one.
*
* It is placed on ONE machine, not all of them. Only the anchor is bootstrapped; every other
* machine joins a mesh that already exists, with the host binary and a token and nothing else.
*/
export async function placeBootstrap(
instanceName: string,
machine: string,
binary: string,
log: (message: string) => void = () => {},
): Promise<string> {
await incus(["file", "push", binary, `${instanceName}${BOOTSTRAP_PATH}`, "--mode", "0755"],
900_000);
const version = (await incusOk(
["exec", instanceName, "--", BOOTSTRAP_PATH, "version"], 60_000,
))?.trim();
if (!version) {
throw new PlacementError(
machine,
`the installer was copied to ${machine} and does not run there. It carries a saved ` +
`container image and is twenty megabytes or so, which is exactly the size at which a ` +
`truncated copy stops being theoretical.`,
);
}
log(` placed the installer on ${machine} (${version})`);
return version;
}
/** Place everything a scenario declares. */
export async function applyPlacements(
scenario: Scenario,
machineNames: Map<string, string>,
log: (message: string) => void = () => {},
): Promise<PlacedHost[]> {
const placements = planPlacements(scenario);
if (placements.length === 0) return [];
// Only demanded when something actually needs it. A scenario placing a runtime and an image
// and no host is a legitimate thing to want, and asking it for a host binary would be
// refusing a scenario for not doing something it never said it would.
const wantsHost = placements.some((p) => p.artifacts.includes("host"));
const binary = hostBinaryPath();
if (wantsHost && !binary) {
throw new Error(
`this scenario places the host, and no host binary was given. Set ` +
`MESH_LAB_HOST_BINARY to a built mesh-host. Guessing at a path would place ` +
`whatever happened to be there.`,
);
}
const placed: PlacedHost[] = [];
for (const { machine, artifacts } of placements) {
const name = machineNames.get(machine);
if (!name) continue;
// Order within a machine is the order declared, because a scenario placing an image
// before a runtime means something different from the reverse and the lab should not
// silently reorder it into working.
for (const artifact of artifacts) {
if (artifact === "host") {
placed.push(await placeHost(name, machine, binary as string, log));
} else if (artifact === "runtime") {
await placeRuntime(name, machine, log);
} else if (artifact.startsWith(IMAGE_PREFIX)) {
await placeImage(name, machine, artifact.slice(IMAGE_PREFIX.length).trim(), log);
}
// Anything else was refused by the validator; reaching here would be a validator bug.
}
}
return placed;
}
/**
* The base image a scenario's machines are built from, when they need a container runtime.
*
* A sealed scenario cannot install one: its segments use documentation ranges and there is no
* route out (novox/hq ADR 0016). So the runtime arrives the way research 012 says everything
* awkward should — **fetched at build time on a machine that has a network, applied on a target
* that then needs nothing.** Building this image is that build time.
*
* Measured before this was written: an Arch VM installs docker in about 30 seconds, and
* publishing the result takes about a minute and yields roughly 700 MiB. That is a one-time
* cost paid once per lab, not per scenario.
*/
export const BASE_IMAGE_ALIAS = "mesh-lab/base";
/** How to build it, said in one place so an error can point at it. */
export const BASE_IMAGE_HOWTO =
`Build it with: mesh-lab base build\n` +
` It launches a machine WITH a network, installs a container runtime, and publishes the\n` +
` result as '${BASE_IMAGE_ALIAS}'. A scenario is sealed and cannot do that for itself.`;
/**
* Confirm a machine has a working container runtime.
*
* Asks the runtime, never the filesystem. A binary being present is not a runtime working —
* that is 04-ISSUES/007, and it is the reason this runs `info` rather than checking a path.
*
* It does not INSTALL one. A sealed machine cannot fetch, so an absent runtime is a scenario
* built on the wrong image, and saying that is more useful than failing inside a package
* manager with no network.
*/
export async function placeRuntime(
instanceName: string,
machine: string,
log: (message: string) => void = () => {},
): Promise<string> {
const version = (await incusOk(
["exec", instanceName, "--", "docker", "info", "--format", "{{.ServerVersion}}"],
60_000,
))?.trim();
if (!version) {
throw new PlacementError(
machine,
`${machine} has no working container runtime, and a sealed scenario cannot install one.\n` +
` This machine needs to be built from '${BASE_IMAGE_ALIAS}'.\n` +
BASE_IMAGE_HOWTO,
);
}
log(` ${machine} has a container runtime (docker ${version})`);
return version;
}
/**
* Put a container image inside a machine.
*
* Exported from this workstation and loaded in the machine, because the machine cannot reach a
* registry. The reference is passed through unchanged — including its digest — so what runs in
* the lab is the image the declaration names rather than whatever a tag pointed at.
*/
export async function placeImage(
instanceName: string,
machine: string,
reference: string,
log: (message: string) => void = () => {},
): Promise<void> {
// A digest reference cannot be placed this way, and finding that out here is much better
// than finding it out when a container fails to start.
//
// `docker save alpine@sha256:...` produces an archive with NO repo tag — only an image ID —
// because a repo digest exists only for an image a registry served. Loading it gives a
// dangling image, so `docker run <that digest>` falls through to the registry, which a
// sealed machine cannot reach. Measured, not assumed: the load says `Loaded image ID:`
// instead of `Loaded image:`, and `docker images` then lists nothing.
//
// What an archive DOES keep is the image's own ID — the digest of its configuration — and that
// is how the mesh's own images are named once loaded. See {@link loadHeldImages}.
if (reference.includes("@sha256:")) {
throw new PlacementError(
machine,
`${reference} is pinned by digest, and an image placed from an archive cannot keep its ` +
`digest — a repo digest only exists for an image a registry served.\n` +
` Placing it would load an image with no name, and a container declaring that digest ` +
`would try to reach a registry.\n` +
` Place it by tag. What survives being loaded is the image's own ID, which is what a ` +
`declaration names it by (mesh-host: an image the machine already holds).`,
);
}
const tar = join(tmpdir(), `mesh-lab-image-${process.pid}-${Date.now()}.tar`);
// Exported from whatever this workstation has. The lab places images; it does not fetch
// them, so an image nobody pulled here is an error rather than a download.
const saved = await local("docker", ["save", reference, "-o", tar], 600_000);
if (!saved.ok) {
throw new PlacementError(
machine,
`cannot export ${reference} from this workstation: ${saved.stderr.trim()}\n` +
` The lab places images it already has, and never fetches on a scenario's behalf.\n` +
` Pull it here first, then raise.`,
);
}
try {
await waitForRuntime(instanceName, machine);
await incus(["file", "push", tar, `${instanceName}/tmp/image.tar`], 900_000);
// Read back what the runtime says, not that the push returned. A file arriving is not an
// image present, and `docker load` names what it loaded — so that is what is checked.
const loaded = await incusOk(
["exec", instanceName, "--", "docker", "load", "-i", "/tmp/image.tar"],
600_000,
);
// `Loaded image:` and `Loaded image ID:` are different outcomes and only one is useful.
// Matching the shorter string accepted both, so an archive that loaded as a dangling
// image reported success — a check that passes on the wrong thing.
if (!loaded?.includes("Loaded image:")) {
throw new PlacementError(
machine,
`${reference} was pushed to ${machine} and did not arrive as a usable image.\n` +
` The runtime said: ${loaded?.trim() || "nothing"}\n` +
` 'Loaded image ID:' means it loaded with no name, which nothing can run by name.`,
);
}
log(` placed ${reference} on ${machine}`);
} finally {
await unlink(tar).catch(() => {});
}
}
/** Run something on THIS workstation. The incus client only speaks to incus. */
function local(
command: string,
args: string[],
timeoutMs: number,
): Promise<{ ok: boolean; stdout: string; stderr: string }> {
// The third and last place the lab runs an external program (novox/hq 04-ISSUES/024).
// `docker save` of a large image is the slowest single thing a raise does.
return around(`${command} ${shorten(args)}`, () => runLocal(command, args, timeoutMs), {
heartbeatMs: 15_000,
});
}
function runLocal(
command: string,
args: string[],
timeoutMs: number,
): Promise<{ ok: boolean; stdout: string; stderr: string }> {
return new Promise((resolve) => {
const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
const timer = setTimeout(() => child.kill("SIGKILL"), timeoutMs);
child.stdout.on("data", (d) => (stdout += d));
child.stderr.on("data", (d) => (stderr += d));
child.on("error", (err) => {
clearTimeout(timer);
resolve({ ok: false, stdout, stderr: err.message });
});
child.on("close", (code) => {
clearTimeout(timer);
if (code !== 0) {
log.debug(` exit ${code}: ${shorten([stderr.trim() || "(nothing on stderr)"], 400)}`);
}
resolve({ ok: code === 0, stdout, stderr });
});
});
}
/**
* Wait until the machine's container runtime will answer, and say why if it will not.
*
* **A stall must become a failure with a reason.** `docker load` against a daemon that is not
* running does not fail — the socket exists and is socket-activated, so the client blocks while
* systemd tries to start a service that may be queued behind something. That turned a
* misconfigured link into a thirty-five minute silence and then a timeout naming the wrong
* thing entirely (novox/hq 04-ISSUES/024).
*
* The one cause seen in practice is named in the message, because it is not guessable from
* "docker did not start": these machines have no DHCP by design, so a link that networkd was
* never told how to configure leaves `network-online.target` unreachable for ever, and Docker
* is ordered after it.
*/
async function waitForRuntime(instanceName: string, machine: string): Promise<void> {
const deadline = Date.now() + 120_000;
while (Date.now() < deadline) {
if (await succeeds(["exec", instanceName, "--", "docker", "info"], 20_000)) return;
await new Promise((r) => setTimeout(r, 2_000));
}
// What it was waiting for, asked once, so the report names the cause rather than the symptom.
const jobs = (await incusOk(
["exec", instanceName, "--", "systemctl", "list-jobs", "--no-pager"], 20_000,
)) ?? "";
const blocked = jobs.includes("network-online.target") || jobs.includes("wait-online");
throw new PlacementError(
machine,
`the container runtime on ${machine} did not answer within 120s, so an image cannot be ` +
`placed into it.\n` +
(blocked
? ` Docker is queued behind network-online.target, which is waiting for a link that ` +
`systemd-networkd was never told how to configure. These machines have no DHCP by ` +
`design, so that wait never ends — give the link a .network unit rather than an ` +
`address set by hand.\n`
: "") +
` systemd is waiting on:\n${jobs.trim() || " (it said nothing)"}`,
);
}
// --- the mesh's own images ----------------------------------------------------------------------
/**
* Which machine is handed which of the mesh's own images.
*
* A machine that says nothing gets the lot, which is right for a one-machine bed. A machine that
* lists some gets those. This is where a workstation running one small module stops paying for
* forty runtimes it will never start.
*/
export function planHeldImages(scenario: Scenario): { machine: string; images: string[] }[] {
const all = scenario.images ?? [];
if (all.length === 0) return [];
return Object.entries(scenario.machines)
.map(([machine, spec]) => ({ machine, images: spec.images ?? all }))
.filter((plan) => plan.images.length > 0);
}
/**
* Put the mesh's own images onto the machines that need them, and say what they are now called.
*
* **This is what replaced the lab's registry**, and the difference is the whole point. A registry
* inside the scenario served every image — third-party ones included — from an address that exists
* in no production mesh, so a bootstrap that could only work against it went green here and would
* have failed anywhere else. There is no such registry now: third-party images are pulled from the
* internet over each machine's `egress` uplink, and the mesh's own arrive the way they arrive on an
* operator's machine — somebody built them and put them there.
*
* The reference a declaration then uses is the image's **own ID**, the digest of its configuration.
* `docker load` preserves it, so the name is identical on the workstation that built the image and
* on every machine handed a copy — immutable, unforgeable, and requiring nothing to have served it
* (mesh-host, *an image may be named by the digest of its own configuration*).
*
* Read back on both sides. The ID is taken from the workstation and then CONFIRMED on the machine,
* because a load that lands a different image than the one exported is exactly the silent fault
* this lab exists to catch — and the reference is what every manifest will be rewritten to.
*/
export async function loadHeldImages(
scenario: Scenario,
machineNames: Map<string, string>,
log: (message: string) => void = () => {},
): Promise<HeldImage[]> {
const plans = planHeldImages(scenario);
if (plans.length === 0) return [];
const held: HeldImage[] = [];
// Validate everything first, so a scenario naming something it should not is refused before any
// machine is touched rather than halfway through a transfer.
for (const requested of scenario.images ?? []) {
if (!mustBeHandedOver(requested)) {
throw new Error(
`images: '${requested}' is not one of the mesh's own images. It is pulled from the ` +
`internet by the machine that needs it, not loaded from this workstation.`,
);
}
const onThisWorkstation = (await local(
"docker", ["image", "inspect", "--format", "{{.Id}}", requested], 60_000,
)).stdout.trim();
if (!/^sha256:[0-9a-f]{64}$/.test(onThisWorkstation)) {
throw new Error(
`${requested} is not on this workstation, so there is nothing to hand the machines.\n` +
` It is one of the mesh's own images and exists in no registry — nothing can pull it.\n` +
` Build it first (mesh-controller's \`make image …\`, or scripts/build-module-runtime.sh).`,
);
}
}
// **One archive per machine, not one per image.**
//
// These images are almost entirely the same bytes: the base they share is 227 MB and a module's
// own code is a few. Exported one at a time, that base is written, pushed and loaded once per
// image — for a bed stocking thirty-odd of them, the same 227 MB crosses thirty-odd times, and
// measured on this workstation the whole set came to 9.7 GB. Exported together it is written
// once: two images alone dedupe by 45%, and the saving grows with the count.
//
// This is the slowest thing a raise does, and a four-machine bed has been exceeding its own
// ninety-minute limit while still copying.
const perMachine = new Map<string, string[]>();
for (const requested of scenario.images ?? []) {
for (const plan of plans) {
if (!plan.images.includes(requested)) continue;
const list = perMachine.get(plan.machine) ?? [];
list.push(requested);
perMachine.set(plan.machine, list);
}
}
// What each machine ended up calling each image, so the disagreement check below still sees
// every machine's answer for every image.
const reported = new Map<string, Map<string, string>>();
for (const [machine, images] of perMachine) {
const name = machineNames.get(machine);
if (!name || images.length === 0) continue;
const tar = join(tmpdir(), `mesh-lab-held-${process.pid}-${Date.now()}.tar`);
const saved = await local("docker", ["save", ...images, "-o", tar], 1_800_000);
if (!saved.ok) {
await unlink(tar).catch(() => {});
throw new Error(
`cannot export ${images.length} image(s) for ${machine} from this workstation: ` +
saved.stderr.trim(),
);
}
try {
await waitForRuntime(name, machine);
await incus(["file", "push", tar, `${name}/tmp/held.tar`], 1_800_000);
const loaded = await incusOk(
["exec", name, "--", "docker", "load", "-i", "/tmp/held.tar"], 1_800_000,
);
if (!loaded?.includes("Loaded image")) {
throw new PlacementError(
machine,
`${images.length} image(s) were pushed to ${machine} and did not load.\n` +
` The runtime said: ${loaded?.trim() || "nothing"}`,
);
}
for (const requested of images) {
// What this machine calls it, asked of the tag it was just loaded under. An image id is
// the digest of a configuration the receiving runtime rewrites, so it is read back rather
// than predicted from here.
const there = (await incusOk(
["exec", name, "--", "docker", "image", "inspect", "--format", "{{.Id}}", requested],
120_000,
))?.trim() ?? "";
if (!/^sha256:[0-9a-f]{64}$/.test(there)) {
throw new PlacementError(
machine,
`${requested} loaded onto ${machine} and the runtime will not say what it holds.\n` +
` It answered '${there || "nothing"}'.\n` +
` Nothing serves this image, so a manifest naming it has only what the machine ` +
`itself reports — and there is nothing to fall back to.`,
);
}
const seen = reported.get(requested) ?? new Map<string, string>();
seen.set(machine, there);
reported.set(requested, seen);
}
await succeeds(["exec", name, "--", "rm", "-f", "/tmp/held.tar"], 60_000);
} finally {
await unlink(tar).catch(() => {});
}
}
// **A manifest carries one reference, so the machines must agree on it.** They are built from one
// base and load one archive, so they do; if that ever stops being true the image cannot be named
// from a catalogue at all, and that is worth stopping for rather than rewriting to whichever
// machine answered last.
for (const requested of scenario.images ?? []) {
const seen = reported.get(requested);
if (!seen || seen.size === 0) continue;
const entries = [...seen];
const first = entries[0];
if (!first) continue;
const [firstMachine, reference] = first;
for (const [machine, there] of seen) {
if (there === reference) continue;
throw new PlacementError(
machine,
`${requested} is ${there} on ${machine} and ${reference} on ${firstMachine}.\n` +
` One manifest cannot name both, and this image exists in no registry to be named by ` +
`instead. The machines' runtimes differ in a way that changes how they store what they ` +
`are given.`,
);
}
held.push({ requested, repository: repositoryOf(requested), reference });
log(` ${requested} → ${reference.slice(0, 19)}… on ${[...seen.keys()].join(", ")}`);
}
return held;
}
/**
* Install the host's systemd packaging, so the installer supervises it as a service rather than a
* background process — the path a real machine takes, and the only one that survives a reboot. The
* lab places the binary at HOST_PATH; the shipped launcher runs $MESH_HOST_BIN (default
* /usr/bin/nox-mesh-host), so a symlink points that at the binary rather than editing the packaged
* unit. The installer's --host-service then starts AND enables it (novox/hq ADR 0005).
*/
export async function installHostService(
instanceName: string,
machine: string,
packagingDir: string,
logMessage: (message: string) => void = () => {},
): Promise<void> {
const launcher = join(packagingDir, "nox-mesh-host-launch");
const unit = join(packagingDir, "nox-mesh-host.service");
await incus(["exec", instanceName, "--", "mkdir", "-p", "/usr/lib/nox-mesh-host"], 60_000);
await incus(["file", "push", launcher, `${instanceName}/usr/lib/nox-mesh-host/launch`, "--mode", "0755"], 120_000);
await incus(["file", "push", unit, `${instanceName}/etc/systemd/system/nox-mesh-host.service`, "--mode", "0644"], 120_000);
await incus(["exec", instanceName, "--", "ln", "-sf", HOST_PATH, "/usr/bin/nox-mesh-host"], 60_000);
await incus(["exec", instanceName, "--", "systemctl", "daemon-reload"], 60_000);
logMessage(`installed nox-mesh-host.service on ${machine}`);
}