@botiverse/k-carrier 0.1.7 → 0.2.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/NOTICE +5 -2
- package/README.md +72 -26
- package/core/src/artifact/download.ts +23 -1
- package/core/src/artifact/gzip.ts +26 -0
- package/core/src/artifact/source.ts +2 -0
- package/core/src/{createUpgrader.ts → createRunner.ts} +15 -14
- package/core/src/index.ts +12 -3
- package/core/src/launcher/launch.ts +15 -0
- package/core/src/launcher/supervise.ts +170 -0
- package/core/src/lifecycle/commandHost.ts +111 -0
- package/core/src/lifecycle/hostAdapter.ts +28 -16
- package/core/src/operation.ts +47 -25
- package/core/src/operationLifecycle.ts +2 -7
- package/core/src/platform/ops.ts +7 -0
- package/core/src/platform/posix.ts +26 -6
- package/core/src/platform/windows.ts +8 -2
- package/core/src/protocol/runner.ts +81 -0
- package/core/src/provenance/journal.ts +1 -1
- package/core/src/quarantine.ts +167 -0
- package/core/src/runner/cli.ts +27 -0
- package/core/src/runner/execute.ts +68 -0
- package/core/src/txn/engine.ts +41 -85
- package/core/src/txn/fileEffects.ts +15 -1
- package/core/src/txn/hostCallBudget.ts +4 -1
- package/core/src/txn/hostCallUncertain.ts +2 -0
- package/core/src/txn/lock.ts +81 -37
- package/core/src/txn/state.ts +1 -1
- package/core/src/upgrade/drive.ts +31 -2
- package/core/src/upgrade/outcome.ts +1 -1
- package/core/src/upgrade/recover.ts +22 -1
- package/core/src/upgrade/retire.ts +1 -1
- package/core/src/upgrader.ts +7 -8
- package/docs/design.md +173 -0
- package/docs/guide.md +196 -0
- package/docs/harness-design.md +75 -170
- package/docs/integration.md +221 -354
- package/docs/prior-art/design-influences.md +26 -0
- package/docs/prior-art/external-runner-research.md +49 -0
- package/docs/reference.md +209 -0
- package/docs/test-plan.md +89 -92
- package/harness/src/adapter/releaseKnob.ts +1 -1
- package/harness/src/adapter/serviceChecks.ts +5 -5
- package/harness/src/artifact/m1.ts +8 -8
- package/harness/src/artifact/m1Resume.ts +2 -2
- package/harness/src/artifact/m3.ts +25 -104
- package/harness/src/artifact/m3Hosts.ts +9 -61
- package/harness/src/artifact/m4.ts +3 -3
- package/harness/src/artifact/m5.ts +5 -5
- package/harness/src/artifact/m6.ts +6 -6
- package/harness/src/artifact/m6Status.ts +1 -1
- package/harness/src/examples/checks.ts +10 -13
- package/harness/src/fixtures/cliToolSource.ts +166 -0
- package/harness/src/fixtures/externalCrashAdapter.ts +19 -0
- package/harness/src/fixtures/managedHost.ts +100 -0
- package/harness/src/fixtures/serviceSource.ts +181 -0
- package/harness/src/fixtures/supervisedAdapter.ts +57 -0
- package/harness/src/scenario/processScan.ts +3 -1
- package/harness/src/scenario/sandbox.ts +2 -2
- package/harness/src/teeth/artifact.ts +3 -3
- package/harness/src/teeth/examples.ts +1 -1
- package/package.json +5 -3
- package/docs/design-v1.md +0 -246
- package/docs/prior-art.md +0 -150
package/NOTICE
CHANGED
|
@@ -5,10 +5,12 @@ This product is licensed under the Apache License, Version 2.0 (see LICENSE).
|
|
|
5
5
|
|
|
6
6
|
Design influences — concepts studied, no code copied
|
|
7
7
|
-----------------------------------------------------
|
|
8
|
-
k-carrier's design was informed by studying
|
|
8
|
+
k-carrier's design was informed by studying other installers and updaters.
|
|
9
9
|
Only architectural concepts were borrowed; no source code was copied, and the
|
|
10
10
|
implementation here is original.
|
|
11
11
|
|
|
12
|
+
- External installer boundary and self-update execution model are informed by Rustup's rustup-init and self-update flow (rust-lang/rustup, client installer/self-update). Concept only; no source code copied.
|
|
13
|
+
|
|
12
14
|
- Two-slot upgrade transaction (stable/experiment slots with promote/rollback)
|
|
13
15
|
and the "the installer is itself a managed package" fleet model are inspired
|
|
14
16
|
by Datadog's fleet installer (DataDog/datadog-agent, pkg/fleet). Concept only.
|
|
@@ -19,6 +21,7 @@ implementation here is original.
|
|
|
19
21
|
signature verification — it verifies artifact integrity via sha256 + size
|
|
20
22
|
only. No distsign / signing code is present in this repository.
|
|
21
23
|
|
|
22
|
-
See docs/prior-art.md
|
|
24
|
+
See docs/prior-art/design-influences.md and
|
|
25
|
+
docs/prior-art/external-runner-research.md for the full analysis, including what each project does
|
|
23
26
|
and where k-carrier deliberately differs (fail-closed restart, post-upgrade
|
|
24
27
|
convergence read-back, and rollback).
|
package/README.md
CHANGED
|
@@ -1,38 +1,84 @@
|
|
|
1
1
|
# K (k-carrier)
|
|
2
2
|
|
|
3
|
-
**
|
|
3
|
+
**Reliable application upgrades, run by an independent installer.**
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
K is for applications you distribute yourself to end users' machines, across
|
|
6
|
+
operating systems, outside any package manager: desktop agents, background
|
|
7
|
+
services installed by `curl | sh`, CLIs that update themselves. Nobody
|
|
8
|
+
operates those machines. A failed upgrade means the product silently stops
|
|
9
|
+
working and nobody can log in to repair it. K combines a rustup-style external
|
|
10
|
+
installer with recoverable, verified service upgrades.
|
|
6
11
|
|
|
7
|
-
|
|
12
|
+
If a package manager, container image or fleet orchestrator already owns your
|
|
13
|
+
installation, that manager owns upgrades too. Your adapter identifies that
|
|
14
|
+
ownership so K can defer; you probably do not need it.
|
|
8
15
|
|
|
9
|
-
##
|
|
16
|
+
## Why upgrading is non-trivial
|
|
10
17
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
18
|
+
Downloading a new executable is only the beginning. The application may still
|
|
19
|
+
be running, power may fail halfway through replacement, or the new version may
|
|
20
|
+
fail to start on a particular machine. Replacing the file does not prove the
|
|
21
|
+
service came back healthy. The installed updater may itself be too old or
|
|
22
|
+
broken to help.
|
|
15
23
|
|
|
16
|
-
|
|
24
|
+
K stages verified bytes in a second slot, stops the service, starts and probes
|
|
25
|
+
the candidate, then commits or restores the previous executable. A durable
|
|
26
|
+
journal lets a subsequent installer recover interrupted work. An upgrade ends
|
|
27
|
+
promoted, rolled back, or explicitly unresolved with the evidence preserved;
|
|
28
|
+
it is never reported as a success K did not observe.
|
|
17
29
|
|
|
18
|
-
|
|
19
|
-
- [`docs/design-v1.md`](docs/design-v1.md) — full design: six layers, architecture, decision record.
|
|
20
|
-
- [`docs/harness-design.md`](docs/harness-design.md) — the test framework, designed first: harness as executable spec (teeth registry, real-process crash injection, adversarial self-verification).
|
|
21
|
-
- [`docs/test-plan.md`](docs/test-plan.md) — executable test plan (M0–M6, must-red per cell).
|
|
22
|
-
- [`docs/prior-art.md`](docs/prior-art.md) — the source-level survey this design stands on (Tailscale / Datadog), and the license-defense record behind `NOTICE`.
|
|
30
|
+
## One installer, multiple entrypoints
|
|
23
31
|
|
|
24
|
-
|
|
32
|
+
`install.sh` and your application's `self upgrade` launch the same external
|
|
33
|
+
installer, built from K and your product adapter. The installer owns the
|
|
34
|
+
upgrade transaction and exits when finished. It can be updated independently
|
|
35
|
+
of the application and run even when the installed application cannot start.
|
|
25
36
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
```
|
|
37
|
+
You distribute **three things**: the bootstrap script, the installer, and the
|
|
38
|
+
application release. They can share a hosting location. You supply the
|
|
39
|
+
release source and service lifecycle operations; K supplies the transaction
|
|
40
|
+
machinery. The runner uses Node 24, either as an external runtime or bundled
|
|
41
|
+
into a Node single executable. A fully runtime-independent installer must also
|
|
42
|
+
package its supervisor and controller dependencies.
|
|
33
43
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
44
|
+
The installer must survive stopping the application. If a worker crashes,
|
|
45
|
+
the temporary supervisor runs bounded recovery. After reboot, an operator or
|
|
46
|
+
OS startup hook must start installation again. K rolls back executables,
|
|
47
|
+
not application data; data migration compatibility remains the product's job.
|
|
48
|
+
Artifact hashes check integrity; your distribution channel establishes trust.
|
|
37
49
|
|
|
38
|
-
Status
|
|
50
|
+
## Status
|
|
51
|
+
|
|
52
|
+
| Area | State |
|
|
53
|
+
|---|---|
|
|
54
|
+
| Two-slot transaction, journal, lock, receipts | Done; generated crash matrix, seeded simulation, and a Lean model of all phases, rollback and crash/recovery interleavings (host honesty assumed) |
|
|
55
|
+
| External runner protocol, supervisor, bounded recovery | Done; Linux/macOS process tests |
|
|
56
|
+
| Command controller boundary | Done; demo controller only |
|
|
57
|
+
| Verified download with resume and gzip | Done |
|
|
58
|
+
| Single-executable (SEA) runner | Verified manually; build flag provided, no CI |
|
|
59
|
+
| Windows | Core is platform-seamed; harness port incomplete, CI informational |
|
|
60
|
+
| Product-ready `install.sh` template | Not yet |
|
|
61
|
+
| Publisher signing of installer or release metadata | Not provided; hash and size only |
|
|
62
|
+
| Automatic restart after machine reboot | Not provided; product OS hook |
|
|
63
|
+
| Receipt archive garbage collection | Not provided |
|
|
64
|
+
|
|
65
|
+
## Documentation
|
|
66
|
+
|
|
67
|
+
Guides, written to be read in order:
|
|
68
|
+
|
|
69
|
+
- [How an upgrade works](docs/guide.md): the processes involved, one upgrade start to finish, what breaks, how to read the result
|
|
70
|
+
- [Runnable example](examples/external-service/README.md)
|
|
71
|
+
- [Integration and distribution guide](docs/integration.md)
|
|
72
|
+
|
|
73
|
+
Contracts, written to be looked up:
|
|
74
|
+
|
|
75
|
+
- [Design](docs/design.md): normative execution model, transaction, supervision and controller obligations
|
|
76
|
+
- [Reference](docs/reference.md): protocol, exit codes, budgets, on-disk layout
|
|
77
|
+
- [Test plan](docs/test-plan.md) and [harness design](docs/harness-design.md)
|
|
78
|
+
- [Formal model](formal/README.md)
|
|
79
|
+
|
|
80
|
+
Background:
|
|
81
|
+
|
|
82
|
+
- [Prior art](docs/prior-art/design-influences.md) and [external installer research](docs/prior-art/external-runner-research.md)
|
|
83
|
+
|
|
84
|
+
Incubating · TypeScript / Node 24 · Apache-2.0. Contributions welcome.
|
|
@@ -32,6 +32,7 @@ import { ArtifactError } from "./errors.ts";
|
|
|
32
32
|
import type { Release } from "./source.ts";
|
|
33
33
|
import { collectStream } from "./collectStream.ts";
|
|
34
34
|
import { partialPathFor } from "./partialPath.ts";
|
|
35
|
+
import { decodeGzipArtifact } from "./gzip.ts";
|
|
35
36
|
import type { DownloadOptions } from "./transferPolicy.ts";
|
|
36
37
|
export { partialPathFor } from "./partialPath.ts";
|
|
37
38
|
export type { DownloadOptions } from "./transferPolicy.ts";
|
|
@@ -41,6 +42,27 @@ export async function downloadVerified(
|
|
|
41
42
|
release: Release,
|
|
42
43
|
opts: DownloadOptions = {},
|
|
43
44
|
): Promise<Uint8Array> {
|
|
45
|
+
if (release.gzip !== undefined) {
|
|
46
|
+
const gzip = release.gzip;
|
|
47
|
+
if (!gzip || typeof gzip.url !== "string" || !gzip.url ||
|
|
48
|
+
!/^[a-f0-9]{64}$/.test(gzip.sha256) || !Number.isSafeInteger(gzip.size) || gzip.size <= 0 ||
|
|
49
|
+
!Number.isSafeInteger(release.size) || release.size < 0 ||
|
|
50
|
+
!/^[a-f0-9]{64}$/.test(release.sha256)) {
|
|
51
|
+
throw new ArtifactError("MANIFEST_INVALID", "invalid gzip artifact identity");
|
|
52
|
+
}
|
|
53
|
+
// Range and partial files refer to compressed bytes, never decoded offsets.
|
|
54
|
+
const compressed = await downloadVerified({
|
|
55
|
+
version: release.version, url: gzip.url, size: gzip.size, sha256: gzip.sha256,
|
|
56
|
+
}, opts);
|
|
57
|
+
try {
|
|
58
|
+
return await decodeGzipArtifact(compressed, release);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
if (opts.resumeDir) {
|
|
61
|
+
await fs.rm(partialPathFor(opts.resumeDir, gzip.url), { force: true }).catch(() => {});
|
|
62
|
+
}
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
44
66
|
const url = release.url;
|
|
45
67
|
const clock = opts.clock ?? systemClock;
|
|
46
68
|
const timeoutMs = opts.timeoutMs ?? 10000;
|
|
@@ -147,7 +169,7 @@ async function fetchAndAppend(
|
|
|
147
169
|
});
|
|
148
170
|
};
|
|
149
171
|
try {
|
|
150
|
-
const headers: Record<string, string> = {};
|
|
172
|
+
const headers: Record<string, string> = { "Accept-Encoding": "identity" };
|
|
151
173
|
if (partialSize > 0) headers["Range"] = `bytes=${partialSize}-`;
|
|
152
174
|
let res: Response;
|
|
153
175
|
armResponse();
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { gunzip } from "node:zlib";
|
|
3
|
+
import { ArtifactError } from "./errors.ts";
|
|
4
|
+
import type { Release } from "./source.ts";
|
|
5
|
+
|
|
6
|
+
/** Decode only verified transport bytes; bound expansion by the canonical size. */
|
|
7
|
+
export async function decodeGzipArtifact(bytes: Uint8Array, release: Release): Promise<Uint8Array> {
|
|
8
|
+
let decoded: Uint8Array;
|
|
9
|
+
try {
|
|
10
|
+
decoded = await new Promise<Buffer>((resolve, reject) => {
|
|
11
|
+
gunzip(bytes, { maxOutputLength: Math.max(1, release.size) }, (error, output) => {
|
|
12
|
+
if (error) reject(error);
|
|
13
|
+
else resolve(output);
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
} catch (cause) {
|
|
17
|
+
throw new ArtifactError("DOWNLOAD_FAILED", "gzip is invalid or exceeds the declared decoded size", { cause });
|
|
18
|
+
}
|
|
19
|
+
if (decoded.length !== release.size) {
|
|
20
|
+
throw new ArtifactError("SIZE_MISMATCH", "decoded gzip size does not match the release");
|
|
21
|
+
}
|
|
22
|
+
if (createHash("sha256").update(decoded).digest("hex") !== release.sha256) {
|
|
23
|
+
throw new ArtifactError("SHA256_MISMATCH", "decoded gzip SHA-256 does not match the release");
|
|
24
|
+
}
|
|
25
|
+
return decoded;
|
|
26
|
+
}
|
|
@@ -39,6 +39,8 @@ export interface Release {
|
|
|
39
39
|
url: string;
|
|
40
40
|
sha256: string;
|
|
41
41
|
size: number;
|
|
42
|
+
/** Optional explicit gzip representation. Outer size/hash always describe installed bytes. */
|
|
43
|
+
gzip?: { url: string; sha256: string; size: number };
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
export interface ReleaseSource {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { HostCallUncertain } from "./txn/hostCallBudget.ts";
|
|
1
2
|
/**
|
|
2
|
-
*
|
|
3
|
+
* createRunner — the one construction every entrypoint uses.
|
|
3
4
|
*
|
|
4
5
|
* Order of gates: lock -> ownership -> source -> policy -> compat ->
|
|
5
6
|
* download -> engine. Everything before the engine can only produce `held`
|
|
@@ -27,9 +28,11 @@ import type { OperationDescriptor } from "./operation.ts";
|
|
|
27
28
|
import { createOperationLifecycle } from "./operationLifecycle.ts";
|
|
28
29
|
import { driveUpgrade } from "./upgrade/drive.ts";
|
|
29
30
|
import type { ArtifactTransferPolicy } from "./artifact/transferPolicy.ts";
|
|
31
|
+
import { quarantineState } from "./quarantine.ts";
|
|
30
32
|
|
|
31
|
-
export interface
|
|
33
|
+
export interface RunnerOptions extends UpgraderConfig {
|
|
32
34
|
clock?: Clock;
|
|
35
|
+
hostCallBudgetMs?: number;
|
|
33
36
|
/** Reports who owns this install; default: we own it. */
|
|
34
37
|
installOwnership?: () => "self" | "managed-elsewhere";
|
|
35
38
|
/** Optional host semantic gate; a string result refuses the transition. */
|
|
@@ -67,7 +70,7 @@ export interface CreateUpgraderOptions extends UpgraderConfig {
|
|
|
67
70
|
artifactTransferPolicy?: ArtifactTransferPolicy;
|
|
68
71
|
}
|
|
69
72
|
|
|
70
|
-
export function
|
|
73
|
+
export function createRunner(opts: RunnerOptions): Upgrader {
|
|
71
74
|
const clock = opts.clock ?? systemClock;
|
|
72
75
|
const effects = fileEffects(opts.stateDir);
|
|
73
76
|
const ownership = opts.installOwnership ?? ((): "self" => "self");
|
|
@@ -91,6 +94,7 @@ export function createUpgrader(opts: CreateUpgraderOptions): Upgrader {
|
|
|
91
94
|
effects,
|
|
92
95
|
host: opts.host,
|
|
93
96
|
clock,
|
|
97
|
+
...(opts.hostCallBudgetMs === undefined ? {} : { hostCallBudgetMs: opts.hostCallBudgetMs }),
|
|
94
98
|
evaluatePredicates: async (evidence: ProcessEvidence, targetVersion: string) => {
|
|
95
99
|
lastEvidence = evidence;
|
|
96
100
|
if (evidence.version !== targetVersion) {
|
|
@@ -178,9 +182,9 @@ export function createUpgrader(opts: CreateUpgraderOptions): Upgrader {
|
|
|
178
182
|
}, request);
|
|
179
183
|
|
|
180
184
|
return {
|
|
181
|
-
recover: async () => {
|
|
185
|
+
recover: async (expected) => {
|
|
182
186
|
operationLifecycle.reset();
|
|
183
|
-
await recoverUpgrade(opts.stateDir, clock, engine, operationLifecycle.settleRecovery);
|
|
187
|
+
await recoverUpgrade(opts.stateDir, clock, engine, operationLifecycle.settleRecovery, expected);
|
|
184
188
|
},
|
|
185
189
|
|
|
186
190
|
async check(): Promise<{ current: string; target: string | null }> {
|
|
@@ -224,6 +228,7 @@ export function createUpgrader(opts: CreateUpgraderOptions): Upgrader {
|
|
|
224
228
|
|
|
225
229
|
async rollback(reason: string): Promise<"rolled-back" | { held: string }> {
|
|
226
230
|
const lock = await acquireUpgradeLock(opts.stateDir, clock.nowMs());
|
|
231
|
+
let release = true;
|
|
227
232
|
try {
|
|
228
233
|
// Gate on the action's nature: settling K's own in-flight
|
|
229
234
|
// transaction is ALWAYS allowed (a held mid-transaction is a
|
|
@@ -239,8 +244,11 @@ export function createUpgrader(opts: CreateUpgraderOptions): Upgrader {
|
|
|
239
244
|
await effects.slots.clearExperiment();
|
|
240
245
|
await opts.notificationSink({ kind: "rolled-back", detail: { reason } });
|
|
241
246
|
return "rolled-back";
|
|
247
|
+
} catch (error) {
|
|
248
|
+
if (error instanceof HostCallUncertain) release = false;
|
|
249
|
+
throw error;
|
|
242
250
|
} finally {
|
|
243
|
-
await lock.release();
|
|
251
|
+
if (release) await lock.release();
|
|
244
252
|
}
|
|
245
253
|
},
|
|
246
254
|
|
|
@@ -259,13 +267,6 @@ export function createUpgrader(opts: CreateUpgraderOptions): Upgrader {
|
|
|
259
267
|
|
|
260
268
|
operation: operationLifecycle.read,
|
|
261
269
|
|
|
262
|
-
|
|
263
|
-
const lock = await acquireUpgradeLock(opts.stateDir, clock.nowMs());
|
|
264
|
-
try {
|
|
265
|
-
return await operationLifecycle.acknowledge(operationId);
|
|
266
|
-
} finally {
|
|
267
|
-
await lock.release();
|
|
268
|
-
}
|
|
269
|
-
},
|
|
270
|
+
quarantineState: (options) => quarantineState(opts.stateDir, options),
|
|
270
271
|
};
|
|
271
272
|
}
|
package/core/src/index.ts
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
// This barrel is the single supported entry point for the core framework;
|
|
4
4
|
// deep imports into ./core/src/** are internal and not part of the public API.
|
|
5
5
|
|
|
6
|
-
//
|
|
7
|
-
export
|
|
6
|
+
// Compose the transaction engine inside a runner; no forwarding factory.
|
|
7
|
+
export { createRunner } from "./createRunner.ts";
|
|
8
|
+
export type { RunnerOptions } from "./createRunner.ts";
|
|
8
9
|
|
|
9
10
|
// One-time adoption of an already-running trusted binary into K's stable
|
|
10
11
|
// slot, plus the K-owned slot resolver host adapters use to launch it.
|
|
@@ -14,9 +15,10 @@ export * from "./bootstrap.ts";
|
|
|
14
15
|
// NotificationEvent.
|
|
15
16
|
export * from "./upgrader.ts";
|
|
16
17
|
export * from "./operation.ts";
|
|
18
|
+
export * from "./quarantine.ts";
|
|
17
19
|
|
|
18
20
|
// The release-source boundary applications implement and the durable
|
|
19
|
-
// provenance journal they wire into
|
|
21
|
+
// provenance journal they wire into createRunner.
|
|
20
22
|
export * from "./artifact/source.ts";
|
|
21
23
|
export * from "./artifact/transferPolicy.ts";
|
|
22
24
|
export * from "./provenance/journal.ts";
|
|
@@ -26,3 +28,10 @@ export * from "./lifecycle/hostAdapter.ts";
|
|
|
26
28
|
|
|
27
29
|
// The built-in invariants and their types (WorldSnapshot, Invariant, ...).
|
|
28
30
|
export * from "./invariants.ts";
|
|
31
|
+
export * from "./protocol/runner.ts";
|
|
32
|
+
export * from "./runner/execute.ts";
|
|
33
|
+
export * from "./runner/cli.ts";
|
|
34
|
+
export * from "./launcher/launch.ts";
|
|
35
|
+
export * from "./lifecycle/commandHost.ts";
|
|
36
|
+
|
|
37
|
+
export { HostCallTimeout, HostCallUncertain } from "./txn/hostCallBudget.ts";
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { superviseRunner, type RunnerLaunch } from "./supervise.ts";
|
|
2
|
+
export { superviseRunner, resumeRunner } from "./supervise.ts";
|
|
3
|
+
export type { RunnerLaunch, RunnerLaunchResult } from "./supervise.ts";
|
|
4
|
+
|
|
5
|
+
/** Installer entrypoint: one final response and an honest recovery exit code. */
|
|
6
|
+
export async function launchRunner(input: RunnerLaunch): Promise<number> {
|
|
7
|
+
const result = await superviseRunner(input);
|
|
8
|
+
const response = result.error ? { protocolVersion: 1, action: input.request.action,
|
|
9
|
+
exitCode: 3, result: "recovery-required", error: result.error,
|
|
10
|
+
operation: result.response?.operation ?? { kind: "unreadable", reason: "worker returned no usable receipt" },
|
|
11
|
+
recoveryFile: result.recoveryFile } : result.response;
|
|
12
|
+
if (response) process.stdout.write(`${JSON.stringify(response)}\n`);
|
|
13
|
+
if (result.error) process.stderr.write(`${result.error}; recovery: ${result.recoveryFile}\n`);
|
|
14
|
+
return result.exitCode;
|
|
15
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import type { Release } from "../artifact/source.ts";
|
|
6
|
+
import { downloadVerified } from "../artifact/download.ts";
|
|
7
|
+
import { artifactTransferTimeouts } from "../artifact/transferPolicy.ts";
|
|
8
|
+
import { parseRunnerRequest, parseRunnerResponse, type RunnerRequest, type RunnerResponse } from "../protocol/runner.ts";
|
|
9
|
+
import { platformOpsFor } from "../platform/index.ts";
|
|
10
|
+
import { systemClock } from "../clock.ts";
|
|
11
|
+
|
|
12
|
+
export interface RunnerLaunch {
|
|
13
|
+
release: Release;
|
|
14
|
+
request: RunnerRequest;
|
|
15
|
+
scratchDir: string;
|
|
16
|
+
interpreter?: string;
|
|
17
|
+
/** Finite worker, recovery-attempt and overall execution budgets. */
|
|
18
|
+
executionTimeoutMs?: number;
|
|
19
|
+
recoveryTimeoutMs?: number;
|
|
20
|
+
totalTimeoutMs?: number;
|
|
21
|
+
recoveryAttempts?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface RunnerLaunchResult {
|
|
25
|
+
exitCode: number;
|
|
26
|
+
response: RunnerResponse | null;
|
|
27
|
+
/** Retained verified helper + invocation; no transaction state is copied. */
|
|
28
|
+
recoveryFile: string | null;
|
|
29
|
+
error: string | null;
|
|
30
|
+
attempts: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface Attempt { code: number; response: RunnerResponse | null; fenced: boolean }
|
|
34
|
+
|
|
35
|
+
function budget(value: number | undefined, fallback: number): number {
|
|
36
|
+
const result = value ?? fallback;
|
|
37
|
+
if (!Number.isSafeInteger(result) || result <= 0 || result > 2_147_483_647) throw new Error("invalid runner budget");
|
|
38
|
+
return result;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A worker is reaped before a successor is allowed. Controller effects are
|
|
42
|
+
* separately fenced by the adapter under K's lock, including after a restart. */
|
|
43
|
+
async function run(file: string, interpreter: string | undefined, request: RunnerRequest, timeout: number): Promise<Attempt> {
|
|
44
|
+
return new Promise((resolve) => {
|
|
45
|
+
const child = spawn(interpreter ?? file, interpreter ? [file] : [], { stdio: ["pipe", "pipe", "inherit"] });
|
|
46
|
+
let output = "";
|
|
47
|
+
let exited = false;
|
|
48
|
+
let expired = false;
|
|
49
|
+
let finished = false;
|
|
50
|
+
let cancelGrace: (() => void) | undefined;
|
|
51
|
+
const finish = (code: number, fenced: boolean): void => {
|
|
52
|
+
if (finished) return;
|
|
53
|
+
finished = true;
|
|
54
|
+
cancel(); cancelGrace?.();
|
|
55
|
+
child.stdin.destroy(); child.stdout.destroy(); child.unref();
|
|
56
|
+
let response: RunnerResponse | null = null;
|
|
57
|
+
if (!expired) {
|
|
58
|
+
try { response = parseRunnerResponse(JSON.parse(output)); }
|
|
59
|
+
catch { /* a missing/malformed response cannot settle a transaction */ }
|
|
60
|
+
}
|
|
61
|
+
if (response && (response.action !== request.action || response.exitCode !== code)) response = null;
|
|
62
|
+
resolve({ code, response, fenced });
|
|
63
|
+
};
|
|
64
|
+
const terminate = (): void => {
|
|
65
|
+
if (expired || finished) return;
|
|
66
|
+
expired = true;
|
|
67
|
+
if (child.pid && !exited) {
|
|
68
|
+
try { platformOpsFor().killProcess(child.pid); } catch { /* require observed exit */ }
|
|
69
|
+
}
|
|
70
|
+
child.stdin.destroy(); child.stdout.destroy();
|
|
71
|
+
cancelGrace = systemClock.after(1000, () => finish(3, exited));
|
|
72
|
+
};
|
|
73
|
+
const cancel = systemClock.after(timeout, terminate);
|
|
74
|
+
child.on("error", () => finish(1, child.pid === undefined));
|
|
75
|
+
child.on("exit", () => { exited = true; });
|
|
76
|
+
child.on("close", (code) => finish(expired ? 3 : code ?? 1, exited || child.pid === undefined));
|
|
77
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
78
|
+
output += chunk.toString();
|
|
79
|
+
if (output.length > 64 * 1024) terminate();
|
|
80
|
+
});
|
|
81
|
+
child.stdin.on("error", () => {});
|
|
82
|
+
child.stdin.end(JSON.stringify(request));
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function settled(response: RunnerResponse | null, request: RunnerRequest): boolean {
|
|
87
|
+
if (!response || response.error !== null) return false;
|
|
88
|
+
const expected = request.action === "upgrade" ? request : request.action === "recover" ? request.expected : undefined;
|
|
89
|
+
if (!expected) return response.exitCode !== 3 && response.operation.kind !== "unreadable" &&
|
|
90
|
+
(response.operation.kind === "genesis" || response.operation.operation.outcome !== null);
|
|
91
|
+
if (response.operation.kind !== "observed") return false;
|
|
92
|
+
const op = response.operation.operation;
|
|
93
|
+
const code = op.outcome === "promoted" || op.outcome === "up-to-date" ? 0 : op.outcome === "held" ? 2 : 1;
|
|
94
|
+
return op.id === expected.id && op.targetVersion === expected.targetVersion && op.outcome !== null && response.exitCode === code;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Own one install until a bound receipt or explicit unresolved recovery. */
|
|
98
|
+
export async function superviseRunner(input: RunnerLaunch): Promise<RunnerLaunchResult> {
|
|
99
|
+
const request = parseRunnerRequest(input.request);
|
|
100
|
+
const execution = budget(input.executionTimeoutMs, 600_000);
|
|
101
|
+
const recovery = budget(input.recoveryTimeoutMs, 120_000);
|
|
102
|
+
const total = budget(input.totalTimeoutMs, execution + recovery * 2);
|
|
103
|
+
const retries = input.recoveryAttempts ?? 2;
|
|
104
|
+
if (!Number.isSafeInteger(retries) || retries < 0 || retries > 10) throw new Error("invalid recovery attempts");
|
|
105
|
+
const transfer = artifactTransferTimeouts(input.release.size);
|
|
106
|
+
const bytes = await downloadVerified(input.release, { timeoutMs: transfer.overallTimeoutMs,
|
|
107
|
+
responseTimeoutMs: transfer.responseTimeoutMs, idleTimeoutMs: transfer.idleTimeoutMs });
|
|
108
|
+
await fs.mkdir(input.scratchDir, { recursive: true });
|
|
109
|
+
const dir = await fs.mkdtemp(path.join(input.scratchDir, "k-runner-"));
|
|
110
|
+
const file = path.join(dir, input.interpreter ? "runner.mjs" : "runner.bin");
|
|
111
|
+
const recoveryFile = path.join(dir, "recovery.json");
|
|
112
|
+
const expected = request.action === "upgrade" ? { id: request.id, targetVersion: request.targetVersion } :
|
|
113
|
+
request.action === "recover" ? request.expected : undefined;
|
|
114
|
+
const recoverRequest: RunnerRequest = { protocolVersion: 1, action: "recover", ...(expected ? { expected } : {}) };
|
|
115
|
+
// Retain enough trusted distribution metadata to verify and recover offline.
|
|
116
|
+
// This is an invocation descriptor, never another transaction log.
|
|
117
|
+
const artifact = await fs.open(file, "wx", 0o700);
|
|
118
|
+
try { await artifact.writeFile(bytes); await artifact.sync(); }
|
|
119
|
+
finally { await artifact.close(); }
|
|
120
|
+
await platformOpsFor().makeExecutable(file);
|
|
121
|
+
const handle = await fs.open(recoveryFile, "w", 0o600);
|
|
122
|
+
try { await handle.writeFile(JSON.stringify({ file, interpreter: input.interpreter,
|
|
123
|
+
sha256: createHash("sha256").update(bytes).digest("hex"), size: bytes.length, request: recoverRequest })); await handle.sync(); }
|
|
124
|
+
finally { await handle.close(); }
|
|
125
|
+
const deadline = systemClock.nowMs() + total;
|
|
126
|
+
let response: RunnerResponse | null = null;
|
|
127
|
+
let attempts = 0;
|
|
128
|
+
for (; attempts <= retries; attempts++) {
|
|
129
|
+
const remaining = deadline - systemClock.nowMs();
|
|
130
|
+
if (remaining <= 0) break;
|
|
131
|
+
const attempt = await run(file, input.interpreter, attempts === 0 ? request : recoverRequest,
|
|
132
|
+
Math.min(remaining, attempts === 0 ? execution : recovery));
|
|
133
|
+
response = attempt.response;
|
|
134
|
+
if (attempt.fenced && (request.action === "status" || settled(response, request))) {
|
|
135
|
+
await fs.rm(dir, { force: true, recursive: true });
|
|
136
|
+
const exitCode = request.action === "status" && !response && attempt.code === 0 ? 1 : attempt.code;
|
|
137
|
+
return { exitCode, response, recoveryFile: null, error: null, attempts: attempts + 1 };
|
|
138
|
+
}
|
|
139
|
+
if (!attempt.fenced || !expected) { attempts++; break; }
|
|
140
|
+
}
|
|
141
|
+
return { exitCode: 3, response, recoveryFile, attempts,
|
|
142
|
+
error: "RECOVERY_UNRESOLVED: retained helper and invocation; transaction evidence is unchanged" };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Resume the retained, hash-verified helper without distribution access. */
|
|
146
|
+
export async function resumeRunner(recoveryFile: string, options: Pick<RunnerLaunch,
|
|
147
|
+
"executionTimeoutMs" | "recoveryTimeoutMs" | "totalTimeoutMs" | "recoveryAttempts"> = {}): Promise<RunnerLaunchResult> {
|
|
148
|
+
const descriptor = JSON.parse(await fs.readFile(recoveryFile, "utf8")) as {
|
|
149
|
+
file: string; interpreter?: string; sha256: string; size: number; request: RunnerRequest;
|
|
150
|
+
};
|
|
151
|
+
const request = parseRunnerRequest(descriptor.request);
|
|
152
|
+
if (request.action !== "recover" || !request.expected ||
|
|
153
|
+
typeof descriptor.file !== "string" || (descriptor.interpreter !== undefined && typeof descriptor.interpreter !== "string")) {
|
|
154
|
+
throw new Error("INVALID_RECOVERY_DESCRIPTOR");
|
|
155
|
+
}
|
|
156
|
+
const bytes = await fs.readFile(descriptor.file);
|
|
157
|
+
if (bytes.length !== descriptor.size || createHash("sha256").update(bytes).digest("hex") !== descriptor.sha256) {
|
|
158
|
+
throw new Error("RECOVERY_ARTIFACT_MISMATCH");
|
|
159
|
+
}
|
|
160
|
+
const result = await superviseRunner({ ...options, request,
|
|
161
|
+
...(descriptor.interpreter ? { interpreter: descriptor.interpreter } : {}),
|
|
162
|
+
scratchDir: path.dirname(path.dirname(recoveryFile)), release: { version: "retained-runner",
|
|
163
|
+
url: `data:application/octet-stream;base64,${bytes.toString("base64")}`, size: descriptor.size, sha256: descriptor.sha256 } });
|
|
164
|
+
if (!result.recoveryFile) {
|
|
165
|
+
// Remove only the files this descriptor owns, never its caller's directory.
|
|
166
|
+
await fs.rm(descriptor.file, { force: true });
|
|
167
|
+
await fs.rm(recoveryFile, { force: true });
|
|
168
|
+
}
|
|
169
|
+
return result;
|
|
170
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { HostCallTimeout, HostCallUncertain } from "../txn/hostCallBudget.ts";
|
|
6
|
+
import type { HostAdapter, ProcessEvidence, Slot } from "./hostAdapter.ts";
|
|
7
|
+
import { slotArtifactPath } from "../bootstrap.ts";
|
|
8
|
+
import { systemClock } from "../clock.ts";
|
|
9
|
+
import { platformOpsFor } from "../platform/index.ts";
|
|
10
|
+
function controllerObject(value: unknown): Record<string, unknown> {
|
|
11
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
12
|
+
throw new Error("HOST_PROTOCOL_INVALID: expected an object");
|
|
13
|
+
}
|
|
14
|
+
return value as Record<string, unknown>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface CommandHostOptions {
|
|
18
|
+
/** Trusted external controller argv. Never a shell expression. */
|
|
19
|
+
command: [string, ...string[]];
|
|
20
|
+
stateDir: string;
|
|
21
|
+
cwd?: string;
|
|
22
|
+
timeoutMs?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Controller stdin is one JSON request; stdout is one JSON response. */
|
|
26
|
+
export function createCommandHost(options: CommandHostOptions): HostAdapter {
|
|
27
|
+
const timeout = options.timeoutMs ?? 30_000;
|
|
28
|
+
if (!Number.isSafeInteger(timeout) || timeout <= 0 || timeout > 120_000) throw new Error("invalid controller timeout");
|
|
29
|
+
const [file, ...args] = options.command;
|
|
30
|
+
const controllersDir = path.join(options.stateDir, "controllers");
|
|
31
|
+
async function drainControllers(): Promise<void> {
|
|
32
|
+
await fs.mkdir(controllersDir, { recursive: true });
|
|
33
|
+
const deadline = systemClock.nowMs() + timeout;
|
|
34
|
+
for (const name of await fs.readdir(controllersDir)) {
|
|
35
|
+
const controllerPath = path.join(controllersDir, name);
|
|
36
|
+
const pid = Number(name.split("-")[0]);
|
|
37
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) throw new Error("HOST_FENCE_UNRESOLVED: invalid controller evidence");
|
|
38
|
+
while (platformOpsFor().isProcessAlive(pid)) {
|
|
39
|
+
if (systemClock.nowMs() >= deadline) throw new HostCallTimeout("fence", timeout);
|
|
40
|
+
await new Promise<void>((resolve) => { systemClock.after(Math.min(50, timeout), resolve); });
|
|
41
|
+
}
|
|
42
|
+
await fs.rm(controllerPath, { force: true });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async function call(action: string, slot?: Slot): Promise<Record<string, unknown>> {
|
|
46
|
+
const input = { protocolVersion: 1, action,
|
|
47
|
+
...(slot ? { slot, artifactPath: slotArtifactPath(options.stateDir, slot) } : {}) };
|
|
48
|
+
const child = spawn(file, args, { stdio: ["pipe", "pipe", "pipe"],
|
|
49
|
+
...(options.cwd ? { cwd: options.cwd } : {}) });
|
|
50
|
+
const controllerPath = path.join(controllersDir, `${child.pid}-${randomUUID()}.json`);
|
|
51
|
+
let exited = false;
|
|
52
|
+
let cancel: (() => void) | undefined;
|
|
53
|
+
let stdout = "";
|
|
54
|
+
const done = new Promise<string>((resolve, reject) => {
|
|
55
|
+
child.on("error", reject);
|
|
56
|
+
child.on("exit", () => { exited = true; });
|
|
57
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
58
|
+
stdout += chunk.toString();
|
|
59
|
+
if (stdout.length > 64 * 1024) reject(new HostCallUncertain("HOST_RESPONSE_TOO_LARGE"));
|
|
60
|
+
});
|
|
61
|
+
child.stderr.resume();
|
|
62
|
+
child.on("close", (code) => code === 0 ? resolve(stdout) : reject(new Error(`HOST_COMMAND_FAILED: ${action} (${code})`)));
|
|
63
|
+
});
|
|
64
|
+
// Install rejection handlers before filesystem IO or an early spawn failure.
|
|
65
|
+
void done.catch(() => {});
|
|
66
|
+
child.stdin.on("error", () => {});
|
|
67
|
+
try {
|
|
68
|
+
if (!child.pid) return await done.then(() => { throw new Error("HOST_SPAWN_FAILED"); });
|
|
69
|
+
await fs.mkdir(controllersDir, { recursive: true });
|
|
70
|
+
const handle = await fs.open(controllerPath, "wx", 0o600);
|
|
71
|
+
try { await handle.writeFile(JSON.stringify({ pid: child.pid })); await handle.sync(); }
|
|
72
|
+
finally { await handle.close(); }
|
|
73
|
+
// No controller gets a command before its lifetime is recorded. A worker
|
|
74
|
+
// dying before this point closes stdin without authorizing any effect.
|
|
75
|
+
child.stdin.end(JSON.stringify(input));
|
|
76
|
+
stdout = await Promise.race([done, new Promise<never>((_resolve, reject) => {
|
|
77
|
+
cancel = systemClock.after(timeout, () => reject(new HostCallTimeout(action, timeout)));
|
|
78
|
+
})]);
|
|
79
|
+
} finally {
|
|
80
|
+
cancel?.();
|
|
81
|
+
if (!exited && child.pid) {
|
|
82
|
+
try { platformOpsFor().killProcess(child.pid); } catch { /* fence will verify */ }
|
|
83
|
+
}
|
|
84
|
+
child.stdin.destroy(); child.stdout.destroy(); child.stderr.destroy(); child.unref();
|
|
85
|
+
// On uncertain termination retain the process evidence for the next fence.
|
|
86
|
+
if (exited) await fs.rm(controllerPath, { force: true });
|
|
87
|
+
}
|
|
88
|
+
const value = controllerObject(JSON.parse(stdout));
|
|
89
|
+
if (value.protocolVersion !== 1 || value.ok !== true) throw new Error(`HOST_PROTOCOL_INVALID: ${action}`);
|
|
90
|
+
return value;
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
fence: async () => {
|
|
94
|
+
await drainControllers();
|
|
95
|
+
// The controller must also fence detached/queued service-manager effects;
|
|
96
|
+
// absence of its old PID alone does not establish that condition.
|
|
97
|
+
await call("fence");
|
|
98
|
+
},
|
|
99
|
+
quiesce: async () => { await call("quiesce"); },
|
|
100
|
+
stop: async (slot) => { await call("stop", slot); },
|
|
101
|
+
start: async (slot) => { await call("start", slot); },
|
|
102
|
+
resume: async () => { await call("resume"); },
|
|
103
|
+
healthProbe: async (): Promise<ProcessEvidence> => {
|
|
104
|
+
const value = controllerObject((await call("probe")).evidence);
|
|
105
|
+
if (typeof value.version !== "string" || !value.version || typeof value.startId !== "string" ||
|
|
106
|
+
!value.startId || !Number.isSafeInteger(value.pid) || typeof value.pid !== "number" || value.pid <= 0 ||
|
|
107
|
+
value.pid === process.pid) throw new Error("HOST_EVIDENCE_INVALID");
|
|
108
|
+
return { version: value.version, pid: value.pid, startId: value.startId };
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|