@botiverse/k-carrier 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +24 -0
- package/README.md +62 -0
- package/core/src/artifact/README.md +1 -0
- package/core/src/artifact/collectStream.ts +49 -0
- package/core/src/artifact/download.ts +300 -0
- package/core/src/artifact/errors.ts +24 -0
- package/core/src/artifact/partialPath.ts +18 -0
- package/core/src/artifact/source.ts +59 -0
- package/core/src/artifact/staticManifestSource.ts +185 -0
- package/core/src/artifact/swap.ts +21 -0
- package/core/src/clock.ts +22 -0
- package/core/src/converge/lifecycle.ts +114 -0
- package/core/src/converge/predicates.ts +57 -0
- package/core/src/converge/report.ts +38 -0
- package/core/src/createUpgrader.ts +300 -0
- package/core/src/drive/README.md +1 -0
- package/core/src/index.ts +17 -0
- package/core/src/invariants.ts +243 -0
- package/core/src/lifecycle/hostAdapter.ts +77 -0
- package/core/src/liveness.ts +80 -0
- package/core/src/platform/README.md +1 -0
- package/core/src/platform/index.ts +11 -0
- package/core/src/platform/ops.ts +53 -0
- package/core/src/platform/posix.ts +99 -0
- package/core/src/platform/windows.ts +73 -0
- package/core/src/policy/README.md +1 -0
- package/core/src/progress.ts +54 -0
- package/core/src/provenance/journal.ts +216 -0
- package/core/src/status/report.ts +76 -0
- package/core/src/status/reportStore.ts +67 -0
- package/core/src/txn/effects.ts +40 -0
- package/core/src/txn/engine.ts +275 -0
- package/core/src/txn/fileEffects.ts +129 -0
- package/core/src/txn/hostCallBudget.ts +28 -0
- package/core/src/txn/lock.ts +124 -0
- package/core/src/txn/state.ts +77 -0
- package/core/src/txn/transitions.ts +49 -0
- package/core/src/upgrade/outcome.ts +53 -0
- package/core/src/upgrade/retire.ts +44 -0
- package/core/src/upgrader.ts +124 -0
- package/docs/design-v1.md +246 -0
- package/docs/harness-design.md +170 -0
- package/docs/integration.md +326 -0
- package/docs/prior-art.md +150 -0
- package/docs/test-plan.md +100 -0
- package/harness/README.md +20 -0
- package/harness/src/adapter/probeChecks.ts +93 -0
- package/harness/src/adapter/releaseKnob.ts +65 -0
- package/harness/src/adapter/serviceChecks.ts +260 -0
- package/harness/src/artifact/checks.ts +232 -0
- package/harness/src/artifact/downloadHoles.ts +300 -0
- package/harness/src/artifact/downloadHolesMutations.ts +170 -0
- package/harness/src/artifact/m1.ts +204 -0
- package/harness/src/artifact/m1Resume.ts +141 -0
- package/harness/src/artifact/m3.ts +296 -0
- package/harness/src/artifact/m3Hosts.ts +241 -0
- package/harness/src/artifact/m4.ts +201 -0
- package/harness/src/artifact/m5.ts +239 -0
- package/harness/src/artifact/m6.ts +274 -0
- package/harness/src/artifact/m6Drive.ts +296 -0
- package/harness/src/artifact/m6DriveMutations.ts +70 -0
- package/harness/src/artifact/m6Mutations.ts +136 -0
- package/harness/src/artifact/m6Status.ts +224 -0
- package/harness/src/artifact-factory/checks.ts +68 -0
- package/harness/src/artifact-factory/demo.ts +70 -0
- package/harness/src/artifact-factory/factory.ts +134 -0
- package/harness/src/artifact-factory/run.ts +107 -0
- package/harness/src/blackbox.ts +284 -0
- package/harness/src/cli.ts +281 -0
- package/harness/src/crash/enumerate.ts +66 -0
- package/harness/src/crash/runner.ts +162 -0
- package/harness/src/examples/checks.ts +286 -0
- package/harness/src/fake-host/checks.ts +188 -0
- package/harness/src/fake-host/daemon-entry.ts +39 -0
- package/harness/src/fake-host/daemon.ts +159 -0
- package/harness/src/fake-host/inproc.ts +256 -0
- package/harness/src/fake-server/manifest.ts +68 -0
- package/harness/src/fake-server/server.ts +205 -0
- package/harness/src/fake-server/store.ts +208 -0
- package/harness/src/fixtures/minimal-adapter.ts +108 -0
- package/harness/src/fixtures/service-adapter.ts +187 -0
- package/harness/src/index.ts +11 -0
- package/harness/src/receipt.ts +83 -0
- package/harness/src/runner.ts +268 -0
- package/harness/src/scenario/processScan.ts +55 -0
- package/harness/src/scenario/sandbox.ts +259 -0
- package/harness/src/scenario/virtualClock.ts +56 -0
- package/harness/src/selfverify/selfVerify.ts +136 -0
- package/harness/src/sim/checks.ts +46 -0
- package/harness/src/sim/corpus.ts +36 -0
- package/harness/src/sim/effectRuntime.ts +113 -0
- package/harness/src/sim/error.ts +14 -0
- package/harness/src/sim/prng.ts +29 -0
- package/harness/src/sim/record.ts +76 -0
- package/harness/src/sim/run.ts +148 -0
- package/harness/src/sim/scheduler.ts +72 -0
- package/harness/src/sim/world.ts +284 -0
- package/harness/src/target.ts +86 -0
- package/harness/src/targetCheck.ts +47 -0
- package/harness/src/teeth/artifact.ts +215 -0
- package/harness/src/teeth/artifactDownload.ts +136 -0
- package/harness/src/teeth/artifactFactory.ts +41 -0
- package/harness/src/teeth/artifactM5.ts +68 -0
- package/harness/src/teeth/artifactM6.ts +264 -0
- package/harness/src/teeth/blackbox.ts +22 -0
- package/harness/src/teeth/checks.ts +278 -0
- package/harness/src/teeth/examples.ts +57 -0
- package/harness/src/teeth/fakeHost.ts +120 -0
- package/harness/src/teeth/index.ts +16 -0
- package/harness/src/teeth/m0.ts +135 -0
- package/harness/src/teeth/registry.ts +267 -0
- package/harness/src/teeth/selfCheck.ts +45 -0
- package/harness/src/teeth/sim.ts +61 -0
- package/package.json +43 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* M6 mutation journals/summaries — the wrong behaviors each tooth's
|
|
3
|
+
* negative control must catch (one per conjunct, the AND-gate rule).
|
|
4
|
+
* Kept separate from m6.ts so both files stay under the line budget.
|
|
5
|
+
*/
|
|
6
|
+
import { promises as fs } from "node:fs";
|
|
7
|
+
import {
|
|
8
|
+
ProvenanceError,
|
|
9
|
+
summarizeProvenance,
|
|
10
|
+
type ProvenanceJournal,
|
|
11
|
+
type ProvenanceEntry,
|
|
12
|
+
type ProvenanceRead,
|
|
13
|
+
type ProvenanceSummary,
|
|
14
|
+
} from "../../../core/src/provenance/journal.ts";
|
|
15
|
+
|
|
16
|
+
/** Mutation journal: reuses an existing seq (history can be collapsed). */
|
|
17
|
+
export function reuseSeqJournal(): ProvenanceJournal {
|
|
18
|
+
const entries: ProvenanceEntry[] = [];
|
|
19
|
+
return {
|
|
20
|
+
async append(entry, explicitSeq) {
|
|
21
|
+
const full: ProvenanceEntry = { seq: explicitSeq ?? 0, who: entry.who, carrier: entry.carrier, when: 0, version: entry.version };
|
|
22
|
+
entries.push(full);
|
|
23
|
+
return full;
|
|
24
|
+
},
|
|
25
|
+
async read() {
|
|
26
|
+
return { kind: "observed", entries };
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Mutation journal: accepts a duplicate seq (rewrites history). */
|
|
32
|
+
export function acceptingJournal(): ProvenanceJournal {
|
|
33
|
+
let seq = -1;
|
|
34
|
+
const entries: ProvenanceEntry[] = [];
|
|
35
|
+
return {
|
|
36
|
+
async append(entry, explicitSeq) {
|
|
37
|
+
const next = explicitSeq ?? seq + 1;
|
|
38
|
+
seq = Math.max(seq, next);
|
|
39
|
+
const full: ProvenanceEntry = { seq: next, who: entry.who, carrier: entry.carrier, when: 0, version: entry.version };
|
|
40
|
+
entries.push(full);
|
|
41
|
+
return full;
|
|
42
|
+
},
|
|
43
|
+
async read() {
|
|
44
|
+
return { kind: "observed", entries };
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Mutation journal: read keeps a torn (unparsed) final line. */
|
|
50
|
+
export function keepTornJournal(inner: ProvenanceJournal, file: string): ProvenanceJournal {
|
|
51
|
+
return {
|
|
52
|
+
append: (e, s) => inner.append(e, s),
|
|
53
|
+
async read() {
|
|
54
|
+
const base = await inner.read();
|
|
55
|
+
if (base.kind !== "observed") return base;
|
|
56
|
+
const text = await fs.readFile(file, "utf8");
|
|
57
|
+
const lines = text.split("\n").filter((l) => l.trim());
|
|
58
|
+
const entries: ProvenanceEntry[] = [];
|
|
59
|
+
for (const line of lines) {
|
|
60
|
+
try {
|
|
61
|
+
entries.push(JSON.parse(line) as ProvenanceEntry);
|
|
62
|
+
} catch {
|
|
63
|
+
// mutation: the torn line is KEPT as an entry — history now
|
|
64
|
+
// contains something that never completed
|
|
65
|
+
entries.push({ seq: 99, who: "torn", carrier: "torn", when: 0, version: "torn" });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { kind: "observed", entries };
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Mutation journal: appends even when the history is unreadable/corrupt —
|
|
74
|
+
* the wrong behavior the tooth guards against. Delegates to the real
|
|
75
|
+
* journal until the corruption, then GUESSES the next seq (the
|
|
76
|
+
* rewrite-under-a-truncated-view attack) instead of refusing. */
|
|
77
|
+
export function appendOnCorruptJournal(file: string, inner: ProvenanceJournal): ProvenanceJournal {
|
|
78
|
+
return {
|
|
79
|
+
async append(entry, explicitSeq) {
|
|
80
|
+
try {
|
|
81
|
+
return await inner.append(entry, explicitSeq);
|
|
82
|
+
} catch (err) {
|
|
83
|
+
if (err instanceof ProvenanceError && err.code === "PROVENANCE_HISTORY_UNREADABLE") {
|
|
84
|
+
const full: ProvenanceEntry = { seq: explicitSeq ?? 0, who: entry.who, carrier: entry.carrier, when: 0, version: entry.version };
|
|
85
|
+
await fs.appendFile(file, `${JSON.stringify(full)}\n`);
|
|
86
|
+
return full;
|
|
87
|
+
}
|
|
88
|
+
throw err;
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
async read() {
|
|
92
|
+
return inner.read();
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Mutation journal: reports genesis for an empty-but-present journal. */
|
|
98
|
+
export function emptyAsGenesisJournal(): ProvenanceJournal {
|
|
99
|
+
return {
|
|
100
|
+
async append(): Promise<ProvenanceEntry> {
|
|
101
|
+
throw new Error("unused");
|
|
102
|
+
},
|
|
103
|
+
async read() {
|
|
104
|
+
return { kind: "genesis" };
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Mutation summary: folds the unreadable bucket into notObserved. */
|
|
110
|
+
export function mergeUnreadableSummary(reads: readonly ProvenanceRead[]): ProvenanceSummary {
|
|
111
|
+
const base = summarizeProvenance(reads);
|
|
112
|
+
return {
|
|
113
|
+
recorded: base.recorded,
|
|
114
|
+
reconciles: base.reconciles,
|
|
115
|
+
notObserved: base.notObserved + base.unreadable,
|
|
116
|
+
unreadable: 0,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Mutation journal: entries are only durable AFTER the outcome (not
|
|
121
|
+
* write-ahead) — the wrong wiring the tooth guards against. */
|
|
122
|
+
export function promoteOnlyJournal(inner: ProvenanceJournal): ProvenanceJournal & { flush(): Promise<void> } {
|
|
123
|
+
const buffered: Array<{ who: string; carrier: string; version: string }> = [];
|
|
124
|
+
return {
|
|
125
|
+
async append(entry) {
|
|
126
|
+
buffered.push({ ...entry });
|
|
127
|
+
return { seq: buffered.length - 1, ...entry, when: 0 };
|
|
128
|
+
},
|
|
129
|
+
async read() {
|
|
130
|
+
return { kind: "observed", entries: [] }; // nothing durable until flushed
|
|
131
|
+
},
|
|
132
|
+
async flush() {
|
|
133
|
+
for (const b of buffered) await inner.append(b);
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* M6 status-report acceptance checks (test-plan M6 rows: the fleet
|
|
3
|
+
* read-back equals the live sources at the same moment; a machine that has
|
|
4
|
+
* never observed a promote reports NOT_OBSERVED — never a fabricated pass;
|
|
5
|
+
* an unreadable report is its own state, never "never observed"; an
|
|
6
|
+
* observed pass survives a restart).
|
|
7
|
+
*
|
|
8
|
+
* These drive createUpgrader in-process: status() is the L5 read-back
|
|
9
|
+
* surface, and the teeth pin that it is a READ-BACK, not an invention.
|
|
10
|
+
*/
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
import { promises as fs } from "node:fs";
|
|
14
|
+
import { type ToothContext } from "../teeth/registry.ts";
|
|
15
|
+
import { fileProvenanceJournal } from "../../../core/src/provenance/journal.ts";
|
|
16
|
+
import { systemClock } from "../../../core/src/clock.ts";
|
|
17
|
+
import type { StatusReport, StatusPredicates } from "../../../core/src/status/report.ts";
|
|
18
|
+
import { serveRelease } from "./m1.ts";
|
|
19
|
+
import { stateDir, makeUpgrader } from "./m6.ts";
|
|
20
|
+
|
|
21
|
+
function reportFile(ctx: ToothContext): string {
|
|
22
|
+
return path.join(stateDir(ctx), "report.json");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// m6.status-report-matches-local
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
/** Mutation: a status report that INVENTED a field (stable says 9.9.9 while
|
|
30
|
+
* the machine is on 1.0.0) — the read-back violation the tooth guards
|
|
31
|
+
* against. */
|
|
32
|
+
function inventedStatus(base: StatusReport): StatusReport {
|
|
33
|
+
return { ...base, stable: "9.9.9" };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Mutation: a REAL conclusion pasted onto the WRONG version (the predicates
|
|
37
|
+
* are 2.0.0's but claim 3.0.0) — worse than a fake, because consumers join
|
|
38
|
+
* on the stamp. */
|
|
39
|
+
function wrongStamp(base: StatusReport): StatusReport {
|
|
40
|
+
if (base.predicates.kind !== "observed") return base;
|
|
41
|
+
return { ...base, predicates: { ...base.predicates, version: "3.0.0" } };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function checkM6StatusReportMatchesLocal(
|
|
45
|
+
ctx: ToothContext,
|
|
46
|
+
opts: { inventStable?: boolean; wrongVersionStamp?: boolean } = {},
|
|
47
|
+
): Promise<void> {
|
|
48
|
+
const journal = fileProvenanceJournal(path.join(stateDir(ctx), "provenance"), systemClock);
|
|
49
|
+
const server = await serveRelease(ctx, { version: "2.0.0", behavior: "ok", name: "target" });
|
|
50
|
+
try {
|
|
51
|
+
const upgrader = await makeUpgrader(ctx, server, journal);
|
|
52
|
+
|
|
53
|
+
// Fresh machine: the report equals the live sources AT THE SAME MOMENT.
|
|
54
|
+
const st1 = await upgrader.state();
|
|
55
|
+
const s1 = opts.inventStable ? inventedStatus(await upgrader.status()) : await upgrader.status();
|
|
56
|
+
assert.equal(
|
|
57
|
+
s1.stable,
|
|
58
|
+
st1.stableVersion,
|
|
59
|
+
"the report's stable is the machine's stable, never an invention (inventStable => RED)",
|
|
60
|
+
);
|
|
61
|
+
assert.equal(s1.experiment, st1.experimentVersion, "the report's experiment is the machine's experiment");
|
|
62
|
+
assert.equal(s1.phase, st1.phase, "the report's phase is the machine's phase");
|
|
63
|
+
assert.equal(s1.policy, "auto", "the report's policy is the configured policy");
|
|
64
|
+
const jr = await journal.read();
|
|
65
|
+
assert.deepEqual(s1.provenance, jr, "the report's provenance is the journal's read");
|
|
66
|
+
|
|
67
|
+
// After a real promote: the predicates are the last REAL report's,
|
|
68
|
+
// stamped with the version they evaluated.
|
|
69
|
+
const outcome = await upgrader.upgradeTo("2.0.0", {
|
|
70
|
+
consented: true,
|
|
71
|
+
provenance: { who: "fleet-control", carrier: "example-host" },
|
|
72
|
+
});
|
|
73
|
+
assert.equal(outcome.result, "promoted", "the reconcile must promote");
|
|
74
|
+
assert.ok(outcome.report !== null, "a promoted reconcile carries a convergence report");
|
|
75
|
+
assert.equal(outcome.report!.version, "2.0.0", "the convergence report is stamped with the version it evaluated");
|
|
76
|
+
const s2 = opts.wrongVersionStamp ? wrongStamp(await upgrader.status()) : await upgrader.status();
|
|
77
|
+
assert.equal(
|
|
78
|
+
s2.predicates.kind,
|
|
79
|
+
"observed",
|
|
80
|
+
"after a promote the predicates are the real report's (wrongVersionStamp => RED)",
|
|
81
|
+
);
|
|
82
|
+
if (s2.predicates.kind !== "observed") return;
|
|
83
|
+
assert.equal(
|
|
84
|
+
s2.predicates.version,
|
|
85
|
+
"2.0.0",
|
|
86
|
+
"the status passes the evaluated version through verbatim — a real conclusion must never be joined to the wrong version (wrongVersionStamp => RED)",
|
|
87
|
+
);
|
|
88
|
+
assert.deepEqual(
|
|
89
|
+
s2.predicates.binaryAtTarget,
|
|
90
|
+
outcome.report!.binaryAtTarget,
|
|
91
|
+
"the report's binaryAtTarget is the last real evaluation",
|
|
92
|
+
);
|
|
93
|
+
assert.deepEqual(
|
|
94
|
+
s2.predicates.hostLifecycleConverged,
|
|
95
|
+
outcome.report!.hostLifecycleConverged,
|
|
96
|
+
"the report's lifecycle predicate is the last real evaluation",
|
|
97
|
+
);
|
|
98
|
+
const st2 = await upgrader.state();
|
|
99
|
+
assert.equal(s2.stable, st2.stableVersion, "after promote, the report still reads the live stable");
|
|
100
|
+
} finally {
|
|
101
|
+
await server.stop();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// m6.status-report-silence-not-evidence
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
/** Mutation: a PASSING predicate a machine never evaluated (silence spent
|
|
110
|
+
* as evidence) — the wrong status reports an `observed` shape on a machine
|
|
111
|
+
* whose predicates are actually genesis. */
|
|
112
|
+
function fabricatedPass(which: "binary" | "lifecycle"): StatusPredicates {
|
|
113
|
+
const fake = { passed: true, source: "fabricated", observedAtMs: 0, detail: {} };
|
|
114
|
+
return which === "binary"
|
|
115
|
+
? { kind: "observed", version: "2.0.0", binaryAtTarget: fake, hostLifecycleConverged: null }
|
|
116
|
+
: { kind: "observed", version: "2.0.0", binaryAtTarget: { ...fake, detail: { version: "2.0.0" } }, hostLifecycleConverged: fake };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Mutation: the report lives only in memory — a restart erases the
|
|
120
|
+
* observation ("observed, I restarted" collapses into "never observed"). */
|
|
121
|
+
function forgettingStatus(base: StatusReport): StatusReport {
|
|
122
|
+
return { ...base, predicates: { kind: "genesis" } };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function checkM6StatusReportSilenceNotEvidence(
|
|
126
|
+
ctx: ToothContext,
|
|
127
|
+
opts: {
|
|
128
|
+
fabricateBinaryPassed?: boolean;
|
|
129
|
+
fabricateConvergedPassed?: boolean;
|
|
130
|
+
fabricateAfterRollback?: boolean;
|
|
131
|
+
noPersistence?: boolean;
|
|
132
|
+
unreadableIsGenesis?: boolean;
|
|
133
|
+
} = {},
|
|
134
|
+
): Promise<void> {
|
|
135
|
+
const journal = fileProvenanceJournal(path.join(stateDir(ctx), "provenance"), systemClock);
|
|
136
|
+
const good = await serveRelease(ctx, { version: "2.0.0", behavior: "ok", name: "good" });
|
|
137
|
+
const bad = await serveRelease(ctx, { version: "3.0.0", behavior: "ok", name: "bad" });
|
|
138
|
+
try {
|
|
139
|
+
const upgrader = await makeUpgrader(ctx, good, journal);
|
|
140
|
+
|
|
141
|
+
// A machine that never observed a promote is NOT_OBSERVED — never a
|
|
142
|
+
// fabricated pass (in either predicate).
|
|
143
|
+
const fresh = await upgrader.status();
|
|
144
|
+
const s1 = opts.fabricateBinaryPassed
|
|
145
|
+
? { ...fresh, predicates: fabricatedPass("binary") }
|
|
146
|
+
: opts.fabricateConvergedPassed
|
|
147
|
+
? { ...fresh, predicates: fabricatedPass("lifecycle") }
|
|
148
|
+
: fresh;
|
|
149
|
+
assert.equal(
|
|
150
|
+
s1.predicates.kind,
|
|
151
|
+
"genesis",
|
|
152
|
+
"never observed => predicates are genesis (NOT_OBSERVED), never a fabricated pass (fabricateBinaryPassed | fabricateConvergedPassed => RED)",
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
// A reconcile that REACHES the machine but FAILS (auto-rollback) does
|
|
156
|
+
// not become a pass either: the machine observed a failure, and
|
|
157
|
+
// "observed a failure" is not "evaluated and passed". A second upgrader
|
|
158
|
+
// on the moved-on source (same machine: stateDir + journal shared) does
|
|
159
|
+
// the failing attempt; it has never promoted, so its predicates must
|
|
160
|
+
// still be genesis.
|
|
161
|
+
const failingUpgrader = await makeUpgrader(ctx, bad, journal);
|
|
162
|
+
const r = await failingUpgrader.upgradeTo("3.0.0", {
|
|
163
|
+
consented: true,
|
|
164
|
+
provenance: { who: "fleet-control", carrier: "example-host" },
|
|
165
|
+
});
|
|
166
|
+
assert.equal(r.result, "rolled-back", "the bad reconcile must roll back");
|
|
167
|
+
const after = await failingUpgrader.status();
|
|
168
|
+
const s2 = opts.fabricateAfterRollback ? { ...after, predicates: fabricatedPass("lifecycle") } : after;
|
|
169
|
+
assert.equal(
|
|
170
|
+
s2.predicates.kind,
|
|
171
|
+
"genesis",
|
|
172
|
+
"a rolled-back reconcile observes a failure, never a pass (fabricateAfterRollback => RED)",
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
// A machine that DID converge must not report NOT_OBSERVED after a
|
|
176
|
+
// restart: "observed, I restarted" is not "never observed". The report
|
|
177
|
+
// is persisted at promote and loaded by a fresh upgrader on the same
|
|
178
|
+
// stateDir.
|
|
179
|
+
const promoted = await upgrader.upgradeTo("2.0.0", {
|
|
180
|
+
consented: true,
|
|
181
|
+
provenance: { who: "fleet-control", carrier: "example-host" },
|
|
182
|
+
});
|
|
183
|
+
assert.equal(promoted.result, "promoted", "the good reconcile must promote");
|
|
184
|
+
const restarted = await makeUpgrader(ctx, good, journal); // same stateDir: the daemon restarted
|
|
185
|
+
const afterRestart = opts.noPersistence
|
|
186
|
+
? forgettingStatus(await restarted.status())
|
|
187
|
+
: await restarted.status();
|
|
188
|
+
assert.equal(
|
|
189
|
+
afterRestart.predicates.kind,
|
|
190
|
+
"observed",
|
|
191
|
+
"an observed pass survives a restart — the persisted report is loaded, never erased (noPersistence => RED)",
|
|
192
|
+
);
|
|
193
|
+
if (afterRestart.predicates.kind !== "observed") return;
|
|
194
|
+
assert.equal(afterRestart.predicates.version, "2.0.0", "the persisted report keeps its version stamp");
|
|
195
|
+
assert.equal(
|
|
196
|
+
afterRestart.predicates.binaryAtTarget.passed,
|
|
197
|
+
true,
|
|
198
|
+
"the persisted report carries the real observed result, not a fabrication",
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
// A report that exists but cannot be read is the THIRD state — never
|
|
202
|
+
// genesis ("I cannot see the data" is not "there is no data"), and
|
|
203
|
+
// retirement names the true reason instead of the fake "never observed".
|
|
204
|
+
await fs.writeFile(reportFile(ctx), '{"version": "2.0.0", "binaryAtTarget": BROKEN', "utf8");
|
|
205
|
+
const afterCorrupt = await makeUpgrader(ctx, good, journal);
|
|
206
|
+
const s4 = opts.unreadableIsGenesis
|
|
207
|
+
? { ...(await afterCorrupt.status()), predicates: { kind: "genesis" as const } }
|
|
208
|
+
: await afterCorrupt.status();
|
|
209
|
+
assert.equal(
|
|
210
|
+
s4.predicates.kind,
|
|
211
|
+
"unreadable",
|
|
212
|
+
"an unreadable report is its own state, never genesis (unreadableIsGenesis => RED)",
|
|
213
|
+
);
|
|
214
|
+
const retire = await afterCorrupt.retireLegacyManager();
|
|
215
|
+
assert.notEqual(retire, "retired", "retirement stays fail-closed on an unreadable report");
|
|
216
|
+
assert.ok(
|
|
217
|
+
typeof retire === "object" && retire.held.includes("cannot be read"),
|
|
218
|
+
"the retirement HOLD names the real reason: unreadable, not never-observed",
|
|
219
|
+
);
|
|
220
|
+
} finally {
|
|
221
|
+
await good.stop();
|
|
222
|
+
await bad.stop();
|
|
223
|
+
}
|
|
224
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* artifact-factory acceptance checks — run bodies of the factory teeth
|
|
3
|
+
* (registered in teeth/artifactFactory.ts). Each throws on violation.
|
|
4
|
+
*
|
|
5
|
+
* The two acceptance claims (harness-design §1.77):
|
|
6
|
+
* - determinism: same (version, behavior) builds byte-identical artifacts
|
|
7
|
+
* — and since Ed25519 is deterministic, the whole published release
|
|
8
|
+
* (artifact + manifest + signatures) reproduces exactly;
|
|
9
|
+
* - the ok artifact is a REAL binary: it runs and reports the stamped
|
|
10
|
+
* version (this is what makes "升到坏版本→自动回滚" black-box testable).
|
|
11
|
+
*/
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
import { type ToothContext } from "../teeth/registry.ts";
|
|
15
|
+
import { ArtifactFactory, type Behavior } from "./factory.ts";
|
|
16
|
+
import { runArtifact } from "./run.ts";
|
|
17
|
+
import { ReleaseStore } from "../fake-server/store.ts";
|
|
18
|
+
import { MANIFEST_FILE } from "../fake-server/manifest.ts";
|
|
19
|
+
|
|
20
|
+
function storeFor(dir: string, name: string): ReleaseStore {
|
|
21
|
+
return new ReleaseStore(path.join(dir, name));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Two independent factories (fresh caches) with the same input must produce
|
|
26
|
+
* byte-identical artifacts and manifests. `secondFactory`
|
|
27
|
+
* overrides factory B for known-red driving.
|
|
28
|
+
*/
|
|
29
|
+
export async function checkDeterministicBuild(
|
|
30
|
+
ctx: ToothContext,
|
|
31
|
+
secondFactory?: ArtifactFactory,
|
|
32
|
+
): Promise<void> {
|
|
33
|
+
const factoryA = new ArtifactFactory({ cacheDir: path.join(ctx.sandboxDir, "cache-a") });
|
|
34
|
+
const factoryB = secondFactory ?? new ArtifactFactory({ cacheDir: path.join(ctx.sandboxDir, "cache-b") });
|
|
35
|
+
const storeA = storeFor(ctx.sandboxDir, "store-a");
|
|
36
|
+
const storeB = storeFor(ctx.sandboxDir, "store-b");
|
|
37
|
+
const a = await factoryA.makeRelease({ version: "2.0.0", behavior: "ok", store: storeA });
|
|
38
|
+
const b = await factoryB.makeRelease({ version: "2.0.0", behavior: "ok", store: storeB });
|
|
39
|
+
assert.deepEqual(
|
|
40
|
+
b.artifactBytes,
|
|
41
|
+
a.artifactBytes,
|
|
42
|
+
"same version+behavior must stamp byte-identical artifacts",
|
|
43
|
+
);
|
|
44
|
+
const manifestA = await storeA.readFile(MANIFEST_FILE);
|
|
45
|
+
const manifestB = await storeB.readFile(MANIFEST_FILE);
|
|
46
|
+
assert.deepEqual(manifestB, manifestA, "manifests must be byte-identical");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The ok artifact must be a real executable that runs and reports its
|
|
51
|
+
* stamped version. `behavior` overrides the build knob for known-red
|
|
52
|
+
* driving (a broken build behaves exactly like a must-red mutation).
|
|
53
|
+
*/
|
|
54
|
+
export async function checkOkArtifactRuns(ctx: ToothContext, behavior: Behavior = "ok"): Promise<void> {
|
|
55
|
+
const factory = new ArtifactFactory({ cacheDir: path.join(ctx.sandboxDir, "cache") });
|
|
56
|
+
const store = storeFor(ctx.sandboxDir, "store");
|
|
57
|
+
const version = "1.2.3";
|
|
58
|
+
const rel = await factory.makeRelease({ version, behavior, store });
|
|
59
|
+
const artifactPath = path.join(ctx.sandboxDir, "store", "releases", version, rel.artifactFile);
|
|
60
|
+
const r = await runArtifact(artifactPath);
|
|
61
|
+
assert.equal(r.timedOut, false, "ok artifact must not hang");
|
|
62
|
+
assert.equal(r.code, 0, "ok artifact must exit 0");
|
|
63
|
+
assert.equal(
|
|
64
|
+
r.stdout.trim(),
|
|
65
|
+
`${version} ready`,
|
|
66
|
+
"ok artifact must report the stamped version",
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The artifact-factory's built-in demo source (harness-design §1.77).
|
|
3
|
+
*
|
|
4
|
+
* A tiny zero-dependency node script that IS the "demo binary". It
|
|
5
|
+
* implements the black-box CLI contract (§1.76): `--version` prints the
|
|
6
|
+
* stamped version, and `self upgrade` performs a cli-profile self-upgrade
|
|
7
|
+
* (L1': fetch the manifest from K_RELEASE_BASE, verify the artifact's
|
|
8
|
+
* sha256, atomically swap its own bytes — next run is the new version).
|
|
9
|
+
* The default run reports its version (the factory teeth's "runs and
|
|
10
|
+
* reports" claim).
|
|
11
|
+
*
|
|
12
|
+
* `__K_VERSION__` / `__K_BEHAVIOR__` are the stamp placeholders; behavior
|
|
13
|
+
* bakes deliberate breakage into the artifact (fixture source for rollback
|
|
14
|
+
* teeth, known-red and adversarial samples).
|
|
15
|
+
*
|
|
16
|
+
* K_RELEASE_BASE is the config surface for the fake-server URL — a real
|
|
17
|
+
* app configures its own release source; the harness points it at localhost.
|
|
18
|
+
*/
|
|
19
|
+
export const DEMO_SOURCE = `#!/usr/bin/env node
|
|
20
|
+
// K demo binary — stamped by artifact-factory (harness-design §1.77).
|
|
21
|
+
// Black-box cli contract (§1.76): --version / self upgrade. K_RELEASE_BASE
|
|
22
|
+
// env = this demo app's own release-source config (localhost in harness).
|
|
23
|
+
"use strict";
|
|
24
|
+
const VERSION = "__K_VERSION__";
|
|
25
|
+
const BEHAVIOR = "__K_BEHAVIOR__";
|
|
26
|
+
const RELEASE_BASE = process.env.K_RELEASE_BASE;
|
|
27
|
+
const fs = require("node:fs");
|
|
28
|
+
// Synchronous writes: process.exit()/SIGKILL can truncate buffered pipe
|
|
29
|
+
// writes, and the black-box assertions read stdout as evidence.
|
|
30
|
+
function report(v) { fs.writeSync(1, v + " ready\\n"); }
|
|
31
|
+
function printVersion() { fs.writeSync(1, VERSION + "\\n"); process.exit(0); }
|
|
32
|
+
|
|
33
|
+
async function selfUpgrade() {
|
|
34
|
+
if (!RELEASE_BASE) { process.stderr.write("K_RELEASE_BASE not set\\n"); process.exit(2); }
|
|
35
|
+
const crypto = require("node:crypto");
|
|
36
|
+
const fs = require("node:fs");
|
|
37
|
+
const res = await fetch(RELEASE_BASE + "/manifest.json");
|
|
38
|
+
if (!res.ok) { process.stderr.write("manifest fetch failed: " + res.status + "\\n"); process.exit(3); }
|
|
39
|
+
const manifest = await res.json();
|
|
40
|
+
const targetKeys = Object.keys(manifest.targets);
|
|
41
|
+
const target = manifest.targets[targetKeys[0]];
|
|
42
|
+
if (!target) { process.stderr.write("manifest has no targets\\n"); process.exit(3); }
|
|
43
|
+
const art = await fetch(RELEASE_BASE + "/" + target.file);
|
|
44
|
+
if (!art.ok) { process.stderr.write("artifact fetch failed: " + art.status + "\\n"); process.exit(3); }
|
|
45
|
+
const bytes = Buffer.from(await art.arrayBuffer());
|
|
46
|
+
const hash = crypto.createHash("sha256").update(bytes).digest("hex");
|
|
47
|
+
if (hash !== target.sha256) { process.stderr.write("sha256 mismatch\\n"); process.exit(4); }
|
|
48
|
+
const selfPath = process.argv[1];
|
|
49
|
+
const tmp = selfPath + ".tmp";
|
|
50
|
+
fs.writeFileSync(tmp, bytes, { mode: 0o755 });
|
|
51
|
+
fs.renameSync(tmp, selfPath); // cli-profile L1': swap bytes, next run is new version
|
|
52
|
+
fs.writeSync(1, "upgraded to " + manifest.version + "\\n");
|
|
53
|
+
process.exit(0);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const args = process.argv.slice(2);
|
|
57
|
+
if (args[0] === "self" && args[1] === "upgrade") {
|
|
58
|
+
selfUpgrade().catch((e) => { process.stderr.write(String(e) + "\\n"); process.exit(5); });
|
|
59
|
+
} else if (args[0] === "--version") {
|
|
60
|
+
printVersion();
|
|
61
|
+
} else {
|
|
62
|
+
switch (BEHAVIOR) {
|
|
63
|
+
case "crash-on-start": report(VERSION); process.exit(1); break;
|
|
64
|
+
case "wrong-version-probe": report("9.9.9"); process.exit(0); break;
|
|
65
|
+
case "hang-on-quiesce": report(VERSION); setInterval(() => {}, 2147483647); break;
|
|
66
|
+
case "ok":
|
|
67
|
+
default: report(VERSION); process.exit(0); break;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
`;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* artifact-factory (harness-design §1.77) — build-once, stamp-many release
|
|
3
|
+
* production for upgrade tests.
|
|
4
|
+
*
|
|
5
|
+
* `makeRelease({version, behavior})` stamps the demo source into a REAL
|
|
6
|
+
* binary (version string injected into the artifact's own bytes — the same
|
|
7
|
+
* shape as SEA-embedded versions), then publishes it with manifest + full
|
|
8
|
+
* signature chain into a ReleaseStore (the fake-server's store).
|
|
9
|
+
*
|
|
10
|
+
* The `behavior` knob bakes deliberate breakage into "new versions":
|
|
11
|
+
* crash-on-start / wrong-version-probe / hang-on-quiesce are the fixture
|
|
12
|
+
* source for rollback teeth and self-verification known-red/adversarial
|
|
13
|
+
* samples ("升到坏版本→自动回滚→stable 完好" must be black-box reproducible).
|
|
14
|
+
*
|
|
15
|
+
* Content-addressed cache (key = source hash × version × behavior) makes
|
|
16
|
+
* repeated requests cheap and byte-identical across scenarios. Stamping is
|
|
17
|
+
* fully deterministic — Ed25519 signing is deterministic too, so the whole
|
|
18
|
+
* published release (artifact + manifest + signatures) is reproducible.
|
|
19
|
+
*/
|
|
20
|
+
import { promises as fs } from "node:fs";
|
|
21
|
+
import * as path from "node:path";
|
|
22
|
+
import { DEMO_SOURCE } from "./demo.ts";
|
|
23
|
+
import { sha256Hex } from "../fake-server/manifest.ts";
|
|
24
|
+
import { ReleaseStore, type PublishReleaseSpec } from "../fake-server/store.ts";
|
|
25
|
+
|
|
26
|
+
export const BEHAVIORS = [
|
|
27
|
+
"ok",
|
|
28
|
+
"crash-on-start",
|
|
29
|
+
"wrong-version-probe",
|
|
30
|
+
"hang-on-quiesce",
|
|
31
|
+
] as const;
|
|
32
|
+
|
|
33
|
+
export type Behavior = (typeof BEHAVIORS)[number];
|
|
34
|
+
|
|
35
|
+
export interface MakeReleaseOptions {
|
|
36
|
+
version: string;
|
|
37
|
+
behavior: Behavior;
|
|
38
|
+
/** The release lands here (the fake-server's store). */
|
|
39
|
+
store: ReleaseStore;
|
|
40
|
+
/** Manifest platform tag. Default: "current". */
|
|
41
|
+
platform?: string;
|
|
42
|
+
/** Track the release is published under (latest | alpha). Optional. */
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface FactoryRelease {
|
|
46
|
+
/** Artifact filename inside the release (served at /<file>). */
|
|
47
|
+
artifactFile: string;
|
|
48
|
+
artifactBytes: Uint8Array;
|
|
49
|
+
sha256: string;
|
|
50
|
+
/** True when the content-addressed cache served this request. */
|
|
51
|
+
cached: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface CacheMeta {
|
|
55
|
+
version: string;
|
|
56
|
+
behavior: Behavior;
|
|
57
|
+
artifactFile: string;
|
|
58
|
+
sha256: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export class ArtifactFactory {
|
|
62
|
+
private readonly source: string;
|
|
63
|
+
private readonly sourceHash: string;
|
|
64
|
+
private readonly cacheDir: string;
|
|
65
|
+
private buildCount = 0;
|
|
66
|
+
|
|
67
|
+
constructor(opts: { demoSource?: string; cacheDir: string }) {
|
|
68
|
+
this.source = opts.demoSource ?? DEMO_SOURCE;
|
|
69
|
+
this.sourceHash = sha256Hex(new TextEncoder().encode(this.source));
|
|
70
|
+
this.cacheDir = opts.cacheDir;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Number of actual stamps performed (cache hits don't count). */
|
|
74
|
+
get builds(): number {
|
|
75
|
+
return this.buildCount;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async makeRelease(opts: MakeReleaseOptions): Promise<FactoryRelease> {
|
|
79
|
+
const key = `${this.sourceHash}:${opts.version}:${opts.behavior}`;
|
|
80
|
+
const keyHash = sha256Hex(new TextEncoder().encode(key));
|
|
81
|
+
const binPath = path.join(this.cacheDir, `${keyHash}.bin`);
|
|
82
|
+
const metaPath = path.join(this.cacheDir, `${keyHash}.json`);
|
|
83
|
+
|
|
84
|
+
let meta: CacheMeta;
|
|
85
|
+
let bytes: Uint8Array;
|
|
86
|
+
let cached = false;
|
|
87
|
+
const metaRaw = await fs.readFile(metaPath).catch(() => null);
|
|
88
|
+
if (metaRaw) {
|
|
89
|
+
meta = JSON.parse(new TextDecoder().decode(metaRaw)) as CacheMeta;
|
|
90
|
+
bytes = new Uint8Array(await fs.readFile(binPath));
|
|
91
|
+
cached = true;
|
|
92
|
+
} else {
|
|
93
|
+
await fs.mkdir(this.cacheDir, { recursive: true });
|
|
94
|
+
bytes = stamp(this.source, opts.version, opts.behavior);
|
|
95
|
+
meta = {
|
|
96
|
+
version: opts.version,
|
|
97
|
+
behavior: opts.behavior,
|
|
98
|
+
artifactFile: artifactFileName(opts.version),
|
|
99
|
+
sha256: sha256Hex(bytes),
|
|
100
|
+
};
|
|
101
|
+
await fs.writeFile(binPath, bytes, { mode: 0o755 });
|
|
102
|
+
await fs.writeFile(metaPath, JSON.stringify(meta));
|
|
103
|
+
this.buildCount += 1;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!opts.store.has(opts.version)) {
|
|
107
|
+
const spec: PublishReleaseSpec = {
|
|
108
|
+
version: opts.version,
|
|
109
|
+
artifacts: { [meta.artifactFile]: bytes },
|
|
110
|
+
binary: meta.artifactFile,
|
|
111
|
+
executable: true,
|
|
112
|
+
};
|
|
113
|
+
if (opts.platform !== undefined) spec.platform = opts.platform;
|
|
114
|
+
await opts.store.publish(spec);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
artifactFile: meta.artifactFile,
|
|
119
|
+
artifactBytes: bytes,
|
|
120
|
+
sha256: meta.sha256,
|
|
121
|
+
cached,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function artifactFileName(version: string): string {
|
|
127
|
+
return `app-${version}.bin`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Replace the stamp placeholders; the only byte-varying step, fully deterministic. */
|
|
131
|
+
export function stamp(source: string, version: string, behavior: Behavior): Uint8Array {
|
|
132
|
+
const stamped = source.replaceAll("__K_VERSION__", version).replaceAll("__K_BEHAVIOR__", behavior);
|
|
133
|
+
return new TextEncoder().encode(stamped);
|
|
134
|
+
}
|