**The adoption.** Software nobody here wrote, taking its credentials the way such software does — from its environment — and needing two containers that reach each other by name. The first module that could not have been declared this morning: it needs the network shape and it needs a sealed value to reach a container's environment. Its password is accepted rather than generated, which is the whole shape of an adoption: a service that already exists keeps the credential it already has. Asserted properly — a wrong password is refused by the same database, so the passing case means something. **The warm scenario.** A mesh kept between runs and returned to, which turned twelve minutes of bootstrap into thirty seconds of restore. Off unless asked for: a run that is meant to mean something raises from nothing. Its guard fired for real during this work, unprompted — a mesh-host commit landed and it refused the stale base, naming both commits, rather than passing tests against yesterday's binary. That is 04-ISSUES/005's rule one level down. Three things the guard learned the hard way and now handles: a snapshot captures disk and not memory, so the host is restarted after a restore and asserted to have come back; the stocked image digests are worked out while raising and a restored instance never raises, so they are kept; and comparing only the repositories this run can see clears the ones it cannot, so both directions are compared. The one real bug behind five failed attempts was in mesh-host and it reported itself precisely: a network shape the language had and no host implemented. Everything else was scaffolding of mine.
211 lines
7.4 KiB
TypeScript
211 lines
7.4 KiB
TypeScript
/**
|
|
* A scenario kept between runs, already brought to a state worth starting from.
|
|
*
|
|
* **Bootstrapping a mesh takes minutes and proves the same thing every time.** The tests worth
|
|
* iterating on are the ones after it — assigning a module, adopting a workload, watching something
|
|
* fail. A warm instance is raised once, brought to that state, snapshotted, and restored on every
|
|
* later run in seconds.
|
|
*
|
|
* **The danger is precisely the one 04-ISSUES/005 is about**, one level down: a mesh snapshotted
|
|
* against yesterday's binaries will pass today's tests and report green, and nothing about the
|
|
* result would say what it was actually run against. So a warm instance records the commits it was
|
|
* built from, and is refused — not silently rebuilt, refused — when they have moved.
|
|
*
|
|
* **Fresh stays the default.** This is for iterating. A run that is meant to mean something raises
|
|
* from nothing, because "it passes" must not quietly come to mean "it passes against a mesh
|
|
* somebody bootstrapped last week".
|
|
*/
|
|
|
|
import { readFileSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
|
|
import { dirname, join } from "node:path";
|
|
import { homedir } from "node:os";
|
|
|
|
import { list, restore, snapshot, snapshots, destroy } from "./lifecycle/operate.ts";
|
|
import type { Against } from "./lastrun.ts";
|
|
import { whatWasTested } from "./lastrun.ts";
|
|
|
|
/** The state a warm instance is kept at. One label, because a second is a state nobody named. */
|
|
export const label = "warm";
|
|
|
|
export interface Warm {
|
|
scenario: string;
|
|
instanceId: string;
|
|
/**
|
|
* The image references the scenario's registry serves, pinned by digest.
|
|
*
|
|
* Kept because they are worked out while raising and a restored instance never raises. Without
|
|
* them a warm run knows nothing about what it can pull, and every test naming an image fails
|
|
* for a reason that has nothing to do with what it was testing.
|
|
*/
|
|
images: string[];
|
|
/** The commit each repository was at when this was brought to its state. */
|
|
against: Against;
|
|
at: string;
|
|
}
|
|
|
|
/** Where the record lives: XDG state, beside the run receipt, for the same reason. */
|
|
export function recordPath(): string {
|
|
const state = process.env["XDG_STATE_HOME"] ?? join(homedir(), ".local", "state");
|
|
return join(state, "mesh-lab", "warm.json");
|
|
}
|
|
|
|
export function remember(warm: Warm): void {
|
|
const path = recordPath();
|
|
mkdirSync(dirname(path), { recursive: true });
|
|
writeFileSync(path, JSON.stringify(warm, null, 2) + "\n");
|
|
}
|
|
|
|
export function remembered(): Warm | null {
|
|
try {
|
|
return JSON.parse(readFileSync(recordPath(), "utf8")) as Warm;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function forget(): void {
|
|
rmSync(recordPath(), { force: true });
|
|
}
|
|
|
|
export type Verdict =
|
|
| { use: "restore"; instanceId: string }
|
|
| { use: "raise"; why: string };
|
|
|
|
/**
|
|
* judge decides whether a remembered instance may be restored.
|
|
*
|
|
* **Every reason to refuse is a reason a test would otherwise pass while meaning nothing**, so
|
|
* each is named rather than collapsed into "not usable".
|
|
*/
|
|
export function judge(
|
|
warm: Warm | null,
|
|
scenario: string,
|
|
standing: string[],
|
|
hasSnapshot: boolean,
|
|
against: Against,
|
|
): Verdict {
|
|
if (!warm) return { use: "raise", why: "nothing is being kept warm" };
|
|
if (warm.scenario !== scenario) {
|
|
return { use: "raise", why: `what is kept warm is ${warm.scenario}, and this is ${scenario}` };
|
|
}
|
|
if (!standing.includes(warm.instanceId)) {
|
|
return { use: "raise", why: `${warm.instanceId} is no longer standing` };
|
|
}
|
|
if (!hasSnapshot) {
|
|
return { use: "raise", why: `${warm.instanceId} has no ${label} snapshot to return to` };
|
|
}
|
|
// The check that keeps this honest. A mesh built from code that has since moved would pass
|
|
// today's tests against yesterday's binaries, and say nothing about it.
|
|
//
|
|
// **Both directions, because comparing only what is in front of you clears what is not.** The
|
|
// first version walked the current repositories alone, so running without the environment that
|
|
// names where they are compared nothing and reported the mesh usable — a warm instance built
|
|
// from code that had since moved, cleared by a check that had looked at neither. That is
|
|
// 04-ISSUES/005's rule again: a record that says nothing about something is not a record that
|
|
// clears it.
|
|
for (const name of new Set([...Object.keys(warm.against), ...Object.keys(against)])) {
|
|
const then = warm.against[name];
|
|
const now = against[name];
|
|
if (then === now) continue;
|
|
if (!now) {
|
|
return {
|
|
use: "raise",
|
|
why: `${name} was at ${then} when this was warmed, and nothing says where it is now — ` +
|
|
`so nothing can say whether it moved`,
|
|
};
|
|
}
|
|
return {
|
|
use: "raise",
|
|
why: `${name} was at ${then ?? "nothing recorded"} when this was warmed, ` +
|
|
`and is now at ${now}`,
|
|
};
|
|
}
|
|
return { use: "restore", instanceId: warm.instanceId };
|
|
}
|
|
|
|
/** What is standing right now, by instance. */
|
|
export async function standingNow(): Promise<string[]> {
|
|
return (await list()).map((i) => i.instanceId);
|
|
}
|
|
|
|
/**
|
|
* ready returns an instance already at its warm state, or says why one must be raised.
|
|
*
|
|
* It never raises: raising needs a scenario, images and a bootstrap, and all of that belongs to
|
|
* whoever is using this rather than here.
|
|
*/
|
|
export async function ready(
|
|
scenario: string,
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
): Promise<Verdict> {
|
|
const warm = remembered();
|
|
const standing = await standingNow();
|
|
const has = warm ? (await snapshots(warm.instanceId)).includes(label) : false;
|
|
return judge(warm, scenario, standing, has, whatWasTested(env));
|
|
}
|
|
|
|
/** returnTo puts a warm instance back to its state, and says how long it took. */
|
|
export async function returnTo(
|
|
instanceId: string,
|
|
log: (message: string) => void = () => {},
|
|
): Promise<number> {
|
|
const { usableSeconds } = await restore(instanceId, label, 180, log);
|
|
return usableSeconds;
|
|
}
|
|
|
|
/**
|
|
* keep snapshots an instance as the state to come back to, and records what it was built from.
|
|
*
|
|
* Called once the caller has brought the scenario to whatever "ready to work" means for it.
|
|
*/
|
|
export async function keep(
|
|
scenario: string,
|
|
instanceId: string,
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
): Promise<Warm> {
|
|
await snapshot(instanceId, label);
|
|
const warm: Warm = {
|
|
scenario,
|
|
instanceId,
|
|
images: stockOf(instanceId),
|
|
against: whatWasTested(env),
|
|
at: new Date().toISOString(),
|
|
};
|
|
remember(warm);
|
|
return warm;
|
|
}
|
|
|
|
/** cool destroys what is being kept and forgets it. */
|
|
export async function cool(): Promise<string | null> {
|
|
const warm = remembered();
|
|
forget();
|
|
if (!warm) return null;
|
|
if ((await standingNow()).includes(warm.instanceId)) {
|
|
await destroy(warm.instanceId);
|
|
}
|
|
return warm.instanceId;
|
|
}
|
|
|
|
/**
|
|
* What a raised scenario stocked, held until it is kept.
|
|
*
|
|
* Raising works the images out and snapshotting happens later, so this carries them between the
|
|
* two without the caller having to hold them.
|
|
*/
|
|
const stock = new Map<string, string[]>();
|
|
|
|
export function rememberStock(instanceId: string, images: string[]): void {
|
|
stock.set(instanceId, images);
|
|
}
|
|
|
|
function stockOf(instanceId: string): string[] {
|
|
return stock.get(instanceId) ?? [];
|
|
}
|
|
|
|
/** What a restored instance's registry serves, from when it was warmed. */
|
|
export function warmStock(instanceId: string): { images: string[] } {
|
|
const warm = remembered();
|
|
if (!warm || warm.instanceId !== instanceId) return { images: [] };
|
|
return { images: warm.images };
|
|
}
|