@botiverse/k-carrier 0.1.0 → 0.1.6
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/README.md +9 -33
- package/core/src/bootstrap.ts +168 -0
- package/core/src/createUpgrader.ts +66 -128
- package/core/src/index.ts +10 -0
- package/core/src/operation.ts +179 -0
- package/core/src/operationLifecycle.ts +113 -0
- package/core/src/provenance/journal.ts +2 -2
- package/core/src/upgrade/drive.ts +177 -0
- package/core/src/upgrade/recover.ts +19 -0
- package/core/src/upgrader.ts +23 -1
- package/docs/design-v1.md +1 -1
- package/docs/integration.md +29 -0
- package/harness/src/cli.ts +0 -0
- package/package.json +7 -2
- package/core/src/artifact/README.md +0 -1
- package/core/src/drive/README.md +0 -1
- package/core/src/platform/README.md +0 -1
- package/core/src/policy/README.md +0 -1
- package/harness/README.md +0 -20
package/README.md
CHANGED
|
@@ -6,34 +6,12 @@ CLI self-update libraries stop at replacing bytes; fleet updaters assume a machi
|
|
|
6
6
|
|
|
7
7
|
**Two process models, defined by how many live incarnations K manages** — `swap` (**0**: K replaces bytes and touches no process; a one-shot CLI and an hours-long agent session are the same case) and `service` (**1**: K stops the old, starts the new, and proves it). OS lifecycle convergence and fleet drive are capabilities you opt into on top, not a third model. Proof is executable: a runnable example per case, and a claim without a green example does not exist.
|
|
8
8
|
|
|
9
|
-
##
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|-----------------------|---------------------------------------|-----------------------------|
|
|
16
|
-
| `rustup self update` | low: replace one file, exit | low: none |
|
|
17
|
-
| `electron-updater` | **high** | low: none |
|
|
18
|
-
| **K** | low: one binary | **high**: transaction, readback, rollback |
|
|
19
|
-
|
|
20
|
-
Measured, not asserted: of electron-updater 6.8.9's ~4,200 lines, ~1,170 are
|
|
21
|
-
per-platform installation (Squirrel.Mac, NSIS, deb/rpm/pacman, AppImage),
|
|
22
|
-
~1,200 orchestration and policy, ~960 feed providers, and ~860 differential
|
|
23
|
-
download. Downloading is not the hard part — **installing is, because
|
|
24
|
-
installing is not yours to do**: you hand off to a system component with its
|
|
25
|
-
own rules (Squirrel.Mac accepts only a URL, so the updater serves the file it
|
|
26
|
-
already downloaded back to itself over a local HTTP server; NSIS may need
|
|
27
|
-
elevation; dpkg needs root). Grep that codebase for rollback and you find none,
|
|
28
|
-
and nothing checks health after the install.
|
|
29
|
-
|
|
30
|
-
K deliberately does not compete on the delivery axis — platform packaging
|
|
31
|
-
belongs to platform tools. It exists on the other one, and specifically for the
|
|
32
|
-
consequence those tools all share and none of them handle: **because something
|
|
33
|
-
else installs your bytes, something else replaces and restarts your process.**
|
|
34
|
-
The process driving the upgrade dies on the *success* path, so the successor
|
|
35
|
-
must be able to tell "the handover worked" from "we crashed" — by evidence,
|
|
36
|
-
never by a flag saying the restart was planned.
|
|
9
|
+
## What K owns
|
|
10
|
+
|
|
11
|
+
K does not replace platform packaging or artifact delivery. It wraps an
|
|
12
|
+
addressable release in a transaction with rollback and convergence readback.
|
|
13
|
+
Because the process driving an upgrade may die on the success path, the
|
|
14
|
+
successor proves the handoff from live evidence rather than trusting a flag.
|
|
37
15
|
|
|
38
16
|
## Start here
|
|
39
17
|
|
|
@@ -53,10 +31,8 @@ examples/ one runnable demo per profile (swap-tool / service-daemon / hosted-s
|
|
|
53
31
|
docs/ guides + design + test plan + prior art
|
|
54
32
|
```
|
|
55
33
|
|
|
56
|
-
**Platform support today:** Linux and macOS
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
than approximating POSIX behaviour and corrupting an install. The Windows CI
|
|
60
|
-
job runs and reports, but does not gate, until those land.
|
|
34
|
+
**Platform support today:** Linux and macOS gate CI. Windows platform
|
|
35
|
+
operations are implemented; its acceptance harness and CI gate are still in
|
|
36
|
+
progress.
|
|
61
37
|
|
|
62
38
|
Status: incubating. TypeScript first. License: **Apache-2.0**.
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* First-adoption bootstrap for an already-running service.
|
|
3
|
+
*
|
|
4
|
+
* K normally owns both slots from the first install. An existing application
|
|
5
|
+
* adopting K has a different starting world: trusted bytes are running, but
|
|
6
|
+
* `slots/stable` does not exist yet. Starting the first transaction in that
|
|
7
|
+
* world would make a failed experiment roll back to an empty slot.
|
|
8
|
+
*
|
|
9
|
+
* `bootstrapStable` closes that one-time gap. It copies the application's
|
|
10
|
+
* current trusted executable into K's stable slot before any transaction can
|
|
11
|
+
* start. The publication is atomic and shares K's upgrade lock, so a crash
|
|
12
|
+
* leaves either no stable slot or one complete stable slot, never half of one.
|
|
13
|
+
*/
|
|
14
|
+
import { promises as fs } from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { platformOpsFor } from "./platform/index.ts";
|
|
17
|
+
import { acquireUpgradeLock } from "./txn/lock.ts";
|
|
18
|
+
import { slotArtifactPath } from "./txn/fileEffects.ts";
|
|
19
|
+
|
|
20
|
+
const VERSION_FILE = "VERSION";
|
|
21
|
+
|
|
22
|
+
export type BootstrapStableResult = "bootstrapped" | "already-initialized";
|
|
23
|
+
|
|
24
|
+
export type BootstrapErrorCode =
|
|
25
|
+
| "BOOTSTRAP_VERSION_INVALID"
|
|
26
|
+
| "BOOTSTRAP_STATE_CONFLICT"
|
|
27
|
+
| "BOOTSTRAP_SOURCE_UNREADABLE"
|
|
28
|
+
| "BOOTSTRAP_WRITE_FAILED";
|
|
29
|
+
|
|
30
|
+
export class BootstrapError extends Error {
|
|
31
|
+
readonly code: BootstrapErrorCode;
|
|
32
|
+
|
|
33
|
+
constructor(code: BootstrapErrorCode, message: string, options?: { cause?: unknown }) {
|
|
34
|
+
super(`[${code}] ${message}`, options);
|
|
35
|
+
this.name = "BootstrapError";
|
|
36
|
+
this.code = code;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface BootstrapStableOptions {
|
|
41
|
+
/** Directory K owns for its journal, slots, and staging area. */
|
|
42
|
+
stateDir: string;
|
|
43
|
+
/** Version of the trusted executable that is running before K adoption. */
|
|
44
|
+
version: string;
|
|
45
|
+
/** Path to those exact trusted executable bytes. */
|
|
46
|
+
artifactPath: string;
|
|
47
|
+
/** Clock seam used only for the shared upgrade-lock receipt. */
|
|
48
|
+
nowMs?: () => number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function pathExists(filePath: string): Promise<boolean> {
|
|
52
|
+
try {
|
|
53
|
+
await fs.stat(filePath);
|
|
54
|
+
return true;
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function readInitializedStable(stateDir: string): Promise<string | null> {
|
|
62
|
+
const stableDir = path.join(stateDir, "slots", "stable");
|
|
63
|
+
if (!(await pathExists(stableDir))) return null;
|
|
64
|
+
|
|
65
|
+
let version: string;
|
|
66
|
+
try {
|
|
67
|
+
version = (await fs.readFile(path.join(stableDir, VERSION_FILE), "utf8")).trim();
|
|
68
|
+
await fs.stat(slotArtifactPath(stateDir, "stable"));
|
|
69
|
+
} catch (error) {
|
|
70
|
+
throw new BootstrapError(
|
|
71
|
+
"BOOTSTRAP_STATE_CONFLICT",
|
|
72
|
+
"the stable slot exists but is incomplete; refusing to overwrite recovery evidence",
|
|
73
|
+
{ cause: error },
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
if (version.length === 0) {
|
|
77
|
+
throw new BootstrapError(
|
|
78
|
+
"BOOTSTRAP_STATE_CONFLICT",
|
|
79
|
+
"the stable slot has an empty version; refusing to overwrite recovery evidence",
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
return version;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function assertPristineTransactionState(stateDir: string): Promise<void> {
|
|
86
|
+
const conflicting = [
|
|
87
|
+
path.join(stateDir, "journal.jsonl"),
|
|
88
|
+
path.join(stateDir, "slots", "experiment"),
|
|
89
|
+
];
|
|
90
|
+
for (const candidate of conflicting) {
|
|
91
|
+
if (await pathExists(candidate)) {
|
|
92
|
+
throw new BootstrapError(
|
|
93
|
+
"BOOTSTRAP_STATE_CONFLICT",
|
|
94
|
+
`transaction state already exists at ${candidate}; refusing to invent an initial stable slot`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Seed K's stable slot exactly once from an application's current trusted
|
|
102
|
+
* executable. A complete existing stable slot means K is already initialized
|
|
103
|
+
* (possibly at a newer version), so the caller's old carrier bytes are ignored.
|
|
104
|
+
*/
|
|
105
|
+
export async function bootstrapStable(opts: BootstrapStableOptions): Promise<BootstrapStableResult> {
|
|
106
|
+
const version = opts.version.trim();
|
|
107
|
+
if (version.length === 0) {
|
|
108
|
+
throw new BootstrapError("BOOTSTRAP_VERSION_INVALID", "version must be non-empty");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const lock = await acquireUpgradeLock(opts.stateDir, (opts.nowMs ?? Date.now)());
|
|
112
|
+
try {
|
|
113
|
+
if ((await readInitializedStable(opts.stateDir)) !== null) return "already-initialized";
|
|
114
|
+
await assertPristineTransactionState(opts.stateDir);
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
const source = await fs.stat(opts.artifactPath);
|
|
118
|
+
if (!source.isFile()) throw new Error("bootstrap source is not a regular file");
|
|
119
|
+
} catch (error) {
|
|
120
|
+
throw new BootstrapError(
|
|
121
|
+
"BOOTSTRAP_SOURCE_UNREADABLE",
|
|
122
|
+
`trusted bootstrap artifact is not readable at ${opts.artifactPath}`,
|
|
123
|
+
{ cause: error },
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const slotsDir = path.join(opts.stateDir, "slots");
|
|
128
|
+
const stableDir = path.join(slotsDir, "stable");
|
|
129
|
+
const stagingDir = `${stableDir}.bootstrap`;
|
|
130
|
+
const stagingArtifact = path.join(stagingDir, "artifact.bin");
|
|
131
|
+
try {
|
|
132
|
+
await fs.rm(stagingDir, { recursive: true, force: true });
|
|
133
|
+
await fs.mkdir(stagingDir, { recursive: true });
|
|
134
|
+
await fs.copyFile(opts.artifactPath, stagingArtifact);
|
|
135
|
+
const artifactHandle = await fs.open(stagingArtifact, "r+");
|
|
136
|
+
try {
|
|
137
|
+
await artifactHandle.sync();
|
|
138
|
+
} finally {
|
|
139
|
+
await artifactHandle.close();
|
|
140
|
+
}
|
|
141
|
+
await platformOpsFor().makeExecutable(stagingArtifact);
|
|
142
|
+
|
|
143
|
+
const versionHandle = await fs.open(path.join(stagingDir, VERSION_FILE), "w");
|
|
144
|
+
try {
|
|
145
|
+
await versionHandle.writeFile(version);
|
|
146
|
+
await versionHandle.sync();
|
|
147
|
+
} finally {
|
|
148
|
+
await versionHandle.close();
|
|
149
|
+
}
|
|
150
|
+
await fs.mkdir(slotsDir, { recursive: true });
|
|
151
|
+
await platformOpsFor().renamePath(stagingDir, stableDir);
|
|
152
|
+
return "bootstrapped";
|
|
153
|
+
} catch (error) {
|
|
154
|
+
await fs.rm(stagingDir, { recursive: true, force: true }).catch(() => {});
|
|
155
|
+
if (error instanceof BootstrapError) throw error;
|
|
156
|
+
throw new BootstrapError(
|
|
157
|
+
"BOOTSTRAP_WRITE_FAILED",
|
|
158
|
+
"could not atomically publish the initial stable slot",
|
|
159
|
+
{ cause: error },
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
} finally {
|
|
163
|
+
await lock.release();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Public slot resolver used by host adapters; layout remains K-owned. */
|
|
168
|
+
export { slotArtifactPath } from "./txn/fileEffects.ts";
|
|
@@ -8,25 +8,24 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import type { Upgrader, UpgraderConfig, UpgradeOutcome, ProvenanceIdentity } from "./upgrader.ts";
|
|
10
10
|
import { phaseAtRest, type TxnState } from "./txn/state.ts";
|
|
11
|
-
import type { Release } from "./artifact/source.ts";
|
|
12
11
|
import type { ProcessEvidence } from "./lifecycle/hostAdapter.ts";
|
|
13
12
|
import { UpgradeEngine } from "./txn/engine.ts";
|
|
14
|
-
import { fileEffects
|
|
13
|
+
import { fileEffects } from "./txn/fileEffects.ts";
|
|
15
14
|
import { acquireUpgradeLock } from "./txn/lock.ts";
|
|
16
|
-
import { downloadVerified } from "./artifact/download.ts";
|
|
17
|
-
import { ArtifactError } from "./artifact/errors.ts";
|
|
18
|
-
import * as path from "node:path";
|
|
19
15
|
import { systemClock, type Clock } from "./clock.ts";
|
|
20
16
|
import { buildSurfaceAllowlist, evaluateLifecycleConvergence } from "./converge/lifecycle.ts";
|
|
21
17
|
import type { ReadbackSurface, PredicateResult } from "./converge/predicates.ts";
|
|
22
18
|
import { platformOpsFor } from "./platform/index.ts";
|
|
23
19
|
import type { UpgradeProgress } from "./progress.ts";
|
|
24
20
|
import { slotArtifactPath } from "./txn/fileEffects.ts";
|
|
25
|
-
import {
|
|
26
|
-
import { finishUpgradeOutcome } from "./upgrade/outcome.ts";
|
|
21
|
+
import type { ProvenanceJournal } from "./provenance/journal.ts";
|
|
27
22
|
import { retireReason } from "./upgrade/retire.ts";
|
|
28
23
|
import { buildStatusReport, type StatusReport } from "./status/report.ts";
|
|
29
24
|
import { persistReport, loadLastReport, type ReportRead } from "./status/reportStore.ts";
|
|
25
|
+
import { recoverUpgrade } from "./upgrade/recover.ts";
|
|
26
|
+
import type { OperationDescriptor } from "./operation.ts";
|
|
27
|
+
import { createOperationLifecycle } from "./operationLifecycle.ts";
|
|
28
|
+
import { driveUpgrade } from "./upgrade/drive.ts";
|
|
30
29
|
|
|
31
30
|
export interface CreateUpgraderOptions extends UpgraderConfig {
|
|
32
31
|
clock?: Clock;
|
|
@@ -65,7 +64,6 @@ export function createUpgrader(opts: CreateUpgraderOptions): Upgrader {
|
|
|
65
64
|
const clock = opts.clock ?? systemClock;
|
|
66
65
|
const effects = fileEffects(opts.stateDir);
|
|
67
66
|
const ownership = opts.installOwnership ?? ((): "self" => "self");
|
|
68
|
-
|
|
69
67
|
// The last predicate evidence, captured for the promote report (the
|
|
70
68
|
// engine only carries pass/fail; the report needs the real results).
|
|
71
69
|
let lastEvidence: ProcessEvidence | null = null;
|
|
@@ -121,116 +119,37 @@ export function createUpgrader(opts: CreateUpgraderOptions): Upgrader {
|
|
|
121
119
|
};
|
|
122
120
|
}
|
|
123
121
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
opts.
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
provenance: ProvenanceIdentity | null = null,
|
|
143
|
-
): Promise<UpgradeOutcome> {
|
|
144
|
-
const owner = ownership();
|
|
145
|
-
if (owner === "managed-elsewhere") {
|
|
146
|
-
await notify("held", { reason: "managed-elsewhere" });
|
|
147
|
-
return { result: "held", reason: "this install is managed by another manager; it does not upgrade itself" };
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
const lock = await acquireUpgradeLock(opts.stateDir, clock.nowMs());
|
|
151
|
-
try {
|
|
152
|
-
await engine.recover(); // finish or undo anything a previous crash left
|
|
153
|
-
|
|
154
|
-
const current = (await readState()).stableVersion;
|
|
155
|
-
reportStage({ stage: "checking" });
|
|
156
|
-
let release: Release | null;
|
|
157
|
-
try {
|
|
158
|
-
release = await pick(current);
|
|
159
|
-
} catch (err) {
|
|
160
|
-
// Consent is to a SPECIFIC version: if the source can no longer
|
|
161
|
-
// serve it (the publisher moved on), the approval is void — a typed
|
|
162
|
-
// refusal, never a silent switch to whatever is current now.
|
|
163
|
-
if (consented && err instanceof ArtifactError && err.code === "PINNED_VERSION_MISMATCH") {
|
|
164
|
-
await notify("held", { reason: "consented-version-unavailable", version: current });
|
|
165
|
-
return {
|
|
166
|
-
result: "held",
|
|
167
|
-
reason: `the approved version is no longer served; nothing was installed`,
|
|
168
|
-
};
|
|
169
|
-
}
|
|
170
|
-
throw err;
|
|
171
|
-
}
|
|
172
|
-
if (release === null) return { result: "up-to-date" };
|
|
173
|
-
|
|
174
|
-
if (!consented && opts.policy === "notify-only") {
|
|
175
|
-
await notify("held", { reason: "notify-only", version: release.version });
|
|
176
|
-
return { result: "held", reason: `policy is notify-only; ${release.version} is available` };
|
|
177
|
-
}
|
|
178
|
-
if (!consented && opts.policy === "confirm") {
|
|
179
|
-
await notify("confirm-request", { version: release.version, current });
|
|
180
|
-
return { result: "held", reason: `policy requires confirmation before upgrading to ${release.version}` };
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
if (opts.checkCompatibility) {
|
|
184
|
-
const refusal = await opts.checkCompatibility(current, release.version);
|
|
185
|
-
if (refusal !== null) {
|
|
186
|
-
await notify("held", { reason: "incompatible", detail: refusal });
|
|
187
|
-
return { result: "held", reason: `incompatible: ${refusal}` };
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// Resume support: an interrupted download (process death
|
|
192
|
-
// mid-fetch) leaves its prefix in stateDir/incoming and the next
|
|
193
|
-
// attempt continues via Range instead of restarting from zero.
|
|
194
|
-
reportStage({ stage: "downloading", version: release.version });
|
|
195
|
-
const bytes = await downloadVerified(release, {
|
|
196
|
-
clock,
|
|
197
|
-
resumeDir: path.join(opts.stateDir, "incoming"),
|
|
198
|
-
onProgress: (downloaded, total) =>
|
|
199
|
-
reportStage({ stage: "downloading", version: release.version, downloaded, total }),
|
|
200
|
-
});
|
|
201
|
-
reportStage({ stage: "verifying", version: release.version });
|
|
202
|
-
|
|
203
|
-
reportStage({ stage: "staging", version: release.version });
|
|
204
|
-
const bytesRef = await materializeArtifact(opts.stateDir, bytes);
|
|
205
|
-
|
|
206
|
-
// M6 provenance: record WHO drove this reconcile, write-ahead of the txn.
|
|
207
|
-
if (opts.provenance) {
|
|
208
|
-
await recordReconcile(opts.provenance, provenance ?? opts.provenanceIdentity, release.version);
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
reportStage({ stage: "handing-over", version: release.version });
|
|
212
|
-
const outcome = await engine.upgrade({ version: release.version, bytesRef });
|
|
213
|
-
const finished = await finishUpgradeOutcome(outcome, {
|
|
214
|
-
notify,
|
|
215
|
-
reportStage,
|
|
216
|
-
targetVersion: release.version,
|
|
217
|
-
declaredSurfaces: (opts.lifecycleSurfaces ?? []).length,
|
|
218
|
-
nowMs: clock.nowMs(),
|
|
219
|
-
evidence: lastEvidence,
|
|
220
|
-
lifecycle: lastLifecycle,
|
|
221
|
-
});
|
|
222
|
-
if (finished.report !== null) {
|
|
223
|
-
lastReport = { kind: "observed", report: finished.report };
|
|
122
|
+
const operationLifecycle = createOperationLifecycle(opts.stateDir, clock, readState);
|
|
123
|
+
const drive = (request: Parameters<typeof driveUpgrade>[1]): Promise<UpgradeOutcome> =>
|
|
124
|
+
driveUpgrade({
|
|
125
|
+
stateDir: opts.stateDir,
|
|
126
|
+
clock,
|
|
127
|
+
engine,
|
|
128
|
+
operation: operationLifecycle,
|
|
129
|
+
ownership,
|
|
130
|
+
readStableVersion: async () => (await readState()).stableVersion,
|
|
131
|
+
policy: opts.policy,
|
|
132
|
+
notificationSink: opts.notificationSink,
|
|
133
|
+
...(opts.onProgress ? { onProgress: opts.onProgress } : {}),
|
|
134
|
+
...(opts.checkCompatibility ? { checkCompatibility: opts.checkCompatibility } : {}),
|
|
135
|
+
lifecycleSurfaceCount: (opts.lifecycleSurfaces ?? []).length,
|
|
136
|
+
evidence: () => lastEvidence,
|
|
137
|
+
lifecycle: () => lastLifecycle,
|
|
138
|
+
persistConvergenceReport: async (report) => {
|
|
139
|
+
lastReport = { kind: "observed", report };
|
|
224
140
|
reportLoaded = true;
|
|
225
|
-
await persistReport(opts.stateDir,
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
}
|
|
231
|
-
}
|
|
141
|
+
await persistReport(opts.stateDir, report);
|
|
142
|
+
},
|
|
143
|
+
...(opts.provenance ? { provenanceJournal: opts.provenance } : {}),
|
|
144
|
+
...(opts.provenanceIdentity ? { provenanceIdentity: opts.provenanceIdentity } : {}),
|
|
145
|
+
}, request);
|
|
232
146
|
|
|
233
147
|
return {
|
|
148
|
+
recover: async () => {
|
|
149
|
+
operationLifecycle.reset();
|
|
150
|
+
await recoverUpgrade(opts.stateDir, clock, engine, operationLifecycle.settleRecovery);
|
|
151
|
+
},
|
|
152
|
+
|
|
234
153
|
async check(): Promise<{ current: string; target: string | null }> {
|
|
235
154
|
const current = (await readState()).stableVersion;
|
|
236
155
|
const release = await opts.source.checkForUpdate({
|
|
@@ -241,21 +160,29 @@ export function createUpgrader(opts: CreateUpgraderOptions): Upgrader {
|
|
|
241
160
|
},
|
|
242
161
|
|
|
243
162
|
async upgrade(): Promise<UpgradeOutcome> {
|
|
244
|
-
return
|
|
245
|
-
opts.source.checkForUpdate({
|
|
246
|
-
|
|
163
|
+
return drive({
|
|
164
|
+
pick: async (current) => opts.source.checkForUpdate({
|
|
165
|
+
currentVersion: current,
|
|
166
|
+
platformKey: platformOpsFor().platformKey(),
|
|
167
|
+
}),
|
|
168
|
+
});
|
|
247
169
|
},
|
|
248
170
|
|
|
249
|
-
async upgradeTo(version: string, opts2?: {
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
171
|
+
async upgradeTo(version: string, opts2?: {
|
|
172
|
+
consented?: boolean;
|
|
173
|
+
provenance?: ProvenanceIdentity;
|
|
174
|
+
operation?: OperationDescriptor;
|
|
175
|
+
}): Promise<UpgradeOutcome> {
|
|
176
|
+
return drive({
|
|
177
|
+
pick: async (current) => opts.source.fetchRelease(version, {
|
|
253
178
|
currentVersion: current,
|
|
254
179
|
platformKey: platformOpsFor().platformKey(),
|
|
255
|
-
|
|
256
|
-
opts2?.consented === true,
|
|
257
|
-
opts2?.provenance
|
|
258
|
-
|
|
180
|
+
}),
|
|
181
|
+
consented: opts2?.consented === true,
|
|
182
|
+
...(opts2?.provenance ? { provenance: opts2.provenance } : {}),
|
|
183
|
+
...(opts2?.operation ? { operation: opts2.operation } : {}),
|
|
184
|
+
targetVersionHint: version,
|
|
185
|
+
});
|
|
259
186
|
},
|
|
260
187
|
|
|
261
188
|
async retireLegacyManager(): Promise<"retired" | { held: string }> {
|
|
@@ -272,12 +199,12 @@ export function createUpgrader(opts: CreateUpgraderOptions): Upgrader {
|
|
|
272
199
|
const last = (await effects.journal.readAll()).at(-1)?.intent;
|
|
273
200
|
const inFlight = last !== undefined && !phaseAtRest(last);
|
|
274
201
|
if (!inFlight && ownership() === "managed-elsewhere") {
|
|
275
|
-
await
|
|
202
|
+
await opts.notificationSink({ kind: "held", detail: { reason: "managed-elsewhere" } });
|
|
276
203
|
return { held: "this install is managed by another manager; it does not roll itself back" };
|
|
277
204
|
}
|
|
278
205
|
await engine.recover();
|
|
279
206
|
await effects.slots.clearExperiment();
|
|
280
|
-
await
|
|
207
|
+
await opts.notificationSink({ kind: "rolled-back", detail: { reason } });
|
|
281
208
|
return "rolled-back";
|
|
282
209
|
} finally {
|
|
283
210
|
await lock.release();
|
|
@@ -296,5 +223,16 @@ export function createUpgrader(opts: CreateUpgraderOptions): Upgrader {
|
|
|
296
223
|
provenance: opts.provenance ? await opts.provenance.read() : null,
|
|
297
224
|
});
|
|
298
225
|
},
|
|
226
|
+
|
|
227
|
+
operation: operationLifecycle.read,
|
|
228
|
+
|
|
229
|
+
async acknowledgeOperation(operationId) {
|
|
230
|
+
const lock = await acquireUpgradeLock(opts.stateDir, clock.nowMs());
|
|
231
|
+
try {
|
|
232
|
+
return await operationLifecycle.acknowledge(operationId);
|
|
233
|
+
} finally {
|
|
234
|
+
await lock.release();
|
|
235
|
+
}
|
|
236
|
+
},
|
|
299
237
|
};
|
|
300
238
|
}
|
package/core/src/index.ts
CHANGED
|
@@ -6,9 +6,19 @@
|
|
|
6
6
|
// The upgrader factory and its configuration.
|
|
7
7
|
export * from "./createUpgrader.ts";
|
|
8
8
|
|
|
9
|
+
// One-time adoption of an already-running trusted binary into K's stable
|
|
10
|
+
// slot, plus the K-owned slot resolver host adapters use to launch it.
|
|
11
|
+
export * from "./bootstrap.ts";
|
|
12
|
+
|
|
9
13
|
// Core types: Upgrader, UpgraderConfig, UpgradeOutcome, ProvenanceIdentity,
|
|
10
14
|
// NotificationEvent.
|
|
11
15
|
export * from "./upgrader.ts";
|
|
16
|
+
export * from "./operation.ts";
|
|
17
|
+
|
|
18
|
+
// The release-source boundary applications implement and the durable
|
|
19
|
+
// provenance journal they wire into createUpgrader.
|
|
20
|
+
export * from "./artifact/source.ts";
|
|
21
|
+
export * from "./provenance/journal.ts";
|
|
12
22
|
|
|
13
23
|
// The host boundary an adopter implements: HostAdapter, Slot, ProcessEvidence.
|
|
14
24
|
export * from "./lifecycle/hostAdapter.ts";
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* K-owned durable operation receipt.
|
|
3
|
+
*
|
|
4
|
+
* Hosts may project this record into their own UI or transport, but they do
|
|
5
|
+
* not maintain a second upgrade state machine. The operation receipt is the
|
|
6
|
+
* single durable answer to: what is running, which version was requested,
|
|
7
|
+
* what stable version can be restored, and whether the terminal receipt has
|
|
8
|
+
* already been acknowledged by the host transport.
|
|
9
|
+
*/
|
|
10
|
+
import { promises as fs } from "node:fs";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
import { platformOpsFor } from "./platform/index.ts";
|
|
13
|
+
import type { ProvenanceIdentity } from "./upgrader.ts";
|
|
14
|
+
|
|
15
|
+
const OPERATION_FILE = "operation.json";
|
|
16
|
+
export const OPERATION_FORMAT_VERSION = 1;
|
|
17
|
+
|
|
18
|
+
export type OperationPhase =
|
|
19
|
+
| "checking"
|
|
20
|
+
| "downloading"
|
|
21
|
+
| "verifying"
|
|
22
|
+
| "staging"
|
|
23
|
+
| "handing-over"
|
|
24
|
+
| "probing"
|
|
25
|
+
| "recovering"
|
|
26
|
+
| "promoted"
|
|
27
|
+
| "rolled-back"
|
|
28
|
+
| "held"
|
|
29
|
+
| "up-to-date"
|
|
30
|
+
| "failed";
|
|
31
|
+
|
|
32
|
+
export type OperationOutcome = "promoted" | "rolled-back" | "held" | "up-to-date" | "failed";
|
|
33
|
+
|
|
34
|
+
const OPERATION_PHASES = new Set<OperationPhase>([
|
|
35
|
+
"checking",
|
|
36
|
+
"downloading",
|
|
37
|
+
"verifying",
|
|
38
|
+
"staging",
|
|
39
|
+
"handing-over",
|
|
40
|
+
"probing",
|
|
41
|
+
"recovering",
|
|
42
|
+
"promoted",
|
|
43
|
+
"rolled-back",
|
|
44
|
+
"held",
|
|
45
|
+
"up-to-date",
|
|
46
|
+
"failed",
|
|
47
|
+
]);
|
|
48
|
+
const OPERATION_OUTCOMES = new Set<OperationOutcome>([
|
|
49
|
+
"promoted",
|
|
50
|
+
"rolled-back",
|
|
51
|
+
"held",
|
|
52
|
+
"up-to-date",
|
|
53
|
+
"failed",
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
export interface OperationDescriptor {
|
|
57
|
+
id: string;
|
|
58
|
+
startedAtMs: number;
|
|
59
|
+
provenance?: ProvenanceIdentity;
|
|
60
|
+
/** Opaque non-secret host correlation fields (for example server/request ids). */
|
|
61
|
+
metadata?: Record<string, string>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface OperationRecord {
|
|
65
|
+
formatVersion: typeof OPERATION_FORMAT_VERSION;
|
|
66
|
+
id: string;
|
|
67
|
+
startedAtMs: number;
|
|
68
|
+
updatedAtMs: number;
|
|
69
|
+
fromVersion: string;
|
|
70
|
+
targetVersion: string;
|
|
71
|
+
/** The stable version that was current when this operation began. */
|
|
72
|
+
previousStableVersion: string;
|
|
73
|
+
phase: OperationPhase;
|
|
74
|
+
outcome: OperationOutcome | null;
|
|
75
|
+
reason: string | null;
|
|
76
|
+
provenance: ProvenanceIdentity | null;
|
|
77
|
+
metadata: Record<string, string>;
|
|
78
|
+
/** Host transport receipt, not a second transaction outcome. */
|
|
79
|
+
acknowledgedAtMs: number | null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type OperationRead =
|
|
83
|
+
| { kind: "genesis" }
|
|
84
|
+
| { kind: "observed"; operation: OperationRecord }
|
|
85
|
+
| { kind: "unreadable"; reason: string };
|
|
86
|
+
|
|
87
|
+
function operationPath(stateDir: string): string {
|
|
88
|
+
return path.join(stateDir, OPERATION_FILE);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function validIdentity(value: unknown): value is ProvenanceIdentity {
|
|
92
|
+
return Boolean(
|
|
93
|
+
value
|
|
94
|
+
&& typeof value === "object"
|
|
95
|
+
&& typeof (value as ProvenanceIdentity).who === "string"
|
|
96
|
+
&& typeof (value as ProvenanceIdentity).carrier === "string",
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function parseOperation(text: string): OperationRecord {
|
|
101
|
+
const parsed = JSON.parse(text) as Partial<OperationRecord>;
|
|
102
|
+
if (
|
|
103
|
+
parsed.formatVersion !== OPERATION_FORMAT_VERSION
|
|
104
|
+
|| typeof parsed.id !== "string"
|
|
105
|
+
|| parsed.id.length === 0
|
|
106
|
+
|| typeof parsed.startedAtMs !== "number"
|
|
107
|
+
|| !Number.isFinite(parsed.startedAtMs)
|
|
108
|
+
|| typeof parsed.updatedAtMs !== "number"
|
|
109
|
+
|| !Number.isFinite(parsed.updatedAtMs)
|
|
110
|
+
|| typeof parsed.fromVersion !== "string"
|
|
111
|
+
|| typeof parsed.targetVersion !== "string"
|
|
112
|
+
|| typeof parsed.previousStableVersion !== "string"
|
|
113
|
+
|| typeof parsed.phase !== "string"
|
|
114
|
+
|| !OPERATION_PHASES.has(parsed.phase as OperationPhase)
|
|
115
|
+
|| !(parsed.outcome === null || (
|
|
116
|
+
typeof parsed.outcome === "string"
|
|
117
|
+
&& OPERATION_OUTCOMES.has(parsed.outcome as OperationOutcome)
|
|
118
|
+
))
|
|
119
|
+
|| !(parsed.reason === null || typeof parsed.reason === "string")
|
|
120
|
+
|| !(parsed.provenance === null || validIdentity(parsed.provenance))
|
|
121
|
+
|| typeof parsed.metadata !== "object"
|
|
122
|
+
|| parsed.metadata === null
|
|
123
|
+
|| Array.isArray(parsed.metadata)
|
|
124
|
+
|| Object.values(parsed.metadata).some((value) => typeof value !== "string")
|
|
125
|
+
|| !(parsed.acknowledgedAtMs === null || typeof parsed.acknowledgedAtMs === "number")
|
|
126
|
+
) {
|
|
127
|
+
throw new Error("operation record has an invalid shape");
|
|
128
|
+
}
|
|
129
|
+
return parsed as OperationRecord;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Atomic and durable replacement. K's upgrade lock serializes writers. */
|
|
133
|
+
export async function persistOperation(stateDir: string, operation: OperationRecord): Promise<void> {
|
|
134
|
+
await fs.mkdir(stateDir, { recursive: true });
|
|
135
|
+
const target = operationPath(stateDir);
|
|
136
|
+
const tmp = `${target}.tmp`;
|
|
137
|
+
const fh = await fs.open(tmp, "w");
|
|
138
|
+
try {
|
|
139
|
+
await fh.writeFile(JSON.stringify(operation));
|
|
140
|
+
await fh.sync();
|
|
141
|
+
} finally {
|
|
142
|
+
await fh.close();
|
|
143
|
+
}
|
|
144
|
+
await platformOpsFor().renamePath(tmp, target);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function loadOperation(stateDir: string): Promise<OperationRead> {
|
|
148
|
+
let text: string;
|
|
149
|
+
try {
|
|
150
|
+
text = await fs.readFile(operationPath(stateDir), "utf8");
|
|
151
|
+
} catch (error) {
|
|
152
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
153
|
+
if (code === "ENOENT") return { kind: "genesis" };
|
|
154
|
+
return { kind: "unreadable", reason: `cannot read ${OPERATION_FILE} (${code ?? (error as Error).message})` };
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
return { kind: "observed", operation: parseOperation(text) };
|
|
158
|
+
} catch (error) {
|
|
159
|
+
return { kind: "unreadable", reason: `corrupt ${OPERATION_FILE}: ${(error as Error).message}` };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function acknowledgeOperation(
|
|
164
|
+
stateDir: string,
|
|
165
|
+
operationId: string,
|
|
166
|
+
acknowledgedAtMs: number,
|
|
167
|
+
): Promise<"acknowledged" | "not-terminal" | "not-found" | "changed"> {
|
|
168
|
+
const current = await loadOperation(stateDir);
|
|
169
|
+
if (current.kind === "genesis") return "not-found";
|
|
170
|
+
if (current.kind === "unreadable") throw new Error(current.reason);
|
|
171
|
+
if (current.operation.id !== operationId) return "changed";
|
|
172
|
+
if (current.operation.outcome === null) return "not-terminal";
|
|
173
|
+
await persistOperation(stateDir, {
|
|
174
|
+
...current.operation,
|
|
175
|
+
updatedAtMs: acknowledgedAtMs,
|
|
176
|
+
acknowledgedAtMs,
|
|
177
|
+
});
|
|
178
|
+
return "acknowledged";
|
|
179
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { Clock } from "./clock.ts";
|
|
2
|
+
import { phaseAtRest, type TxnState } from "./txn/state.ts";
|
|
3
|
+
import type { ProvenanceIdentity } from "./upgrader.ts";
|
|
4
|
+
import {
|
|
5
|
+
acknowledgeOperation,
|
|
6
|
+
loadOperation,
|
|
7
|
+
persistOperation,
|
|
8
|
+
type OperationDescriptor,
|
|
9
|
+
type OperationOutcome,
|
|
10
|
+
type OperationPhase,
|
|
11
|
+
type OperationRead,
|
|
12
|
+
type OperationRecord,
|
|
13
|
+
} from "./operation.ts";
|
|
14
|
+
|
|
15
|
+
export interface OperationLifecycle {
|
|
16
|
+
begin(
|
|
17
|
+
descriptor: OperationDescriptor | null,
|
|
18
|
+
fromVersion: string,
|
|
19
|
+
targetVersion: string,
|
|
20
|
+
provenance: ProvenanceIdentity | null,
|
|
21
|
+
): Promise<void>;
|
|
22
|
+
transition(input: {
|
|
23
|
+
phase: OperationPhase;
|
|
24
|
+
outcome?: OperationOutcome | null;
|
|
25
|
+
reason?: string | null;
|
|
26
|
+
}): Promise<void>;
|
|
27
|
+
settleRecovery(): Promise<void>;
|
|
28
|
+
reset(): void;
|
|
29
|
+
read(): Promise<OperationRead>;
|
|
30
|
+
acknowledge(operationId: string): Promise<"acknowledged" | "not-terminal" | "not-found" | "changed">;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createOperationLifecycle(
|
|
34
|
+
stateDir: string,
|
|
35
|
+
clock: Clock,
|
|
36
|
+
readState: () => Promise<TxnState>,
|
|
37
|
+
): OperationLifecycle {
|
|
38
|
+
let record: OperationRecord | null = null;
|
|
39
|
+
|
|
40
|
+
const transition: OperationLifecycle["transition"] = async (input) => {
|
|
41
|
+
if (record === null) return;
|
|
42
|
+
record = {
|
|
43
|
+
...record,
|
|
44
|
+
updatedAtMs: clock.nowMs(),
|
|
45
|
+
phase: input.phase,
|
|
46
|
+
outcome: input.outcome === undefined ? record.outcome : input.outcome,
|
|
47
|
+
reason: input.reason === undefined ? record.reason : input.reason,
|
|
48
|
+
};
|
|
49
|
+
await persistOperation(stateDir, record);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
async begin(descriptor, fromVersion, targetVersion, provenance) {
|
|
54
|
+
if (descriptor === null) return;
|
|
55
|
+
const existing = await loadOperation(stateDir);
|
|
56
|
+
if (existing.kind === "unreadable") throw new Error(existing.reason);
|
|
57
|
+
if (existing.kind === "observed" && existing.operation.id !== descriptor.id) {
|
|
58
|
+
if (existing.operation.outcome === null) {
|
|
59
|
+
throw new Error(`OPERATION_IN_PROGRESS: ${existing.operation.id}`);
|
|
60
|
+
}
|
|
61
|
+
if (existing.operation.acknowledgedAtMs === null) {
|
|
62
|
+
throw new Error(`OPERATION_RECEIPT_PENDING: ${existing.operation.id}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (existing.kind === "observed" && existing.operation.id === descriptor.id) {
|
|
66
|
+
record = existing.operation;
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
record = {
|
|
70
|
+
formatVersion: 1,
|
|
71
|
+
id: descriptor.id,
|
|
72
|
+
startedAtMs: descriptor.startedAtMs,
|
|
73
|
+
updatedAtMs: clock.nowMs(),
|
|
74
|
+
fromVersion,
|
|
75
|
+
targetVersion,
|
|
76
|
+
previousStableVersion: fromVersion,
|
|
77
|
+
phase: "checking",
|
|
78
|
+
outcome: null,
|
|
79
|
+
reason: null,
|
|
80
|
+
provenance: descriptor.provenance ?? provenance,
|
|
81
|
+
metadata: { ...descriptor.metadata },
|
|
82
|
+
acknowledgedAtMs: null,
|
|
83
|
+
};
|
|
84
|
+
await persistOperation(stateDir, record);
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
transition,
|
|
88
|
+
|
|
89
|
+
async settleRecovery() {
|
|
90
|
+
const observed = await loadOperation(stateDir);
|
|
91
|
+
if (observed.kind !== "observed" || observed.operation.outcome !== null) return;
|
|
92
|
+
record = observed.operation;
|
|
93
|
+
const state = await readState();
|
|
94
|
+
if (state.phase === "promoted" && state.stableVersion === record.targetVersion) {
|
|
95
|
+
await transition({ phase: "promoted", outcome: "promoted" });
|
|
96
|
+
} else if (phaseAtRest(state.phase)) {
|
|
97
|
+
await transition({
|
|
98
|
+
phase: "rolled-back",
|
|
99
|
+
outcome: "rolled-back",
|
|
100
|
+
reason: state.rollbackReason ?? `recovery settled at ${state.phase} with stable ${state.stableVersion}`,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
reset() {
|
|
106
|
+
record = null;
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
read: () => loadOperation(stateDir),
|
|
110
|
+
|
|
111
|
+
acknowledge: (operationId) => acknowledgeOperation(stateDir, operationId, clock.nowMs()),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
*/
|
|
24
24
|
import { promises as fs } from "node:fs";
|
|
25
25
|
import path from "node:path";
|
|
26
|
-
import type
|
|
26
|
+
import { systemClock, type Clock } from "../clock.ts";
|
|
27
27
|
|
|
28
28
|
const PROVENANCE_FILE = "provenance.jsonl";
|
|
29
29
|
|
|
@@ -84,7 +84,7 @@ export interface ProvenanceJournal {
|
|
|
84
84
|
read(): Promise<ProvenanceRead>;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
export function fileProvenanceJournal(stateDir: string, clock: Clock): ProvenanceJournal {
|
|
87
|
+
export function fileProvenanceJournal(stateDir: string, clock: Clock = systemClock): ProvenanceJournal {
|
|
88
88
|
const filePath = path.join(stateDir, PROVENANCE_FILE);
|
|
89
89
|
|
|
90
90
|
/** Read the raw file. Throws ProvenanceHistoryUnreadableError when the
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import type { Release } from "../artifact/source.ts";
|
|
3
|
+
import { downloadVerified } from "../artifact/download.ts";
|
|
4
|
+
import { ArtifactError } from "../artifact/errors.ts";
|
|
5
|
+
import { materializeArtifact } from "../txn/fileEffects.ts";
|
|
6
|
+
import { acquireUpgradeLock } from "../txn/lock.ts";
|
|
7
|
+
import type { UpgradeEngine, EngineOutcome } from "../txn/engine.ts";
|
|
8
|
+
import type { Clock } from "../clock.ts";
|
|
9
|
+
import type { UpgradeProgress } from "../progress.ts";
|
|
10
|
+
import type { ProcessEvidence } from "../lifecycle/hostAdapter.ts";
|
|
11
|
+
import type { PredicateResult, ConvergenceReport } from "../converge/predicates.ts";
|
|
12
|
+
import type { OperationDescriptor } from "../operation.ts";
|
|
13
|
+
import type { OperationLifecycle } from "../operationLifecycle.ts";
|
|
14
|
+
import type {
|
|
15
|
+
NotificationEvent,
|
|
16
|
+
ProvenanceIdentity,
|
|
17
|
+
UpgradeOutcome,
|
|
18
|
+
} from "../upgrader.ts";
|
|
19
|
+
import type { ProvenanceJournal } from "../provenance/journal.ts";
|
|
20
|
+
import { recordReconcile } from "../provenance/journal.ts";
|
|
21
|
+
import { finishUpgradeOutcome } from "./outcome.ts";
|
|
22
|
+
|
|
23
|
+
export interface UpgradeDriveDeps {
|
|
24
|
+
stateDir: string;
|
|
25
|
+
clock: Clock;
|
|
26
|
+
engine: UpgradeEngine;
|
|
27
|
+
operation: OperationLifecycle;
|
|
28
|
+
ownership: () => "self" | "managed-elsewhere";
|
|
29
|
+
readStableVersion: () => Promise<string>;
|
|
30
|
+
policy: "auto" | "confirm" | "notify-only";
|
|
31
|
+
notificationSink: (event: NotificationEvent) => Promise<void>;
|
|
32
|
+
onProgress?: (progress: UpgradeProgress) => void;
|
|
33
|
+
checkCompatibility?: (from: string, to: string) => Promise<string | null>;
|
|
34
|
+
lifecycleSurfaceCount: number;
|
|
35
|
+
evidence: () => ProcessEvidence | null;
|
|
36
|
+
lifecycle: () => PredicateResult | null;
|
|
37
|
+
persistConvergenceReport: (report: ConvergenceReport) => Promise<void>;
|
|
38
|
+
provenanceJournal?: ProvenanceJournal;
|
|
39
|
+
provenanceIdentity?: ProvenanceIdentity;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface UpgradeDriveRequest {
|
|
43
|
+
pick: (current: string) => Promise<Release | null>;
|
|
44
|
+
consented?: boolean;
|
|
45
|
+
provenance?: ProvenanceIdentity;
|
|
46
|
+
operation?: OperationDescriptor;
|
|
47
|
+
targetVersionHint?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function driveUpgrade(
|
|
51
|
+
deps: UpgradeDriveDeps,
|
|
52
|
+
request: UpgradeDriveRequest,
|
|
53
|
+
): Promise<UpgradeOutcome> {
|
|
54
|
+
const notify = (kind: NotificationEvent["kind"], detail: Record<string, string>) =>
|
|
55
|
+
deps.notificationSink({ kind, detail });
|
|
56
|
+
const progress = (value: UpgradeProgress): void => {
|
|
57
|
+
try { deps.onProgress?.(value); } catch { /* observation cannot fail an upgrade */ }
|
|
58
|
+
};
|
|
59
|
+
if (deps.ownership() === "managed-elsewhere") {
|
|
60
|
+
await notify("held", { reason: "managed-elsewhere" });
|
|
61
|
+
return { result: "held", reason: "this install is managed by another manager; it does not upgrade itself" };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const lock = await acquireUpgradeLock(deps.stateDir, deps.clock.nowMs());
|
|
65
|
+
try {
|
|
66
|
+
await deps.engine.recover();
|
|
67
|
+
await deps.operation.settleRecovery();
|
|
68
|
+
const current = await deps.readStableVersion();
|
|
69
|
+
await deps.operation.begin(
|
|
70
|
+
request.operation ?? null,
|
|
71
|
+
current,
|
|
72
|
+
request.targetVersionHint ?? current,
|
|
73
|
+
request.provenance ?? null,
|
|
74
|
+
);
|
|
75
|
+
progress({ stage: "checking" });
|
|
76
|
+
|
|
77
|
+
let release: Release | null;
|
|
78
|
+
try {
|
|
79
|
+
release = await request.pick(current);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (request.consented && error instanceof ArtifactError && error.code === "PINNED_VERSION_MISMATCH") {
|
|
82
|
+
await deps.operation.transition({ phase: "held", outcome: "held", reason: "consented-version-unavailable" });
|
|
83
|
+
await notify("held", { reason: "consented-version-unavailable", version: current });
|
|
84
|
+
return { result: "held", reason: "the approved version is no longer served; nothing was installed" };
|
|
85
|
+
}
|
|
86
|
+
await deps.operation.transition({
|
|
87
|
+
phase: "failed",
|
|
88
|
+
outcome: "failed",
|
|
89
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
90
|
+
});
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
if (release === null) {
|
|
94
|
+
await deps.operation.transition({ phase: "up-to-date", outcome: "up-to-date" });
|
|
95
|
+
return { result: "up-to-date" };
|
|
96
|
+
}
|
|
97
|
+
if (!request.consented && deps.policy === "notify-only") {
|
|
98
|
+
await deps.operation.transition({ phase: "held", outcome: "held", reason: "notify-only" });
|
|
99
|
+
await notify("held", { reason: "notify-only", version: release.version });
|
|
100
|
+
return { result: "held", reason: `policy is notify-only; ${release.version} is available` };
|
|
101
|
+
}
|
|
102
|
+
if (!request.consented && deps.policy === "confirm") {
|
|
103
|
+
await deps.operation.transition({ phase: "held", outcome: "held", reason: "confirmation-required" });
|
|
104
|
+
await notify("confirm-request", { version: release.version, current });
|
|
105
|
+
return { result: "held", reason: `policy requires confirmation before upgrading to ${release.version}` };
|
|
106
|
+
}
|
|
107
|
+
const refusal = deps.checkCompatibility
|
|
108
|
+
? await deps.checkCompatibility(current, release.version)
|
|
109
|
+
: null;
|
|
110
|
+
if (refusal !== null) {
|
|
111
|
+
await deps.operation.transition({ phase: "held", outcome: "held", reason: refusal });
|
|
112
|
+
await notify("held", { reason: "incompatible", detail: refusal });
|
|
113
|
+
return { result: "held", reason: `incompatible: ${refusal}` };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
await deps.operation.transition({ phase: "downloading" });
|
|
117
|
+
progress({ stage: "downloading", version: release.version });
|
|
118
|
+
const bytes = await downloadVerified(release, {
|
|
119
|
+
clock: deps.clock,
|
|
120
|
+
resumeDir: path.join(deps.stateDir, "incoming"),
|
|
121
|
+
onProgress: (downloaded, total) =>
|
|
122
|
+
progress({ stage: "downloading", version: release.version, downloaded, total }),
|
|
123
|
+
});
|
|
124
|
+
await deps.operation.transition({ phase: "verifying" });
|
|
125
|
+
progress({ stage: "verifying", version: release.version });
|
|
126
|
+
await deps.operation.transition({ phase: "staging" });
|
|
127
|
+
progress({ stage: "staging", version: release.version });
|
|
128
|
+
const bytesRef = await materializeArtifact(deps.stateDir, bytes);
|
|
129
|
+
if (deps.provenanceJournal) {
|
|
130
|
+
await recordReconcile(
|
|
131
|
+
deps.provenanceJournal,
|
|
132
|
+
request.provenance ?? deps.provenanceIdentity,
|
|
133
|
+
release.version,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
await deps.operation.transition({ phase: "handing-over" });
|
|
138
|
+
progress({ stage: "handing-over", version: release.version });
|
|
139
|
+
let engineOutcome: EngineOutcome;
|
|
140
|
+
try {
|
|
141
|
+
engineOutcome = await deps.engine.upgrade({ version: release.version, bytesRef });
|
|
142
|
+
} catch (error) {
|
|
143
|
+
await deps.operation.transition({
|
|
144
|
+
phase: "recovering",
|
|
145
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
146
|
+
});
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
const finished = await finishUpgradeOutcome(engineOutcome, {
|
|
150
|
+
notify,
|
|
151
|
+
reportStage: progress,
|
|
152
|
+
targetVersion: release.version,
|
|
153
|
+
declaredSurfaces: deps.lifecycleSurfaceCount,
|
|
154
|
+
nowMs: deps.clock.nowMs(),
|
|
155
|
+
evidence: deps.evidence(),
|
|
156
|
+
lifecycle: deps.lifecycle(),
|
|
157
|
+
});
|
|
158
|
+
if (finished.report !== null) await deps.persistConvergenceReport(finished.report);
|
|
159
|
+
switch (finished.outcome.result) {
|
|
160
|
+
case "promoted":
|
|
161
|
+
await deps.operation.transition({ phase: "promoted", outcome: "promoted" });
|
|
162
|
+
break;
|
|
163
|
+
case "rolled-back":
|
|
164
|
+
await deps.operation.transition({ phase: "rolled-back", outcome: "rolled-back", reason: finished.outcome.reason });
|
|
165
|
+
break;
|
|
166
|
+
case "held":
|
|
167
|
+
await deps.operation.transition({ phase: "held", outcome: "held", reason: finished.outcome.reason });
|
|
168
|
+
break;
|
|
169
|
+
case "up-to-date":
|
|
170
|
+
await deps.operation.transition({ phase: "up-to-date", outcome: "up-to-date" });
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
return finished.outcome;
|
|
174
|
+
} finally {
|
|
175
|
+
await lock.release();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Clock } from "../clock.ts";
|
|
2
|
+
import type { UpgradeEngine } from "../txn/engine.ts";
|
|
3
|
+
import { acquireUpgradeLock } from "../txn/lock.ts";
|
|
4
|
+
|
|
5
|
+
/** Settle an existing transaction under the same lock as every other drive. */
|
|
6
|
+
export async function recoverUpgrade(
|
|
7
|
+
stateDir: string,
|
|
8
|
+
clock: Clock,
|
|
9
|
+
engine: UpgradeEngine,
|
|
10
|
+
afterRecover?: () => Promise<void>,
|
|
11
|
+
): Promise<void> {
|
|
12
|
+
const lock = await acquireUpgradeLock(stateDir, clock.nowMs());
|
|
13
|
+
try {
|
|
14
|
+
await engine.recover();
|
|
15
|
+
await afterRecover?.();
|
|
16
|
+
} finally {
|
|
17
|
+
await lock.release();
|
|
18
|
+
}
|
|
19
|
+
}
|
package/core/src/upgrader.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { TxnState } from "./txn/state.js";
|
|
|
3
3
|
import type { ConvergenceReport } from "./converge/predicates.js";
|
|
4
4
|
import type { StatusReport } from "./status/report.js";
|
|
5
5
|
import type { ReleaseSource } from "./artifact/source.js";
|
|
6
|
+
import type { OperationDescriptor, OperationRead } from "./operation.js";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Who drove a reconcile, recorded in the provenance journal (M6, L5).
|
|
@@ -24,6 +25,17 @@ export interface ProvenanceIdentity {
|
|
|
24
25
|
* convergence" (the class of bug this framework exists to kill).
|
|
25
26
|
*/
|
|
26
27
|
export interface Upgrader {
|
|
28
|
+
/**
|
|
29
|
+
* Settle any transaction a previous coordinator left in flight.
|
|
30
|
+
*
|
|
31
|
+
* Recovery uses the same durable journal, host adapter, predicates and
|
|
32
|
+
* upgrade lock as ordinary upgrades. It never consults the release source
|
|
33
|
+
* or begins a new transaction; it only replays or rolls back work already
|
|
34
|
+
* recorded by K. Hosts should run this from a coordinator that survives
|
|
35
|
+
* service replacement, because recovery may stop and restart the service.
|
|
36
|
+
*/
|
|
37
|
+
recover(): Promise<void>;
|
|
38
|
+
|
|
27
39
|
/**
|
|
28
40
|
* Ask the release source whether this install should move, without moving
|
|
29
41
|
* it. `target: null` means nothing to do.
|
|
@@ -51,7 +63,11 @@ export interface Upgrader {
|
|
|
51
63
|
* silently installing whatever is current now. Consent is to a SPECIFIC
|
|
52
64
|
* version, never to "the upgrade" as an event.
|
|
53
65
|
*/
|
|
54
|
-
upgradeTo(version: string, opts?: {
|
|
66
|
+
upgradeTo(version: string, opts?: {
|
|
67
|
+
consented?: boolean;
|
|
68
|
+
provenance?: ProvenanceIdentity;
|
|
69
|
+
operation?: OperationDescriptor;
|
|
70
|
+
}): Promise<UpgradeOutcome>;
|
|
55
71
|
|
|
56
72
|
/**
|
|
57
73
|
* Explicit rollback while an experiment is live (pre-promote).
|
|
@@ -84,6 +100,12 @@ export interface Upgrader {
|
|
|
84
100
|
* are null (NOT_OBSERVED), never a fabricated pass.
|
|
85
101
|
*/
|
|
86
102
|
status(): Promise<StatusReport>;
|
|
103
|
+
|
|
104
|
+
/** K's single durable operation receipt; hosts project it, never mirror it. */
|
|
105
|
+
operation(): Promise<OperationRead>;
|
|
106
|
+
|
|
107
|
+
/** Mark one exact terminal operation delivered by the host transport. */
|
|
108
|
+
acknowledgeOperation(operationId: string): Promise<"acknowledged" | "not-terminal" | "not-found" | "changed">;
|
|
87
109
|
}
|
|
88
110
|
|
|
89
111
|
export type UpgradeOutcome =
|
package/docs/design-v1.md
CHANGED
|
@@ -239,7 +239,7 @@ K 交付低 / 保证高 单二进制;事务 + 回读 +
|
|
|
239
239
|
3. 本仓库 = 原"release/publish 侧 spec"的上位替代;接入方视角见 `docs/integration.md`。
|
|
240
240
|
|
|
241
241
|
## 6. 决定记录(原开放问题,已拍部分)
|
|
242
|
-
1. **名字/仓库 ✅(08-05)**:公开名 **k-carrier**(`github.com/botiverse/k-carrier
|
|
242
|
+
1. **名字/仓库 ✅(08-05)**:公开名 **k-carrier**(`github.com/botiverse/k-carrier`),口头名 **K**。理由:单字母不可检索 + kframework/k 撞名;k-carrier 自解释。
|
|
243
243
|
2. **core 语言 = TS 起步 ✅(默认成立,未被否)**:与 daemon 同栈、宿主壳复用最快、测试教义全在 TS 生态;留 FFI/重写门。
|
|
244
244
|
3. **并行方式 = 1.0.16 先行 ✅(默认成立)**:按本仓库接口形状写,core 骨架随后收编。
|
|
245
245
|
4. **drive 协议(仍开放)**:对齐现有远程配置生态 vs 自定义最小集 —— 到 L5 动工时拍。
|
package/docs/integration.md
CHANGED
|
@@ -241,6 +241,35 @@ The stages are not a parallel state machine: they are derived from the L1
|
|
|
241
241
|
transaction phases (`stageForPhase`), so a progress display can never show a
|
|
242
242
|
state the transaction does not have.
|
|
243
243
|
|
|
244
|
+
## 4.6 One durable operation receipt
|
|
245
|
+
|
|
246
|
+
When a host detaches the transaction driver from the service it replaces,
|
|
247
|
+
pass an exact operation descriptor to `upgradeTo`. K then owns the only
|
|
248
|
+
durable operation state, including the previous stable version and terminal
|
|
249
|
+
outcome:
|
|
250
|
+
|
|
251
|
+
```ts
|
|
252
|
+
await upgrader.upgradeTo("2.0.0", {
|
|
253
|
+
consented: true,
|
|
254
|
+
operation: {
|
|
255
|
+
id: requestId,
|
|
256
|
+
startedAtMs: Date.now(),
|
|
257
|
+
metadata: { originServerId }, // non-secret host correlation only
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const receipt = await upgrader.operation();
|
|
262
|
+
if (receipt.kind === "observed" && receipt.operation.outcome !== null) {
|
|
263
|
+
await deliver(receipt.operation);
|
|
264
|
+
await upgrader.acknowledgeOperation(receipt.operation.id);
|
|
265
|
+
}
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
The host may project this receipt into UI or transport, but must not maintain
|
|
269
|
+
a second pending/status/previous-version state machine. `recover()` settles an
|
|
270
|
+
active receipt under K's upgrade lock before the host reads it again. A corrupt
|
|
271
|
+
or future-version receipt is `unreadable`, never treated as genesis or success.
|
|
272
|
+
|
|
244
273
|
## 5. Publishing releases
|
|
245
274
|
|
|
246
275
|
If you use the built-in `staticManifestSource`, its layout is:
|
package/harness/src/cli.ts
CHANGED
|
File without changes
|
package/package.json
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@botiverse/k-carrier",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
|
+
"packageManager": "pnpm@11.18.0",
|
|
4
5
|
"description": "A fail-closed upgrade carrier for long-running managed services: two-slot upgrade transactions (promote/rollback), host-driven quiesce/resume handoff, and post-upgrade convergence read-back.",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/botiverse/k-carrier"
|
|
9
|
+
},
|
|
5
10
|
"license": "Apache-2.0",
|
|
6
11
|
"type": "module",
|
|
7
12
|
"engines": {
|
|
@@ -12,7 +17,7 @@
|
|
|
12
17
|
"./harness": "./harness/src/index.ts"
|
|
13
18
|
},
|
|
14
19
|
"bin": {
|
|
15
|
-
"k-harness": "
|
|
20
|
+
"k-harness": "harness/src/cli.ts"
|
|
16
21
|
},
|
|
17
22
|
"files": [
|
|
18
23
|
"core/src/**/*.ts",
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
# artifact — see docs/design-v1.md for this layer's spec. Interfaces land here next.
|
package/core/src/drive/README.md
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
# drive — see docs/design-v1.md for this layer's spec. Interfaces land here next.
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
# platform — see docs/design-v1.md for this layer's spec. Interfaces land here next.
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
# policy — see docs/design-v1.md for this layer's spec. Interfaces land here next.
|
package/harness/README.md
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
# K harness
|
|
2
|
-
|
|
3
|
-
The generic acceptance bed runs the same registered teeth against K, a real
|
|
4
|
-
binary, or an adopter's HostAdapter. It also owns K's deterministic simulator.
|
|
5
|
-
|
|
6
|
-
```sh
|
|
7
|
-
k-harness --list
|
|
8
|
-
k-harness --profile service
|
|
9
|
-
k-harness sim # fixed PR smoke corpus
|
|
10
|
-
k-harness sim --seed 3737844653 --json # exact replay
|
|
11
|
-
k-harness sim --start-seed 1 --seeds 50000
|
|
12
|
-
```
|
|
13
|
-
|
|
14
|
-
Simulation uses the real `UpgradeEngine` over an in-memory `TxnEffects` and
|
|
15
|
-
HostAdapter. Every journal, slot, host and predicate effect is a seeded fault
|
|
16
|
-
point. A failure prints its exact replay command and is atomically merged into
|
|
17
|
-
`.k-harness/sim-failures.json`; the nightly workflow uploads that corpus.
|
|
18
|
-
|
|
19
|
-
The simulator covers transaction/convergence logic. It does not replace the
|
|
20
|
-
real-process crash matrix or real-OS test beds.
|