@openparachute/hub 0.6.2 → 0.6.3-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +87 -35
- package/package.json +1 -1
- package/src/__tests__/api-hub-upgrade.test.ts +690 -0
- package/src/__tests__/api-modules-ops.test.ts +359 -3
- package/src/__tests__/api-modules.test.ts +54 -0
- package/src/__tests__/expose-cloudflare.test.ts +163 -72
- package/src/__tests__/expose-off-auto.test.ts +26 -1
- package/src/__tests__/expose.test.ts +260 -240
- package/src/__tests__/hub-control.test.ts +1 -242
- package/src/__tests__/hub-server.test.ts +64 -0
- package/src/__tests__/hub-unit.test.ts +574 -0
- package/src/__tests__/init.test.ts +219 -2
- package/src/__tests__/lifecycle.test.ts +416 -1448
- package/src/__tests__/managed-unit.test.ts +575 -0
- package/src/__tests__/migrate-cutover.test.ts +840 -0
- package/src/__tests__/migrate-offer.test.ts +240 -0
- package/src/__tests__/migrate.test.ts +132 -0
- package/src/__tests__/module-ops-client.test.ts +556 -0
- package/src/__tests__/port-probe.test.ts +23 -0
- package/src/__tests__/setup-wizard.test.ts +130 -0
- package/src/__tests__/status-supervisor.test.ts +504 -0
- package/src/__tests__/status.test.ts +157 -708
- package/src/__tests__/supervisor.test.ts +471 -6
- package/src/__tests__/upgrade.test.ts +351 -5
- package/src/api-hub-upgrade.ts +384 -0
- package/src/api-hub.ts +2 -1
- package/src/api-modules-ops.ts +221 -0
- package/src/api-modules.ts +18 -2
- package/src/cli.ts +97 -12
- package/src/cloudflare/connector-service.ts +117 -322
- package/src/commands/expose-cloudflare.ts +63 -71
- package/src/commands/expose-supervisor.ts +247 -0
- package/src/commands/expose.ts +59 -48
- package/src/commands/init.ts +225 -12
- package/src/commands/lifecycle.ts +455 -816
- package/src/commands/migrate-cutover.ts +837 -0
- package/src/commands/migrate.ts +71 -2
- package/src/commands/serve-boot.ts +71 -25
- package/src/commands/status.ts +535 -235
- package/src/commands/upgrade.ts +100 -2
- package/src/help.ts +128 -68
- package/src/hub-control.ts +23 -162
- package/src/hub-server.ts +39 -0
- package/src/hub-unit.ts +735 -0
- package/src/hub-upgrade-helper.ts +306 -0
- package/src/hub-upgrade-mode.ts +209 -0
- package/src/hub-upgrade-status.ts +150 -0
- package/src/managed-unit.ts +692 -0
- package/src/migrate-offer.ts +186 -0
- package/src/module-ops-client.ts +457 -0
- package/src/port-probe.ts +50 -0
- package/src/process-state.ts +19 -3
- package/src/setup-wizard.ts +80 -1
- package/src/supervisor.ts +389 -38
- package/web/ui/dist/assets/index-D_6AFvZy.js +61 -0
- package/web/ui/dist/assets/{index-BiBlvEaj.css → index-mz8XcVPP.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-CIN3mnmf.js +0 -61
|
@@ -27,6 +27,12 @@ import { type ExposeState, readExposeState, writeExposeState } from "../expose-s
|
|
|
27
27
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
28
28
|
import { hubFetch } from "../hub-server.ts";
|
|
29
29
|
import { getSetting, setSetting } from "../hub-settings.ts";
|
|
30
|
+
import { validateAccessToken } from "../jwt-sign.ts";
|
|
31
|
+
import {
|
|
32
|
+
OPERATOR_TOKEN_SCOPE_SET_CLAIM,
|
|
33
|
+
readOperatorTokenFile,
|
|
34
|
+
writeOperatorTokenFile,
|
|
35
|
+
} from "../operator-token.ts";
|
|
30
36
|
import { writeManifest } from "../services-manifest.ts";
|
|
31
37
|
import { SESSION_COOKIE_NAME } from "../sessions.ts";
|
|
32
38
|
import {
|
|
@@ -39,6 +45,7 @@ import {
|
|
|
39
45
|
handleSetupVaultPost,
|
|
40
46
|
postVaultImportImpl,
|
|
41
47
|
} from "../setup-wizard.ts";
|
|
48
|
+
import { rotateSigningKey } from "../signing-keys.ts";
|
|
42
49
|
import { Supervisor } from "../supervisor.ts";
|
|
43
50
|
import { createUser, getUserByUsername, userCount } from "../users.ts";
|
|
44
51
|
|
|
@@ -1007,6 +1014,129 @@ describe("handleSetupAccountPost", () => {
|
|
|
1007
1014
|
});
|
|
1008
1015
|
});
|
|
1009
1016
|
|
|
1017
|
+
// --- Phase 3b Deliverable A: fresh-box operator-token closure (§3.1) ------
|
|
1018
|
+
//
|
|
1019
|
+
// After the wizard creates the first admin, it persists ~/.parachute/operator.token
|
|
1020
|
+
// so the box has a CLI operator credential immediately — otherwise the Phase 3b
|
|
1021
|
+
// per-module verbs (start/stop/restart <svc> over the module-ops API) would 401.
|
|
1022
|
+
|
|
1023
|
+
describe("handleSetupAccountPost — operator-token closure (Phase 3b §3.1)", () => {
|
|
1024
|
+
let h: Harness;
|
|
1025
|
+
beforeEach(() => {
|
|
1026
|
+
h = makeHarness();
|
|
1027
|
+
_resetOperationsRegistryForTests();
|
|
1028
|
+
});
|
|
1029
|
+
afterEach(() => h.cleanup());
|
|
1030
|
+
|
|
1031
|
+
/** Drive a valid account-creation POST against the given deps. */
|
|
1032
|
+
async function createFirstAdmin(
|
|
1033
|
+
db: ReturnType<typeof openHubDb>,
|
|
1034
|
+
deps: Partial<Parameters<typeof handleSetupAccountPost>[1]> = {},
|
|
1035
|
+
username = "ops",
|
|
1036
|
+
): Promise<Response> {
|
|
1037
|
+
const baseDeps = {
|
|
1038
|
+
db,
|
|
1039
|
+
manifestPath: h.manifestPath,
|
|
1040
|
+
configDir: h.dir,
|
|
1041
|
+
readExposeStateFn: h.readExposeStateFn,
|
|
1042
|
+
issuer: "https://hub.example",
|
|
1043
|
+
registry: getDefaultOperationsRegistry(),
|
|
1044
|
+
};
|
|
1045
|
+
const get = handleSetupGet(req("/admin/setup"), baseDeps);
|
|
1046
|
+
const csrf = setCookie(get, CSRF_COOKIE_NAME) ?? "";
|
|
1047
|
+
const form = formBody({
|
|
1048
|
+
username,
|
|
1049
|
+
password: "correct horse battery",
|
|
1050
|
+
password_confirm: "correct horse battery",
|
|
1051
|
+
[CSRF_FIELD_NAME]: csrf,
|
|
1052
|
+
});
|
|
1053
|
+
return handleSetupAccountPost(
|
|
1054
|
+
req("/admin/setup/account", {
|
|
1055
|
+
method: "POST",
|
|
1056
|
+
body: form.body,
|
|
1057
|
+
headers: { ...form.headers, cookie: `${CSRF_COOKIE_NAME}=${csrf}` },
|
|
1058
|
+
}),
|
|
1059
|
+
{ ...baseDeps, ...deps },
|
|
1060
|
+
);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
test("persists operator.token (admin scope-set, carries parachute:host:admin)", async () => {
|
|
1064
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
1065
|
+
try {
|
|
1066
|
+
rotateSigningKey(db); // real issuance needs a signing key
|
|
1067
|
+
const post = await createFirstAdmin(db);
|
|
1068
|
+
expect(post.status).toBe(303);
|
|
1069
|
+
// The token file now exists on disk…
|
|
1070
|
+
const token = await readOperatorTokenFile(h.dir);
|
|
1071
|
+
expect(token).not.toBeNull();
|
|
1072
|
+
// …and decodes with the admin scope (the scope module-ops gates on).
|
|
1073
|
+
// The JWT carries the OAuth `scope` claim as a space-delimited string.
|
|
1074
|
+
const { payload } = await validateAccessToken(db, token ?? "", "https://hub.example");
|
|
1075
|
+
const scopes = String(payload.scope ?? "").split(" ");
|
|
1076
|
+
expect(scopes).toContain("parachute:host:admin");
|
|
1077
|
+
expect((payload as Record<string, unknown>)[OPERATOR_TOKEN_SCOPE_SET_CLAIM]).toBe("admin");
|
|
1078
|
+
} finally {
|
|
1079
|
+
db.close();
|
|
1080
|
+
}
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
test("does NOT clobber an existing operator.token", async () => {
|
|
1084
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
1085
|
+
try {
|
|
1086
|
+
rotateSigningKey(db);
|
|
1087
|
+
// Plant a sentinel token before the wizard runs.
|
|
1088
|
+
await writeOperatorTokenFile("sentinel.preexisting.token", h.dir);
|
|
1089
|
+
// Use a stub issuer that fails the test if it's ever called.
|
|
1090
|
+
const post = await createFirstAdmin(db, {
|
|
1091
|
+
issueOperatorToken: async () => {
|
|
1092
|
+
throw new Error("issueOperatorToken must NOT run when a token already exists");
|
|
1093
|
+
},
|
|
1094
|
+
});
|
|
1095
|
+
expect(post.status).toBe(303);
|
|
1096
|
+
// The pre-existing token is untouched.
|
|
1097
|
+
expect(await readOperatorTokenFile(h.dir)).toBe("sentinel.preexisting.token");
|
|
1098
|
+
} finally {
|
|
1099
|
+
db.close();
|
|
1100
|
+
}
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1103
|
+
test("no admin created (already-bootstrapped guard) → no token written", async () => {
|
|
1104
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
1105
|
+
try {
|
|
1106
|
+
rotateSigningKey(db);
|
|
1107
|
+
// An admin already exists, so the wizard's already-bootstrapped guard
|
|
1108
|
+
// returns early (303 to /admin/setup) WITHOUT reaching createUser — and
|
|
1109
|
+
// therefore WITHOUT minting a token. The closure only fires for a
|
|
1110
|
+
// genuinely-created first admin.
|
|
1111
|
+
await createUser(db, "owner", "pw");
|
|
1112
|
+
const post = await createFirstAdmin(db, {}, "interloper");
|
|
1113
|
+
expect(post.status).toBe(303);
|
|
1114
|
+
expect(await readOperatorTokenFile(h.dir)).toBeNull();
|
|
1115
|
+
} finally {
|
|
1116
|
+
db.close();
|
|
1117
|
+
}
|
|
1118
|
+
});
|
|
1119
|
+
|
|
1120
|
+
test("token-write failure is non-fatal — account creation still succeeds", async () => {
|
|
1121
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
1122
|
+
try {
|
|
1123
|
+
const post = await createFirstAdmin(db, {
|
|
1124
|
+
issueOperatorToken: async () => {
|
|
1125
|
+
throw new Error("disk full");
|
|
1126
|
+
},
|
|
1127
|
+
});
|
|
1128
|
+
// The admin + session were committed despite the token-write failure.
|
|
1129
|
+
expect(post.status).toBe(303);
|
|
1130
|
+
expect(setCookie(post, SESSION_COOKIE_NAME)).toBeDefined();
|
|
1131
|
+
expect(userCount(db)).toBe(1);
|
|
1132
|
+
// No token landed (the issuer threw), but that didn't fail the request.
|
|
1133
|
+
expect(await readOperatorTokenFile(h.dir)).toBeNull();
|
|
1134
|
+
} finally {
|
|
1135
|
+
db.close();
|
|
1136
|
+
}
|
|
1137
|
+
});
|
|
1138
|
+
});
|
|
1139
|
+
|
|
1010
1140
|
// --- POST /admin/setup/vault ---------------------------------------------
|
|
1011
1141
|
|
|
1012
1142
|
describe("handleSetupVaultPost", () => {
|
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { status } from "../commands/status.ts";
|
|
6
|
+
import type { HubUnitDeps, HubUnitStateResult } from "../hub-unit.ts";
|
|
7
|
+
import {
|
|
8
|
+
type ModuleStatesResult,
|
|
9
|
+
NoOperatorTokenError,
|
|
10
|
+
OperatorTokenExpiredError,
|
|
11
|
+
} from "../module-ops-client.ts";
|
|
12
|
+
import { upsertService } from "../services-manifest.ts";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Phase 5b (design §6.4): `status` reads the hub row from the platform manager +
|
|
16
|
+
* `/health` and the module rows from the running supervisor (`GET /api/modules`)
|
|
17
|
+
* — the ONLY runtime now that the detached pidfile arm is retired. Everything
|
|
18
|
+
* below is driven through the `supervisor` seams — no real launchd/systemd/
|
|
19
|
+
* socket/HTTP/db call. The manifest-derived rendering (URLs, version, persisted
|
|
20
|
+
* start-error) is covered in `status.test.ts` (also supervised-arm now).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
function makeTempPath(): { path: string; cleanup: () => void; configDir: string } {
|
|
24
|
+
const dir = mkdtempSync(join(tmpdir(), "pcli-status-sup-"));
|
|
25
|
+
return {
|
|
26
|
+
path: join(dir, "services.json"),
|
|
27
|
+
configDir: dir,
|
|
28
|
+
cleanup: () => rmSync(dir, { recursive: true, force: true }),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Install-source deps that never touch the real filesystem (so the hub row's +
|
|
34
|
+
* module rows' source classification is deterministic in the test runner).
|
|
35
|
+
*/
|
|
36
|
+
const STUB_INSTALL_SOURCE = {
|
|
37
|
+
bunGlobalPrefixes: () => [] as string[],
|
|
38
|
+
resolveBunGlobal: () => null,
|
|
39
|
+
readJson: () => ({ version: "0.6.2" }),
|
|
40
|
+
readGitHead: () => undefined,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A throwaway db handle exposing ONLY `{ close }`. This is intentionally minimal:
|
|
45
|
+
* on the supervisor status path the db is never READ — module states come from
|
|
46
|
+
* the API (`fetchModuleStates`, stubbed here), and `buildSupervisorRows` only
|
|
47
|
+
* opens the handle to pass it through + `close()` it in `finally`. The
|
|
48
|
+
* `as unknown as Database` cast at the call site widens this to the full type;
|
|
49
|
+
* if a future change adds a real db read on this path, it will fail at RUNTIME
|
|
50
|
+
* (missing method) rather than typecheck — so wire the needed method in here.
|
|
51
|
+
*/
|
|
52
|
+
function fakeOpenDb(): { close: () => void } {
|
|
53
|
+
return { close: () => {} };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Minimal `HubUnitDeps` — only the fields the seams that ARE wired through deps
|
|
58
|
+
* touch. `queryHubUnitState` / `probeHubHealth` / `fetchModuleStates` are
|
|
59
|
+
* injected directly as the `supervisor` seams, so the deps here only need to be
|
|
60
|
+
* a well-typed placeholder.
|
|
61
|
+
*/
|
|
62
|
+
const FAKE_HUB_UNIT_DEPS = {
|
|
63
|
+
platform: "linux",
|
|
64
|
+
getuid: () => 1000,
|
|
65
|
+
homeDir: () => "/home/op",
|
|
66
|
+
userName: () => "op",
|
|
67
|
+
which: () => "/usr/bin/systemctl",
|
|
68
|
+
run: () => ({ code: 0, stdout: "", stderr: "" }),
|
|
69
|
+
writeFile: () => {},
|
|
70
|
+
removeFile: () => {},
|
|
71
|
+
readFile: () => undefined,
|
|
72
|
+
exists: () => false,
|
|
73
|
+
probeHealth: async () => true,
|
|
74
|
+
portListening: async () => true,
|
|
75
|
+
sleep: async () => {},
|
|
76
|
+
} as unknown as HubUnitDeps;
|
|
77
|
+
|
|
78
|
+
interface SupervisorArmOpts {
|
|
79
|
+
managerState: HubUnitStateResult;
|
|
80
|
+
hubHealthy: boolean;
|
|
81
|
+
moduleStates?: ModuleStatesResult;
|
|
82
|
+
fetchModuleStatesImpl?: () => Promise<ModuleStatesResult>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Drive `status` through the supervisor arm with fully stubbed seams. */
|
|
86
|
+
function supervisorOpts(configDir: string, path: string, o: SupervisorArmOpts) {
|
|
87
|
+
return {
|
|
88
|
+
manifestPath: path,
|
|
89
|
+
configDir,
|
|
90
|
+
installSourceDeps: STUB_INSTALL_SOURCE,
|
|
91
|
+
hubSrcDir: "/nonexistent/hub/src",
|
|
92
|
+
supervisor: {
|
|
93
|
+
hubUnitDeps: FAKE_HUB_UNIT_DEPS,
|
|
94
|
+
queryHubUnitState: () => o.managerState,
|
|
95
|
+
probeHubHealth: async () => o.hubHealthy,
|
|
96
|
+
fetchModuleStates:
|
|
97
|
+
o.fetchModuleStatesImpl ??
|
|
98
|
+
(async () => o.moduleStates ?? { supervisorAvailable: true, modules: [] }),
|
|
99
|
+
openDb: fakeOpenDb as unknown as (configDir: string) => import("bun:sqlite").Database,
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
describe("status — Phase 3c supervisor arm: hub row", () => {
|
|
105
|
+
test("manager active + /health OK → running (active) with port", async () => {
|
|
106
|
+
const { path, configDir, cleanup } = makeTempPath();
|
|
107
|
+
try {
|
|
108
|
+
const lines: string[] = [];
|
|
109
|
+
const code = await status({
|
|
110
|
+
...supervisorOpts(configDir, path, {
|
|
111
|
+
managerState: { state: "active" },
|
|
112
|
+
hubHealthy: true,
|
|
113
|
+
moduleStates: { supervisorAvailable: true, modules: [] },
|
|
114
|
+
}),
|
|
115
|
+
print: (l) => lines.push(l),
|
|
116
|
+
});
|
|
117
|
+
// With no modules + the hub active, status exits 0.
|
|
118
|
+
expect(code).toBe(0);
|
|
119
|
+
const out = lines.join("\n");
|
|
120
|
+
expect(out).toMatch(/parachute-hub \(internal\)/);
|
|
121
|
+
// Hub row is `active` and shows the canonical port (no manifest entry).
|
|
122
|
+
const hubLine = lines.find((l) => l.includes("parachute-hub (internal)"));
|
|
123
|
+
expect(hubLine).toBeDefined();
|
|
124
|
+
expect(hubLine).toMatch(/\bactive\b/);
|
|
125
|
+
expect(hubLine).toMatch(/1939/);
|
|
126
|
+
} finally {
|
|
127
|
+
cleanup();
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("manager failed → failing + surfaces last exit code", async () => {
|
|
132
|
+
const { path, configDir, cleanup } = makeTempPath();
|
|
133
|
+
try {
|
|
134
|
+
const lines: string[] = [];
|
|
135
|
+
const code = await status({
|
|
136
|
+
...supervisorOpts(configDir, path, {
|
|
137
|
+
managerState: { state: "failed", lastExitCode: 7 },
|
|
138
|
+
hubHealthy: false,
|
|
139
|
+
}),
|
|
140
|
+
print: (l) => lines.push(l),
|
|
141
|
+
});
|
|
142
|
+
expect(code).toBe(1);
|
|
143
|
+
const out = lines.join("\n");
|
|
144
|
+
expect(out).toMatch(/\bfailing\b/);
|
|
145
|
+
expect(out).toMatch(/the hub unit failed/);
|
|
146
|
+
expect(out).toMatch(/last exit code 7/);
|
|
147
|
+
} finally {
|
|
148
|
+
cleanup();
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("manager active but /health down → failing with starting/unhealthy nuance", async () => {
|
|
153
|
+
const { path, configDir, cleanup } = makeTempPath();
|
|
154
|
+
try {
|
|
155
|
+
const lines: string[] = [];
|
|
156
|
+
const code = await status({
|
|
157
|
+
...supervisorOpts(configDir, path, {
|
|
158
|
+
managerState: { state: "active" },
|
|
159
|
+
hubHealthy: false,
|
|
160
|
+
}),
|
|
161
|
+
print: (l) => lines.push(l),
|
|
162
|
+
});
|
|
163
|
+
expect(code).toBe(1);
|
|
164
|
+
const out = lines.join("\n");
|
|
165
|
+
const hubLine = lines.find((l) => l.includes("parachute-hub (internal)"));
|
|
166
|
+
expect(hubLine).toMatch(/\bfailing\b/);
|
|
167
|
+
expect(out).toMatch(/\/health not answering yet \(starting or unhealthy\)/);
|
|
168
|
+
} finally {
|
|
169
|
+
cleanup();
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("no on-box manager (container) → /health is liveness, 'container runtime (managed)' note", async () => {
|
|
174
|
+
const { path, configDir, cleanup } = makeTempPath();
|
|
175
|
+
try {
|
|
176
|
+
const lines: string[] = [];
|
|
177
|
+
const code = await status({
|
|
178
|
+
...supervisorOpts(configDir, path, {
|
|
179
|
+
managerState: { state: "no-manager" },
|
|
180
|
+
hubHealthy: true,
|
|
181
|
+
moduleStates: { supervisorAvailable: true, modules: [] },
|
|
182
|
+
}),
|
|
183
|
+
print: (l) => lines.push(l),
|
|
184
|
+
});
|
|
185
|
+
expect(code).toBe(0);
|
|
186
|
+
const out = lines.join("\n");
|
|
187
|
+
const hubLine = lines.find((l) => l.includes("parachute-hub (internal)"));
|
|
188
|
+
expect(hubLine).toMatch(/\bactive\b/);
|
|
189
|
+
expect(out).toMatch(/container runtime \(managed\)/);
|
|
190
|
+
} finally {
|
|
191
|
+
cleanup();
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("container with /health down → hub row failing", async () => {
|
|
196
|
+
const { path, configDir, cleanup } = makeTempPath();
|
|
197
|
+
try {
|
|
198
|
+
const lines: string[] = [];
|
|
199
|
+
const code = await status({
|
|
200
|
+
...supervisorOpts(configDir, path, {
|
|
201
|
+
managerState: { state: "no-manager" },
|
|
202
|
+
hubHealthy: false,
|
|
203
|
+
}),
|
|
204
|
+
print: (l) => lines.push(l),
|
|
205
|
+
});
|
|
206
|
+
expect(code).toBe(1);
|
|
207
|
+
const hubLine = lines.find((l) => l.includes("parachute-hub (internal)"));
|
|
208
|
+
expect(hubLine).toMatch(/\bfailing\b/);
|
|
209
|
+
} finally {
|
|
210
|
+
cleanup();
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("a thrown manager query never crashes status — degrades to /health verdict", async () => {
|
|
215
|
+
const { path, configDir, cleanup } = makeTempPath();
|
|
216
|
+
try {
|
|
217
|
+
const lines: string[] = [];
|
|
218
|
+
const code = await status({
|
|
219
|
+
...supervisorOpts(configDir, path, {
|
|
220
|
+
managerState: { state: "active" }, // overridden by the throwing stub below
|
|
221
|
+
hubHealthy: true,
|
|
222
|
+
moduleStates: { supervisorAvailable: true, modules: [] },
|
|
223
|
+
}),
|
|
224
|
+
// Replace the query with one that throws — status must not crash.
|
|
225
|
+
supervisor: {
|
|
226
|
+
hubUnitDeps: FAKE_HUB_UNIT_DEPS,
|
|
227
|
+
queryHubUnitState: () => {
|
|
228
|
+
throw new Error("systemctl exploded");
|
|
229
|
+
},
|
|
230
|
+
probeHubHealth: async () => true,
|
|
231
|
+
fetchModuleStates: async () => ({ supervisorAvailable: true, modules: [] }),
|
|
232
|
+
openDb: fakeOpenDb as unknown as (configDir: string) => import("bun:sqlite").Database,
|
|
233
|
+
},
|
|
234
|
+
print: (l) => lines.push(l),
|
|
235
|
+
});
|
|
236
|
+
// /health answered → unknown manager state falls back to active.
|
|
237
|
+
expect(code).toBe(0);
|
|
238
|
+
const hubLine = lines.find((l) => l.includes("parachute-hub (internal)"));
|
|
239
|
+
expect(hubLine).toMatch(/\bactive\b/);
|
|
240
|
+
} finally {
|
|
241
|
+
cleanup();
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
describe("status — Phase 3c supervisor arm: module rows", () => {
|
|
247
|
+
test("hub up → module states come from the stubbed GET /api/modules", async () => {
|
|
248
|
+
const { path, configDir, cleanup } = makeTempPath();
|
|
249
|
+
try {
|
|
250
|
+
upsertService(
|
|
251
|
+
{ name: "parachute-vault", port: 1940, paths: ["/"], health: "/health", version: "0.6.2" },
|
|
252
|
+
path,
|
|
253
|
+
);
|
|
254
|
+
upsertService(
|
|
255
|
+
{
|
|
256
|
+
name: "parachute-scribe",
|
|
257
|
+
port: 3200,
|
|
258
|
+
paths: ["/scribe"],
|
|
259
|
+
health: "/scribe/health",
|
|
260
|
+
version: "0.6.2",
|
|
261
|
+
},
|
|
262
|
+
path,
|
|
263
|
+
);
|
|
264
|
+
const lines: string[] = [];
|
|
265
|
+
const code = await status({
|
|
266
|
+
...supervisorOpts(configDir, path, {
|
|
267
|
+
managerState: { state: "active" },
|
|
268
|
+
hubHealthy: true,
|
|
269
|
+
moduleStates: {
|
|
270
|
+
supervisorAvailable: true,
|
|
271
|
+
modules: [
|
|
272
|
+
{
|
|
273
|
+
short: "vault",
|
|
274
|
+
installed: true,
|
|
275
|
+
installed_version: "0.6.2",
|
|
276
|
+
supervisor_status: "running",
|
|
277
|
+
pid: 5151,
|
|
278
|
+
supervisor_start_error: null,
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
short: "scribe",
|
|
282
|
+
installed: true,
|
|
283
|
+
installed_version: "0.6.2",
|
|
284
|
+
supervisor_status: "crashed",
|
|
285
|
+
pid: null,
|
|
286
|
+
supervisor_start_error: null,
|
|
287
|
+
},
|
|
288
|
+
],
|
|
289
|
+
},
|
|
290
|
+
}),
|
|
291
|
+
print: (l) => lines.push(l),
|
|
292
|
+
});
|
|
293
|
+
// scribe crashed → failing → overall exit 1.
|
|
294
|
+
expect(code).toBe(1);
|
|
295
|
+
const vaultLine = lines.find((l) => l.includes("parachute-vault"));
|
|
296
|
+
const scribeLine = lines.find((l) => l.includes("parachute-scribe"));
|
|
297
|
+
expect(vaultLine).toMatch(/\bactive\b/);
|
|
298
|
+
expect(vaultLine).toMatch(/5151/); // pid from the supervisor snapshot
|
|
299
|
+
expect(scribeLine).toMatch(/\bfailing\b/);
|
|
300
|
+
// The failing row surfaces the supervisor status on a continuation line.
|
|
301
|
+
expect(lines.some((l) => l.includes("supervisor: crashed"))).toBe(true);
|
|
302
|
+
} finally {
|
|
303
|
+
cleanup();
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("module with a structured startError surfaces the missing-dependency note", async () => {
|
|
308
|
+
const { path, configDir, cleanup } = makeTempPath();
|
|
309
|
+
try {
|
|
310
|
+
upsertService(
|
|
311
|
+
{ name: "parachute-vault", port: 1940, paths: ["/"], health: "/health", version: "0.6.2" },
|
|
312
|
+
path,
|
|
313
|
+
);
|
|
314
|
+
const lines: string[] = [];
|
|
315
|
+
await status({
|
|
316
|
+
...supervisorOpts(configDir, path, {
|
|
317
|
+
managerState: { state: "active" },
|
|
318
|
+
hubHealthy: true,
|
|
319
|
+
moduleStates: {
|
|
320
|
+
supervisorAvailable: true,
|
|
321
|
+
modules: [
|
|
322
|
+
{
|
|
323
|
+
short: "vault",
|
|
324
|
+
installed: true,
|
|
325
|
+
installed_version: "0.6.2",
|
|
326
|
+
supervisor_status: "crashed",
|
|
327
|
+
pid: null,
|
|
328
|
+
supervisor_start_error: {
|
|
329
|
+
error_type: "missing_dependency",
|
|
330
|
+
error_description: "parachute-vault is required",
|
|
331
|
+
binary: "parachute-vault",
|
|
332
|
+
at: "2026-06-01T00:00:00Z",
|
|
333
|
+
},
|
|
334
|
+
},
|
|
335
|
+
],
|
|
336
|
+
},
|
|
337
|
+
}),
|
|
338
|
+
print: (l) => lines.push(l),
|
|
339
|
+
});
|
|
340
|
+
const out = lines.join("\n");
|
|
341
|
+
expect(out).toMatch(/failed to start: parachute-vault not installed/);
|
|
342
|
+
} finally {
|
|
343
|
+
cleanup();
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test("hub DOWN → modules degrade to inactive + 'hub is down' note, no hang/crash, exit 0", async () => {
|
|
348
|
+
const { path, configDir, cleanup } = makeTempPath();
|
|
349
|
+
try {
|
|
350
|
+
upsertService(
|
|
351
|
+
{ name: "parachute-vault", port: 1940, paths: ["/"], health: "/health", version: "0.6.2" },
|
|
352
|
+
path,
|
|
353
|
+
);
|
|
354
|
+
let fetched = false;
|
|
355
|
+
const lines: string[] = [];
|
|
356
|
+
const code = await status({
|
|
357
|
+
...supervisorOpts(configDir, path, {
|
|
358
|
+
managerState: { state: "inactive" },
|
|
359
|
+
hubHealthy: false,
|
|
360
|
+
fetchModuleStatesImpl: async () => {
|
|
361
|
+
fetched = true;
|
|
362
|
+
return { supervisorAvailable: true, modules: [] };
|
|
363
|
+
},
|
|
364
|
+
}),
|
|
365
|
+
print: (l) => lines.push(l),
|
|
366
|
+
});
|
|
367
|
+
// Hub down → modules are `inactive` (expected, not a failure) → exit 0.
|
|
368
|
+
expect(code).toBe(0);
|
|
369
|
+
// We must NOT call the module-states API when the hub is down (children
|
|
370
|
+
// die with the hub; the call would just connection-refuse).
|
|
371
|
+
expect(fetched).toBe(false);
|
|
372
|
+
const vaultLine = lines.find((l) => l.includes("parachute-vault"));
|
|
373
|
+
expect(vaultLine).toMatch(/\binactive\b/);
|
|
374
|
+
expect(lines.some((l) => l.includes("hub is down — its modules are stopped"))).toBe(true);
|
|
375
|
+
} finally {
|
|
376
|
+
cleanup();
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test("no operator token → graceful degrade (manifest rows + actionable hint), no 401 crash", async () => {
|
|
381
|
+
const { path, configDir, cleanup } = makeTempPath();
|
|
382
|
+
try {
|
|
383
|
+
upsertService(
|
|
384
|
+
{ name: "parachute-vault", port: 1940, paths: ["/"], health: "/health", version: "0.6.2" },
|
|
385
|
+
path,
|
|
386
|
+
);
|
|
387
|
+
const lines: string[] = [];
|
|
388
|
+
const code = await status({
|
|
389
|
+
...supervisorOpts(configDir, path, {
|
|
390
|
+
managerState: { state: "active" },
|
|
391
|
+
hubHealthy: true,
|
|
392
|
+
fetchModuleStatesImpl: async () => {
|
|
393
|
+
throw new NoOperatorTokenError();
|
|
394
|
+
},
|
|
395
|
+
}),
|
|
396
|
+
print: (l) => lines.push(l),
|
|
397
|
+
});
|
|
398
|
+
// We could not read run-state, but didn't crash. The module row falls back
|
|
399
|
+
// to `inactive` (no supervisor snapshot) — a stopped row is exit 0.
|
|
400
|
+
expect(code).toBe(0);
|
|
401
|
+
const out = lines.join("\n");
|
|
402
|
+
expect(out).toMatch(/parachute-vault/);
|
|
403
|
+
expect(out).toMatch(/run `parachute auth rotate-operator`/);
|
|
404
|
+
} finally {
|
|
405
|
+
cleanup();
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test("expired operator token → graceful degrade, no crash", async () => {
|
|
410
|
+
const { path, configDir, cleanup } = makeTempPath();
|
|
411
|
+
try {
|
|
412
|
+
upsertService(
|
|
413
|
+
{ name: "parachute-vault", port: 1940, paths: ["/"], health: "/health", version: "0.6.2" },
|
|
414
|
+
path,
|
|
415
|
+
);
|
|
416
|
+
const lines: string[] = [];
|
|
417
|
+
const code = await status({
|
|
418
|
+
...supervisorOpts(configDir, path, {
|
|
419
|
+
managerState: { state: "active" },
|
|
420
|
+
hubHealthy: true,
|
|
421
|
+
fetchModuleStatesImpl: async () => {
|
|
422
|
+
throw new OperatorTokenExpiredError(
|
|
423
|
+
"token expired — run `parachute auth rotate-operator`",
|
|
424
|
+
);
|
|
425
|
+
},
|
|
426
|
+
}),
|
|
427
|
+
print: (l) => lines.push(l),
|
|
428
|
+
});
|
|
429
|
+
expect(code).toBe(0);
|
|
430
|
+
expect(lines.some((l) => l.includes("rotate-operator"))).toBe(true);
|
|
431
|
+
} finally {
|
|
432
|
+
cleanup();
|
|
433
|
+
}
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
test("API error reading module states → degrade with the message, no crash", async () => {
|
|
437
|
+
const { path, configDir, cleanup } = makeTempPath();
|
|
438
|
+
try {
|
|
439
|
+
upsertService(
|
|
440
|
+
{ name: "parachute-vault", port: 1940, paths: ["/"], health: "/health", version: "0.6.2" },
|
|
441
|
+
path,
|
|
442
|
+
);
|
|
443
|
+
const lines: string[] = [];
|
|
444
|
+
const code = await status({
|
|
445
|
+
...supervisorOpts(configDir, path, {
|
|
446
|
+
managerState: { state: "active" },
|
|
447
|
+
hubHealthy: true,
|
|
448
|
+
fetchModuleStatesImpl: async () => {
|
|
449
|
+
throw new Error("the api blew up");
|
|
450
|
+
},
|
|
451
|
+
}),
|
|
452
|
+
print: (l) => lines.push(l),
|
|
453
|
+
});
|
|
454
|
+
expect(code).toBe(0);
|
|
455
|
+
expect(lines.some((l) => l.includes("couldn't read live module state"))).toBe(true);
|
|
456
|
+
expect(lines.some((l) => l.includes("the api blew up"))).toBe(true);
|
|
457
|
+
} finally {
|
|
458
|
+
cleanup();
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
test("starting/restarting supervisor status → pending, not a failure (exit 0)", async () => {
|
|
463
|
+
const { path, configDir, cleanup } = makeTempPath();
|
|
464
|
+
try {
|
|
465
|
+
upsertService(
|
|
466
|
+
{ name: "parachute-vault", port: 1940, paths: ["/"], health: "/health", version: "0.6.2" },
|
|
467
|
+
path,
|
|
468
|
+
);
|
|
469
|
+
const lines: string[] = [];
|
|
470
|
+
const code = await status({
|
|
471
|
+
...supervisorOpts(configDir, path, {
|
|
472
|
+
managerState: { state: "active" },
|
|
473
|
+
hubHealthy: true,
|
|
474
|
+
moduleStates: {
|
|
475
|
+
supervisorAvailable: true,
|
|
476
|
+
modules: [
|
|
477
|
+
{
|
|
478
|
+
short: "vault",
|
|
479
|
+
installed: true,
|
|
480
|
+
installed_version: "0.6.2",
|
|
481
|
+
supervisor_status: "starting",
|
|
482
|
+
pid: 9090,
|
|
483
|
+
supervisor_start_error: null,
|
|
484
|
+
},
|
|
485
|
+
],
|
|
486
|
+
},
|
|
487
|
+
}),
|
|
488
|
+
print: (l) => lines.push(l),
|
|
489
|
+
});
|
|
490
|
+
expect(code).toBe(0);
|
|
491
|
+
const vaultLine = lines.find((l) => l.includes("parachute-vault"));
|
|
492
|
+
expect(vaultLine).toMatch(/\bpending\b/);
|
|
493
|
+
} finally {
|
|
494
|
+
cleanup();
|
|
495
|
+
}
|
|
496
|
+
});
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
// The "Phase 3c discriminant" block (no-supervisor / unitInstalled:false →
|
|
500
|
+
// detached pidfile-probe arm) was removed in Phase 5b: the detached arm is
|
|
501
|
+
// retired, so there is no discriminant — `status` always reads the platform
|
|
502
|
+
// manager + supervisor. The supervisor-path readout is exercised throughout the
|
|
503
|
+
// suites above; a box with no hub unit degrades gracefully (manager `no-unit` /
|
|
504
|
+
// `/health` down → inactive rows), which the hub-row + module-row suites cover.
|