@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
|
@@ -30,8 +30,26 @@ export declare const STATE_FILES: {
|
|
|
30
30
|
readonly scheduleExitLog: "schedule-exit.log";
|
|
31
31
|
/** How the current instance was launched (recorded by the plugin at apply). */
|
|
32
32
|
readonly instanceLaunch: "instance-launch.json";
|
|
33
|
+
/** The atomically selected full launch configuration (stable or in cutover). */
|
|
34
|
+
readonly launchSpec: "launch-spec.json";
|
|
35
|
+
/** Redacted durable receipt for the latest launch-configuration cutover. */
|
|
36
|
+
readonly launchCutover: "launch-cutover.json";
|
|
37
|
+
/** Legacy combined operator-control marker, retained for rolling upgrades. */
|
|
38
|
+
readonly cutoverControl: "launch-cutover-control.json";
|
|
39
|
+
/** Operator → watchdog: abort according to the pre-approved recovery policy. */
|
|
40
|
+
readonly cutoverAbort: "launch-cutover-abort.json";
|
|
41
|
+
/** Monotonic stronger operator action: explicitly restore the previous spec. */
|
|
42
|
+
readonly cutoverRestorePrevious: "launch-cutover-restore-previous.json";
|
|
43
|
+
/** Original browser-tab registry: per-tab capability hashes only; retained through terminal recovery. */
|
|
44
|
+
readonly browserHandoffRequest: "browser-handoff-request.json";
|
|
45
|
+
/** Browser → watchdog acknowledgement for one proven final process. */
|
|
46
|
+
readonly browserHandoffAck: "browser-handoff-ack.json";
|
|
33
47
|
/** Whether the restart-protocol skill registered at apply (and why not). */
|
|
34
48
|
readonly skillRegistration: "skill-registration.json";
|
|
49
|
+
/** Directory: the healthy-boot snapshot of the profile composition inputs. */
|
|
50
|
+
readonly lastGoodComposition: "last-good-composition";
|
|
51
|
+
/** Directory prefix: a failing composition backed up before rollback restores over it. */
|
|
52
|
+
readonly compositionBackup: "composition-backup-";
|
|
35
53
|
};
|
|
36
54
|
/** A STATE_FILES key. */
|
|
37
55
|
export type StateFileRole = keyof typeof STATE_FILES;
|
|
@@ -46,4 +64,16 @@ export declare function stateFile(stateDir: string, role: StateFileRole): string
|
|
|
46
64
|
* @returns the stamped revision, or undefined.
|
|
47
65
|
*/
|
|
48
66
|
export declare function lastGoodBootRevision(stateDir: string): string | undefined;
|
|
67
|
+
/**
|
|
68
|
+
* Whether the pid named by this raw pid/lock-file content is alive. Empty
|
|
69
|
+
* content reads as NO holder: Number('') is 0 and kill(0, 0) probes our own
|
|
70
|
+
* process group (always succeeds), which once read as "alive" and refused
|
|
71
|
+
* every restart forever — the bug that had to be fixed in two copies of this
|
|
72
|
+
* logic before it was consolidated here.
|
|
73
|
+
*/
|
|
74
|
+
export declare function pidAlive(raw: string): boolean;
|
|
75
|
+
/** The live pid named by a pid/lock file (as the raw string), or null when absent/stale. */
|
|
76
|
+
export declare function livePidIn(file: string): string | null;
|
|
77
|
+
/** The live supervising watchdog's pid, or null when none is (pidfile + kill 0). */
|
|
78
|
+
export declare function liveWatchdogPid(stateDir: string): number | null;
|
|
49
79
|
//# sourceMappingURL=state-files.d.ts.map
|
package/lib/types/state-files.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { readFileSync } from 'node:fs';
|
|
13
13
|
import { join } from 'node:path';
|
|
14
|
+
import { appendTestLifecycleEvent } from "./test-seam.js";
|
|
14
15
|
/** Every state-directory file name, keyed by role. */
|
|
15
16
|
export const STATE_FILES = {
|
|
16
17
|
/** The guard's credential/checkpoint/audit state (state.ts). */
|
|
@@ -43,8 +44,26 @@ export const STATE_FILES = {
|
|
|
43
44
|
scheduleExitLog: 'schedule-exit.log',
|
|
44
45
|
/** How the current instance was launched (recorded by the plugin at apply). */
|
|
45
46
|
instanceLaunch: 'instance-launch.json',
|
|
47
|
+
/** The atomically selected full launch configuration (stable or in cutover). */
|
|
48
|
+
launchSpec: 'launch-spec.json',
|
|
49
|
+
/** Redacted durable receipt for the latest launch-configuration cutover. */
|
|
50
|
+
launchCutover: 'launch-cutover.json',
|
|
51
|
+
/** Legacy combined operator-control marker, retained for rolling upgrades. */
|
|
52
|
+
cutoverControl: 'launch-cutover-control.json',
|
|
53
|
+
/** Operator → watchdog: abort according to the pre-approved recovery policy. */
|
|
54
|
+
cutoverAbort: 'launch-cutover-abort.json',
|
|
55
|
+
/** Monotonic stronger operator action: explicitly restore the previous spec. */
|
|
56
|
+
cutoverRestorePrevious: 'launch-cutover-restore-previous.json',
|
|
57
|
+
/** Original browser-tab registry: per-tab capability hashes only; retained through terminal recovery. */
|
|
58
|
+
browserHandoffRequest: 'browser-handoff-request.json',
|
|
59
|
+
/** Browser → watchdog acknowledgement for one proven final process. */
|
|
60
|
+
browserHandoffAck: 'browser-handoff-ack.json',
|
|
46
61
|
/** Whether the restart-protocol skill registered at apply (and why not). */
|
|
47
62
|
skillRegistration: 'skill-registration.json',
|
|
63
|
+
/** Directory: the healthy-boot snapshot of the profile composition inputs. */
|
|
64
|
+
lastGoodComposition: 'last-good-composition',
|
|
65
|
+
/** Directory prefix: a failing composition backed up before rollback restores over it. */
|
|
66
|
+
compositionBackup: 'composition-backup-',
|
|
48
67
|
};
|
|
49
68
|
/** Absolute path of a state file inside a state directory. */
|
|
50
69
|
export function stateFile(stateDir, role) {
|
|
@@ -67,3 +86,39 @@ export function lastGoodBootRevision(stateDir) {
|
|
|
67
86
|
return undefined;
|
|
68
87
|
}
|
|
69
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* Whether the pid named by this raw pid/lock-file content is alive. Empty
|
|
91
|
+
* content reads as NO holder: Number('') is 0 and kill(0, 0) probes our own
|
|
92
|
+
* process group (always succeeds), which once read as "alive" and refused
|
|
93
|
+
* every restart forever — the bug that had to be fixed in two copies of this
|
|
94
|
+
* logic before it was consolidated here.
|
|
95
|
+
*/
|
|
96
|
+
export function pidAlive(raw) {
|
|
97
|
+
const pid = Number(raw);
|
|
98
|
+
if (raw === '' || !Number.isInteger(pid) || pid <= 0)
|
|
99
|
+
return false;
|
|
100
|
+
try {
|
|
101
|
+
process.kill(pid, 0);
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/** The live pid named by a pid/lock file (as the raw string), or null when absent/stale. */
|
|
109
|
+
export function livePidIn(file) {
|
|
110
|
+
try {
|
|
111
|
+
const raw = readFileSync(file, 'utf8').trim();
|
|
112
|
+
return pidAlive(raw) ? raw : null;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** The live supervising watchdog's pid, or null when none is (pidfile + kill 0). */
|
|
119
|
+
export function liveWatchdogPid(stateDir) {
|
|
120
|
+
const raw = livePidIn(stateFile(stateDir, 'watchdogPid'));
|
|
121
|
+
const pid = raw === null ? null : Number(raw);
|
|
122
|
+
appendTestLifecycleEvent('watchdog-liveness-probe', { pid: pid ?? 0, live: pid !== null }, 'parent-observer');
|
|
123
|
+
return pid;
|
|
124
|
+
}
|
package/lib/types/state.d.ts
CHANGED
|
@@ -9,6 +9,29 @@ export interface GuardCredential {
|
|
|
9
9
|
/** The exact command that produced the green state. */
|
|
10
10
|
command: string;
|
|
11
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* Durable proof that one exact deployment passed restart readiness + canary.
|
|
14
|
+
* Digests contain no command, profile content, or credential values.
|
|
15
|
+
*/
|
|
16
|
+
export interface ProvenDeployment {
|
|
17
|
+
version: 1;
|
|
18
|
+
provenAt: number;
|
|
19
|
+
credential: {
|
|
20
|
+
revision: string;
|
|
21
|
+
recordedAt: number;
|
|
22
|
+
scope: string;
|
|
23
|
+
commandSha256: string;
|
|
24
|
+
};
|
|
25
|
+
fingerprint: {
|
|
26
|
+
version: 1;
|
|
27
|
+
credentialRevision: string;
|
|
28
|
+
harnessRevision: string;
|
|
29
|
+
launchSpecSha256: string;
|
|
30
|
+
profileSha256: string;
|
|
31
|
+
hostRuntimeSha256: string;
|
|
32
|
+
};
|
|
33
|
+
fingerprintSha256: string;
|
|
34
|
+
}
|
|
12
35
|
/** A pre-batch snapshot commit the guard can reset back to. */
|
|
13
36
|
export interface GuardCheckpoint {
|
|
14
37
|
/** Commit SHA of the checkpoint. */
|
|
@@ -20,13 +43,14 @@ export interface GuardCheckpoint {
|
|
|
20
43
|
}
|
|
21
44
|
/** Append-only audit trail of state mutations (capped; verify is read-only). */
|
|
22
45
|
export interface GuardAuditEntry {
|
|
23
|
-
action: 'record' | 'clear' | 'checkpoint';
|
|
46
|
+
action: 'record' | 'clear' | 'checkpoint' | 'prove-deployment';
|
|
24
47
|
ts: number;
|
|
25
48
|
detail: string;
|
|
26
49
|
}
|
|
27
50
|
/** The whole persisted state file. */
|
|
28
51
|
export interface GuardState {
|
|
29
52
|
credential?: GuardCredential;
|
|
53
|
+
provenDeployment?: ProvenDeployment;
|
|
30
54
|
checkpoint?: GuardCheckpoint;
|
|
31
55
|
audit: readonly GuardAuditEntry[];
|
|
32
56
|
}
|
|
@@ -69,6 +93,8 @@ export declare function recordCredential(stateDir: string, input: RecordInput, n
|
|
|
69
93
|
* @returns the persisted state.
|
|
70
94
|
*/
|
|
71
95
|
export declare function clearCredential(stateDir: string, now: number): GuardState;
|
|
96
|
+
/** Persist a deployment proof after the watchdog has completed its canary. */
|
|
97
|
+
export declare function setProvenDeployment(stateDir: string, proof: ProvenDeployment, now: number): GuardState;
|
|
72
98
|
/**
|
|
73
99
|
* Persist a pre-batch checkpoint commit reference.
|
|
74
100
|
* @param stateDir - state directory.
|
|
@@ -87,7 +113,8 @@ export declare function setCheckpoint(stateDir: string, input: {
|
|
|
87
113
|
* @param currentRevision - the git HEAD of the checkout, or null when unavailable.
|
|
88
114
|
* @param now - epoch milliseconds (injected for deterministic tests).
|
|
89
115
|
* @param maxAgeMinutes - freshness window.
|
|
116
|
+
* @param workingTreeClean - whether staged, unstaged, and untracked inputs are absent.
|
|
90
117
|
* @returns ok plus a human reason either way.
|
|
91
118
|
*/
|
|
92
|
-
export declare function verifyCredential(state: GuardState, currentRevision: string | null, now: number, maxAgeMinutes: number): VerifyResult;
|
|
119
|
+
export declare function verifyCredential(state: GuardState, currentRevision: string | null, now: number, maxAgeMinutes: number, workingTreeClean?: boolean): VerifyResult;
|
|
93
120
|
//# sourceMappingURL=state.d.ts.map
|
package/lib/types/state.js
CHANGED
|
@@ -3,10 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The guard records one "green build" credential — bound to the git HEAD it
|
|
5
5
|
* was recorded on and to a freshness window — and answers `verify()` against
|
|
6
|
-
* the CURRENT head and wall clock. A
|
|
7
|
-
* credential
|
|
8
|
-
*
|
|
9
|
-
* authorize a restart of unverified code. Framework-free: the cordis plugin,
|
|
6
|
+
* the CURRENT head and wall clock. A later fully gated boot may promote that
|
|
7
|
+
* credential into a deployment proof whose exact runtime fingerprint can be
|
|
8
|
+
* reused by the same-launch restart path. Framework-free: the cordis plugin,
|
|
10
9
|
* the CLI, and the invariant companion all share this module.
|
|
11
10
|
*/
|
|
12
11
|
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
@@ -33,11 +32,34 @@ function isCheckpoint(value) {
|
|
|
33
32
|
const c = value;
|
|
34
33
|
return typeof c.revision === 'string' && typeof c.recordedAt === 'number' && typeof c.message === 'string';
|
|
35
34
|
}
|
|
35
|
+
function isSha256(value) {
|
|
36
|
+
return typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
|
|
37
|
+
}
|
|
38
|
+
function isProvenDeployment(value) {
|
|
39
|
+
if (typeof value !== 'object' || value === null)
|
|
40
|
+
return false;
|
|
41
|
+
const proof = value;
|
|
42
|
+
const credential = proof.credential;
|
|
43
|
+
const fingerprint = proof.fingerprint;
|
|
44
|
+
return proof.version === 1 && typeof proof.provenAt === 'number'
|
|
45
|
+
&& credential !== undefined
|
|
46
|
+
&& typeof credential.revision === 'string' && credential.revision !== ''
|
|
47
|
+
&& typeof credential.recordedAt === 'number'
|
|
48
|
+
&& typeof credential.scope === 'string'
|
|
49
|
+
&& isSha256(credential.commandSha256)
|
|
50
|
+
&& fingerprint?.version === 1
|
|
51
|
+
&& typeof fingerprint.credentialRevision === 'string' && fingerprint.credentialRevision !== ''
|
|
52
|
+
&& typeof fingerprint.harnessRevision === 'string' && fingerprint.harnessRevision !== ''
|
|
53
|
+
&& isSha256(fingerprint.launchSpecSha256)
|
|
54
|
+
&& isSha256(fingerprint.profileSha256)
|
|
55
|
+
&& isSha256(fingerprint.hostRuntimeSha256)
|
|
56
|
+
&& isSha256(proof.fingerprintSha256);
|
|
57
|
+
}
|
|
36
58
|
function isAuditEntry(value) {
|
|
37
59
|
if (typeof value !== 'object' || value === null)
|
|
38
60
|
return false;
|
|
39
61
|
const e = value;
|
|
40
|
-
return (e.action === 'record' || e.action === 'clear' || e.action === 'checkpoint')
|
|
62
|
+
return (e.action === 'record' || e.action === 'clear' || e.action === 'checkpoint' || e.action === 'prove-deployment')
|
|
41
63
|
&& typeof e.ts === 'number' && typeof e.detail === 'string';
|
|
42
64
|
}
|
|
43
65
|
/**
|
|
@@ -56,6 +78,8 @@ export function loadState(stateDir) {
|
|
|
56
78
|
const state = { audit };
|
|
57
79
|
if (isCredential(parsed.credential))
|
|
58
80
|
state.credential = parsed.credential;
|
|
81
|
+
if (isProvenDeployment(parsed.provenDeployment))
|
|
82
|
+
state.provenDeployment = parsed.provenDeployment;
|
|
59
83
|
if (isCheckpoint(parsed.checkpoint))
|
|
60
84
|
state.checkpoint = parsed.checkpoint;
|
|
61
85
|
return state;
|
|
@@ -91,7 +115,10 @@ export function recordCredential(stateDir, input, now) {
|
|
|
91
115
|
const credential = { ...input, recordedAt: now };
|
|
92
116
|
const state = loadState(stateDir);
|
|
93
117
|
const next = withAudit(state, { action: 'record', ts: now, detail: `${input.scope} @ ${input.revision}` });
|
|
94
|
-
|
|
118
|
+
// A new proof attempt invalidates the old deployed-runtime proof even when
|
|
119
|
+
// it happens to target the same HEAD: ignored build outputs may be changing.
|
|
120
|
+
const { provenDeployment: _oldProof, ...withoutOldProof } = next;
|
|
121
|
+
const updated = { ...withoutOldProof, credential };
|
|
95
122
|
saveState(stateDir, updated);
|
|
96
123
|
return updated;
|
|
97
124
|
}
|
|
@@ -110,6 +137,17 @@ export function clearCredential(stateDir, now) {
|
|
|
110
137
|
saveState(stateDir, updated);
|
|
111
138
|
return updated;
|
|
112
139
|
}
|
|
140
|
+
/** Persist a deployment proof after the watchdog has completed its canary. */
|
|
141
|
+
export function setProvenDeployment(stateDir, proof, now) {
|
|
142
|
+
const state = loadState(stateDir);
|
|
143
|
+
const next = withAudit(state, {
|
|
144
|
+
action: 'prove-deployment', ts: now,
|
|
145
|
+
detail: `${proof.credential.revision} ${proof.fingerprintSha256.slice(0, 16)}`,
|
|
146
|
+
});
|
|
147
|
+
const updated = { ...next, provenDeployment: proof };
|
|
148
|
+
saveState(stateDir, updated);
|
|
149
|
+
return updated;
|
|
150
|
+
}
|
|
113
151
|
/**
|
|
114
152
|
* Persist a pre-batch checkpoint commit reference.
|
|
115
153
|
* @param stateDir - state directory.
|
|
@@ -132,15 +170,22 @@ export function setCheckpoint(stateDir, input, now) {
|
|
|
132
170
|
* @param currentRevision - the git HEAD of the checkout, or null when unavailable.
|
|
133
171
|
* @param now - epoch milliseconds (injected for deterministic tests).
|
|
134
172
|
* @param maxAgeMinutes - freshness window.
|
|
173
|
+
* @param workingTreeClean - whether staged, unstaged, and untracked inputs are absent.
|
|
135
174
|
* @returns ok plus a human reason either way.
|
|
136
175
|
*/
|
|
137
|
-
export function verifyCredential(state, currentRevision, now, maxAgeMinutes) {
|
|
176
|
+
export function verifyCredential(state, currentRevision, now, maxAgeMinutes, workingTreeClean = true) {
|
|
138
177
|
const credential = state.credential;
|
|
139
178
|
if (credential === undefined)
|
|
140
179
|
return { ok: false, reason: 'no green-build credential recorded' };
|
|
141
180
|
if (currentRevision === null) {
|
|
142
181
|
return { ok: false, reason: 'current git HEAD unavailable (not inside a git repository?)' };
|
|
143
182
|
}
|
|
183
|
+
if (!workingTreeClean) {
|
|
184
|
+
return {
|
|
185
|
+
ok: false,
|
|
186
|
+
reason: 'working tree is dirty (staged, unstaged, or untracked changes exist) — commit or remove them, then rebuild and re-record',
|
|
187
|
+
};
|
|
188
|
+
}
|
|
144
189
|
if (credential.revision !== currentRevision) {
|
|
145
190
|
return {
|
|
146
191
|
ok: false,
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type ProcessIdentity } from './processes.ts';
|
|
2
|
+
/** Owner proof for a crash-left temporary artifact that may later be reaped. */
|
|
3
|
+
export declare const TEMP_ARTIFACT_OWNER_FILE = ".ankh-guard-owner.json";
|
|
4
|
+
export interface TempArtifactOwnerRecord {
|
|
5
|
+
version: 1;
|
|
6
|
+
kind: 'preflight-snapshot';
|
|
7
|
+
createdAt: number;
|
|
8
|
+
owner: ProcessIdentity;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Bind one private temporary artifact to the exact process identity creating
|
|
12
|
+
* it. A later reaper may remove the artifact only after this identity is gone.
|
|
13
|
+
*/
|
|
14
|
+
export declare function writeTempArtifactOwner(root: string, kind: TempArtifactOwnerRecord['kind']): TempArtifactOwnerRecord;
|
|
15
|
+
//# sourceMappingURL=temp-artifact.d.ts.map
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { processIdentity } from "./processes.js";
|
|
4
|
+
/** Owner proof for a crash-left temporary artifact that may later be reaped. */
|
|
5
|
+
export const TEMP_ARTIFACT_OWNER_FILE = '.ankh-guard-owner.json';
|
|
6
|
+
/**
|
|
7
|
+
* Bind one private temporary artifact to the exact process identity creating
|
|
8
|
+
* it. A later reaper may remove the artifact only after this identity is gone.
|
|
9
|
+
*/
|
|
10
|
+
export function writeTempArtifactOwner(root, kind) {
|
|
11
|
+
const owner = processIdentity(process.pid);
|
|
12
|
+
if (owner === null)
|
|
13
|
+
throw new Error(`cannot capture temporary-artifact owner identity for pid ${process.pid}`);
|
|
14
|
+
const record = { version: 1, kind, createdAt: Date.now(), owner };
|
|
15
|
+
writeFileSync(join(root, TEMP_ARTIFACT_OWNER_FILE), `${JSON.stringify(record)}\n`, { flag: 'wx', mode: 0o600 });
|
|
16
|
+
return record;
|
|
17
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** Internal executable used only by dsh-watchdog.sh under an explicit test run. */
|
|
3
|
+
import { appendTestLifecycleEventForProcess, registerCurrentTestProcess, registerTestProcess, TEST_PROCESS_ROLE_ENV, } from "./test-seam.js";
|
|
4
|
+
const command = process.argv[2] ?? 'register';
|
|
5
|
+
if (command === 'register-parent') {
|
|
6
|
+
const role = process.argv[3] ?? process.env[TEST_PROCESS_ROLE_ENV] ?? 'unknown';
|
|
7
|
+
registerTestProcess(process.ppid, role, { source: 'child-self' });
|
|
8
|
+
}
|
|
9
|
+
else if (command === 'event-parent') {
|
|
10
|
+
const role = process.argv[3] ?? process.env[TEST_PROCESS_ROLE_ENV] ?? 'unknown';
|
|
11
|
+
const event = process.argv[4] ?? 'unknown';
|
|
12
|
+
appendTestLifecycleEventForProcess(process.ppid, role, event);
|
|
13
|
+
}
|
|
14
|
+
else if (command === 'register-pid') {
|
|
15
|
+
const pid = Number(process.argv[3]);
|
|
16
|
+
const role = process.argv[4] ?? process.env[TEST_PROCESS_ROLE_ENV] ?? 'unknown';
|
|
17
|
+
registerTestProcess(pid, role, { source: 'child-self' });
|
|
18
|
+
}
|
|
19
|
+
else if (command === 'event-pid') {
|
|
20
|
+
const pid = Number(process.argv[3]);
|
|
21
|
+
const role = process.argv[4] ?? process.env[TEST_PROCESS_ROLE_ENV] ?? 'unknown';
|
|
22
|
+
const event = process.argv[5] ?? 'unknown';
|
|
23
|
+
appendTestLifecycleEventForProcess(pid, role, event);
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
registerCurrentTestProcess();
|
|
27
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { type ProcessIdentity } from './processes.ts';
|
|
2
|
+
export declare const TEST_RUN_DIR_ENV = "ANKH_GUARD_TEST_RUN_DIR";
|
|
3
|
+
export declare const TEST_RUN_TOKEN_ENV = "ANKH_GUARD_TEST_RUN_TOKEN";
|
|
4
|
+
export declare const TEST_PROCESS_ROLE_ENV = "ANKH_GUARD_TEST_PROCESS_ROLE";
|
|
5
|
+
export declare const TEST_PROCESS_PORT_ENV = "ANKH_GUARD_TEST_PROCESS_PORT";
|
|
6
|
+
export declare const TEST_PROCESS_TEMP_ROOT_ENV = "ANKH_GUARD_TEST_PROCESS_TEMP_ROOT";
|
|
7
|
+
export declare const TEST_REGISTER_BIN_ENV = "ANKH_GUARD_TEST_REGISTER_BIN";
|
|
8
|
+
export declare const TEST_SLEEP_SCALE_ENV = "ANKH_GUARD_TEST_SLEEP_SCALE";
|
|
9
|
+
export type TestObservationSource = 'child-self' | 'parent-observer' | 'node-exit-callback' | 'node-close-callback' | 'ps-sampler';
|
|
10
|
+
export interface TestProcessLeaseRecord {
|
|
11
|
+
version: 1;
|
|
12
|
+
runToken: string;
|
|
13
|
+
role: string;
|
|
14
|
+
pid: number;
|
|
15
|
+
pgid: number;
|
|
16
|
+
startToken: string;
|
|
17
|
+
groupRoot: boolean;
|
|
18
|
+
registeredAt: number;
|
|
19
|
+
source: 'child-self' | 'parent-observer';
|
|
20
|
+
tempRoot?: string;
|
|
21
|
+
port?: number;
|
|
22
|
+
}
|
|
23
|
+
export interface TestLifecycleEvent {
|
|
24
|
+
version: 1;
|
|
25
|
+
runToken: string;
|
|
26
|
+
source: TestObservationSource;
|
|
27
|
+
role: string;
|
|
28
|
+
event: string;
|
|
29
|
+
pid: number;
|
|
30
|
+
pgid?: number;
|
|
31
|
+
startToken?: string;
|
|
32
|
+
wallTimeMs: number;
|
|
33
|
+
monotonicNs: string;
|
|
34
|
+
detail?: Record<string, string | number | boolean | null>;
|
|
35
|
+
}
|
|
36
|
+
interface RegisterOptions {
|
|
37
|
+
env?: NodeJS.ProcessEnv;
|
|
38
|
+
identity?: ProcessIdentity;
|
|
39
|
+
source?: 'child-self' | 'parent-observer';
|
|
40
|
+
tempRoot?: string;
|
|
41
|
+
port?: number;
|
|
42
|
+
}
|
|
43
|
+
export declare function appendTestLifecycleEventForProcess(subjectPid: number, roleValue: string, event: string, detail?: Record<string, string | number | boolean | null>, source?: TestObservationSource, env?: NodeJS.ProcessEnv): void;
|
|
44
|
+
/** Record one credential-free event in this process's append-only event file. */
|
|
45
|
+
export declare function appendTestLifecycleEvent(event: string, detail?: Record<string, string | number | boolean | null>, source?: TestObservationSource, env?: NodeJS.ProcessEnv): void;
|
|
46
|
+
/**
|
|
47
|
+
* Atomically publish an immutable PID/PGID/start-identity lease. The same PID
|
|
48
|
+
* may be observed by its parent and then self-register; the first complete
|
|
49
|
+
* record wins because both describe the same kernel start identity.
|
|
50
|
+
*/
|
|
51
|
+
export declare function registerTestProcess(pid: number, role: string, options?: RegisterOptions): TestProcessLeaseRecord | null;
|
|
52
|
+
/** Self-register only when an explicit test role accompanies the run lease. */
|
|
53
|
+
export declare function registerCurrentTestProcess(env?: NodeJS.ProcessEnv): TestProcessLeaseRecord | null;
|
|
54
|
+
export {};
|
|
55
|
+
//# sourceMappingURL=test-seam.d.ts.map
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Explicit test-only process/event registration seam. Production execution is
|
|
3
|
+
* inert unless a test runner supplies all ANKH_GUARD_TEST_* ownership values.
|
|
4
|
+
*/
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import { appendFileSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { processGroupId, processIdentity } from "./processes.js";
|
|
9
|
+
export const TEST_RUN_DIR_ENV = 'ANKH_GUARD_TEST_RUN_DIR';
|
|
10
|
+
export const TEST_RUN_TOKEN_ENV = 'ANKH_GUARD_TEST_RUN_TOKEN';
|
|
11
|
+
export const TEST_PROCESS_ROLE_ENV = 'ANKH_GUARD_TEST_PROCESS_ROLE';
|
|
12
|
+
export const TEST_PROCESS_PORT_ENV = 'ANKH_GUARD_TEST_PROCESS_PORT';
|
|
13
|
+
export const TEST_PROCESS_TEMP_ROOT_ENV = 'ANKH_GUARD_TEST_PROCESS_TEMP_ROOT';
|
|
14
|
+
export const TEST_REGISTER_BIN_ENV = 'ANKH_GUARD_TEST_REGISTER_BIN';
|
|
15
|
+
export const TEST_SLEEP_SCALE_ENV = 'ANKH_GUARD_TEST_SLEEP_SCALE';
|
|
16
|
+
function testCoordinates(env) {
|
|
17
|
+
const runDir = env[TEST_RUN_DIR_ENV];
|
|
18
|
+
const runToken = env[TEST_RUN_TOKEN_ENV];
|
|
19
|
+
if (runDir === undefined || runDir === '' || runToken === undefined || !/^[a-f0-9-]{16,}$/.test(runToken))
|
|
20
|
+
return null;
|
|
21
|
+
return { runDir, runToken };
|
|
22
|
+
}
|
|
23
|
+
function safeRole(value) {
|
|
24
|
+
const role = value.trim().replace(/[^a-zA-Z0-9._-]+/g, '-').slice(0, 80);
|
|
25
|
+
return role === '' ? 'unknown' : role;
|
|
26
|
+
}
|
|
27
|
+
export function appendTestLifecycleEventForProcess(subjectPid, roleValue, event, detail, source = 'child-self', env = process.env) {
|
|
28
|
+
const coordinates = testCoordinates(env);
|
|
29
|
+
if (coordinates === null)
|
|
30
|
+
return;
|
|
31
|
+
const role = safeRole(roleValue);
|
|
32
|
+
const identity = processIdentity(subjectPid);
|
|
33
|
+
const pgid = processGroupId(subjectPid);
|
|
34
|
+
const record = {
|
|
35
|
+
version: 1,
|
|
36
|
+
runToken: coordinates.runToken,
|
|
37
|
+
source,
|
|
38
|
+
role,
|
|
39
|
+
event: safeRole(event),
|
|
40
|
+
pid: subjectPid,
|
|
41
|
+
...(pgid === null ? {} : { pgid }),
|
|
42
|
+
...(identity === null ? {} : { startToken: identity.startToken }),
|
|
43
|
+
wallTimeMs: Date.now(),
|
|
44
|
+
monotonicNs: process.hrtime.bigint().toString(),
|
|
45
|
+
...(detail === undefined ? {} : { detail }),
|
|
46
|
+
};
|
|
47
|
+
try {
|
|
48
|
+
const dir = join(coordinates.runDir, 'events');
|
|
49
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
50
|
+
appendFileSync(join(dir, `${subjectPid}-${source}.jsonl`), `${JSON.stringify(record)}\n`, { mode: 0o600 });
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// Diagnostics must never change the lifecycle under test.
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** Record one credential-free event in this process's append-only event file. */
|
|
57
|
+
export function appendTestLifecycleEvent(event, detail, source = 'child-self', env = process.env) {
|
|
58
|
+
appendTestLifecycleEventForProcess(process.pid, env[TEST_PROCESS_ROLE_ENV] ?? 'unknown', event, detail, source, env);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Atomically publish an immutable PID/PGID/start-identity lease. The same PID
|
|
62
|
+
* may be observed by its parent and then self-register; the first complete
|
|
63
|
+
* record wins because both describe the same kernel start identity.
|
|
64
|
+
*/
|
|
65
|
+
export function registerTestProcess(pid, role, options = {}) {
|
|
66
|
+
const env = options.env ?? process.env;
|
|
67
|
+
const coordinates = testCoordinates(env);
|
|
68
|
+
if (coordinates === null)
|
|
69
|
+
return null;
|
|
70
|
+
const identity = options.identity ?? processIdentity(pid);
|
|
71
|
+
const pgid = processGroupId(pid);
|
|
72
|
+
if (identity === null || pgid === null)
|
|
73
|
+
return null;
|
|
74
|
+
const portText = env[TEST_PROCESS_PORT_ENV];
|
|
75
|
+
const envPort = portText === undefined ? undefined : Number(portText);
|
|
76
|
+
const port = options.port ?? (Number.isInteger(envPort) && (envPort ?? 0) > 0 ? envPort : undefined);
|
|
77
|
+
const tempRoot = options.tempRoot ?? env[TEST_PROCESS_TEMP_ROOT_ENV];
|
|
78
|
+
const record = {
|
|
79
|
+
version: 1,
|
|
80
|
+
runToken: coordinates.runToken,
|
|
81
|
+
role: safeRole(role),
|
|
82
|
+
pid,
|
|
83
|
+
pgid,
|
|
84
|
+
startToken: identity.startToken,
|
|
85
|
+
groupRoot: pgid === pid,
|
|
86
|
+
registeredAt: Date.now(),
|
|
87
|
+
source: options.source ?? (pid === process.pid ? 'child-self' : 'parent-observer'),
|
|
88
|
+
...(tempRoot === undefined || tempRoot === '' ? {} : { tempRoot }),
|
|
89
|
+
...(port === undefined ? {} : { port }),
|
|
90
|
+
};
|
|
91
|
+
try {
|
|
92
|
+
const dir = join(coordinates.runDir, 'processes');
|
|
93
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
94
|
+
const identityHash = createHash('sha256').update(identity.startToken).digest('hex').slice(0, 16);
|
|
95
|
+
writeFileSync(join(dir, `${pid}-${identityHash}.json`), `${JSON.stringify(record)}\n`, { flag: 'wx', mode: 0o600 });
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
if (error.code !== 'EEXIST')
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
return record;
|
|
102
|
+
}
|
|
103
|
+
/** Self-register only when an explicit test role accompanies the run lease. */
|
|
104
|
+
export function registerCurrentTestProcess(env = process.env) {
|
|
105
|
+
const role = env[TEST_PROCESS_ROLE_ENV];
|
|
106
|
+
if (role === undefined || role === '')
|
|
107
|
+
return null;
|
|
108
|
+
const record = registerTestProcess(process.pid, role, { env, source: 'child-self' });
|
|
109
|
+
if (record !== null)
|
|
110
|
+
appendTestLifecycleEvent('process-registered', { pgid: record.pgid }, 'child-self', env);
|
|
111
|
+
return record;
|
|
112
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/** One reversible filesystem operation approved before a cutover. */
|
|
2
|
+
export interface TransitionOperation {
|
|
3
|
+
kind: 'quarantine';
|
|
4
|
+
/** Path relative to the shared previous/target dsh home. */
|
|
5
|
+
path: string;
|
|
6
|
+
/** State that both isolated preflight and live apply must observe. */
|
|
7
|
+
expect: 'present' | 'absent';
|
|
8
|
+
}
|
|
9
|
+
/** Caller-authored transition plan. Unknown operation kinds fail closed. */
|
|
10
|
+
export interface TransitionPlan {
|
|
11
|
+
schemaVersion: 1;
|
|
12
|
+
/** Canonical dsh home containing every operation path. */
|
|
13
|
+
home: string;
|
|
14
|
+
operations: TransitionOperation[];
|
|
15
|
+
}
|
|
16
|
+
/** Immutable reference carried by the durable launch state and receipt. */
|
|
17
|
+
export interface TransitionReference {
|
|
18
|
+
version: 1;
|
|
19
|
+
planPath: string;
|
|
20
|
+
planSha256: string;
|
|
21
|
+
operationCount: number;
|
|
22
|
+
}
|
|
23
|
+
type ApplyState = 'pending' | 'moving' | 'absent' | 'quarantined';
|
|
24
|
+
type RollbackState = 'pending' | 'moving-target' | 'target-retained' | 'target-absent' | 'restoring' | 'restored';
|
|
25
|
+
export type TransitionPhase = 'prepared' | 'applying' | 'applied' | 'rolling-back' | 'rolled-back' | 'failed';
|
|
26
|
+
interface TransitionEntryState {
|
|
27
|
+
path: string;
|
|
28
|
+
original: 'present' | 'absent';
|
|
29
|
+
apply: ApplyState;
|
|
30
|
+
rollback: RollbackState;
|
|
31
|
+
}
|
|
32
|
+
/** Crash-recovery journal for one transition. */
|
|
33
|
+
export interface TransitionRecord {
|
|
34
|
+
version: 1;
|
|
35
|
+
cutoverId: string;
|
|
36
|
+
planSha256: string;
|
|
37
|
+
phase: TransitionPhase;
|
|
38
|
+
entries: TransitionEntryState[];
|
|
39
|
+
updatedAt: number;
|
|
40
|
+
failure?: {
|
|
41
|
+
operation: 'apply' | 'rollback';
|
|
42
|
+
detail: string;
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/** Result returned after an idempotent transition operation. */
|
|
46
|
+
export interface TransitionResult {
|
|
47
|
+
phase: 'applied' | 'rolled-back';
|
|
48
|
+
changed: string[];
|
|
49
|
+
unchanged: string[];
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Validate and normalize an untrusted transition plan.
|
|
53
|
+
* @param raw - Parsed JSON plan.
|
|
54
|
+
* @param expectedHome - Home shared by the previous and target launch specs.
|
|
55
|
+
* @param stateDir - Guard state directory, which no operation may move.
|
|
56
|
+
* @returns A canonical plan safe for durable preparation.
|
|
57
|
+
*/
|
|
58
|
+
export declare function validateTransitionPlan(raw: unknown, expectedHome: string, stateDir?: string): TransitionPlan;
|
|
59
|
+
/**
|
|
60
|
+
* Persist the immutable plan and an empty crash-recovery journal before the previous host stops.
|
|
61
|
+
* @param raw - Validated or untrusted plan JSON.
|
|
62
|
+
* @param expectedHome - Shared launch home.
|
|
63
|
+
* @param stateDir - Guard state directory.
|
|
64
|
+
* @param cutoverId - Unique launch cutover identifier.
|
|
65
|
+
* @returns Reference stored in the launch state and redacted receipt.
|
|
66
|
+
*/
|
|
67
|
+
export declare function prepareTransition(raw: unknown, expectedHome: string, stateDir: string, cutoverId: string): TransitionReference;
|
|
68
|
+
/**
|
|
69
|
+
* Apply a prepared transition after the previous host has stopped.
|
|
70
|
+
* @param reference - Immutable reference from the launch state.
|
|
71
|
+
* @param expectedHome - Shared launch home.
|
|
72
|
+
* @param stateDir - Guard state directory.
|
|
73
|
+
* @param cutoverId - Active cutover identifier.
|
|
74
|
+
* @returns Idempotent apply result.
|
|
75
|
+
*/
|
|
76
|
+
export declare function applyTransition(reference: TransitionReference, expectedHome: string, stateDir: string, cutoverId: string): TransitionResult;
|
|
77
|
+
/**
|
|
78
|
+
* Roll back an applied or partially applied transition before previous starts.
|
|
79
|
+
* Target-created replacements are retained for diagnosis rather than deleted.
|
|
80
|
+
* @param reference - Immutable reference from the launch state.
|
|
81
|
+
* @param expectedHome - Shared launch home.
|
|
82
|
+
* @param stateDir - Guard state directory.
|
|
83
|
+
* @param cutoverId - Active cutover identifier.
|
|
84
|
+
* @returns Idempotent rollback result.
|
|
85
|
+
*/
|
|
86
|
+
export declare function rollbackTransition(reference: TransitionReference, expectedHome: string, stateDir: string, cutoverId: string): TransitionResult;
|
|
87
|
+
/**
|
|
88
|
+
* Read and verify a transition record through its immutable reference.
|
|
89
|
+
* @param reference - Reference stored in launch state.
|
|
90
|
+
* @param expectedHome - Shared launch home.
|
|
91
|
+
* @param stateDir - Guard state directory.
|
|
92
|
+
* @param cutoverId - Cutover identifier.
|
|
93
|
+
* @returns Verified crash-recovery journal.
|
|
94
|
+
*/
|
|
95
|
+
export declare function readTransitionRecord(reference: TransitionReference, expectedHome: string, stateDir: string, cutoverId: string): TransitionRecord;
|
|
96
|
+
/**
|
|
97
|
+
* Clone a live home while retaining a contained package-link graph. Internal
|
|
98
|
+
* links are rebuilt against copied nodes; external targets are deduplicated in
|
|
99
|
+
* a snapshot-owned materialization area. No retained link resolves outside the
|
|
100
|
+
* snapshot root, so writes through pnpm/Cordis links cannot reach live bytes.
|
|
101
|
+
* Runtime entries without copyable content (sockets, FIFOs — and links to
|
|
102
|
+
* them) are skipped and counted, never copied; the top-level scratch/ tree is
|
|
103
|
+
* excluded for size. Device nodes still fail closed.
|
|
104
|
+
* @param sourceHome - Live dsh home to read.
|
|
105
|
+
* @returns Isolated home, an idempotent cleanup callback, and the count of skipped runtime entries.
|
|
106
|
+
*/
|
|
107
|
+
export declare function createPreflightSnapshot(sourceHome: string): {
|
|
108
|
+
home: string;
|
|
109
|
+
root: string;
|
|
110
|
+
skippedRuntimeEntries: number;
|
|
111
|
+
cleanup(): void;
|
|
112
|
+
};
|
|
113
|
+
export declare function createTransitionPreflightSnapshot(plan: TransitionPlan): {
|
|
114
|
+
home: string;
|
|
115
|
+
cleanup(): void;
|
|
116
|
+
};
|
|
117
|
+
export {};
|
|
118
|
+
//# sourceMappingURL=transition.d.ts.map
|