@khorsheed/dsh-ankh-guard 0.1.0 → 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/CHANGELOG.md +28 -0
- package/README.en.md +75 -29
- package/README.i18n.yaml +2 -2
- package/README.md +74 -29
- package/lib/cli.js +2383 -209
- package/lib/client.js +257 -0
- package/lib/exit-agent.js +5 -2
- package/lib/index.js +722 -38
- package/lib/invariant.js +1 -1
- package/lib/preflight-runner.js +125 -47
- package/lib/processes-BjZgJjQr.js +344 -0
- package/lib/restart-context-D6nISh28.js +1245 -0
- package/lib/restart-context-DUyExi9O.js +1245 -0
- package/lib/{state-Dhx9VG44.js → state-4f7yny39.js} +60 -13
- package/lib/state-CZMypGkB.js +323 -0
- package/lib/test-seam-DnvLWTeO.js +119 -0
- package/lib/test-seam-cli.js +24 -0
- package/lib/test-seam-dwvaKjRp.js +459 -0
- package/lib/test-seam.js +2 -0
- package/lib/types/browser-handoff.d.ts +55 -0
- package/lib/types/browser-handoff.js +489 -0
- package/lib/types/cli.d.ts +34 -4
- package/lib/types/cli.js +1487 -225
- package/lib/types/client/index.d.ts +15 -0
- package/lib/types/client/index.js +264 -0
- package/lib/types/deployment-proof.d.ts +24 -0
- package/lib/types/deployment-proof.js +314 -0
- package/lib/types/exit-agent.js +2 -0
- package/lib/types/git.d.ts +12 -3
- package/lib/types/git.js +69 -7
- package/lib/types/index.d.ts +66 -3
- package/lib/types/index.js +157 -39
- package/lib/types/launch-spec.d.ts +263 -0
- package/lib/types/launch-spec.js +823 -0
- package/lib/types/preflight-runner.d.ts +23 -12
- package/lib/types/preflight-runner.js +152 -57
- package/lib/types/processes.d.ts +38 -6
- package/lib/types/processes.js +236 -10
- package/lib/types/restart-context.d.ts +50 -0
- package/lib/types/restart-context.js +106 -0
- package/lib/types/restart-request.d.ts +32 -0
- package/lib/types/restart-request.js +128 -0
- package/lib/types/state-files.d.ts +30 -0
- package/lib/types/state-files.js +55 -0
- package/lib/types/state.d.ts +29 -2
- package/lib/types/state.js +52 -7
- package/lib/types/temp-artifact.d.ts +15 -0
- package/lib/types/temp-artifact.js +17 -0
- package/lib/types/test-seam-cli.d.ts +3 -0
- package/lib/types/test-seam-cli.js +27 -0
- package/lib/types/test-seam.d.ts +55 -0
- package/lib/types/test-seam.js +112 -0
- package/lib/types/transition.d.ts +118 -0
- package/lib/types/transition.js +717 -0
- package/package.json +29 -9
- package/scripts/dsh-watchdog.sh +1388 -80
- package/scripts/install-launchd.sh +43 -5
- package/scripts/install-systemd.sh +43 -5
- package/scripts/on-install.js +1 -1
- package/skills/dsh-self-restart-guard/SKILL.md +38 -12
- package/lib/processes-hCAmwma-.js +0 -127
- package/lib/restart-context-DmnQXNf-.js +0 -421
|
@@ -0,0 +1,717 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reversible filesystem transitions for launch cutovers.
|
|
3
|
+
*
|
|
4
|
+
* A transition plan names home-relative paths that the next host cannot read.
|
|
5
|
+
* The guard moves those paths into a cutover-scoped quarantine only after the
|
|
6
|
+
* previous process has stopped. If the target is rejected, target-created
|
|
7
|
+
* replacements are retained separately before the previous bytes are restored.
|
|
8
|
+
*/
|
|
9
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
10
|
+
import { chmodSync, constants, copyFileSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync, } from 'node:fs';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
13
|
+
import { writeTempArtifactOwner } from "./temp-artifact.js";
|
|
14
|
+
const MAX_OPERATIONS = 64;
|
|
15
|
+
const CUTOVER_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$/;
|
|
16
|
+
function isObject(value) {
|
|
17
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
18
|
+
}
|
|
19
|
+
function exactKeys(value, expected, label) {
|
|
20
|
+
const actual = Object.keys(value).sort();
|
|
21
|
+
const wanted = [...expected].sort();
|
|
22
|
+
if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) {
|
|
23
|
+
throw new Error(`${label} requires exactly: ${wanted.join(', ')}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function canonicalDirectory(path, label) {
|
|
27
|
+
if (!isAbsolute(path))
|
|
28
|
+
throw new Error(`${label} must be absolute`);
|
|
29
|
+
let canonical;
|
|
30
|
+
try {
|
|
31
|
+
canonical = realpathSync(path);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
throw new Error(`${label} does not exist: ${path}`);
|
|
35
|
+
}
|
|
36
|
+
if (!statSync(canonical).isDirectory())
|
|
37
|
+
throw new Error(`${label} must be a directory: ${path}`);
|
|
38
|
+
return canonical;
|
|
39
|
+
}
|
|
40
|
+
function pathExists(path) {
|
|
41
|
+
try {
|
|
42
|
+
lstatSync(path);
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
if (error.code === 'ENOENT')
|
|
47
|
+
return false;
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function normalizeRelativePath(path) {
|
|
52
|
+
if (typeof path !== 'string' || path === '' || isAbsolute(path) || path.includes('\\') || path.includes('\0')) {
|
|
53
|
+
throw new Error('transition operation path must be a non-empty home-relative POSIX path');
|
|
54
|
+
}
|
|
55
|
+
const parts = path.split('/');
|
|
56
|
+
if (parts.some(part => part === '' || part === '.' || part === '..')) {
|
|
57
|
+
throw new Error(`transition operation path contains an empty or traversal segment: ${path}`);
|
|
58
|
+
}
|
|
59
|
+
return parts.join(sep);
|
|
60
|
+
}
|
|
61
|
+
function overlaps(left, right) {
|
|
62
|
+
return left === right || left.startsWith(`${right}${sep}`) || right.startsWith(`${left}${sep}`);
|
|
63
|
+
}
|
|
64
|
+
function assertPathHasNoSymlink(home, path) {
|
|
65
|
+
let current = home;
|
|
66
|
+
for (const part of path.split(sep)) {
|
|
67
|
+
current = join(current, part);
|
|
68
|
+
if (!pathExists(current))
|
|
69
|
+
return;
|
|
70
|
+
const stat = lstatSync(current);
|
|
71
|
+
if (stat.isSymbolicLink())
|
|
72
|
+
throw new Error(`transition path crosses a symbolic link: ${path}`);
|
|
73
|
+
if (current !== join(home, path) && !stat.isDirectory()) {
|
|
74
|
+
throw new Error(`transition path has a non-directory ancestor: ${path}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function assertOperationScope(plan, stateDir) {
|
|
79
|
+
const paths = plan.operations.map(operation => operation.path);
|
|
80
|
+
for (let index = 0; index < paths.length; index++) {
|
|
81
|
+
const path = paths[index];
|
|
82
|
+
assertPathHasNoSymlink(plan.home, path);
|
|
83
|
+
for (const other of paths.slice(index + 1)) {
|
|
84
|
+
if (overlaps(path, other))
|
|
85
|
+
throw new Error(`transition paths may not overlap: ${path} and ${other}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (stateDir === undefined)
|
|
89
|
+
return;
|
|
90
|
+
const canonicalState = canonicalDirectory(stateDir, 'transition state directory');
|
|
91
|
+
const stateRelative = relative(plan.home, canonicalState);
|
|
92
|
+
if (stateRelative === '') {
|
|
93
|
+
throw new Error('transition state directory must not equal the transition home');
|
|
94
|
+
}
|
|
95
|
+
if (!stateRelative.startsWith(`..${sep}`) && stateRelative !== '..' && !isAbsolute(stateRelative)) {
|
|
96
|
+
for (const path of paths) {
|
|
97
|
+
if (overlaps(path, stateRelative)) {
|
|
98
|
+
throw new Error(`transition path overlaps the guard state directory: ${path}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Validate and normalize an untrusted transition plan.
|
|
105
|
+
* @param raw - Parsed JSON plan.
|
|
106
|
+
* @param expectedHome - Home shared by the previous and target launch specs.
|
|
107
|
+
* @param stateDir - Guard state directory, which no operation may move.
|
|
108
|
+
* @returns A canonical plan safe for durable preparation.
|
|
109
|
+
*/
|
|
110
|
+
export function validateTransitionPlan(raw, expectedHome, stateDir) {
|
|
111
|
+
if (!isObject(raw))
|
|
112
|
+
throw new Error('transition plan must be an object');
|
|
113
|
+
exactKeys(raw, ['schemaVersion', 'home', 'operations'], 'transition plan');
|
|
114
|
+
if (raw.schemaVersion !== 1)
|
|
115
|
+
throw new Error('transition plan requires schemaVersion: 1');
|
|
116
|
+
if (typeof raw.home !== 'string')
|
|
117
|
+
throw new Error('transition plan home must be a string');
|
|
118
|
+
const home = canonicalDirectory(raw.home, 'transition plan home');
|
|
119
|
+
const expected = canonicalDirectory(expectedHome, 'cutover home');
|
|
120
|
+
if (home !== expected)
|
|
121
|
+
throw new Error('transition plan home must equal the cutover home');
|
|
122
|
+
if (!Array.isArray(raw.operations) || raw.operations.length === 0 || raw.operations.length > MAX_OPERATIONS) {
|
|
123
|
+
throw new Error(`transition plan requires 1-${MAX_OPERATIONS} operations`);
|
|
124
|
+
}
|
|
125
|
+
const seen = new Set();
|
|
126
|
+
const operations = raw.operations.map((operation, index) => {
|
|
127
|
+
if (!isObject(operation))
|
|
128
|
+
throw new Error(`transition operation ${index} must be an object`);
|
|
129
|
+
exactKeys(operation, ['expect', 'kind', 'path'], `transition operation ${index}`);
|
|
130
|
+
if (operation.kind !== 'quarantine')
|
|
131
|
+
throw new Error(`transition operation ${index} has unsupported kind`);
|
|
132
|
+
if (operation.expect !== 'present' && operation.expect !== 'absent') {
|
|
133
|
+
throw new Error(`transition operation ${index} requires expect: present or absent`);
|
|
134
|
+
}
|
|
135
|
+
const path = normalizeRelativePath(operation.path);
|
|
136
|
+
if (seen.has(path))
|
|
137
|
+
throw new Error(`transition operation path is duplicated: ${path}`);
|
|
138
|
+
seen.add(path);
|
|
139
|
+
return { kind: 'quarantine', path, expect: operation.expect };
|
|
140
|
+
});
|
|
141
|
+
const plan = { schemaVersion: 1, home, operations };
|
|
142
|
+
assertOperationScope(plan, stateDir);
|
|
143
|
+
return plan;
|
|
144
|
+
}
|
|
145
|
+
function jsonBytes(value) {
|
|
146
|
+
return Buffer.from(`${JSON.stringify(value, null, 2)}\n`);
|
|
147
|
+
}
|
|
148
|
+
function sha256(value) {
|
|
149
|
+
return createHash('sha256').update(value).digest('hex');
|
|
150
|
+
}
|
|
151
|
+
function atomicWrite(file, bytes) {
|
|
152
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
153
|
+
const temporary = `${file}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`;
|
|
154
|
+
writeFileSync(temporary, bytes, { flag: 'wx', mode: 0o600 });
|
|
155
|
+
try {
|
|
156
|
+
renameSync(temporary, file);
|
|
157
|
+
try {
|
|
158
|
+
chmodSync(file, 0o600);
|
|
159
|
+
}
|
|
160
|
+
catch { /* best effort on non-POSIX filesystems */ }
|
|
161
|
+
}
|
|
162
|
+
finally {
|
|
163
|
+
rmSync(temporary, { force: true });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function atomicWriteJson(file, value) {
|
|
167
|
+
atomicWrite(file, jsonBytes(value));
|
|
168
|
+
}
|
|
169
|
+
function transitionRoot(stateDir, cutoverId) {
|
|
170
|
+
if (!CUTOVER_ID_PATTERN.test(cutoverId))
|
|
171
|
+
throw new Error('transition cutover id is invalid');
|
|
172
|
+
return join(canonicalDirectory(stateDir, 'transition state directory'), 'launch-transitions', cutoverId);
|
|
173
|
+
}
|
|
174
|
+
function expectedPlanPath(stateDir, cutoverId) {
|
|
175
|
+
return join(transitionRoot(stateDir, cutoverId), 'plan.json');
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Persist the immutable plan and an empty crash-recovery journal before the previous host stops.
|
|
179
|
+
* @param raw - Validated or untrusted plan JSON.
|
|
180
|
+
* @param expectedHome - Shared launch home.
|
|
181
|
+
* @param stateDir - Guard state directory.
|
|
182
|
+
* @param cutoverId - Unique launch cutover identifier.
|
|
183
|
+
* @returns Reference stored in the launch state and redacted receipt.
|
|
184
|
+
*/
|
|
185
|
+
export function prepareTransition(raw, expectedHome, stateDir, cutoverId) {
|
|
186
|
+
const plan = validateTransitionPlan(raw, expectedHome, stateDir);
|
|
187
|
+
const root = transitionRoot(stateDir, cutoverId);
|
|
188
|
+
if (pathExists(root))
|
|
189
|
+
throw new Error(`transition directory already exists for cutover ${cutoverId}`);
|
|
190
|
+
if (statSync(plan.home).dev !== statSync(canonicalDirectory(stateDir, 'transition state directory')).dev) {
|
|
191
|
+
throw new Error('transition home and state directory must be on the same filesystem');
|
|
192
|
+
}
|
|
193
|
+
for (const operation of plan.operations) {
|
|
194
|
+
const source = join(plan.home, operation.path);
|
|
195
|
+
if (pathExists(source) !== (operation.expect === 'present')) {
|
|
196
|
+
throw new Error(`transition source ${operation.path} is ${pathExists(source) ? 'present' : 'absent'}, expected ${operation.expect}`);
|
|
197
|
+
}
|
|
198
|
+
if (pathExists(source) && lstatSync(source).dev !== statSync(canonicalDirectory(stateDir, 'transition state directory')).dev) {
|
|
199
|
+
throw new Error(`transition source is on a different filesystem: ${operation.path}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
203
|
+
const planPath = join(root, 'plan.json');
|
|
204
|
+
const bytes = jsonBytes(plan);
|
|
205
|
+
atomicWrite(planPath, bytes);
|
|
206
|
+
const planSha256 = sha256(bytes);
|
|
207
|
+
const record = {
|
|
208
|
+
version: 1,
|
|
209
|
+
cutoverId,
|
|
210
|
+
planSha256,
|
|
211
|
+
phase: 'prepared',
|
|
212
|
+
entries: plan.operations.map(operation => ({
|
|
213
|
+
path: operation.path,
|
|
214
|
+
original: operation.expect,
|
|
215
|
+
apply: 'pending',
|
|
216
|
+
rollback: 'pending',
|
|
217
|
+
})),
|
|
218
|
+
updatedAt: Date.now(),
|
|
219
|
+
};
|
|
220
|
+
atomicWriteJson(join(root, 'state.json'), record);
|
|
221
|
+
return { version: 1, planPath, planSha256, operationCount: plan.operations.length };
|
|
222
|
+
}
|
|
223
|
+
function parseRecord(raw, cutoverId, reference, plan) {
|
|
224
|
+
if (!isObject(raw) || raw.version !== 1 || raw.cutoverId !== cutoverId
|
|
225
|
+
|| raw.planSha256 !== reference.planSha256 || typeof raw.updatedAt !== 'number'
|
|
226
|
+
|| !['prepared', 'applying', 'applied', 'rolling-back', 'rolled-back', 'failed'].includes(String(raw.phase))
|
|
227
|
+
|| !Array.isArray(raw.entries) || raw.entries.length !== plan.operations.length) {
|
|
228
|
+
throw new Error('transition state is malformed or does not match its plan');
|
|
229
|
+
}
|
|
230
|
+
const entries = raw.entries;
|
|
231
|
+
for (let index = 0; index < entries.length; index++) {
|
|
232
|
+
const entry = entries[index];
|
|
233
|
+
if (!isObject(entry) || entry.path !== plan.operations[index]?.path
|
|
234
|
+
|| !['present', 'absent'].includes(String(entry.original))
|
|
235
|
+
|| !['pending', 'moving', 'absent', 'quarantined'].includes(String(entry.apply))
|
|
236
|
+
|| !['pending', 'moving-target', 'target-retained', 'target-absent', 'restoring', 'restored'].includes(String(entry.rollback))) {
|
|
237
|
+
throw new Error(`transition state entry ${index} is malformed`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return raw;
|
|
241
|
+
}
|
|
242
|
+
function loadTransition(reference, expectedHome, stateDir, cutoverId) {
|
|
243
|
+
if (reference.version !== 1 || !/^[a-f0-9]{64}$/.test(reference.planSha256)
|
|
244
|
+
|| !Number.isInteger(reference.operationCount) || reference.operationCount < 1) {
|
|
245
|
+
throw new Error('transition reference is malformed');
|
|
246
|
+
}
|
|
247
|
+
const planPath = expectedPlanPath(stateDir, cutoverId);
|
|
248
|
+
if (resolve(reference.planPath) !== planPath)
|
|
249
|
+
throw new Error('transition plan path is outside its cutover directory');
|
|
250
|
+
const bytes = readFileSync(planPath);
|
|
251
|
+
if (sha256(bytes) !== reference.planSha256)
|
|
252
|
+
throw new Error('transition plan changed after preparation');
|
|
253
|
+
let rawPlan;
|
|
254
|
+
try {
|
|
255
|
+
rawPlan = JSON.parse(bytes.toString('utf8'));
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
throw new Error('transition plan is not valid JSON');
|
|
259
|
+
}
|
|
260
|
+
const plan = validateTransitionPlan(rawPlan, expectedHome, stateDir);
|
|
261
|
+
if (plan.operations.length !== reference.operationCount)
|
|
262
|
+
throw new Error('transition operation count changed after preparation');
|
|
263
|
+
const root = dirname(planPath);
|
|
264
|
+
const stateFile = join(root, 'state.json');
|
|
265
|
+
let rawRecord;
|
|
266
|
+
try {
|
|
267
|
+
rawRecord = JSON.parse(readFileSync(stateFile, 'utf8'));
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
throw new Error('transition state is unreadable');
|
|
271
|
+
}
|
|
272
|
+
return { plan, record: parseRecord(rawRecord, cutoverId, reference, plan), root, stateFile };
|
|
273
|
+
}
|
|
274
|
+
function writeRecord(loaded) {
|
|
275
|
+
loaded.record.updatedAt = Date.now();
|
|
276
|
+
atomicWriteJson(loaded.stateFile, loaded.record);
|
|
277
|
+
}
|
|
278
|
+
function previousPath(root, path) {
|
|
279
|
+
return join(root, 'previous', path);
|
|
280
|
+
}
|
|
281
|
+
function rejectedTargetPath(root, path) {
|
|
282
|
+
return join(root, 'rejected-target', path);
|
|
283
|
+
}
|
|
284
|
+
function markFailure(loaded, operation, error) {
|
|
285
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
286
|
+
loaded.record.phase = 'failed';
|
|
287
|
+
loaded.record.failure = { operation, detail };
|
|
288
|
+
try {
|
|
289
|
+
writeRecord(loaded);
|
|
290
|
+
}
|
|
291
|
+
catch { /* preserve the original filesystem failure */ }
|
|
292
|
+
throw new Error(`transition ${operation} failed: ${detail}`);
|
|
293
|
+
}
|
|
294
|
+
function reconcileMovingApply(loaded, entry) {
|
|
295
|
+
const source = join(loaded.plan.home, entry.path);
|
|
296
|
+
const retained = previousPath(loaded.root, entry.path);
|
|
297
|
+
const sourceExists = pathExists(source);
|
|
298
|
+
const retainedExists = pathExists(retained);
|
|
299
|
+
if (!sourceExists && retainedExists) {
|
|
300
|
+
entry.original = 'present';
|
|
301
|
+
entry.apply = 'quarantined';
|
|
302
|
+
writeRecord(loaded);
|
|
303
|
+
return true;
|
|
304
|
+
}
|
|
305
|
+
if (sourceExists && !retainedExists) {
|
|
306
|
+
entry.apply = 'pending';
|
|
307
|
+
writeRecord(loaded);
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
310
|
+
throw new Error(`cannot reconcile interrupted quarantine for ${entry.path}`);
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Apply a prepared transition after the previous host has stopped.
|
|
314
|
+
* @param reference - Immutable reference from the launch state.
|
|
315
|
+
* @param expectedHome - Shared launch home.
|
|
316
|
+
* @param stateDir - Guard state directory.
|
|
317
|
+
* @param cutoverId - Active cutover identifier.
|
|
318
|
+
* @returns Idempotent apply result.
|
|
319
|
+
*/
|
|
320
|
+
export function applyTransition(reference, expectedHome, stateDir, cutoverId) {
|
|
321
|
+
const loaded = loadTransition(reference, expectedHome, stateDir, cutoverId);
|
|
322
|
+
if (loaded.record.phase === 'rolled-back')
|
|
323
|
+
throw new Error('a rolled-back transition cannot be applied again');
|
|
324
|
+
if (loaded.record.phase === 'applied') {
|
|
325
|
+
return { phase: 'applied', changed: [], unchanged: loaded.record.entries.map(entry => entry.path) };
|
|
326
|
+
}
|
|
327
|
+
const changed = [];
|
|
328
|
+
const unchanged = [];
|
|
329
|
+
loaded.record.phase = 'applying';
|
|
330
|
+
delete loaded.record.failure;
|
|
331
|
+
writeRecord(loaded);
|
|
332
|
+
try {
|
|
333
|
+
for (const entry of loaded.record.entries) {
|
|
334
|
+
assertPathHasNoSymlink(loaded.plan.home, entry.path);
|
|
335
|
+
if (entry.apply === 'quarantined' || entry.apply === 'absent') {
|
|
336
|
+
unchanged.push(entry.path);
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
if (entry.apply === 'moving' && reconcileMovingApply(loaded, entry)) {
|
|
340
|
+
unchanged.push(entry.path);
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
const source = join(loaded.plan.home, entry.path);
|
|
344
|
+
const retained = previousPath(loaded.root, entry.path);
|
|
345
|
+
const sourceExists = pathExists(source);
|
|
346
|
+
if (sourceExists !== (entry.original === 'present')) {
|
|
347
|
+
throw new Error(`transition source changed after preparation: ${entry.path}`);
|
|
348
|
+
}
|
|
349
|
+
if (!sourceExists) {
|
|
350
|
+
if (pathExists(retained))
|
|
351
|
+
throw new Error(`unexpected retained source for absent path ${entry.path}`);
|
|
352
|
+
entry.apply = 'absent';
|
|
353
|
+
writeRecord(loaded);
|
|
354
|
+
unchanged.push(entry.path);
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
if (lstatSync(source).isSymbolicLink())
|
|
358
|
+
throw new Error(`transition source is a symbolic link: ${entry.path}`);
|
|
359
|
+
if (pathExists(retained))
|
|
360
|
+
throw new Error(`transition quarantine destination already exists: ${entry.path}`);
|
|
361
|
+
entry.apply = 'moving';
|
|
362
|
+
writeRecord(loaded);
|
|
363
|
+
mkdirSync(dirname(retained), { recursive: true, mode: 0o700 });
|
|
364
|
+
renameSync(source, retained);
|
|
365
|
+
entry.apply = 'quarantined';
|
|
366
|
+
writeRecord(loaded);
|
|
367
|
+
changed.push(entry.path);
|
|
368
|
+
}
|
|
369
|
+
loaded.record.phase = 'applied';
|
|
370
|
+
writeRecord(loaded);
|
|
371
|
+
return { phase: 'applied', changed, unchanged };
|
|
372
|
+
}
|
|
373
|
+
catch (error) {
|
|
374
|
+
return markFailure(loaded, 'apply', error);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
function finishTargetRetention(loaded, entry) {
|
|
378
|
+
const source = join(loaded.plan.home, entry.path);
|
|
379
|
+
const target = rejectedTargetPath(loaded.root, entry.path);
|
|
380
|
+
const sourceExists = pathExists(source);
|
|
381
|
+
const targetExists = pathExists(target);
|
|
382
|
+
if (entry.rollback === 'moving-target') {
|
|
383
|
+
if (!sourceExists && targetExists) {
|
|
384
|
+
entry.rollback = 'target-retained';
|
|
385
|
+
writeRecord(loaded);
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
if (!sourceExists || targetExists) {
|
|
389
|
+
throw new Error(`cannot reconcile rejected target output for ${entry.path}`);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
if (sourceExists) {
|
|
393
|
+
if (targetExists)
|
|
394
|
+
throw new Error(`rejected target quarantine already exists for ${entry.path}`);
|
|
395
|
+
entry.rollback = 'moving-target';
|
|
396
|
+
writeRecord(loaded);
|
|
397
|
+
mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
|
|
398
|
+
renameSync(source, target);
|
|
399
|
+
entry.rollback = 'target-retained';
|
|
400
|
+
}
|
|
401
|
+
else {
|
|
402
|
+
entry.rollback = targetExists ? 'target-retained' : 'target-absent';
|
|
403
|
+
}
|
|
404
|
+
writeRecord(loaded);
|
|
405
|
+
}
|
|
406
|
+
function finishPreviousRestore(loaded, entry) {
|
|
407
|
+
if (entry.original === 'absent') {
|
|
408
|
+
entry.rollback = 'restored';
|
|
409
|
+
writeRecord(loaded);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
if (entry.original !== 'present')
|
|
413
|
+
throw new Error(`transition original state is unknown for ${entry.path}`);
|
|
414
|
+
const source = join(loaded.plan.home, entry.path);
|
|
415
|
+
const retained = previousPath(loaded.root, entry.path);
|
|
416
|
+
const sourceExists = pathExists(source);
|
|
417
|
+
const retainedExists = pathExists(retained);
|
|
418
|
+
if (entry.rollback === 'restoring') {
|
|
419
|
+
if (sourceExists && !retainedExists) {
|
|
420
|
+
entry.rollback = 'restored';
|
|
421
|
+
writeRecord(loaded);
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
if (sourceExists || !retainedExists) {
|
|
425
|
+
throw new Error(`cannot reconcile previous-state restore for ${entry.path}`);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
if (sourceExists || !retainedExists)
|
|
429
|
+
throw new Error(`previous-state quarantine is incomplete for ${entry.path}`);
|
|
430
|
+
entry.rollback = 'restoring';
|
|
431
|
+
writeRecord(loaded);
|
|
432
|
+
mkdirSync(dirname(source), { recursive: true });
|
|
433
|
+
renameSync(retained, source);
|
|
434
|
+
entry.rollback = 'restored';
|
|
435
|
+
writeRecord(loaded);
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Roll back an applied or partially applied transition before previous starts.
|
|
439
|
+
* Target-created replacements are retained for diagnosis rather than deleted.
|
|
440
|
+
* @param reference - Immutable reference from the launch state.
|
|
441
|
+
* @param expectedHome - Shared launch home.
|
|
442
|
+
* @param stateDir - Guard state directory.
|
|
443
|
+
* @param cutoverId - Active cutover identifier.
|
|
444
|
+
* @returns Idempotent rollback result.
|
|
445
|
+
*/
|
|
446
|
+
export function rollbackTransition(reference, expectedHome, stateDir, cutoverId) {
|
|
447
|
+
const loaded = loadTransition(reference, expectedHome, stateDir, cutoverId);
|
|
448
|
+
if (loaded.record.phase === 'rolled-back') {
|
|
449
|
+
return { phase: 'rolled-back', changed: [], unchanged: loaded.record.entries.map(entry => entry.path) };
|
|
450
|
+
}
|
|
451
|
+
const changed = [];
|
|
452
|
+
const unchanged = [];
|
|
453
|
+
loaded.record.phase = 'rolling-back';
|
|
454
|
+
delete loaded.record.failure;
|
|
455
|
+
writeRecord(loaded);
|
|
456
|
+
try {
|
|
457
|
+
for (const entry of [...loaded.record.entries].reverse()) {
|
|
458
|
+
if (entry.rollback === 'restored') {
|
|
459
|
+
unchanged.push(entry.path);
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
if (entry.apply === 'moving')
|
|
463
|
+
reconcileMovingApply(loaded, entry);
|
|
464
|
+
if (entry.apply === 'pending') {
|
|
465
|
+
entry.rollback = 'restored';
|
|
466
|
+
writeRecord(loaded);
|
|
467
|
+
unchanged.push(entry.path);
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
assertPathHasNoSymlink(loaded.plan.home, entry.path);
|
|
471
|
+
if (entry.rollback !== 'restoring')
|
|
472
|
+
finishTargetRetention(loaded, entry);
|
|
473
|
+
finishPreviousRestore(loaded, entry);
|
|
474
|
+
changed.push(entry.path);
|
|
475
|
+
}
|
|
476
|
+
loaded.record.phase = 'rolled-back';
|
|
477
|
+
writeRecord(loaded);
|
|
478
|
+
return { phase: 'rolled-back', changed, unchanged };
|
|
479
|
+
}
|
|
480
|
+
catch (error) {
|
|
481
|
+
return markFailure(loaded, 'rollback', error);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Read and verify a transition record through its immutable reference.
|
|
486
|
+
* @param reference - Reference stored in launch state.
|
|
487
|
+
* @param expectedHome - Shared launch home.
|
|
488
|
+
* @param stateDir - Guard state directory.
|
|
489
|
+
* @param cutoverId - Cutover identifier.
|
|
490
|
+
* @returns Verified crash-recovery journal.
|
|
491
|
+
*/
|
|
492
|
+
export function readTransitionRecord(reference, expectedHome, stateDir, cutoverId) {
|
|
493
|
+
return loadTransition(reference, expectedHome, stateDir, cutoverId).record;
|
|
494
|
+
}
|
|
495
|
+
function snapshotCopyError(source, error) {
|
|
496
|
+
const code = error.code;
|
|
497
|
+
if (code === 'ELOOP')
|
|
498
|
+
return new Error(`preflight snapshot refused an unresolvable symbolic-link cycle at ${source}`);
|
|
499
|
+
return new Error(`preflight snapshot could not safely copy ${source}: ${String(error)}`);
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Top-level home entries that never join a preflight snapshot: scratch is
|
|
503
|
+
* ephemeral by definition, and copying it can push prepare+canary past the
|
|
504
|
+
* credential's freshness window (observed: a 24 GB scratch expired the
|
|
505
|
+
* credential mid-cutover, the target canary then failed and restored).
|
|
506
|
+
*/
|
|
507
|
+
const SNAPSHOT_SKIPPED_TOP_LEVEL = new Set(['scratch']);
|
|
508
|
+
function canonicalSnapshotSource(source) {
|
|
509
|
+
try {
|
|
510
|
+
return realpathSync(source);
|
|
511
|
+
}
|
|
512
|
+
catch (error) {
|
|
513
|
+
throw snapshotCopyError(source, error);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
function createContainedSnapshotLink(destination, target, directory) {
|
|
517
|
+
const targetFromLink = relative(dirname(destination), target) || '.';
|
|
518
|
+
symlinkSync(targetFromLink, destination, directory ? 'dir' : 'file');
|
|
519
|
+
}
|
|
520
|
+
/** Copy physical entries once; source symlinks become deferred graph edges. */
|
|
521
|
+
function copySnapshotNode(source, destination, context) {
|
|
522
|
+
let linkMetadata;
|
|
523
|
+
try {
|
|
524
|
+
linkMetadata = lstatSync(source);
|
|
525
|
+
}
|
|
526
|
+
catch (error) {
|
|
527
|
+
throw snapshotCopyError(source, error);
|
|
528
|
+
}
|
|
529
|
+
if (linkMetadata.isSymbolicLink()) {
|
|
530
|
+
context.pendingLinks.push({ source, destination });
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
// Sockets and FIFOs have no copyable content — a live instance's own
|
|
534
|
+
// runtime endpoints (member-bridge.sock et al.) are recreated by whichever
|
|
535
|
+
// process boots on the snapshot. Device nodes and anything more exotic
|
|
536
|
+
// still fail closed.
|
|
537
|
+
if (linkMetadata.isSocket() || linkMetadata.isFIFO()) {
|
|
538
|
+
context.skippedRuntimeEntries++;
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
if (!linkMetadata.isDirectory() && !linkMetadata.isFile()) {
|
|
542
|
+
throw new Error(`preflight snapshot refused a special filesystem entry at ${source}`);
|
|
543
|
+
}
|
|
544
|
+
const canonical = canonicalSnapshotSource(source);
|
|
545
|
+
const existing = context.destinations.get(canonical);
|
|
546
|
+
if (existing !== undefined) {
|
|
547
|
+
createContainedSnapshotLink(destination, existing, linkMetadata.isDirectory());
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
context.destinations.set(canonical, destination);
|
|
551
|
+
if (linkMetadata.isDirectory()) {
|
|
552
|
+
mkdirSync(destination, { mode: 0o700 });
|
|
553
|
+
context.directories.push({
|
|
554
|
+
destination,
|
|
555
|
+
mode: linkMetadata.mode & 0o7777,
|
|
556
|
+
atime: linkMetadata.atime,
|
|
557
|
+
mtime: linkMetadata.mtime,
|
|
558
|
+
});
|
|
559
|
+
try {
|
|
560
|
+
for (const name of readdirSync(canonical)) {
|
|
561
|
+
if (canonical === context.rootCanonical && SNAPSHOT_SKIPPED_TOP_LEVEL.has(name))
|
|
562
|
+
continue;
|
|
563
|
+
copySnapshotNode(join(canonical, name), join(destination, name), context);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
catch (error) {
|
|
567
|
+
if (error instanceof Error && error.message.startsWith('preflight snapshot '))
|
|
568
|
+
throw error;
|
|
569
|
+
throw snapshotCopyError(source, error);
|
|
570
|
+
}
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
try {
|
|
574
|
+
copyFileSync(canonical, destination, constants.COPYFILE_FICLONE);
|
|
575
|
+
chmodSync(destination, linkMetadata.mode & 0o7777);
|
|
576
|
+
utimesSync(destination, linkMetadata.atime, linkMetadata.mtime);
|
|
577
|
+
}
|
|
578
|
+
catch (error) {
|
|
579
|
+
throw snapshotCopyError(source, error);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* Preserve Node's ancestor node_modules lookup for an external package while
|
|
584
|
+
* avoiding one copy per package link. Other external targets are materialized
|
|
585
|
+
* individually and still deduplicated by canonical path.
|
|
586
|
+
*/
|
|
587
|
+
function externalMaterializationAnchor(target) {
|
|
588
|
+
const parsed = resolve(target).split(sep);
|
|
589
|
+
const nodeModulesIndex = parsed.indexOf('node_modules');
|
|
590
|
+
if (nodeModulesIndex >= 0) {
|
|
591
|
+
const prefix = parsed.slice(0, nodeModulesIndex + 1).join(sep) || sep;
|
|
592
|
+
return { source: prefix, destination: 'node_modules' };
|
|
593
|
+
}
|
|
594
|
+
return { source: target, destination: basename(target) || 'target' };
|
|
595
|
+
}
|
|
596
|
+
function resolveSnapshotLinks(context) {
|
|
597
|
+
for (let index = 0; index < context.pendingLinks.length; index++) {
|
|
598
|
+
const link = context.pendingLinks[index];
|
|
599
|
+
const target = canonicalSnapshotSource(link.source);
|
|
600
|
+
let metadata;
|
|
601
|
+
try {
|
|
602
|
+
metadata = statSync(target);
|
|
603
|
+
}
|
|
604
|
+
catch (error) {
|
|
605
|
+
throw snapshotCopyError(link.source, error);
|
|
606
|
+
}
|
|
607
|
+
// A link to a socket/FIFO is dropped with the same rule as the entry
|
|
608
|
+
// itself; a link to a device node or anything more exotic still refuses.
|
|
609
|
+
if (metadata.isSocket() || metadata.isFIFO()) {
|
|
610
|
+
context.skippedRuntimeEntries++;
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
if (!metadata.isDirectory() && !metadata.isFile()) {
|
|
614
|
+
throw new Error(`preflight snapshot refused a link to a special filesystem entry at ${link.source}`);
|
|
615
|
+
}
|
|
616
|
+
let mapped = context.destinations.get(target);
|
|
617
|
+
if (mapped === undefined) {
|
|
618
|
+
const anchor = externalMaterializationAnchor(target);
|
|
619
|
+
const canonicalAnchor = canonicalSnapshotSource(anchor.source);
|
|
620
|
+
mapped = context.destinations.get(target);
|
|
621
|
+
if (mapped === undefined) {
|
|
622
|
+
mkdirSync(context.externalRoot, { recursive: true, mode: 0o700 });
|
|
623
|
+
const anchorDestination = join(context.externalRoot, sha256(Buffer.from(canonicalAnchor)), anchor.destination);
|
|
624
|
+
mkdirSync(dirname(anchorDestination), { recursive: true, mode: 0o700 });
|
|
625
|
+
copySnapshotNode(canonicalAnchor, anchorDestination, context);
|
|
626
|
+
mapped = context.destinations.get(target);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
if (mapped === undefined) {
|
|
630
|
+
throw new Error(`preflight snapshot could not map symbolic-link target for ${link.source}`);
|
|
631
|
+
}
|
|
632
|
+
createContainedSnapshotLink(link.destination, mapped, metadata.isDirectory());
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
function pathIsWithin(root, path) {
|
|
636
|
+
const offset = relative(root, path);
|
|
637
|
+
return offset === '' || (!isAbsolute(offset) && offset !== '..' && !offset.startsWith(`..${sep}`));
|
|
638
|
+
}
|
|
639
|
+
/** Prove every retained link resolves to a writable target inside the snapshot. */
|
|
640
|
+
function assertSnapshotLinksContained(path, canonicalRoot) {
|
|
641
|
+
const metadata = lstatSync(path);
|
|
642
|
+
if (metadata.isSymbolicLink()) {
|
|
643
|
+
const target = canonicalSnapshotSource(path);
|
|
644
|
+
if (!pathIsWithin(canonicalRoot, target)) {
|
|
645
|
+
throw new Error(`preflight snapshot retained a writable link escape at ${path}`);
|
|
646
|
+
}
|
|
647
|
+
const targetMetadata = statSync(target);
|
|
648
|
+
if (!targetMetadata.isDirectory() && !targetMetadata.isFile()) {
|
|
649
|
+
throw new Error(`preflight snapshot retained a link to a special filesystem entry at ${path}`);
|
|
650
|
+
}
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
if (metadata.isDirectory()) {
|
|
654
|
+
for (const name of readdirSync(path))
|
|
655
|
+
assertSnapshotLinksContained(join(path, name), canonicalRoot);
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
if (!metadata.isFile())
|
|
659
|
+
throw new Error(`preflight snapshot retained a special filesystem entry at ${path}`);
|
|
660
|
+
}
|
|
661
|
+
function finalizeSnapshotDirectories(context) {
|
|
662
|
+
for (const directory of [...context.directories].reverse()) {
|
|
663
|
+
chmodSync(directory.destination, directory.mode);
|
|
664
|
+
utimesSync(directory.destination, directory.atime, directory.mtime);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Clone a live home while retaining a contained package-link graph. Internal
|
|
669
|
+
* links are rebuilt against copied nodes; external targets are deduplicated in
|
|
670
|
+
* a snapshot-owned materialization area. No retained link resolves outside the
|
|
671
|
+
* snapshot root, so writes through pnpm/Cordis links cannot reach live bytes.
|
|
672
|
+
* Runtime entries without copyable content (sockets, FIFOs — and links to
|
|
673
|
+
* them) are skipped and counted, never copied; the top-level scratch/ tree is
|
|
674
|
+
* excluded for size. Device nodes still fail closed.
|
|
675
|
+
* @param sourceHome - Live dsh home to read.
|
|
676
|
+
* @returns Isolated home, an idempotent cleanup callback, and the count of skipped runtime entries.
|
|
677
|
+
*/
|
|
678
|
+
export function createPreflightSnapshot(sourceHome) {
|
|
679
|
+
const root = mkdtempSync(join(tmpdir(), 'ankh-transition-preflight-'));
|
|
680
|
+
const home = join(root, 'home');
|
|
681
|
+
try {
|
|
682
|
+
writeTempArtifactOwner(root, 'preflight-snapshot');
|
|
683
|
+
const source = canonicalDirectory(sourceHome, 'preflight source home');
|
|
684
|
+
const context = {
|
|
685
|
+
externalRoot: join(root, 'materialized'),
|
|
686
|
+
rootCanonical: source,
|
|
687
|
+
destinations: new Map(),
|
|
688
|
+
pendingLinks: [],
|
|
689
|
+
directories: [],
|
|
690
|
+
skippedRuntimeEntries: 0,
|
|
691
|
+
};
|
|
692
|
+
copySnapshotNode(source, home, context);
|
|
693
|
+
resolveSnapshotLinks(context);
|
|
694
|
+
assertSnapshotLinksContained(root, realpathSync(root));
|
|
695
|
+
finalizeSnapshotDirectories(context);
|
|
696
|
+
return { home, root, skippedRuntimeEntries: context.skippedRuntimeEntries, cleanup: () => { rmSync(root, { recursive: true, force: true }); } };
|
|
697
|
+
}
|
|
698
|
+
catch (error) {
|
|
699
|
+
rmSync(root, { recursive: true, force: true });
|
|
700
|
+
throw error;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
export function createTransitionPreflightSnapshot(plan) {
|
|
704
|
+
const snapshot = createPreflightSnapshot(plan.home);
|
|
705
|
+
try {
|
|
706
|
+
const stateDir = join(snapshot.root, 'guard-state');
|
|
707
|
+
mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
708
|
+
const rebound = { ...plan, home: snapshot.home };
|
|
709
|
+
const reference = prepareTransition(rebound, snapshot.home, stateDir, 'preflight');
|
|
710
|
+
applyTransition(reference, snapshot.home, stateDir, 'preflight');
|
|
711
|
+
return { home: snapshot.home, cleanup: snapshot.cleanup };
|
|
712
|
+
}
|
|
713
|
+
catch (error) {
|
|
714
|
+
snapshot.cleanup();
|
|
715
|
+
throw error;
|
|
716
|
+
}
|
|
717
|
+
}
|