@openparachute/hub 0.6.1 → 0.6.3-rc.1
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/package.json +1 -1
- package/src/__tests__/account-home-ui.test.ts +34 -0
- package/src/__tests__/api-modules-ops.test.ts +359 -3
- package/src/__tests__/api-modules.test.ts +54 -0
- package/src/__tests__/cloudflare-connector-service.test.ts +441 -0
- package/src/__tests__/expose-cloudflare.test.ts +272 -0
- package/src/__tests__/hub-unit.test.ts +574 -0
- package/src/__tests__/init.test.ts +219 -2
- package/src/__tests__/lifecycle.test.ts +423 -0
- package/src/__tests__/managed-unit.test.ts +575 -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 +569 -0
- package/src/__tests__/supervisor.test.ts +471 -6
- package/src/account-home-ui.ts +4 -1
- package/src/api-modules-ops.ts +221 -0
- package/src/api-modules.ts +18 -2
- package/src/cli.ts +14 -4
- package/src/cloudflare/connector-service.ts +273 -0
- package/src/cloudflare/state.ts +13 -1
- package/src/commands/expose-cloudflare.ts +143 -10
- package/src/commands/init.ts +225 -12
- package/src/commands/lifecycle.ts +366 -38
- package/src/commands/serve-boot.ts +71 -25
- package/src/commands/status.ts +596 -49
- package/src/hub-server.ts +11 -0
- package/src/hub-unit.ts +735 -0
- package/src/managed-unit.ts +674 -0
- package/src/module-ops-client.ts +457 -0
- package/src/port-probe.ts +50 -0
- package/src/setup-wizard.ts +80 -1
- package/src/supervisor.ts +360 -14
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
installConnectorService,
|
|
4
|
+
launchdLabel,
|
|
5
|
+
launchdPlistPath,
|
|
6
|
+
renderLaunchdPlist,
|
|
7
|
+
renderSystemdUnit,
|
|
8
|
+
systemdUnitName,
|
|
9
|
+
systemdUnitPath,
|
|
10
|
+
} from "../cloudflare/connector-service.ts";
|
|
11
|
+
import {
|
|
12
|
+
type ManagedUnit,
|
|
13
|
+
type ManagedUnitDeps,
|
|
14
|
+
type ManagedUnitMessages,
|
|
15
|
+
type ServiceCommandResult,
|
|
16
|
+
buildHubManagedUnit,
|
|
17
|
+
installManagedUnit,
|
|
18
|
+
removeManagedUnit,
|
|
19
|
+
renderManagedLaunchdPlist,
|
|
20
|
+
renderManagedSystemdUnit,
|
|
21
|
+
} from "../managed-unit.ts";
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// THE single most important test: the rendered cloudflared-connector systemd
|
|
25
|
+
// unit + launchd plist must be BYTE-IDENTICAL before/after the ManagedUnit
|
|
26
|
+
// extraction. The connector is live on both production boxes (gitcoin: ec2-user
|
|
27
|
+
// systemd user unit + linger; our: root systemd system unit). A future
|
|
28
|
+
// `expose --cloudflare` re-run must produce a behaviorally identical unit.
|
|
29
|
+
//
|
|
30
|
+
// These golden strings were CAPTURED from the connector renderers at the commit
|
|
31
|
+
// BEFORE the extraction (Phase 2a / #496 HEAD), then frozen here. The renderers
|
|
32
|
+
// must keep reproducing them character-for-character. Do NOT regenerate these
|
|
33
|
+
// from current code — that would make the test tautological; they are the
|
|
34
|
+
// ground truth.
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
const CONN_TUNNEL = "parachute-vault-example-com";
|
|
38
|
+
const CONN_CONFIG = "/home/op/.parachute/cloudflared/parachute-vault-example-com/config.yml";
|
|
39
|
+
const CONN_LOG = "/home/op/.parachute/cloudflared/parachute-vault-example-com/cloudflared.log";
|
|
40
|
+
const CONN_CF_BIN = "/usr/local/bin/cloudflared";
|
|
41
|
+
|
|
42
|
+
const GOLDEN_PLIST = `<?xml version="1.0" encoding="UTF-8"?>
|
|
43
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
44
|
+
<!-- Generated by parachute expose public --cloudflare — do not edit by hand. -->
|
|
45
|
+
<plist version="1.0">
|
|
46
|
+
<dict>
|
|
47
|
+
<key>Label</key>
|
|
48
|
+
<string>computer.parachute.cloudflared.parachute-vault-example-com</string>
|
|
49
|
+
<key>ProgramArguments</key>
|
|
50
|
+
<array>
|
|
51
|
+
<string>/usr/local/bin/cloudflared</string>
|
|
52
|
+
<string>tunnel</string>
|
|
53
|
+
<string>--config</string>
|
|
54
|
+
<string>/home/op/.parachute/cloudflared/parachute-vault-example-com/config.yml</string>
|
|
55
|
+
<string>run</string>
|
|
56
|
+
</array>
|
|
57
|
+
<key>RunAtLoad</key>
|
|
58
|
+
<true/>
|
|
59
|
+
<key>KeepAlive</key>
|
|
60
|
+
<true/>
|
|
61
|
+
<key>StandardOutPath</key>
|
|
62
|
+
<string>/home/op/.parachute/cloudflared/parachute-vault-example-com/cloudflared.log</string>
|
|
63
|
+
<key>StandardErrorPath</key>
|
|
64
|
+
<string>/home/op/.parachute/cloudflared/parachute-vault-example-com/cloudflared.log</string>
|
|
65
|
+
</dict>
|
|
66
|
+
</plist>
|
|
67
|
+
`;
|
|
68
|
+
|
|
69
|
+
const GOLDEN_SYSTEMD_USER = `# Generated by parachute expose public --cloudflare — do not edit by hand.
|
|
70
|
+
[Unit]
|
|
71
|
+
Description=Parachute Cloudflare connector (parachute-vault-example-com)
|
|
72
|
+
After=network-online.target
|
|
73
|
+
Wants=network-online.target
|
|
74
|
+
|
|
75
|
+
[Service]
|
|
76
|
+
Type=simple
|
|
77
|
+
ExecStart=/usr/local/bin/cloudflared tunnel --config /home/op/.parachute/cloudflared/parachute-vault-example-com/config.yml run
|
|
78
|
+
Restart=always
|
|
79
|
+
RestartSec=5
|
|
80
|
+
|
|
81
|
+
[Install]
|
|
82
|
+
WantedBy=default.target
|
|
83
|
+
`;
|
|
84
|
+
|
|
85
|
+
const GOLDEN_SYSTEMD_ROOT = `# Generated by parachute expose public --cloudflare — do not edit by hand.
|
|
86
|
+
[Unit]
|
|
87
|
+
Description=Parachute Cloudflare connector (parachute-vault-example-com)
|
|
88
|
+
After=network-online.target
|
|
89
|
+
Wants=network-online.target
|
|
90
|
+
|
|
91
|
+
[Service]
|
|
92
|
+
Type=simple
|
|
93
|
+
User=op
|
|
94
|
+
ExecStart=/usr/local/bin/cloudflared tunnel --config /home/op/.parachute/cloudflared/parachute-vault-example-com/config.yml run
|
|
95
|
+
Restart=always
|
|
96
|
+
RestartSec=5
|
|
97
|
+
|
|
98
|
+
[Install]
|
|
99
|
+
WantedBy=multi-user.target
|
|
100
|
+
`;
|
|
101
|
+
|
|
102
|
+
describe("connector-output regression (BYTE-IDENTICAL — connector is in production)", () => {
|
|
103
|
+
test("launchd plist is character-identical to the pre-extraction golden", () => {
|
|
104
|
+
const plist = renderLaunchdPlist({
|
|
105
|
+
tunnelName: CONN_TUNNEL,
|
|
106
|
+
cloudflaredPath: CONN_CF_BIN,
|
|
107
|
+
configPath: CONN_CONFIG,
|
|
108
|
+
logPath: CONN_LOG,
|
|
109
|
+
});
|
|
110
|
+
expect(plist).toBe(GOLDEN_PLIST);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("systemd USER unit (root:false) is character-identical to the golden", () => {
|
|
114
|
+
const unit = renderSystemdUnit({
|
|
115
|
+
tunnelName: CONN_TUNNEL,
|
|
116
|
+
cloudflaredPath: CONN_CF_BIN,
|
|
117
|
+
configPath: CONN_CONFIG,
|
|
118
|
+
logPath: CONN_LOG,
|
|
119
|
+
root: false,
|
|
120
|
+
userName: "op",
|
|
121
|
+
});
|
|
122
|
+
expect(unit).toBe(GOLDEN_SYSTEMD_USER);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("systemd SYSTEM unit (root:true, User=op) is character-identical to the golden", () => {
|
|
126
|
+
const unit = renderSystemdUnit({
|
|
127
|
+
tunnelName: CONN_TUNNEL,
|
|
128
|
+
cloudflaredPath: CONN_CF_BIN,
|
|
129
|
+
configPath: CONN_CONFIG,
|
|
130
|
+
logPath: CONN_LOG,
|
|
131
|
+
root: true,
|
|
132
|
+
userName: "op",
|
|
133
|
+
});
|
|
134
|
+
expect(unit).toBe(GOLDEN_SYSTEMD_ROOT);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// Generalized renderers — the env block + crash-loop ceiling. The connector
|
|
140
|
+
// (empty env, no ceiling) must render exactly as today; the populated case is
|
|
141
|
+
// the hub-unit shape.
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
/** A minimal connector-shaped descriptor (empty env, no crash-loop). */
|
|
145
|
+
function connectorShapedUnit(over: Partial<ManagedUnit> = {}): ManagedUnit {
|
|
146
|
+
return {
|
|
147
|
+
launchdLabel: "computer.parachute.cloudflared.x",
|
|
148
|
+
systemdUnitName: "parachute-cloudflared-x.service",
|
|
149
|
+
headerComment: "Generated by parachute expose public --cloudflare — do not edit by hand.",
|
|
150
|
+
systemdDescription: "Parachute Cloudflare connector (x)",
|
|
151
|
+
execStart: ["/usr/local/bin/cloudflared", "tunnel", "--config", "/cfg", "run"],
|
|
152
|
+
env: {},
|
|
153
|
+
logPath: "/log",
|
|
154
|
+
runAsInvokingUserOnSystemUnit: true,
|
|
155
|
+
...over,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
describe("renderManagedSystemdUnit — env block", () => {
|
|
160
|
+
test("EMPTY env → emits NO Environment= line (connector parity)", () => {
|
|
161
|
+
const unit = renderManagedSystemdUnit(connectorShapedUnit(), { root: false, userName: "op" });
|
|
162
|
+
expect(unit).not.toContain("Environment=");
|
|
163
|
+
// The line immediately before ExecStart is the [Service]/Type lines — no
|
|
164
|
+
// env wedged in.
|
|
165
|
+
expect(unit).toContain("Type=simple\nExecStart=");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("populated env → one Environment=KEY=VAL line per entry, after User=", () => {
|
|
169
|
+
const unit = renderManagedSystemdUnit(
|
|
170
|
+
connectorShapedUnit({
|
|
171
|
+
env: {
|
|
172
|
+
PARACHUTE_HOME: "/home/op/.parachute",
|
|
173
|
+
PORT: "1939",
|
|
174
|
+
PATH: "/home/op/.bun/bin:/usr/bin",
|
|
175
|
+
BUN_INSTALL: "/home/op/.bun",
|
|
176
|
+
},
|
|
177
|
+
}),
|
|
178
|
+
{ root: true, userName: "op" },
|
|
179
|
+
);
|
|
180
|
+
expect(unit).toContain("Environment=PARACHUTE_HOME=/home/op/.parachute");
|
|
181
|
+
expect(unit).toContain("Environment=PORT=1939");
|
|
182
|
+
expect(unit).toContain("Environment=PATH=/home/op/.bun/bin:/usr/bin");
|
|
183
|
+
expect(unit).toContain("Environment=BUN_INSTALL=/home/op/.bun");
|
|
184
|
+
// env lines sit after User= (root/system unit) and before ExecStart.
|
|
185
|
+
expect(unit).toContain("User=op\nEnvironment=PARACHUTE_HOME=");
|
|
186
|
+
expect(unit.indexOf("Environment=")).toBeLessThan(unit.indexOf("ExecStart="));
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe("renderManagedSystemdUnit — crash-loop ceiling", () => {
|
|
191
|
+
test("unset crashLoop → NO StartLimit lines (connector parity)", () => {
|
|
192
|
+
const unit = renderManagedSystemdUnit(connectorShapedUnit(), { root: false, userName: "op" });
|
|
193
|
+
expect(unit).not.toContain("StartLimit");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("set crashLoop → StartLimitIntervalSec + StartLimitBurst after RestartSec", () => {
|
|
197
|
+
const unit = renderManagedSystemdUnit(
|
|
198
|
+
connectorShapedUnit({ crashLoop: { intervalSec: 300, burst: 5, throttleIntervalSec: 10 } }),
|
|
199
|
+
{ root: false, userName: "op" },
|
|
200
|
+
);
|
|
201
|
+
expect(unit).toContain("StartLimitIntervalSec=300");
|
|
202
|
+
expect(unit).toContain("StartLimitBurst=5");
|
|
203
|
+
expect(unit).toContain(
|
|
204
|
+
"RestartSec=5\nStartLimitIntervalSec=300\nStartLimitBurst=5\n\n[Install]",
|
|
205
|
+
);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe("renderManagedLaunchdPlist — env block + throttle", () => {
|
|
210
|
+
test("EMPTY env + no crashLoop → NO EnvironmentVariables dict, NO ThrottleInterval", () => {
|
|
211
|
+
const plist = renderManagedLaunchdPlist(connectorShapedUnit());
|
|
212
|
+
expect(plist).not.toContain("EnvironmentVariables");
|
|
213
|
+
expect(plist).not.toContain("ThrottleInterval");
|
|
214
|
+
// </array> flows straight into RunAtLoad (no env dict wedged in).
|
|
215
|
+
expect(plist).toContain(" </array>\n <key>RunAtLoad</key>");
|
|
216
|
+
// KeepAlive flows straight into StandardOutPath (no throttle wedged in).
|
|
217
|
+
expect(plist).toContain("<key>KeepAlive</key>\n <true/>\n <key>StandardOutPath</key>");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("populated env + crashLoop → EnvironmentVariables dict + ThrottleInterval", () => {
|
|
221
|
+
const plist = renderManagedLaunchdPlist(
|
|
222
|
+
connectorShapedUnit({
|
|
223
|
+
env: {
|
|
224
|
+
PARACHUTE_HOME: "/home/op/.parachute",
|
|
225
|
+
PORT: "1939",
|
|
226
|
+
PATH: "/home/op/.bun/bin",
|
|
227
|
+
BUN_INSTALL: "/home/op/.bun",
|
|
228
|
+
},
|
|
229
|
+
crashLoop: { intervalSec: 300, burst: 5, throttleIntervalSec: 10 },
|
|
230
|
+
}),
|
|
231
|
+
);
|
|
232
|
+
expect(plist).toContain("<key>EnvironmentVariables</key>");
|
|
233
|
+
expect(plist).toContain("<key>PARACHUTE_HOME</key>\n <string>/home/op/.parachute</string>");
|
|
234
|
+
expect(plist).toContain("<key>PORT</key>\n <string>1939</string>");
|
|
235
|
+
expect(plist).toContain("<key>PATH</key>\n <string>/home/op/.bun/bin</string>");
|
|
236
|
+
expect(plist).toContain("<key>BUN_INSTALL</key>\n <string>/home/op/.bun</string>");
|
|
237
|
+
expect(plist).toContain("<key>ThrottleInterval</key>\n <integer>10</integer>");
|
|
238
|
+
// env dict sits between </array> and RunAtLoad.
|
|
239
|
+
expect(plist.indexOf("EnvironmentVariables")).toBeLessThan(plist.indexOf("RunAtLoad"));
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
// Injectable deps for installManagedUnit / removeManagedUnit tests.
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
interface FakeDepsState {
|
|
248
|
+
deps: ManagedUnitDeps;
|
|
249
|
+
calls: string[][];
|
|
250
|
+
files: Map<string, string>;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function fakeDeps(
|
|
254
|
+
over: Partial<ManagedUnitDeps> & { runResults?: ServiceCommandResult[] } = {},
|
|
255
|
+
): FakeDepsState {
|
|
256
|
+
const calls: string[][] = [];
|
|
257
|
+
const files = new Map<string, string>();
|
|
258
|
+
let runIdx = 0;
|
|
259
|
+
const ok: ServiceCommandResult = { code: 0, stdout: "", stderr: "" };
|
|
260
|
+
const deps: ManagedUnitDeps = {
|
|
261
|
+
platform: over.platform ?? "darwin",
|
|
262
|
+
getuid: over.getuid ?? (() => 501),
|
|
263
|
+
homeDir: over.homeDir ?? (() => "/home/op"),
|
|
264
|
+
userName: over.userName ?? (() => "op"),
|
|
265
|
+
which:
|
|
266
|
+
over.which ??
|
|
267
|
+
((b) => {
|
|
268
|
+
if (b === "bun") return "/home/op/.bun/bin/bun";
|
|
269
|
+
if (b === "launchctl" || b === "systemctl" || b === "loginctl") return `/usr/bin/${b}`;
|
|
270
|
+
return null;
|
|
271
|
+
}),
|
|
272
|
+
run:
|
|
273
|
+
over.run ??
|
|
274
|
+
((cmd) => {
|
|
275
|
+
calls.push([...cmd]);
|
|
276
|
+
const r = over.runResults?.[runIdx++];
|
|
277
|
+
return r ?? ok;
|
|
278
|
+
}),
|
|
279
|
+
writeFile: over.writeFile ?? ((p, c) => void files.set(p, c)),
|
|
280
|
+
removeFile: over.removeFile ?? ((p) => void files.delete(p)),
|
|
281
|
+
readFile: over.readFile ?? ((p) => files.get(p)),
|
|
282
|
+
exists: over.exists ?? ((p) => files.has(p)),
|
|
283
|
+
};
|
|
284
|
+
return { deps, calls, files };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const HUB_MESSAGES: ManagedUnitMessages = {
|
|
288
|
+
launchctlMissing: "launchctl not found",
|
|
289
|
+
systemctlMissing: "systemctl not found",
|
|
290
|
+
lingerWarning: "could not enable lingering",
|
|
291
|
+
writeFailedPrefix: "Failed to write unit",
|
|
292
|
+
launchctlLoadFailedPrefix: "launchctl could not load",
|
|
293
|
+
daemonReloadFailedPrefix: "daemon-reload failed",
|
|
294
|
+
enableFailedPrefix: "enable --now failed",
|
|
295
|
+
launchdInstalled: (label, started) => `Installed launchd ${label} (started=${started})`,
|
|
296
|
+
systemdInstalled: (unitName, root, started) =>
|
|
297
|
+
`Installed systemd ${root ? "system" : "user"} ${unitName} (started=${started})`,
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
function hubUnit(deps: ManagedUnitDeps): ManagedUnit {
|
|
301
|
+
return buildHubManagedUnit({
|
|
302
|
+
parachuteHome: "/home/op/.parachute",
|
|
303
|
+
bunInstall: "/home/op/.bun",
|
|
304
|
+
path: "/home/op/.bun/bin:/usr/local/bin:/usr/bin:/bin",
|
|
305
|
+
cliPath: "/home/op/parachute-hub/src/cli.ts",
|
|
306
|
+
logPath: "/home/op/.parachute/hub/logs/hub.log",
|
|
307
|
+
deps,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
describe("installManagedUnit — start:boolean (§7.1)", () => {
|
|
312
|
+
test("systemd start:false → daemon-reload but NOT enable --now", () => {
|
|
313
|
+
const f = fakeDeps({ platform: "linux", getuid: () => 0, userName: () => "root" });
|
|
314
|
+
const result = installManagedUnit({
|
|
315
|
+
unit: hubUnit(f.deps),
|
|
316
|
+
deps: f.deps,
|
|
317
|
+
messages: HUB_MESSAGES,
|
|
318
|
+
start: false,
|
|
319
|
+
});
|
|
320
|
+
expect(result.outcome).toBe("installed");
|
|
321
|
+
expect(result.messages[0]).toContain("started=false");
|
|
322
|
+
// daemon-reload ran...
|
|
323
|
+
expect(f.calls).toContainEqual(["systemctl", "daemon-reload"]);
|
|
324
|
+
// ...but NO enable --now.
|
|
325
|
+
expect(f.calls.some((c) => c.includes("enable"))).toBe(false);
|
|
326
|
+
// unit file IS written.
|
|
327
|
+
const unitPath = systemdUnitPath("hub-ignored", "/home/op", true).replace(
|
|
328
|
+
"parachute-cloudflared-hub-ignored.service",
|
|
329
|
+
"parachute-hub.service",
|
|
330
|
+
);
|
|
331
|
+
expect(f.files.has(unitPath)).toBe(true);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test("systemd start:true → full daemon-reload + enable --now sequence (default)", () => {
|
|
335
|
+
const f = fakeDeps({ platform: "linux", getuid: () => 0, userName: () => "root" });
|
|
336
|
+
const result = installManagedUnit({
|
|
337
|
+
unit: hubUnit(f.deps),
|
|
338
|
+
deps: f.deps,
|
|
339
|
+
messages: HUB_MESSAGES,
|
|
340
|
+
});
|
|
341
|
+
expect(result.outcome).toBe("installed");
|
|
342
|
+
expect(result.messages[0]).toContain("started=true");
|
|
343
|
+
expect(f.calls).toContainEqual(["systemctl", "daemon-reload"]);
|
|
344
|
+
expect(f.calls).toContainEqual(["systemctl", "enable", "--now", "parachute-hub.service"]);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test("launchd start:false → writes plist but NOT bootstrap/kickstart", () => {
|
|
348
|
+
const f = fakeDeps({ platform: "darwin", getuid: () => 501 });
|
|
349
|
+
const result = installManagedUnit({
|
|
350
|
+
unit: hubUnit(f.deps),
|
|
351
|
+
deps: f.deps,
|
|
352
|
+
messages: HUB_MESSAGES,
|
|
353
|
+
start: false,
|
|
354
|
+
});
|
|
355
|
+
expect(result.outcome).toBe("installed");
|
|
356
|
+
expect(result.messages[0]).toContain("started=false");
|
|
357
|
+
const plistPath = launchdPlistPath("hub-ignored", "/home/op").replace(
|
|
358
|
+
"computer.parachute.cloudflared.hub-ignored.plist",
|
|
359
|
+
"computer.parachute.hub.plist",
|
|
360
|
+
);
|
|
361
|
+
// The plist is written to the correct path AND carries the rendered content
|
|
362
|
+
// (not just an empty key) — start:false skips the launch but still installs.
|
|
363
|
+
expect(f.files.has(plistPath)).toBe(true);
|
|
364
|
+
expect(f.files.get(plistPath)).toBe(renderManagedLaunchdPlist(hubUnit(f.deps)));
|
|
365
|
+
// No launchctl bootout/bootstrap/kickstart in start:false mode.
|
|
366
|
+
expect(f.calls.some((c) => c[0] === "launchctl")).toBe(false);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test("launchd start:true → writes plist AND bootout+bootstrap+kickstart (default)", () => {
|
|
370
|
+
const f = fakeDeps({ platform: "darwin", getuid: () => 501 });
|
|
371
|
+
const result = installManagedUnit({
|
|
372
|
+
unit: hubUnit(f.deps),
|
|
373
|
+
deps: f.deps,
|
|
374
|
+
messages: HUB_MESSAGES,
|
|
375
|
+
});
|
|
376
|
+
expect(result.outcome).toBe("installed");
|
|
377
|
+
expect(result.messages[0]).toContain("started=true");
|
|
378
|
+
expect(f.calls).toContainEqual(["launchctl", "bootout", "gui/501/computer.parachute.hub"]);
|
|
379
|
+
expect(f.calls.some((c) => c[1] === "bootstrap")).toBe(true);
|
|
380
|
+
expect(f.calls).toContainEqual([
|
|
381
|
+
"launchctl",
|
|
382
|
+
"kickstart",
|
|
383
|
+
"-k",
|
|
384
|
+
"gui/501/computer.parachute.hub",
|
|
385
|
+
]);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test("systemd start:false still enables linger (boot-survival nicety, non-root)", () => {
|
|
389
|
+
const f = fakeDeps({ platform: "linux", getuid: () => 1000, userName: () => "op" });
|
|
390
|
+
installManagedUnit({
|
|
391
|
+
unit: hubUnit(f.deps),
|
|
392
|
+
deps: f.deps,
|
|
393
|
+
messages: HUB_MESSAGES,
|
|
394
|
+
start: false,
|
|
395
|
+
});
|
|
396
|
+
expect(f.calls).toContainEqual(["loginctl", "enable-linger", "op"]);
|
|
397
|
+
// user-scoped daemon-reload, no enable.
|
|
398
|
+
expect(f.calls).toContainEqual(["systemctl", "--user", "daemon-reload"]);
|
|
399
|
+
expect(f.calls.some((c) => c.includes("enable"))).toBe(false);
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
// ---------------------------------------------------------------------------
|
|
404
|
+
// buildHubManagedUnit — the §4.1 hub-unit shape (NOT wired into any command).
|
|
405
|
+
// ---------------------------------------------------------------------------
|
|
406
|
+
|
|
407
|
+
describe("buildHubManagedUnit — §4.1 hub-unit shape", () => {
|
|
408
|
+
test("descriptor: abs bun resolved via which, ExecStart = [bun, cli, serve]", () => {
|
|
409
|
+
const f = fakeDeps({ platform: "linux" });
|
|
410
|
+
const unit = hubUnit(f.deps);
|
|
411
|
+
expect(unit.launchdLabel).toBe("computer.parachute.hub");
|
|
412
|
+
expect(unit.systemdUnitName).toBe("parachute-hub.service");
|
|
413
|
+
expect(unit.execStart).toEqual([
|
|
414
|
+
"/home/op/.bun/bin/bun",
|
|
415
|
+
"/home/op/parachute-hub/src/cli.ts",
|
|
416
|
+
"serve",
|
|
417
|
+
]);
|
|
418
|
+
expect(unit.crashLoop).toEqual({ intervalSec: 300, burst: 5, throttleIntervalSec: 10 });
|
|
419
|
+
expect(unit.runAsInvokingUserOnSystemUnit).toBe(true);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
test("throws (fail-loud) when bun is unresolvable — never bakes a broken ExecStart", () => {
|
|
423
|
+
// launchd/systemd don't search $PATH; a literal "bun" fallback would produce
|
|
424
|
+
// a unit that fails to start with a cryptic error. Refuse to build it.
|
|
425
|
+
const f = fakeDeps({
|
|
426
|
+
platform: "linux",
|
|
427
|
+
which: (b) => (b === "bun" ? null : `/usr/bin/${b}`),
|
|
428
|
+
});
|
|
429
|
+
expect(() => hubUnit(f.deps)).toThrow(/'bun' not found on PATH/);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test("env carries the 4 vars and INTENTIONALLY OMITS PARACHUTE_HUB_ORIGIN", () => {
|
|
433
|
+
const f = fakeDeps({ platform: "linux" });
|
|
434
|
+
const unit = hubUnit(f.deps);
|
|
435
|
+
expect(unit.env).toEqual({
|
|
436
|
+
PARACHUTE_HOME: "/home/op/.parachute",
|
|
437
|
+
PORT: "1939",
|
|
438
|
+
PATH: "/home/op/.bun/bin:/usr/local/bin:/usr/bin:/bin",
|
|
439
|
+
BUN_INSTALL: "/home/op/.bun",
|
|
440
|
+
});
|
|
441
|
+
expect(unit.env.PARACHUTE_HUB_ORIGIN).toBeUndefined();
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
test("PARACHUTE_HOME is the captured param, NOT the default (§4.2)", () => {
|
|
445
|
+
const f = fakeDeps({ platform: "linux" });
|
|
446
|
+
const unit = buildHubManagedUnit({
|
|
447
|
+
parachuteHome: "/custom/home/.parachute",
|
|
448
|
+
bunInstall: "/home/op/.bun",
|
|
449
|
+
path: "/home/op/.bun/bin",
|
|
450
|
+
cliPath: "/x/src/cli.ts",
|
|
451
|
+
logPath: "/x.log",
|
|
452
|
+
deps: f.deps,
|
|
453
|
+
});
|
|
454
|
+
expect(unit.env.PARACHUTE_HOME).toBe("/custom/home/.parachute");
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test("default port is 1939; override is respected", () => {
|
|
458
|
+
const f = fakeDeps({ platform: "linux" });
|
|
459
|
+
const unit = buildHubManagedUnit({
|
|
460
|
+
parachuteHome: "/home/op/.parachute",
|
|
461
|
+
port: 2939,
|
|
462
|
+
bunInstall: "/home/op/.bun",
|
|
463
|
+
path: "/p",
|
|
464
|
+
cliPath: "/x/src/cli.ts",
|
|
465
|
+
logPath: "/x.log",
|
|
466
|
+
deps: f.deps,
|
|
467
|
+
});
|
|
468
|
+
expect(unit.env.PORT).toBe("2939");
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
test("rendered systemd SYSTEM unit: 4 Environment= vars, User= present, StartLimit present", () => {
|
|
472
|
+
const f = fakeDeps({ platform: "linux", getuid: () => 0, userName: () => "op" });
|
|
473
|
+
const unit = renderManagedSystemdUnit(hubUnit(f.deps), { root: true, userName: "op" });
|
|
474
|
+
expect(unit).toContain("Description=Parachute hub (serve + supervisor)");
|
|
475
|
+
expect(unit).toContain("User=op");
|
|
476
|
+
expect(unit).toContain("Environment=PARACHUTE_HOME=/home/op/.parachute");
|
|
477
|
+
expect(unit).toContain("Environment=PORT=1939");
|
|
478
|
+
expect(unit).toContain("Environment=PATH=/home/op/.bun/bin:/usr/local/bin:/usr/bin:/bin");
|
|
479
|
+
expect(unit).toContain("Environment=BUN_INSTALL=/home/op/.bun");
|
|
480
|
+
expect(unit).not.toContain("PARACHUTE_HUB_ORIGIN");
|
|
481
|
+
expect(unit).toContain("StartLimitIntervalSec=300");
|
|
482
|
+
expect(unit).toContain("StartLimitBurst=5");
|
|
483
|
+
expect(unit).toContain(
|
|
484
|
+
"ExecStart=/home/op/.bun/bin/bun /home/op/parachute-hub/src/cli.ts serve",
|
|
485
|
+
);
|
|
486
|
+
expect(unit).toContain("WantedBy=multi-user.target");
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
test("rendered systemd USER unit: NO User= line (User= is system-only)", () => {
|
|
490
|
+
const f = fakeDeps({ platform: "linux", getuid: () => 1000, userName: () => "op" });
|
|
491
|
+
const unit = renderManagedSystemdUnit(hubUnit(f.deps), { root: false, userName: "op" });
|
|
492
|
+
expect(unit).not.toContain("User=");
|
|
493
|
+
expect(unit).toContain("Environment=PARACHUTE_HOME=/home/op/.parachute");
|
|
494
|
+
expect(unit).toContain("WantedBy=default.target");
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
test("rendered launchd plist: EnvironmentVariables dict (4 vars) + ThrottleInterval + abs ProgramArguments", () => {
|
|
498
|
+
const f = fakeDeps({ platform: "darwin" });
|
|
499
|
+
const plist = renderManagedLaunchdPlist(hubUnit(f.deps));
|
|
500
|
+
expect(plist).toContain("<key>Label</key>\n <string>computer.parachute.hub</string>");
|
|
501
|
+
expect(plist).toContain("<string>/home/op/.bun/bin/bun</string>");
|
|
502
|
+
expect(plist).toContain("<string>/home/op/parachute-hub/src/cli.ts</string>");
|
|
503
|
+
expect(plist).toContain("<string>serve</string>");
|
|
504
|
+
expect(plist).toContain("<key>EnvironmentVariables</key>");
|
|
505
|
+
expect(plist).toContain("<key>PARACHUTE_HOME</key>\n <string>/home/op/.parachute</string>");
|
|
506
|
+
expect(plist).toContain("<key>PORT</key>\n <string>1939</string>");
|
|
507
|
+
expect(plist).toContain("<key>BUN_INSTALL</key>\n <string>/home/op/.bun</string>");
|
|
508
|
+
expect(plist).not.toContain("PARACHUTE_HUB_ORIGIN");
|
|
509
|
+
expect(plist).toContain("<key>ThrottleInterval</key>\n <integer>10</integer>");
|
|
510
|
+
});
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
describe("removeManagedUnit (smoke — connector delegates here)", () => {
|
|
514
|
+
test("darwin: boots out + removes plist", () => {
|
|
515
|
+
const f = fakeDeps({ platform: "darwin", getuid: () => 501 });
|
|
516
|
+
const label = "computer.parachute.hub";
|
|
517
|
+
const plistPath = "/home/op/Library/LaunchAgents/computer.parachute.hub.plist";
|
|
518
|
+
f.files.set(plistPath, "<plist/>");
|
|
519
|
+
const result = removeManagedUnit({
|
|
520
|
+
launchdLabel: label,
|
|
521
|
+
systemdUnitName: "parachute-hub.service",
|
|
522
|
+
deps: f.deps,
|
|
523
|
+
removedLaunchdMessage: (l) => `Removed ${l}`,
|
|
524
|
+
removedSystemdMessage: (u) => `Removed ${u}`,
|
|
525
|
+
});
|
|
526
|
+
expect(result.removed).toBe(true);
|
|
527
|
+
expect(f.files.has(plistPath)).toBe(false);
|
|
528
|
+
expect(f.calls).toContainEqual(["launchctl", "bootout", `gui/501/${label}`]);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
test("no-op (no throw) when no unit file exists", () => {
|
|
532
|
+
const f = fakeDeps({ platform: "darwin" });
|
|
533
|
+
const result = removeManagedUnit({
|
|
534
|
+
launchdLabel: "computer.parachute.hub",
|
|
535
|
+
systemdUnitName: "parachute-hub.service",
|
|
536
|
+
deps: f.deps,
|
|
537
|
+
removedLaunchdMessage: (l) => `Removed ${l}`,
|
|
538
|
+
removedSystemdMessage: (u) => `Removed ${u}`,
|
|
539
|
+
});
|
|
540
|
+
expect(result.removed).toBe(false);
|
|
541
|
+
expect(f.calls.length).toBe(0);
|
|
542
|
+
});
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
// ---------------------------------------------------------------------------
|
|
546
|
+
// Cross-check: the connector still installs end-to-end through the generalized
|
|
547
|
+
// machinery (start:true default), proving the delegation path is wired.
|
|
548
|
+
// ---------------------------------------------------------------------------
|
|
549
|
+
|
|
550
|
+
describe("installConnectorService still drives the generalized installer (start:true)", () => {
|
|
551
|
+
test("macOS: writes the connector plist + bootstraps (unchanged behavior)", () => {
|
|
552
|
+
const f = fakeDeps({
|
|
553
|
+
platform: "darwin",
|
|
554
|
+
getuid: () => 501,
|
|
555
|
+
which: (b) => {
|
|
556
|
+
if (b === "cloudflared") return CONN_CF_BIN;
|
|
557
|
+
if (b === "launchctl") return "/bin/launchctl";
|
|
558
|
+
return null;
|
|
559
|
+
},
|
|
560
|
+
});
|
|
561
|
+
const result = installConnectorService({
|
|
562
|
+
tunnelName: CONN_TUNNEL,
|
|
563
|
+
configPath: CONN_CONFIG,
|
|
564
|
+
logPath: CONN_LOG,
|
|
565
|
+
deps: f.deps,
|
|
566
|
+
});
|
|
567
|
+
expect(result.outcome).toBe("installed");
|
|
568
|
+
expect(result.kind).toBe("launchd");
|
|
569
|
+
const plistPath = launchdPlistPath(CONN_TUNNEL, "/home/op");
|
|
570
|
+
// The connector plist written through the generalized path is byte-identical
|
|
571
|
+
// to the frozen golden.
|
|
572
|
+
expect(f.files.get(plistPath)).toBe(GOLDEN_PLIST);
|
|
573
|
+
expect(f.calls).toContainEqual(["launchctl", "bootstrap", "gui/501", plistPath]);
|
|
574
|
+
});
|
|
575
|
+
});
|