@botiverse/k-carrier 0.1.7 → 0.1.8
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/core/src/createUpgrader.ts +3 -0
- package/core/src/index.ts +1 -0
- package/core/src/quarantine.ts +167 -0
- package/core/src/upgrader.ts +4 -0
- package/docs/integration.md +10 -0
- package/package.json +1 -1
|
@@ -27,6 +27,7 @@ import type { OperationDescriptor } from "./operation.ts";
|
|
|
27
27
|
import { createOperationLifecycle } from "./operationLifecycle.ts";
|
|
28
28
|
import { driveUpgrade } from "./upgrade/drive.ts";
|
|
29
29
|
import type { ArtifactTransferPolicy } from "./artifact/transferPolicy.ts";
|
|
30
|
+
import { quarantineState } from "./quarantine.ts";
|
|
30
31
|
|
|
31
32
|
export interface CreateUpgraderOptions extends UpgraderConfig {
|
|
32
33
|
clock?: Clock;
|
|
@@ -267,5 +268,7 @@ export function createUpgrader(opts: CreateUpgraderOptions): Upgrader {
|
|
|
267
268
|
await lock.release();
|
|
268
269
|
}
|
|
269
270
|
},
|
|
271
|
+
|
|
272
|
+
quarantineState: (options) => quarantineState(opts.stateDir, options),
|
|
270
273
|
};
|
|
271
274
|
}
|
package/core/src/index.ts
CHANGED
|
@@ -14,6 +14,7 @@ export * from "./bootstrap.ts";
|
|
|
14
14
|
// NotificationEvent.
|
|
15
15
|
export * from "./upgrader.ts";
|
|
16
16
|
export * from "./operation.ts";
|
|
17
|
+
export * from "./quarantine.ts";
|
|
17
18
|
|
|
18
19
|
// The release-source boundary applications implement and the durable
|
|
19
20
|
// provenance journal they wire into createUpgrader.
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { loadOperation } from "./operation.ts";
|
|
4
|
+
import { acquireUpgradeLock, type UpgradeLock } from "./txn/lock.ts";
|
|
5
|
+
import { UpgradeLockError } from "./txn/lock.ts";
|
|
6
|
+
import { platformOpsFor } from "./platform/index.ts";
|
|
7
|
+
|
|
8
|
+
export type QuarantineResult =
|
|
9
|
+
| { status: "quarantined"; sourcePath: string; quarantinePath: string; operationId: string; timestampMs: number }
|
|
10
|
+
| { status: "already-quarantined"; sourcePath: string; quarantinePath: string; operationId: string; timestampMs: number }
|
|
11
|
+
| { status: "not-found"; sourcePath: string; quarantinePath: string; operationId: string; timestampMs: number };
|
|
12
|
+
|
|
13
|
+
export type QuarantineErrorCode =
|
|
14
|
+
| "QUARANTINE_INVALID_DESTINATION"
|
|
15
|
+
| "QUARANTINE_DESTINATION_CONFLICT"
|
|
16
|
+
| "QUARANTINE_ACTIVE_OPERATION"
|
|
17
|
+
| "QUARANTINE_ACTIVE_LOCK"
|
|
18
|
+
| "QUARANTINE_STATE_UNREADABLE"
|
|
19
|
+
| "QUARANTINE_WRITE_FAILED";
|
|
20
|
+
|
|
21
|
+
export class QuarantineError extends Error {
|
|
22
|
+
readonly code: QuarantineErrorCode;
|
|
23
|
+
|
|
24
|
+
constructor(code: QuarantineErrorCode, message: string, options?: { cause?: unknown }) {
|
|
25
|
+
super(`[${code}] ${message}`, options);
|
|
26
|
+
this.name = "QuarantineError";
|
|
27
|
+
this.code = code;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface QuarantineOptions {
|
|
32
|
+
/** Absolute destination. It must be outside stateDir and must not exist. */
|
|
33
|
+
destination: string;
|
|
34
|
+
/** Timestamp supplied by the host clock so the receipt is deterministic. */
|
|
35
|
+
timestampMs: number;
|
|
36
|
+
/** Host proof run while K's single-writer lock is held. */
|
|
37
|
+
assertActiveHandoff?: () => Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function assertDestination(stateDir: string, destination: string): void {
|
|
41
|
+
if (!path.isAbsolute(destination)) {
|
|
42
|
+
throw new QuarantineError("QUARANTINE_INVALID_DESTINATION", "destination must be absolute");
|
|
43
|
+
}
|
|
44
|
+
const source = path.resolve(stateDir);
|
|
45
|
+
const target = path.resolve(destination);
|
|
46
|
+
if (source === target || target.startsWith(`${source}${path.sep}`)) {
|
|
47
|
+
throw new QuarantineError("QUARANTINE_INVALID_DESTINATION", "destination must be outside stateDir");
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function exists(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
|
+
interface QuarantineReceipt {
|
|
62
|
+
formatVersion: 1;
|
|
63
|
+
kind: "k-fresh-install-quarantine";
|
|
64
|
+
sourcePath: string;
|
|
65
|
+
quarantinePath: string;
|
|
66
|
+
operationId: string;
|
|
67
|
+
timestampMs: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function readReceipt(destination: string): Promise<QuarantineReceipt | null> {
|
|
71
|
+
try {
|
|
72
|
+
const parsed = JSON.parse(await fs.readFile(path.join(destination, "fresh-install-quarantine.json"), "utf8")) as Partial<QuarantineReceipt>;
|
|
73
|
+
if (parsed.formatVersion !== 1 || parsed.kind !== "k-fresh-install-quarantine" || typeof parsed.sourcePath !== "string" || typeof parsed.quarantinePath !== "string" || typeof parsed.operationId !== "string" || typeof parsed.timestampMs !== "number") return null;
|
|
74
|
+
return parsed as QuarantineReceipt;
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Move the complete K state directory to an audit-only quarantine.
|
|
82
|
+
*
|
|
83
|
+
* The state lock is acquired before the terminal check and the directory
|
|
84
|
+
* rename is one filesystem operation. The lock release is ownership-aware, so
|
|
85
|
+
* a new state directory created immediately after the rename cannot have its
|
|
86
|
+
* lock removed by the old holder. Active operations are never killed or
|
|
87
|
+
* silently detached; callers must first complete the host handoff contract.
|
|
88
|
+
*/
|
|
89
|
+
export async function quarantineState(stateDir: string, options: QuarantineOptions): Promise<QuarantineResult> {
|
|
90
|
+
assertDestination(stateDir, options.destination);
|
|
91
|
+
const timestampMs = options.timestampMs;
|
|
92
|
+
const sourcePath = path.resolve(stateDir);
|
|
93
|
+
const quarantinePath = path.resolve(options.destination);
|
|
94
|
+
const existingDestination = await exists(quarantinePath);
|
|
95
|
+
const existingOperation = await loadOperation(sourcePath);
|
|
96
|
+
const operationId = existingOperation.kind === "observed" ? existingOperation.operation.id : "genesis";
|
|
97
|
+
|
|
98
|
+
if (existingDestination) {
|
|
99
|
+
if (await exists(sourcePath)) {
|
|
100
|
+
throw new QuarantineError("QUARANTINE_DESTINATION_CONFLICT", `destination already exists: ${quarantinePath}`);
|
|
101
|
+
}
|
|
102
|
+
const receipt = await readReceipt(quarantinePath);
|
|
103
|
+
if (receipt === null) throw new QuarantineError("QUARANTINE_DESTINATION_CONFLICT", `destination has no valid quarantine receipt: ${quarantinePath}`);
|
|
104
|
+
return { status: "already-quarantined", sourcePath: receipt.sourcePath, quarantinePath: receipt.quarantinePath, operationId: receipt.operationId, timestampMs: receipt.timestampMs };
|
|
105
|
+
}
|
|
106
|
+
if (!(await exists(sourcePath))) {
|
|
107
|
+
return { status: "not-found", sourcePath, quarantinePath, operationId, timestampMs };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let lock: UpgradeLock | null = null;
|
|
111
|
+
try {
|
|
112
|
+
lock = await acquireUpgradeLock(sourcePath, timestampMs);
|
|
113
|
+
const operation = await loadOperation(sourcePath);
|
|
114
|
+
if (operation.kind === "unreadable") {
|
|
115
|
+
throw new QuarantineError("QUARANTINE_STATE_UNREADABLE", operation.reason);
|
|
116
|
+
}
|
|
117
|
+
if (operation.kind === "observed" && operation.operation.outcome === null) {
|
|
118
|
+
if (options.assertActiveHandoff === undefined) {
|
|
119
|
+
throw new QuarantineError(
|
|
120
|
+
"QUARANTINE_ACTIVE_OPERATION",
|
|
121
|
+
`operation ${operation.operation.id} is active; complete host handoff before quarantine`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
await options.assertActiveHandoff();
|
|
126
|
+
} catch (error) {
|
|
127
|
+
throw new QuarantineError("QUARANTINE_ACTIVE_OPERATION", `active operation ${operation.operation.id} handoff was not proven`, { cause: error });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (await exists(quarantinePath)) {
|
|
131
|
+
throw new QuarantineError("QUARANTINE_DESTINATION_CONFLICT", `destination already exists: ${quarantinePath}`);
|
|
132
|
+
}
|
|
133
|
+
await fs.mkdir(path.dirname(quarantinePath), { recursive: true });
|
|
134
|
+
const receipt: QuarantineReceipt = {
|
|
135
|
+
formatVersion: 1,
|
|
136
|
+
kind: "k-fresh-install-quarantine",
|
|
137
|
+
sourcePath,
|
|
138
|
+
quarantinePath,
|
|
139
|
+
operationId: operation.kind === "observed" ? operation.operation.id : "genesis",
|
|
140
|
+
timestampMs,
|
|
141
|
+
};
|
|
142
|
+
const receiptPath = path.join(sourcePath, "fresh-install-quarantine.json");
|
|
143
|
+
const fh = await fs.open(receiptPath, "w");
|
|
144
|
+
try {
|
|
145
|
+
await fh.writeFile(JSON.stringify(receipt));
|
|
146
|
+
await fh.sync();
|
|
147
|
+
} finally {
|
|
148
|
+
await fh.close();
|
|
149
|
+
}
|
|
150
|
+
await platformOpsFor().renamePath(sourcePath, quarantinePath);
|
|
151
|
+
return {
|
|
152
|
+
status: "quarantined",
|
|
153
|
+
sourcePath,
|
|
154
|
+
quarantinePath,
|
|
155
|
+
operationId: receipt.operationId,
|
|
156
|
+
timestampMs,
|
|
157
|
+
};
|
|
158
|
+
} catch (error) {
|
|
159
|
+
if (error instanceof QuarantineError) throw error;
|
|
160
|
+
if (error instanceof UpgradeLockError) {
|
|
161
|
+
throw new QuarantineError("QUARANTINE_ACTIVE_LOCK", error.message, { cause: error });
|
|
162
|
+
}
|
|
163
|
+
throw new QuarantineError("QUARANTINE_WRITE_FAILED", `could not quarantine ${sourcePath}`, { cause: error });
|
|
164
|
+
} finally {
|
|
165
|
+
await lock?.release();
|
|
166
|
+
}
|
|
167
|
+
}
|
package/core/src/upgrader.ts
CHANGED
|
@@ -4,6 +4,7 @@ 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
6
|
import type { OperationDescriptor, OperationRead } from "./operation.js";
|
|
7
|
+
import type { QuarantineOptions, QuarantineResult } from "./quarantine.ts";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Who drove a reconcile, recorded in the provenance journal (M6, L5).
|
|
@@ -106,6 +107,9 @@ export interface Upgrader {
|
|
|
106
107
|
|
|
107
108
|
/** Mark one exact terminal operation delivered by the host transport. */
|
|
108
109
|
acknowledgeOperation(operationId: string): Promise<"acknowledged" | "not-terminal" | "not-found" | "changed">;
|
|
110
|
+
|
|
111
|
+
/** Atomically move quiesced K state to an audit-only fresh-install backup. */
|
|
112
|
+
quarantineState(options: QuarantineOptions): Promise<QuarantineResult>;
|
|
109
113
|
}
|
|
110
114
|
|
|
111
115
|
export type UpgradeOutcome =
|
package/docs/integration.md
CHANGED
|
@@ -286,6 +286,16 @@ if (receipt.kind === "observed" && receipt.operation.outcome !== null) {
|
|
|
286
286
|
await deliver(receipt.operation);
|
|
287
287
|
await upgrader.acknowledgeOperation(receipt.operation.id);
|
|
288
288
|
}
|
|
289
|
+
|
|
290
|
+
// Fresh-install hosts may quarantine a complete, quiesced K state atomically.
|
|
291
|
+
// The destination must be an absolute path outside stateDir and the host must
|
|
292
|
+
// supply its clock timestamp. Terminal receipts are moved without deletion;
|
|
293
|
+
// active receipts require an in-lock host handoff proof.
|
|
294
|
+
const backup = await upgrader.quarantineState({
|
|
295
|
+
destination: "/var/lib/myapp/k-quarantine/op-123-1700000000000",
|
|
296
|
+
timestampMs: 1700000000000,
|
|
297
|
+
});
|
|
298
|
+
// backup.quarantinePath is the durable, non-secret audit location.
|
|
289
299
|
```
|
|
290
300
|
|
|
291
301
|
The host may project this receipt into UI or transport, but must not maintain
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@botiverse/k-carrier",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"packageManager": "pnpm@11.18.0",
|
|
5
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
6
|
"repository": {
|