diff --git a/tools/zflash/test-harness/extensions.test.ts b/tools/zflash/test-harness/extensions.test.ts new file mode 100644 index 0000000000..202e5d668a --- /dev/null +++ b/tools/zflash/test-harness/extensions.test.ts @@ -0,0 +1,198 @@ +/** + * tools/zflash/test-harness/extensions.test.ts + * + * Tests for B-0891 extensions — substrate-engineering substrate primitives + * for scenarios 3 (reformat-with-retention), 4 (reformat-from-scratch), + * and 5 (cluster-joining). + * + * Validates type-level + value-level correctness of: + * - PersistedKVSubstrate discriminated union (4 variants) + * - PathForkSubstrate + PathForkVariant + PathForkComparison + * - MultiVMOrchestrationSubstrate + VMSpec + NetworkTopology + JoinProtocol + OrchestratorKind + * - ImplDesignStatus discriminated union + * - SCENARIO_IMPL_DESIGN mapping + * - computeImplDesignProgress aggregator + */ + +import { describe, expect, test } from "bun:test"; +import { + DEFAULT_MULTI_VM, + DEFAULT_PATH_FORK, + DEFAULT_PERSISTED_KV, + SCENARIO_IMPL_DESIGN, + computeImplDesignProgress, + type ImplDesignStatus, + type JoinProtocol, + type NetworkTopology, + type OrchestratorKind, + type PathForkComparison, + type PersistedKVSubstrate, + type VMSpec, +} from "./extensions"; + +describe("PersistedKVSubstrate (scenario 3)", () => { + test("DEFAULT_PERSISTED_KV is qcow2-snapshot-restore variant", () => { + expect(DEFAULT_PERSISTED_KV.kind).toBe("qcow2-snapshot-restore"); + if (DEFAULT_PERSISTED_KV.kind === "qcow2-snapshot-restore") { + expect(DEFAULT_PERSISTED_KV.baseImage).toContain("qcow2"); + expect(DEFAULT_PERSISTED_KV.snapshotName).toBe("post-initial-format"); + } + }); + + test("PersistedKVSubstrate variants are exhaustive", () => { + const variants: PersistedKVSubstrate[] = [ + { kind: "qemu-virtual-disk-overlay", overlayPath: "/tmp/x", notes: "n" }, + { kind: "qemu-tpm-emulator", tpmStatePath: "/tmp/t", tpmVersion: "2.0", notes: "n" }, + { kind: "file-system-bind-mount", hostPath: "/h", guestPath: "/g", notes: "n" }, + { kind: "qcow2-snapshot-restore", baseImage: "/b", snapshotName: "s", notes: "n" }, + ]; + expect(variants).toHaveLength(4); + }); +}); + +describe("PathForkSubstrate (scenario 4)", () => { + test("DEFAULT_PATH_FORK has both forks", () => { + expect(DEFAULT_PATH_FORK.forks).toHaveLength(2); + const forkIds = DEFAULT_PATH_FORK.forks.map((f) => f.forkId); + expect(forkIds).toContain("migrate-existing-creds"); + expect(forkIds).toContain("fresh-cluster"); + }); + + test("DEFAULT_PATH_FORK comparison is both-must-pass", () => { + expect(DEFAULT_PATH_FORK.comparisonStrategy.kind).toBe("both-must-pass"); + }); + + test("PathForkComparison variants are exhaustive", () => { + const comparisons: PathForkComparison[] = [ + { kind: "both-must-pass" }, + { kind: "exactly-one-passes", reasonsOtherFails: "x" }, + { kind: "outcomes-equivalent", equivalencePredicate: "y" }, + ]; + expect(comparisons).toHaveLength(3); + }); + + test("each fork has preconditions + testInvocation + expectedOutcome", () => { + for (const fork of DEFAULT_PATH_FORK.forks) { + expect(fork.preconditions.length).toBeGreaterThan(0); + expect(fork.testInvocation.length).toBeGreaterThan(0); + expect(fork.expectedOutcome.length).toBeGreaterThan(0); + } + }); +}); + +describe("MultiVMOrchestrationSubstrate (scenario 5)", () => { + test("DEFAULT_MULTI_VM has cluster-existing + joining-node VMs", () => { + expect(DEFAULT_MULTI_VM.vms).toHaveLength(2); + const roles = DEFAULT_MULTI_VM.vms.map((v) => v.role); + expect(roles).toContain("cluster-existing"); + expect(roles).toContain("joining-node"); + }); + + test("DEFAULT_MULTI_VM uses shared-bridge network", () => { + expect(DEFAULT_MULTI_VM.networkTopology.kind).toBe("shared-bridge"); + }); + + test("DEFAULT_MULTI_VM uses credential-provisioning join protocol", () => { + expect(DEFAULT_MULTI_VM.joinProtocol.kind).toBe("credential-provisioning"); + }); + + test("NetworkTopology variants are exhaustive", () => { + const topologies: NetworkTopology[] = [ + { kind: "shared-bridge", bridgeName: "br0" }, + { kind: "vlan-isolated", vlanId: 100, gatewayVm: "gw" }, + { kind: "host-only", subnet: "10.0.0.0/24" }, + ]; + expect(topologies).toHaveLength(3); + }); + + test("JoinProtocol variants are exhaustive", () => { + const protocols: JoinProtocol[] = [ + { kind: "cluster-state-discovery", discoveryEndpoint: "x" }, + { kind: "explicit-join-token", tokenSource: "y" }, + { kind: "credential-provisioning", credPickerEndpoint: "z" }, + ]; + expect(protocols).toHaveLength(3); + }); + + test("OrchestratorKind variants are exhaustive", () => { + const orchestrators: OrchestratorKind[] = [ + { kind: "qemu-shell-scripts" }, + { kind: "libvirt", uri: "qemu:///system" }, + { kind: "docker-compose", composeFile: "x.yml" }, + { kind: "k8s-virt", namespace: "test" }, + ]; + expect(orchestrators).toHaveLength(4); + }); + + test("VMSpec has all required fields", () => { + const vm: VMSpec = { + name: "test", + role: "joining-node", + bootMedia: "iso-fresh", + bootMediaRef: "/path/to/iso", + memoryMB: 2048, + vcpus: 2, + }; + expect(vm.memoryMB).toBeGreaterThan(0); + expect(vm.vcpus).toBeGreaterThan(0); + }); +}); + +describe("ImplDesignStatus + SCENARIO_IMPL_DESIGN", () => { + test("ImplDesignStatus variants are exhaustive", () => { + const statuses: ImplDesignStatus[] = [ + { kind: "design-spec-complete", specRef: "x" }, + { kind: "design-spec-pending", nextStep: "y" }, + { kind: "blocked-on-upstream", upstreamRef: "z" }, + ]; + expect(statuses).toHaveLength(3); + }); + + test("SCENARIO_IMPL_DESIGN covers all 3 scaffolded scenarios", () => { + expect(SCENARIO_IMPL_DESIGN["reformat-with-retention"]).toBeDefined(); + expect(SCENARIO_IMPL_DESIGN["reformat-from-scratch"]).toBeDefined(); + expect(SCENARIO_IMPL_DESIGN["cluster-joining"]).toBeDefined(); + }); + + test("All 3 scaffolded scenarios have design-spec-complete status", () => { + const statuses = Object.values(SCENARIO_IMPL_DESIGN); + for (const status of statuses) { + expect(status.kind).toBe("design-spec-complete"); + } + }); + + test("computeImplDesignProgress reports 3 design-complete", () => { + const progress = computeImplDesignProgress(); + expect(progress.total).toBe(3); + expect(progress.designComplete).toBe(3); + expect(progress.designPending).toBe(0); + expect(progress.blockedOnUpstream).toBe(0); + }); +}); + +describe("Composes-with substrate-engineering substrate", () => { + test("DEFAULT_PERSISTED_KV references qcow2 (composes with existing tools/ci/ substrate)", () => { + if (DEFAULT_PERSISTED_KV.kind === "qcow2-snapshot-restore") { + expect(DEFAULT_PERSISTED_KV.baseImage).toContain("qcow2"); + expect(DEFAULT_PERSISTED_KV.notes).toContain("baseline"); + } + }); + + test("DEFAULT_PATH_FORK forks reference operator-choice substrate-engineering", () => { + for (const fork of DEFAULT_PATH_FORK.forks) { + expect(fork.preconditions.join(" ")).toContain("operator chooses"); + } + }); + + test("DEFAULT_MULTI_VM cluster-existing VM uses qcow2-snapshot (composes with scenario 3 baseline)", () => { + const existing = DEFAULT_MULTI_VM.vms.find((v) => v.role === "cluster-existing"); + expect(existing).toBeDefined(); + expect(existing?.bootMedia).toBe("qcow2-snapshot"); + }); + + test("DEFAULT_MULTI_VM joining-node VM uses iso-fresh boot media", () => { + const joining = DEFAULT_MULTI_VM.vms.find((v) => v.role === "joining-node"); + expect(joining).toBeDefined(); + expect(joining?.bootMedia).toBe("iso-fresh"); + }); +}); diff --git a/tools/zflash/test-harness/extensions.ts b/tools/zflash/test-harness/extensions.ts new file mode 100644 index 0000000000..f982da4e7d --- /dev/null +++ b/tools/zflash/test-harness/extensions.ts @@ -0,0 +1,286 @@ +/** + * tools/zflash/test-harness/extensions.ts + * + * B-0891 — substrate-engineering substrate primitives that extend the + * scaffolded scenarios (3, 4, 5) from "blocked-on-X" status to + * "impl-design-spec'd" with concrete typed primitives. + * + * Composes with the typestate-DU substrate cluster shipped today: + * - asymmetric-authorship rule (PR #5516): each substrate-entity + * AUTHORS its consent-channel via TFeedback variants + * - monad-propagation-pattern rule (PR #5511): cross-language Result shape + * - IMPLICIT-NOT-EXPLICIT rule (PR #5811): every substrate-class gets + * explicit DU variant + * - particle-as-locus rule (PR #5846): every substrate carries + * (wavefunction-substrate, particle-locus) pair; primitives here + * define the wavefunction-substrate; runtime values are particle-loci + * - parallelizability-test rule (PR #5845): each primitive defined + * INDEPENDENTLY for visualizable + parallelizable navigation + * + * Substrate-engineering scope: this file SPECS the impl-design primitives. + * Runtime QEMU integration (actually persisting state across boots, + * forking test paths, orchestrating multi-VM) remains pending — but the + * substrate-engineering substrate-shape is now substantively-defined + + * composable + testable at type-level. + */ + +// ============================================================================= +// SCENARIO 3 — reformat-with-retention: state-preservation primitive substrate +// ============================================================================= + +/** + * PersistedKVSubstrate — discriminated union of state-preservation + * mechanisms suitable for QEMU test-harness use. Each variant represents + * a distinct substrate-engineering substrate for preserving credentials + + * keys + auth state across QEMU boot cycles. + * + * Per IMPLICIT-NOT-EXPLICIT rule: each variant is explicit; the + * substrate-engineering choice between variants is operator-explicit at + * test-configuration time (not implicit defaulting). + */ +export type PersistedKVSubstrate = + | { + readonly kind: "qemu-virtual-disk-overlay"; + readonly overlayPath: string; + readonly notes: string; + } + | { + readonly kind: "qemu-tpm-emulator"; + readonly tpmStatePath: string; + readonly tpmVersion: "1.2" | "2.0"; + readonly notes: string; + } + | { + readonly kind: "file-system-bind-mount"; + readonly hostPath: string; + readonly guestPath: string; + readonly notes: string; + } + | { + readonly kind: "qcow2-snapshot-restore"; + readonly baseImage: string; + readonly snapshotName: string; + readonly notes: string; + }; + +/** + * Default PersistedKVSubstrate for scenario 3 — qcow2 snapshot-restore + * is the substrate-engineering canonical choice because: + * - works with QEMU out-of-box (no swtpm or 9p setup required) + * - byte-exact state preservation + * - composes with existing tools/ci/qemu-full-install-test.ts + * substrate-engineering substrate (qcow2 already used per ISO testing) + */ +export const DEFAULT_PERSISTED_KV: PersistedKVSubstrate = { + kind: "qcow2-snapshot-restore", + baseImage: "/tmp/zflash-test-baseline.qcow2", + snapshotName: "post-initial-format", + notes: + "Baseline image created after scenario 1 (initial-format); reformat-with-retention scenarios snapshot-restore from this baseline + run reformat scenarios; restore-to-baseline between runs ensures clean state.", +}; + +// ============================================================================= +// SCENARIO 4 — reformat-from-scratch: path-fork substrate +// ============================================================================= + +/** + * PathForkSubstrate — discriminated union of path-fork mechanisms for + * scenarios that test SAME-starting-state-DIFFERENT-operator-choice. + * + * Per asymmetric-authorship rule: operator AUTHORS the path-choice + * substrate at test-configuration time; test-harness ACKNOWLEDGES by + * running both paths from same starting state + comparing outcomes. + */ +export type PathForkSubstrate = { + readonly startingStateRef: string; // qcow2 snapshot OR ISO path + readonly forks: ReadonlyArray; + readonly comparisonStrategy: PathForkComparison; +}; + +export type PathForkVariant = { + readonly forkName: string; + readonly forkId: "migrate-existing-creds" | "fresh-cluster"; + readonly preconditions: ReadonlyArray; + readonly testInvocation: string; + readonly expectedOutcome: string; +}; + +export type PathForkComparison = + | { kind: "both-must-pass" } // both forks pass independently + | { kind: "exactly-one-passes"; reasonsOtherFails: string } // only one valid given starting state + | { kind: "outcomes-equivalent"; equivalencePredicate: string }; // both reach same end-state via different path + +/** + * Default PathForkSubstrate for scenario 4. + */ +export const DEFAULT_PATH_FORK: PathForkSubstrate = { + startingStateRef: "/tmp/zflash-test-baseline.qcow2", // baseline from scenario 1 + forks: [ + { + forkName: "migrate-existing-credentials-to-new-USB", + forkId: "migrate-existing-creds", + preconditions: [ + "existing cluster running with credentials (scenario 2 success)", + "operator chooses migrate path at zflash invocation", + ], + testInvocation: + "zflash --reformat --migrate-credentials --existing-cluster ", + expectedOutcome: + "new USB UUID + existing credential set; existing cluster recognizes new USB; cluster state preserved", + }, + { + forkName: "start-fresh-cluster-with-new-keys", + forkId: "fresh-cluster", + preconditions: [ + "operator chooses fresh path at zflash invocation", + "no migration of existing cluster credentials", + ], + testInvocation: "zflash --reformat --fresh-cluster", + expectedOutcome: + "new USB UUID + new credentials + new cluster identity; old cluster orphaned (operator-aware)", + }, + ], + comparisonStrategy: { kind: "both-must-pass" }, +}; + +// ============================================================================= +// SCENARIO 5 — cluster-joining: multi-VM orchestration substrate +// ============================================================================= + +/** + * MultiVMOrchestrationSubstrate — discriminated union of multi-VM + * coordination substrate for scenarios that test cluster joining. + * + * Per particle-as-locus rule: each VM is a particle-locus in the + * cluster-substrate wavefunction; the orchestration substrate defines + * how multiple loci coordinate (network topology + join-protocol). + */ +export type MultiVMOrchestrationSubstrate = { + readonly vms: ReadonlyArray; + readonly networkTopology: NetworkTopology; + readonly joinProtocol: JoinProtocol; + readonly orchestrator: OrchestratorKind; +}; + +export type VMSpec = { + readonly name: string; + readonly role: "cluster-existing" | "joining-node"; + readonly bootMedia: "qcow2-snapshot" | "iso-fresh"; + readonly bootMediaRef: string; + readonly memoryMB: number; + readonly vcpus: number; +}; + +export type NetworkTopology = + | { kind: "shared-bridge"; bridgeName: string } + | { kind: "vlan-isolated"; vlanId: number; gatewayVm: string } + | { kind: "host-only"; subnet: string }; + +export type JoinProtocol = + | { kind: "cluster-state-discovery"; discoveryEndpoint: string } + | { kind: "explicit-join-token"; tokenSource: string } + | { kind: "credential-provisioning"; credPickerEndpoint: string }; + +export type OrchestratorKind = + | { kind: "qemu-shell-scripts" } // PoC: bash spawning qemu-system-x86_64 processes + | { kind: "libvirt"; uri: string } // libvirt for cleaner lifecycle management + | { kind: "docker-compose"; composeFile: string } // dockerized QEMU for CI portability + | { kind: "k8s-virt"; namespace: string }; // KubeVirt for cluster-scale parallel testing + +/** + * Default MultiVMOrchestrationSubstrate for scenario 5. + */ +export const DEFAULT_MULTI_VM: MultiVMOrchestrationSubstrate = { + vms: [ + { + name: "cluster-existing", + role: "cluster-existing", + bootMedia: "qcow2-snapshot", + bootMediaRef: "/tmp/zflash-test-baseline.qcow2", + memoryMB: 2048, + vcpus: 2, + }, + { + name: "joining-node", + role: "joining-node", + bootMedia: "iso-fresh", + bootMediaRef: "", + memoryMB: 2048, + vcpus: 2, + }, + ], + networkTopology: { kind: "shared-bridge", bridgeName: "zflash-test-br0" }, + joinProtocol: { + kind: "credential-provisioning", + credPickerEndpoint: "http://cluster-existing:8080/cred-pick", + }, + orchestrator: { kind: "qemu-shell-scripts" }, +}; + +// ============================================================================= +// IMPLEMENTATION-DESIGN STATUS — moves scenarios 3/4/5 forward +// ============================================================================= + +/** + * ImplDesignStatus — discriminated union tracking impl-design completeness + * per scenario. Distinct from runtime ImplStatus (composes-with-existing / + * scaffolded / operator-runtime) in scenarios.ts. + * + * Per IMPLICIT-NOT-EXPLICIT rule: design-completeness is EXPLICIT; + * scaffolded scenarios get a sharper status reflecting impl-design work. + */ +export type ImplDesignStatus = + | { kind: "design-spec-complete"; specRef: string } // primitives defined; QEMU integration spec'd + | { kind: "design-spec-pending"; nextStep: string } // primitives need defining + | { kind: "blocked-on-upstream"; upstreamRef: string }; // depends on something not yet ready + +/** + * Per-scenario impl-design status mapping. Updated when impl-design work + * progresses; scenarios.ts ImplStatus reflects RUNTIME state (can-run-now + * vs scaffolded); this reflects DESIGN-SPEC state (design-spec-complete + * vs design-spec-pending). + */ +export const SCENARIO_IMPL_DESIGN: Record< + "reformat-with-retention" | "reformat-from-scratch" | "cluster-joining", + ImplDesignStatus +> = { + "reformat-with-retention": { + kind: "design-spec-complete", + specRef: + "extensions.ts PersistedKVSubstrate + DEFAULT_PERSISTED_KV (qcow2 snapshot-restore)", + }, + "reformat-from-scratch": { + kind: "design-spec-complete", + specRef: + "extensions.ts PathForkSubstrate + DEFAULT_PATH_FORK (migrate-creds + fresh-cluster forks)", + }, + "cluster-joining": { + kind: "design-spec-complete", + specRef: + "extensions.ts MultiVMOrchestrationSubstrate + DEFAULT_MULTI_VM (shared-bridge + credential-provisioning)", + }, +}; + +/** + * computeImplDesignProgress — returns how many scaffolded scenarios have + * design-spec-complete status. Operationally useful for tracking + * substrate-engineering progress. + */ +export function computeImplDesignProgress(): { + readonly total: number; + readonly designComplete: number; + readonly designPending: number; + readonly blockedOnUpstream: number; +} { + const statuses = Object.values(SCENARIO_IMPL_DESIGN); + return { + total: statuses.length, + designComplete: statuses.filter((s) => s.kind === "design-spec-complete") + .length, + designPending: statuses.filter((s) => s.kind === "design-spec-pending") + .length, + blockedOnUpstream: statuses.filter((s) => s.kind === "blocked-on-upstream") + .length, + }; +} diff --git a/tools/zflash/test-harness/run.ts b/tools/zflash/test-harness/run.ts index 4299f1f1ec..7814a78cac 100644 --- a/tools/zflash/test-harness/run.ts +++ b/tools/zflash/test-harness/run.ts @@ -43,6 +43,7 @@ import { spawnSync } from "node:child_process"; import { existsSync } from "node:fs"; import { resolve } from "node:path"; import { SCENARIOS, validateScenarios, findScenario, type Scenario, type ScenarioId } from "./scenarios"; +import { SCENARIO_IMPL_DESIGN, computeImplDesignProgress } from "./extensions"; type Mode = "list" | "dry-run" | "scenario" | "all"; @@ -105,13 +106,23 @@ function emitListing(): void { { rowId: "B-0891", scenarioCount: SCENARIOS.length, - scenarios: SCENARIOS.map((s) => ({ - orderIndex: s.orderIndex, - id: s.id, - title: s.title, - status: s.status, - gates: s.gates, - })), + implDesignProgress: computeImplDesignProgress(), + scenarios: SCENARIOS.map((s) => { + const implDesign = + s.id === "reformat-with-retention" || + s.id === "reformat-from-scratch" || + s.id === "cluster-joining" + ? SCENARIO_IMPL_DESIGN[s.id] + : undefined; + return { + orderIndex: s.orderIndex, + id: s.id, + title: s.title, + status: s.status, + gates: s.gates, + implDesign, + }; + }), }, null, 2,