@openparachute/hub 0.6.3-rc.3 → 0.6.3-rc.4
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__/managed-unit.test.ts +23 -3
- package/src/__tests__/migrate-cutover.test.ts +60 -1
- package/src/__tests__/stale-module-units.test.ts +286 -0
- package/src/cloudflare/connector-service.ts +13 -2
- package/src/commands/migrate-cutover.ts +48 -0
- package/src/managed-unit.ts +24 -4
- package/src/stale-module-units.ts +374 -0
package/package.json
CHANGED
|
@@ -429,10 +429,13 @@ describe("buildHubManagedUnit — §4.1 hub-unit shape", () => {
|
|
|
429
429
|
expect(() => hubUnit(f.deps)).toThrow(/'bun' not found on PATH/);
|
|
430
430
|
});
|
|
431
431
|
|
|
432
|
-
test("env carries the
|
|
432
|
+
test("env carries the 5 vars and INTENTIONALLY OMITS PARACHUTE_HUB_ORIGIN", () => {
|
|
433
433
|
const f = fakeDeps({ platform: "linux" });
|
|
434
434
|
const unit = hubUnit(f.deps);
|
|
435
435
|
expect(unit.env).toEqual({
|
|
436
|
+
// Forced loopback (security): a self-hosted supervised hub must NOT inherit
|
|
437
|
+
// serve.ts's container-first 0.0.0.0 default and bare-serve all-interfaces.
|
|
438
|
+
PARACHUTE_BIND_HOST: "127.0.0.1",
|
|
436
439
|
PARACHUTE_HOME: "/home/op/.parachute",
|
|
437
440
|
PORT: "1939",
|
|
438
441
|
PATH: "/home/op/.bun/bin:/usr/local/bin:/usr/bin:/bin",
|
|
@@ -441,6 +444,17 @@ describe("buildHubManagedUnit — §4.1 hub-unit shape", () => {
|
|
|
441
444
|
expect(unit.env.PARACHUTE_HUB_ORIGIN).toBeUndefined();
|
|
442
445
|
});
|
|
443
446
|
|
|
447
|
+
test("env forces PARACHUTE_BIND_HOST=127.0.0.1 (loopback trust model — never 0.0.0.0)", () => {
|
|
448
|
+
const f = fakeDeps({ platform: "linux" });
|
|
449
|
+
const unit = hubUnit(f.deps);
|
|
450
|
+
// The whole point of the fix: the supervised hub unit binds loopback, NOT
|
|
451
|
+
// the serve.ts container-first 0.0.0.0 default. Covers both init + migrate
|
|
452
|
+
// (both route through buildHubManagedUnit) and both platforms (the env is
|
|
453
|
+
// platform-agnostic; systemd/launchd render shapes are asserted below).
|
|
454
|
+
expect(unit.env.PARACHUTE_BIND_HOST).toBe("127.0.0.1");
|
|
455
|
+
expect(unit.env.PARACHUTE_BIND_HOST).not.toBe("0.0.0.0");
|
|
456
|
+
});
|
|
457
|
+
|
|
444
458
|
test("PARACHUTE_HOME is the captured param, NOT the default (§4.2)", () => {
|
|
445
459
|
const f = fakeDeps({ platform: "linux" });
|
|
446
460
|
const unit = buildHubManagedUnit({
|
|
@@ -468,11 +482,14 @@ describe("buildHubManagedUnit — §4.1 hub-unit shape", () => {
|
|
|
468
482
|
expect(unit.env.PORT).toBe("2939");
|
|
469
483
|
});
|
|
470
484
|
|
|
471
|
-
test("rendered systemd SYSTEM unit:
|
|
485
|
+
test("rendered systemd SYSTEM unit: 5 Environment= vars, User= present, StartLimit present", () => {
|
|
472
486
|
const f = fakeDeps({ platform: "linux", getuid: () => 0, userName: () => "op" });
|
|
473
487
|
const unit = renderManagedSystemdUnit(hubUnit(f.deps), { root: true, userName: "op" });
|
|
474
488
|
expect(unit).toContain("Description=Parachute hub (serve + supervisor)");
|
|
475
489
|
expect(unit).toContain("User=op");
|
|
490
|
+
// Forced loopback bind (security): the supervised hub must bind 127.0.0.1.
|
|
491
|
+
expect(unit).toContain("Environment=PARACHUTE_BIND_HOST=127.0.0.1");
|
|
492
|
+
expect(unit).not.toContain("PARACHUTE_BIND_HOST=0.0.0.0");
|
|
476
493
|
expect(unit).toContain("Environment=PARACHUTE_HOME=/home/op/.parachute");
|
|
477
494
|
expect(unit).toContain("Environment=PORT=1939");
|
|
478
495
|
expect(unit).toContain("Environment=PATH=/home/op/.bun/bin:/usr/local/bin:/usr/bin:/bin");
|
|
@@ -494,7 +511,7 @@ describe("buildHubManagedUnit — §4.1 hub-unit shape", () => {
|
|
|
494
511
|
expect(unit).toContain("WantedBy=default.target");
|
|
495
512
|
});
|
|
496
513
|
|
|
497
|
-
test("rendered launchd plist: EnvironmentVariables dict (
|
|
514
|
+
test("rendered launchd plist: EnvironmentVariables dict (5 vars) + ThrottleInterval + abs ProgramArguments", () => {
|
|
498
515
|
const f = fakeDeps({ platform: "darwin" });
|
|
499
516
|
const plist = renderManagedLaunchdPlist(hubUnit(f.deps));
|
|
500
517
|
expect(plist).toContain("<key>Label</key>\n <string>computer.parachute.hub</string>");
|
|
@@ -502,6 +519,9 @@ describe("buildHubManagedUnit — §4.1 hub-unit shape", () => {
|
|
|
502
519
|
expect(plist).toContain("<string>/home/op/parachute-hub/src/cli.ts</string>");
|
|
503
520
|
expect(plist).toContain("<string>serve</string>");
|
|
504
521
|
expect(plist).toContain("<key>EnvironmentVariables</key>");
|
|
522
|
+
// Forced loopback bind (security): the supervised hub must bind 127.0.0.1.
|
|
523
|
+
expect(plist).toContain("<key>PARACHUTE_BIND_HOST</key>\n <string>127.0.0.1</string>");
|
|
524
|
+
expect(plist).not.toContain("<string>0.0.0.0</string>");
|
|
505
525
|
expect(plist).toContain("<key>PARACHUTE_HOME</key>\n <string>/home/op/.parachute</string>");
|
|
506
526
|
expect(plist).toContain("<key>PORT</key>\n <string>1939</string>");
|
|
507
527
|
expect(plist).toContain("<key>BUN_INSTALL</key>\n <string>/home/op/.bun</string>");
|
|
@@ -122,6 +122,12 @@ function makeFakeCutover(over: Partial<CutoverDeps> = {}): FakeCutover {
|
|
|
122
122
|
messages: ["started unit"],
|
|
123
123
|
};
|
|
124
124
|
},
|
|
125
|
+
// Hermetic default: the stale-unit disable is a no-op (no real
|
|
126
|
+
// systemctl/launchctl). Tests that exercise #522 override this to trace + act.
|
|
127
|
+
disableStaleModuleUnits: () => {
|
|
128
|
+
trace.push("disableStaleUnits");
|
|
129
|
+
return { actions: [] };
|
|
130
|
+
},
|
|
125
131
|
...over,
|
|
126
132
|
};
|
|
127
133
|
// Expose the world via closure for tests that want to manipulate it.
|
|
@@ -184,6 +190,43 @@ describe("cutoverToSupervised — happy path (§7.1)", () => {
|
|
|
184
190
|
}
|
|
185
191
|
});
|
|
186
192
|
|
|
193
|
+
test("#522: stale-unit disable runs in the STOP phase — after detached stop, before unit start", async () => {
|
|
194
|
+
const h = makeHarness();
|
|
195
|
+
try {
|
|
196
|
+
seedManifest(h.manifestPath, [{ name: "vault", port: 1940 }]);
|
|
197
|
+
const fc = makeFakeCutover();
|
|
198
|
+
const w = getWorld(fc.deps);
|
|
199
|
+
w.listening.add(1939);
|
|
200
|
+
w.listening.add(1940);
|
|
201
|
+
w.alivePids.add(5555);
|
|
202
|
+
writePid("vault", 5555, h.configDir);
|
|
203
|
+
const baseKill = fc.deps.kill;
|
|
204
|
+
fc.deps.kill = (pid, signal) => {
|
|
205
|
+
baseKill?.(pid, signal);
|
|
206
|
+
if (pid === 5555) getWorld(fc.deps).listening.delete(1940);
|
|
207
|
+
};
|
|
208
|
+
const result = await cutoverToSupervised({
|
|
209
|
+
configDir: h.configDir,
|
|
210
|
+
manifestPath: h.manifestPath,
|
|
211
|
+
deps: fc.deps,
|
|
212
|
+
log: () => {},
|
|
213
|
+
pollMs: 0,
|
|
214
|
+
});
|
|
215
|
+
expect(result.outcome).toBe("migrated");
|
|
216
|
+
const stopIdx = fc.trace.indexOf("stopHub");
|
|
217
|
+
const disableIdx = fc.trace.indexOf("disableStaleUnits");
|
|
218
|
+
const startIdx = fc.trace.indexOf("startUnit");
|
|
219
|
+
// The disable runs AFTER the detached stop and BEFORE the unit start, so a
|
|
220
|
+
// KeepAlive/Restart=always unit can't re-grab the port between freeing it
|
|
221
|
+
// and the supervised module binding it.
|
|
222
|
+
expect(disableIdx).toBeGreaterThanOrEqual(0);
|
|
223
|
+
expect(stopIdx).toBeLessThan(disableIdx);
|
|
224
|
+
expect(disableIdx).toBeLessThan(startIdx);
|
|
225
|
+
} finally {
|
|
226
|
+
h.cleanup();
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
187
230
|
test("verify-ports-free runs before start (start never races a held port)", async () => {
|
|
188
231
|
const h = makeHarness();
|
|
189
232
|
try {
|
|
@@ -443,6 +486,7 @@ describe("cutoverToSupervised — fail-safe recovery states", () => {
|
|
|
443
486
|
describe("teardownHubUnit (§7.4)", () => {
|
|
444
487
|
test("removes the hub unit (idempotent success path)", () => {
|
|
445
488
|
let removeArgs: { launchdLabel: string; systemdUnitName: string } | undefined;
|
|
489
|
+
let staleCalled = false;
|
|
446
490
|
const log: string[] = [];
|
|
447
491
|
const res = teardownHubUnit({
|
|
448
492
|
log: (l) => log.push(l),
|
|
@@ -450,21 +494,36 @@ describe("teardownHubUnit (§7.4)", () => {
|
|
|
450
494
|
removeArgs = { launchdLabel: opts.launchdLabel, systemdUnitName: opts.systemdUnitName };
|
|
451
495
|
return { removed: true, messages: [opts.removedSystemdMessage(opts.systemdUnitName)] };
|
|
452
496
|
},
|
|
497
|
+
// Hermetic stub — no real systemctl/launchctl.
|
|
498
|
+
disableStaleModuleUnits: () => {
|
|
499
|
+
staleCalled = true;
|
|
500
|
+
return { actions: [] };
|
|
501
|
+
},
|
|
453
502
|
});
|
|
454
503
|
expect(res.removed).toBe(true);
|
|
455
504
|
expect(removeArgs?.launchdLabel).toBe("computer.parachute.hub");
|
|
456
505
|
expect(removeArgs?.systemdUnitName).toBe("parachute-hub.service");
|
|
506
|
+
// #522: teardown also runs the stale-per-module-autostart disable.
|
|
507
|
+
expect(staleCalled).toBe(true);
|
|
457
508
|
// Surfaces the fallback hint.
|
|
458
509
|
expect(log.join("\n")).toContain("parachute serve");
|
|
459
510
|
});
|
|
460
511
|
|
|
461
|
-
test("no unit installed → no-op, friendly message", () => {
|
|
512
|
+
test("no unit installed → no-op, friendly message (still runs the stale-unit disable)", () => {
|
|
513
|
+
let staleCalled = false;
|
|
462
514
|
const log: string[] = [];
|
|
463
515
|
const res = teardownHubUnit({
|
|
464
516
|
log: (l) => log.push(l),
|
|
465
517
|
remove: (): ManagedUnitRemoveResult => ({ removed: false, messages: [] }),
|
|
518
|
+
disableStaleModuleUnits: () => {
|
|
519
|
+
staleCalled = true;
|
|
520
|
+
return { actions: [] };
|
|
521
|
+
},
|
|
466
522
|
});
|
|
467
523
|
expect(res.removed).toBe(false);
|
|
524
|
+
// #522: a leftover module autostart must be cleaned even when the hub unit was
|
|
525
|
+
// never installed (a partial / never-migrated box rolling back).
|
|
526
|
+
expect(staleCalled).toBe(true);
|
|
468
527
|
expect(log.join("\n")).toContain("nothing to tear down");
|
|
469
528
|
});
|
|
470
529
|
});
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { ManagedUnitDeps, ServiceCommandResult } from "../managed-unit.ts";
|
|
3
|
+
import {
|
|
4
|
+
disableStaleModuleUnits,
|
|
5
|
+
moduleLaunchdLabel,
|
|
6
|
+
moduleSystemdUnitName,
|
|
7
|
+
targetModuleShorts,
|
|
8
|
+
} from "../stale-module-units.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* #522 — migrate/teardown must DETECT + DISABLE any stale per-module autostart
|
|
12
|
+
* unit (a leftover `parachute-<short>.service` systemd KeepAlive / a
|
|
13
|
+
* `computer.parachute.<short>` launchd KeepAlive) so it stops respawning an
|
|
14
|
+
* unsupervised module that fights the supervised hub for the module's port.
|
|
15
|
+
*
|
|
16
|
+
* ALL tests run against a stubbed `ManagedUnitDeps.run` — NO real
|
|
17
|
+
* systemctl/launchctl. Each fake records the commands it received so we can
|
|
18
|
+
* assert exactly which units were disabled, which were skipped, and that a
|
|
19
|
+
* disable failure / system-level unit is non-fatal.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
type RunResponder = (cmd: readonly string[]) => ServiceCommandResult;
|
|
23
|
+
|
|
24
|
+
function ok(stdout = ""): ServiceCommandResult {
|
|
25
|
+
return { code: 0, stdout, stderr: "" };
|
|
26
|
+
}
|
|
27
|
+
function fail(stderr = "boom", code = 1): ServiceCommandResult {
|
|
28
|
+
return { code, stdout: "", stderr };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function makeDeps(platform: NodeJS.Platform, respond: RunResponder) {
|
|
32
|
+
const calls: string[][] = [];
|
|
33
|
+
const deps: ManagedUnitDeps = {
|
|
34
|
+
platform,
|
|
35
|
+
getuid: () => 501,
|
|
36
|
+
homeDir: () => "/home/op",
|
|
37
|
+
userName: () => "op",
|
|
38
|
+
which: (b) => (b === "systemctl" || b === "launchctl" ? `/usr/bin/${b}` : null),
|
|
39
|
+
run: (cmd) => {
|
|
40
|
+
calls.push([...cmd]);
|
|
41
|
+
return respond(cmd);
|
|
42
|
+
},
|
|
43
|
+
writeFile: () => {},
|
|
44
|
+
removeFile: () => {},
|
|
45
|
+
readFile: () => undefined,
|
|
46
|
+
exists: () => false,
|
|
47
|
+
};
|
|
48
|
+
return { deps, calls };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function joined(calls: string[][]): string[] {
|
|
52
|
+
return calls.map((c) => c.join(" "));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe("targetModuleShorts() — known module shorts, never hub/cloudflared", () => {
|
|
56
|
+
test("includes the canonical module shorts and excludes hub", () => {
|
|
57
|
+
const shorts = targetModuleShorts();
|
|
58
|
+
// The canonical knownServices() set: vault / scribe / runner / surface / notes / channel.
|
|
59
|
+
expect(shorts).toContain("vault");
|
|
60
|
+
expect(shorts).toContain("scribe");
|
|
61
|
+
expect(shorts).toContain("surface");
|
|
62
|
+
expect(shorts).toContain("notes");
|
|
63
|
+
// hub is the supervised model itself — never a target.
|
|
64
|
+
expect(shorts).not.toContain("hub");
|
|
65
|
+
expect(shorts).not.toContain("cloudflared");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("disableStaleModuleUnits — systemd (Linux)", () => {
|
|
70
|
+
test("a stale ENABLED user unit parachute-vault.service is disabled --now + reported", () => {
|
|
71
|
+
const { deps, calls } = makeDeps("linux", (cmd) => {
|
|
72
|
+
const line = cmd.join(" ");
|
|
73
|
+
// Only vault's USER unit reads enabled; everything else is disabled.
|
|
74
|
+
if (line === "systemctl --user is-enabled parachute-vault.service") return ok("enabled\n");
|
|
75
|
+
if (line.startsWith("systemctl --user is-enabled")) return fail("disabled", 1);
|
|
76
|
+
if (line.startsWith("systemctl is-enabled")) return fail("disabled", 1);
|
|
77
|
+
if (line === "systemctl --user disable --now parachute-vault.service") return ok();
|
|
78
|
+
return ok();
|
|
79
|
+
});
|
|
80
|
+
const log: string[] = [];
|
|
81
|
+
const res = disableStaleModuleUnits({ deps, log: (l) => log.push(l) });
|
|
82
|
+
|
|
83
|
+
// Exactly one unit acted on: vault, disabled at user scope.
|
|
84
|
+
expect(res.actions).toHaveLength(1);
|
|
85
|
+
expect(res.actions[0]).toMatchObject({
|
|
86
|
+
short: "vault",
|
|
87
|
+
kind: "systemd-user",
|
|
88
|
+
unit: "parachute-vault.service",
|
|
89
|
+
result: "disabled",
|
|
90
|
+
});
|
|
91
|
+
// The disable --now command was actually invoked.
|
|
92
|
+
expect(joined(calls)).toContain("systemctl --user disable --now parachute-vault.service");
|
|
93
|
+
// The action is reported so the operator sees what changed.
|
|
94
|
+
expect(log.join("\n")).toContain("Disabled stale parachute-vault.service");
|
|
95
|
+
expect(log.join("\n")).toContain("vault's port");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("SKIPS the hub unit + cloudflared units — never queried, never disabled", () => {
|
|
99
|
+
const { deps, calls } = makeDeps("linux", () => fail("disabled", 1));
|
|
100
|
+
disableStaleModuleUnits({ deps });
|
|
101
|
+
const lines = joined(calls);
|
|
102
|
+
// The hub unit is never probed or disabled by this sweep.
|
|
103
|
+
expect(lines.some((l) => l.includes("parachute-hub.service"))).toBe(false);
|
|
104
|
+
// No cloudflared connector unit is touched.
|
|
105
|
+
expect(lines.some((l) => l.includes("parachute-cloudflared"))).toBe(false);
|
|
106
|
+
// And no disable command runs at all (everything reads disabled).
|
|
107
|
+
expect(lines.some((l) => l.includes("disable"))).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("a non-matching / arbitrary unit is never touched (only parachute-<known-short> is queried)", () => {
|
|
111
|
+
const { deps, calls } = makeDeps("linux", () => fail("disabled", 1));
|
|
112
|
+
disableStaleModuleUnits({ deps });
|
|
113
|
+
const lines = joined(calls);
|
|
114
|
+
// Every is-enabled probe targets a parachute-<known-short>.service and nothing else.
|
|
115
|
+
const probes = lines.filter((l) => l.includes("is-enabled"));
|
|
116
|
+
expect(probes.length).toBeGreaterThan(0);
|
|
117
|
+
for (const probe of probes) {
|
|
118
|
+
const m = probe.match(/is-enabled (parachute-[a-z]+\.service)$/);
|
|
119
|
+
expect(m).not.toBeNull();
|
|
120
|
+
const unit = m?.[1] ?? "";
|
|
121
|
+
// The probed unit must be a known module short, and never the hub/cloudflared.
|
|
122
|
+
expect(targetModuleShorts().map(moduleSystemdUnitName)).toContain(unit);
|
|
123
|
+
expect(unit).not.toBe("parachute-hub.service");
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("idempotent: every unit already disabled → clean no-op (no disable, no actions)", () => {
|
|
128
|
+
const { deps, calls } = makeDeps("linux", () => fail("disabled", 1));
|
|
129
|
+
const res = disableStaleModuleUnits({ deps });
|
|
130
|
+
expect(res.actions).toHaveLength(0);
|
|
131
|
+
expect(joined(calls).some((l) => l.includes("disable"))).toBe(false);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("system-level unit (no --user enabled, system enabled) → WARNS with manual sudo command, doesn't abort, doesn't sudo", () => {
|
|
135
|
+
const { deps, calls } = makeDeps("linux", (cmd) => {
|
|
136
|
+
const line = cmd.join(" ");
|
|
137
|
+
// vault's USER unit is NOT enabled, but the SYSTEM unit IS.
|
|
138
|
+
if (line === "systemctl --user is-enabled parachute-vault.service")
|
|
139
|
+
return fail("disabled", 1);
|
|
140
|
+
if (line === "systemctl is-enabled parachute-vault.service") return ok("enabled\n");
|
|
141
|
+
if (line.startsWith("systemctl --user is-enabled")) return fail("disabled", 1);
|
|
142
|
+
if (line.startsWith("systemctl is-enabled")) return fail("disabled", 1);
|
|
143
|
+
return ok();
|
|
144
|
+
});
|
|
145
|
+
const log: string[] = [];
|
|
146
|
+
const res = disableStaleModuleUnits({ deps, log: (l) => log.push(l) });
|
|
147
|
+
|
|
148
|
+
const vaultAction = res.actions.find((a) => a.short === "vault");
|
|
149
|
+
expect(vaultAction).toMatchObject({ kind: "systemd-system", result: "warn-system" });
|
|
150
|
+
const out = log.join("\n");
|
|
151
|
+
// The exact manual command is surfaced.
|
|
152
|
+
expect(out).toContain("sudo systemctl disable --now parachute-vault.service");
|
|
153
|
+
// It NEVER attempted a sudo / system disable itself.
|
|
154
|
+
expect(joined(calls)).not.toContain("systemctl disable --now parachute-vault.service");
|
|
155
|
+
expect(joined(calls).some((l) => l.startsWith("sudo"))).toBe(false);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("non-fatal: a disable command that fails → warn + continue (the other units are still swept)", () => {
|
|
159
|
+
const { deps, calls } = makeDeps("linux", (cmd) => {
|
|
160
|
+
const line = cmd.join(" ");
|
|
161
|
+
// Both vault + scribe user units read enabled; vault's disable FAILS.
|
|
162
|
+
if (line === "systemctl --user is-enabled parachute-vault.service") return ok("enabled\n");
|
|
163
|
+
if (line === "systemctl --user is-enabled parachute-scribe.service") return ok("enabled\n");
|
|
164
|
+
if (line.startsWith("systemctl --user is-enabled")) return fail("disabled", 1);
|
|
165
|
+
if (line.startsWith("systemctl is-enabled")) return fail("disabled", 1);
|
|
166
|
+
if (line === "systemctl --user disable --now parachute-vault.service")
|
|
167
|
+
return fail("permission denied");
|
|
168
|
+
if (line === "systemctl --user disable --now parachute-scribe.service") return ok();
|
|
169
|
+
return ok();
|
|
170
|
+
});
|
|
171
|
+
const log: string[] = [];
|
|
172
|
+
const res = disableStaleModuleUnits({ deps, log: (l) => log.push(l) });
|
|
173
|
+
|
|
174
|
+
const vault = res.actions.find((a) => a.short === "vault");
|
|
175
|
+
const scribe = res.actions.find((a) => a.short === "scribe");
|
|
176
|
+
// vault's disable failed → warned, not fatal; scribe was still disabled.
|
|
177
|
+
expect(vault?.result).toBe("failed");
|
|
178
|
+
expect(scribe?.result).toBe("disabled");
|
|
179
|
+
// Both disable attempts ran — the failure didn't abort the sweep.
|
|
180
|
+
expect(joined(calls)).toContain("systemctl --user disable --now parachute-vault.service");
|
|
181
|
+
expect(joined(calls)).toContain("systemctl --user disable --now parachute-scribe.service");
|
|
182
|
+
expect(log.join("\n")).toContain("Could not disable");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("no systemctl on the box → clean no-op", () => {
|
|
186
|
+
const calls: string[][] = [];
|
|
187
|
+
const deps: ManagedUnitDeps = {
|
|
188
|
+
platform: "linux",
|
|
189
|
+
getuid: () => 501,
|
|
190
|
+
homeDir: () => "/home/op",
|
|
191
|
+
userName: () => "op",
|
|
192
|
+
which: () => null, // no systemctl
|
|
193
|
+
run: (cmd) => {
|
|
194
|
+
calls.push([...cmd]);
|
|
195
|
+
return ok();
|
|
196
|
+
},
|
|
197
|
+
writeFile: () => {},
|
|
198
|
+
removeFile: () => {},
|
|
199
|
+
readFile: () => undefined,
|
|
200
|
+
exists: () => false,
|
|
201
|
+
};
|
|
202
|
+
const res = disableStaleModuleUnits({ deps });
|
|
203
|
+
expect(res.actions).toHaveLength(0);
|
|
204
|
+
expect(calls).toHaveLength(0);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe("disableStaleModuleUnits — launchd (Mac)", () => {
|
|
209
|
+
test("a loaded computer.parachute.vault LaunchAgent is booted out + reported", () => {
|
|
210
|
+
const { deps, calls } = makeDeps("darwin", (cmd) => {
|
|
211
|
+
const line = cmd.join(" ");
|
|
212
|
+
// Only vault's label is loaded (print succeeds with content); others print empty.
|
|
213
|
+
if (line === "launchctl print gui/501/computer.parachute.vault")
|
|
214
|
+
return ok("com.apple...\nstate = running\n");
|
|
215
|
+
if (line.startsWith("launchctl print")) return ok(""); // not loaded
|
|
216
|
+
if (line === "launchctl bootout gui/501/computer.parachute.vault") return ok();
|
|
217
|
+
return ok();
|
|
218
|
+
});
|
|
219
|
+
const log: string[] = [];
|
|
220
|
+
const res = disableStaleModuleUnits({ deps, log: (l) => log.push(l) });
|
|
221
|
+
|
|
222
|
+
expect(res.actions).toHaveLength(1);
|
|
223
|
+
expect(res.actions[0]).toMatchObject({
|
|
224
|
+
short: "vault",
|
|
225
|
+
kind: "launchd",
|
|
226
|
+
unit: "computer.parachute.vault",
|
|
227
|
+
result: "disabled",
|
|
228
|
+
});
|
|
229
|
+
expect(joined(calls)).toContain("launchctl bootout gui/501/computer.parachute.vault");
|
|
230
|
+
expect(log.join("\n")).toContain("Disabled stale computer.parachute.vault");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("SKIPS the hub label + cloudflared labels — never printed, never booted out", () => {
|
|
234
|
+
const { deps, calls } = makeDeps("darwin", () => ok("")); // nothing loaded
|
|
235
|
+
disableStaleModuleUnits({ deps });
|
|
236
|
+
const lines = joined(calls);
|
|
237
|
+
expect(lines.some((l) => l.includes("computer.parachute.hub"))).toBe(false);
|
|
238
|
+
expect(lines.some((l) => l.includes("computer.parachute.cloudflared"))).toBe(false);
|
|
239
|
+
// Every print targets a parachute.<known-short> label.
|
|
240
|
+
const prints = lines.filter((l) => l.startsWith("launchctl print"));
|
|
241
|
+
for (const p of prints) {
|
|
242
|
+
const m = p.match(/computer\.parachute\.([a-z]+)$/);
|
|
243
|
+
expect(m).not.toBeNull();
|
|
244
|
+
expect(targetModuleShorts()).toContain(m?.[1] ?? "");
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("idempotent: nothing loaded → clean no-op (no bootout, no actions)", () => {
|
|
249
|
+
const { deps, calls } = makeDeps("darwin", () => ok("")); // print returns empty for all
|
|
250
|
+
const res = disableStaleModuleUnits({ deps });
|
|
251
|
+
expect(res.actions).toHaveLength(0);
|
|
252
|
+
expect(joined(calls).some((l) => l.includes("bootout"))).toBe(false);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("non-fatal: a bootout that fails → warn + continue", () => {
|
|
256
|
+
const { deps } = makeDeps("darwin", (cmd) => {
|
|
257
|
+
const line = cmd.join(" ");
|
|
258
|
+
if (line === "launchctl print gui/501/computer.parachute.vault")
|
|
259
|
+
return ok("state = running\n");
|
|
260
|
+
if (line.startsWith("launchctl print")) return ok("");
|
|
261
|
+
if (line === "launchctl bootout gui/501/computer.parachute.vault")
|
|
262
|
+
return fail("Operation not permitted");
|
|
263
|
+
return ok();
|
|
264
|
+
});
|
|
265
|
+
const log: string[] = [];
|
|
266
|
+
const res = disableStaleModuleUnits({ deps, log: (l) => log.push(l) });
|
|
267
|
+
expect(res.actions.find((a) => a.short === "vault")?.result).toBe("failed");
|
|
268
|
+
expect(log.join("\n")).toContain("Could not disable the stale LaunchAgent");
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
describe("disableStaleModuleUnits — unsupported platform", () => {
|
|
273
|
+
test("no per-platform manager (e.g. win32) → clean no-op", () => {
|
|
274
|
+
const { deps, calls } = makeDeps("win32", () => ok());
|
|
275
|
+
const res = disableStaleModuleUnits({ deps });
|
|
276
|
+
expect(res.actions).toHaveLength(0);
|
|
277
|
+
expect(calls).toHaveLength(0);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
describe("unit-name helpers", () => {
|
|
282
|
+
test("moduleSystemdUnitName / moduleLaunchdLabel build the exact per-module names", () => {
|
|
283
|
+
expect(moduleSystemdUnitName("vault")).toBe("parachute-vault.service");
|
|
284
|
+
expect(moduleLaunchdLabel("vault")).toBe("computer.parachute.vault");
|
|
285
|
+
});
|
|
286
|
+
});
|
|
@@ -69,10 +69,21 @@ export type ConnectorServiceDeps = ManagedUnitDeps;
|
|
|
69
69
|
|
|
70
70
|
export const defaultServiceDeps: ConnectorServiceDeps = defaultManagedUnitDeps;
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Reverse-DNS prefix for the launchd label + plist filename. Exported so the
|
|
74
|
+
* migrate/teardown stale-per-module-autostart sweep (`src/stale-module-units.ts`,
|
|
75
|
+
* hub#522) can reuse it as a SKIP-list anchor — the connector unit is owned by
|
|
76
|
+
* the supervised model (`expose off --cloudflare` tears it down), and the sweep
|
|
77
|
+
* must never touch it. Reusing the constant keeps the skip-list from drifting if
|
|
78
|
+
* this prefix ever changes.
|
|
79
|
+
*/
|
|
80
|
+
export const CLOUDFLARED_LAUNCHD_LABEL_PREFIX = "computer.parachute.cloudflared";
|
|
81
|
+
/** systemd unit name prefix. Exported for the same skip-list reason as above. */
|
|
82
|
+
export const CLOUDFLARED_SYSTEMD_UNIT_PREFIX = "parachute-cloudflared-";
|
|
72
83
|
/** Reverse-DNS prefix for the launchd label + plist filename. */
|
|
73
|
-
const LAUNCHD_LABEL_PREFIX =
|
|
84
|
+
const LAUNCHD_LABEL_PREFIX = CLOUDFLARED_LAUNCHD_LABEL_PREFIX;
|
|
74
85
|
/** systemd unit name prefix. */
|
|
75
|
-
const SYSTEMD_UNIT_PREFIX =
|
|
86
|
+
const SYSTEMD_UNIT_PREFIX = CLOUDFLARED_SYSTEMD_UNIT_PREFIX;
|
|
76
87
|
/** Provenance comment baked into every rendered connector unit file. */
|
|
77
88
|
const CONNECTOR_HEADER = "Generated by parachute expose public --cloudflare — do not edit by hand.";
|
|
78
89
|
|
|
@@ -86,6 +86,11 @@ import { type PortListeningFn, defaultPortListening } from "../port-probe.ts";
|
|
|
86
86
|
import { type AliveFn, clearPid, readPid } from "../process-state.ts";
|
|
87
87
|
import { shortNameForManifest } from "../service-spec.ts";
|
|
88
88
|
import { type ServiceEntry, readManifestLenient } from "../services-manifest.ts";
|
|
89
|
+
import {
|
|
90
|
+
type DisableStaleModuleUnitsOpts,
|
|
91
|
+
type DisableStaleModuleUnitsResult,
|
|
92
|
+
disableStaleModuleUnits,
|
|
93
|
+
} from "../stale-module-units.ts";
|
|
89
94
|
|
|
90
95
|
/**
|
|
91
96
|
* Absolute path to this hub checkout's `src/cli.ts` — the entry the hub unit's
|
|
@@ -180,6 +185,19 @@ export interface CutoverDeps {
|
|
|
180
185
|
sleep: (ms: number) => Promise<void>;
|
|
181
186
|
/** The hub-unit deps for install / detect / manager calls. */
|
|
182
187
|
hubUnitDeps: HubUnitDeps;
|
|
188
|
+
/**
|
|
189
|
+
* Detect + DISABLE any stale per-module autostart unit (#522 — the load-bearing
|
|
190
|
+
* fix). A leftover standalone `parachute-<short>.service` (systemd KeepAlive) /
|
|
191
|
+
* `computer.parachute.<short>` (launchd KeepAlive) from the pre-supervisor era
|
|
192
|
+
* keeps RESPAWNING an unsupervised module that binds the module's port — the
|
|
193
|
+
* supervised child then EADDRINUSE-crash-loops. Killing the process is
|
|
194
|
+
* whack-a-mole (the unit resurrects it); we must disable the UNIT. Run in the
|
|
195
|
+
* STOP phase (after the per-module detached stop, before the port-free verify)
|
|
196
|
+
* so the freed port lets the supervised module bind. Ownership-safe (known
|
|
197
|
+
* module shorts only; hub + cloudflared skipped), idempotent, non-fatal.
|
|
198
|
+
* Injectable so tests never touch real systemctl/launchctl.
|
|
199
|
+
*/
|
|
200
|
+
disableStaleModuleUnits: (opts?: DisableStaleModuleUnitsOpts) => DisableStaleModuleUnitsResult;
|
|
183
201
|
}
|
|
184
202
|
|
|
185
203
|
export interface WriteUnitOpts {
|
|
@@ -329,6 +347,7 @@ export const defaultCutoverDeps: CutoverDeps = {
|
|
|
329
347
|
probeHealth: defaultHubUnitDeps.probeHealth,
|
|
330
348
|
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
331
349
|
hubUnitDeps: defaultHubUnitDeps,
|
|
350
|
+
disableStaleModuleUnits,
|
|
332
351
|
};
|
|
333
352
|
|
|
334
353
|
export interface CutoverOpts {
|
|
@@ -673,6 +692,22 @@ export async function cutoverToSupervised(opts: CutoverOpts = {}): Promise<Cutov
|
|
|
673
692
|
await stopDetachedModule(target, configDir, deps, timeoutMs, pollMs, log);
|
|
674
693
|
}
|
|
675
694
|
|
|
695
|
+
// --- Step 3b (#522): DISABLE stale per-module autostart UNITS. ---
|
|
696
|
+
// The load-bearing fix for the recurring "port 1940 taken" crash-loop: a
|
|
697
|
+
// leftover standalone `parachute-<short>.service` (systemd KeepAlive) or
|
|
698
|
+
// `computer.parachute.<short>` (launchd KeepAlive) from the pre-supervisor era
|
|
699
|
+
// keeps RESPAWNING an unsupervised module that binds the port — so the
|
|
700
|
+
// per-module stop above (and the orphan sweep below) is whack-a-mole: the unit
|
|
701
|
+
// resurrects the process within seconds, serving OLD code. We must DISABLE the
|
|
702
|
+
// UNIT so the port stays free for the supervised child. MUST run HERE — after
|
|
703
|
+
// the detached stop, BEFORE the verify-ports-free + unit start — so the freed
|
|
704
|
+
// port lets the supervised module bind. Ownership-safe (known module shorts
|
|
705
|
+
// only; hub + cloudflared skipped), idempotent, non-fatal (a failed disable
|
|
706
|
+
// warns + continues; a system-level unit it can't disable → warn with the
|
|
707
|
+
// manual sudo command). Every disabled unit is reported.
|
|
708
|
+
log("Checking for stale per-module autostart units to disable…");
|
|
709
|
+
deps.disableStaleModuleUnits({ deps: deps.hubUnitDeps, log: (l) => log(l) });
|
|
710
|
+
|
|
676
711
|
// --- Step 4: §7.2 ORPHAN SWEEP — per services.json port + the hub port. ---
|
|
677
712
|
// The HUB port keeps the pre-existing blind-adopt (mirrors stopHub's 1939
|
|
678
713
|
// orphan-adoption — out of scope for MUST-FIX 2). The MODULE ports get the
|
|
@@ -797,6 +832,13 @@ export interface TeardownOpts {
|
|
|
797
832
|
removedLaunchdMessage: (label: string) => string;
|
|
798
833
|
removedSystemdMessage: (unitName: string) => string;
|
|
799
834
|
}) => ManagedUnitRemoveResult;
|
|
835
|
+
/**
|
|
836
|
+
* Test seam: the stale-per-module-autostart disable (#522). Teardown also
|
|
837
|
+
* disables any leftover standalone module autostart unit so a rollback to
|
|
838
|
+
* foreground `serve` doesn't leave a competing module respawning at boot.
|
|
839
|
+
* Injectable so tests never touch real systemctl/launchctl.
|
|
840
|
+
*/
|
|
841
|
+
disableStaleModuleUnits?: (opts?: DisableStaleModuleUnitsOpts) => DisableStaleModuleUnitsResult;
|
|
800
842
|
}
|
|
801
843
|
|
|
802
844
|
/**
|
|
@@ -815,6 +857,7 @@ export function teardownHubUnit(opts: TeardownOpts = {}): { removed: boolean; me
|
|
|
815
857
|
const log = opts.log ?? ((line) => console.log(line));
|
|
816
858
|
const deps = opts.deps ?? defaultHubUnitDeps;
|
|
817
859
|
const remove = opts.remove ?? removeManagedUnit;
|
|
860
|
+
const disableStale = opts.disableStaleModuleUnits ?? disableStaleModuleUnits;
|
|
818
861
|
const res = remove({
|
|
819
862
|
launchdLabel: HUB_LAUNCHD_LABEL,
|
|
820
863
|
systemdUnitName: HUB_SYSTEMD_UNIT_NAME,
|
|
@@ -824,6 +867,11 @@ export function teardownHubUnit(opts: TeardownOpts = {}): { removed: boolean; me
|
|
|
824
867
|
removedSystemdMessage: (unitName) =>
|
|
825
868
|
`Removed systemd unit ${unitName} — the hub no longer starts on boot.`,
|
|
826
869
|
});
|
|
870
|
+
// #522: also disable any leftover standalone per-module autostart unit so a
|
|
871
|
+
// rollback to foreground `serve` doesn't leave a competing module respawning at
|
|
872
|
+
// boot to race whatever the operator brings up next. Ownership-safe (known
|
|
873
|
+
// module shorts only; hub + cloudflared skipped), idempotent, non-fatal.
|
|
874
|
+
disableStale({ deps, log });
|
|
827
875
|
if (res.removed) {
|
|
828
876
|
for (const m of res.messages) log(m);
|
|
829
877
|
log("");
|
package/src/managed-unit.ts
CHANGED
|
@@ -648,10 +648,25 @@ export interface BuildHubManagedUnitOpts {
|
|
|
648
648
|
*
|
|
649
649
|
* Resolves the absolute `bun` path via the `which` seam (launchd/systemd don't
|
|
650
650
|
* search `$PATH` — mirrors how the connector resolves cloudflared). The env
|
|
651
|
-
* carries `
|
|
652
|
-
* OMITS `PARACHUTE_HUB_ORIGIN`: baking a stale
|
|
653
|
-
* iss-mismatch class; `resolveStartupIssuer`
|
|
654
|
-
* the operator token + vault `.env` to the
|
|
651
|
+
* carries `PARACHUTE_BIND_HOST` / `PARACHUTE_HOME` / `PORT` / `PATH` /
|
|
652
|
+
* `BUN_INSTALL` — and INTENTIONALLY OMITS `PARACHUTE_HUB_ORIGIN`: baking a stale
|
|
653
|
+
* origin here would re-create the iss-mismatch class; `resolveStartupIssuer`
|
|
654
|
+
* derives it and start-hub self-heals the operator token + vault `.env` to the
|
|
655
|
+
* current origin (design §4.1 comment).
|
|
656
|
+
*
|
|
657
|
+
* BIND HOST — `PARACHUTE_BIND_HOST=127.0.0.1` is forced here so every
|
|
658
|
+
* self-hosted supervised hub binds loopback. `parachute serve` itself defaults
|
|
659
|
+
* the bind host to `0.0.0.0` (serve.ts), which is correct for the container
|
|
660
|
+
* shape (the platform's HTTP forwarder must reach the hub) but WRONG for a
|
|
661
|
+
* self-hosted box — bare `serve` would expose the admin/OAuth surfaces on every
|
|
662
|
+
* interface, contradicting the pre-supervisor detached behavior and the trust
|
|
663
|
+
* model `layerOf` (hub-server.ts) assumes (header-absent ⇒ "loopback"). The
|
|
664
|
+
* container path never calls this builder (the Dockerfile pins
|
|
665
|
+
* `ENV PARACHUTE_BIND_HOST=0.0.0.0` + runs `serve` directly), so it stays
|
|
666
|
+
* 0.0.0.0. The canonical expose path is unaffected: cloudflared/tailscale dial
|
|
667
|
+
* `127.0.0.1:<port>` from the same host, and the hub's own proxy targets
|
|
668
|
+
* `http://127.0.0.1:<port>` (hub-server.ts). An operator who genuinely wants
|
|
669
|
+
* all-interfaces can override the generated unit; the default is loopback.
|
|
655
670
|
*
|
|
656
671
|
* NOT called by any command in this PR (additive — Phase 3 wires it into `init`).
|
|
657
672
|
*/
|
|
@@ -676,6 +691,11 @@ export function buildHubManagedUnit(opts: BuildHubManagedUnitOpts): ManagedUnit
|
|
|
676
691
|
systemdDescription: "Parachute hub (serve + supervisor)",
|
|
677
692
|
execStart: [bunPath, opts.cliPath, "serve"],
|
|
678
693
|
env: {
|
|
694
|
+
// Force loopback on every self-hosted supervised hub. serve.ts defaults
|
|
695
|
+
// to 0.0.0.0 (container-first); a self-hosted box must NOT bare-serve
|
|
696
|
+
// all-interfaces. Container path bypasses this builder (Dockerfile pins
|
|
697
|
+
// its own 0.0.0.0). See the docstring for the full trust-model rationale.
|
|
698
|
+
PARACHUTE_BIND_HOST: "127.0.0.1",
|
|
679
699
|
// PARACHUTE_HOME captured at install time (design §4.2) — NOT the default.
|
|
680
700
|
PARACHUTE_HOME: opts.parachuteHome,
|
|
681
701
|
PORT: String(port),
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detect + disable STALE per-module autostart units during the
|
|
3
|
+
* detached→supervised cutover + teardown (hub#522, design
|
|
4
|
+
* `parachute.computer/design/2026-06-01-hub-as-supervisor-unification.md` §7.2).
|
|
5
|
+
*
|
|
6
|
+
* THE BUG (validated hands-on on friends.parachute.computer): after a box
|
|
7
|
+
* migrates to the supervised model, a leftover STANDALONE per-module autostart
|
|
8
|
+
* unit from the pre-supervisor era — a systemd user unit `parachute-vault.service`
|
|
9
|
+
* with `Restart=always`, or a launchd `computer.parachute.vault` LaunchAgent with
|
|
10
|
+
* `KeepAlive` — keeps RESPAWNING an unsupervised vault that binds port 1940. The
|
|
11
|
+
* supervised hub's own vault child then can't bind → EADDRINUSE crash-loop →
|
|
12
|
+
* `crashed`, giving up. Killing the squatting PROCESS is whack-a-mole: the unit's
|
|
13
|
+
* KeepAlive / Restart=always resurrects it within seconds, serving OLD code.
|
|
14
|
+
*
|
|
15
|
+
* THE FIX (the load-bearing half of #522): the cutover must DISABLE THE UNIT, not
|
|
16
|
+
* just kill the process. Disabling deregisters the keep-alive intent so the
|
|
17
|
+
* module stays down and the supervised hub owns the port. The complementary half
|
|
18
|
+
* — the supervisor reclaiming its own port on EADDRINUSE at every start — is a
|
|
19
|
+
* separate follow-on; THIS module is the unit-disable that stops the respawn at
|
|
20
|
+
* the source.
|
|
21
|
+
*
|
|
22
|
+
* SCOPE + OWNERSHIP SAFETY (the hard constraint): we ONLY ever disable a unit
|
|
23
|
+
* whose name EXACTLY matches `parachute-<short>.service` (systemd) or
|
|
24
|
+
* `computer.parachute.<short>` (launchd) for a KNOWN module short
|
|
25
|
+
* (`knownServices()` — vault / scribe / runner / surface / notes / channel). We
|
|
26
|
+
* NEVER disable an arbitrary or unrecognized unit — an unknown unit is invisible
|
|
27
|
+
* to this sweep by construction (we look up exact names, never enumerate-and-
|
|
28
|
+
* match-loosely). On top of that we EXPLICITLY exclude the units the supervised
|
|
29
|
+
* model legitimately owns:
|
|
30
|
+
* - the hub unit (`computer.parachute.hub` / `parachute-hub.service`), and
|
|
31
|
+
* - the cloudflared connector (`computer.parachute.cloudflared.*` /
|
|
32
|
+
* `parachute-cloudflared-*`, owned by `expose off --cloudflare`).
|
|
33
|
+
* The skip-list reuses the canonical name constants (HUB_* + the cloudflared
|
|
34
|
+
* prefixes) so it can't drift.
|
|
35
|
+
*
|
|
36
|
+
* BEHAVIOR per platform (reuses the `ManagedUnitDeps` seam — `which` / `run`):
|
|
37
|
+
* - systemd (Linux): for each known short, query the USER unit
|
|
38
|
+
* `systemctl --user is-enabled parachute-<short>.service`. If it reads
|
|
39
|
+
* enabled (`enabled` / `enabled-runtime` / `static` / `alias`/`indirect`-ish)
|
|
40
|
+
* → `systemctl --user disable --now parachute-<short>.service`. A SYSTEM-level
|
|
41
|
+
* unit of the same name (detected via `systemctl is-enabled` without --user)
|
|
42
|
+
* is NOT touched (migrate has no sudo) — we WARN with the exact manual
|
|
43
|
+
* `sudo systemctl disable --now …` command instead.
|
|
44
|
+
* - launchd (Mac): for each known short, `launchctl print
|
|
45
|
+
* gui/<uid>/computer.parachute.<short>`; if the label is loaded → `launchctl
|
|
46
|
+
* bootout gui/<uid>/computer.parachute.<short>`.
|
|
47
|
+
*
|
|
48
|
+
* IDEMPOTENT: a unit that's already disabled / not-enabled / absent is a clean
|
|
49
|
+
* no-op (we never report disabling it). NON-FATAL: a disable that fails (perms,
|
|
50
|
+
* launchctl quirk) WARNS + continues — it never aborts the cutover. EVERYTHING
|
|
51
|
+
* behind the injectable `ManagedUnitDeps` seam so tests never touch real
|
|
52
|
+
* systemctl/launchctl.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
import {
|
|
56
|
+
CLOUDFLARED_LAUNCHD_LABEL_PREFIX,
|
|
57
|
+
CLOUDFLARED_SYSTEMD_UNIT_PREFIX,
|
|
58
|
+
} from "./cloudflare/connector-service.ts";
|
|
59
|
+
import {
|
|
60
|
+
HUB_LAUNCHD_LABEL,
|
|
61
|
+
HUB_SYSTEMD_UNIT_NAME,
|
|
62
|
+
type ManagedUnitDeps,
|
|
63
|
+
defaultManagedUnitDeps,
|
|
64
|
+
} from "./managed-unit.ts";
|
|
65
|
+
import { knownServices } from "./service-spec.ts";
|
|
66
|
+
|
|
67
|
+
/** systemd unit name for a module short, e.g. `vault` → `parachute-vault.service`. */
|
|
68
|
+
export function moduleSystemdUnitName(short: string): string {
|
|
69
|
+
return `parachute-${short}.service`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** launchd label for a module short, e.g. `vault` → `computer.parachute.vault`. */
|
|
73
|
+
export function moduleLaunchdLabel(short: string): string {
|
|
74
|
+
return `computer.parachute.${short}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Is this systemd unit name one the supervised model legitimately owns (and the
|
|
79
|
+
* sweep must therefore NEVER disable)? The hub unit + any cloudflared connector
|
|
80
|
+
* unit. Reuses the canonical name constants so the skip can't drift.
|
|
81
|
+
*/
|
|
82
|
+
function isProtectedSystemdUnit(unitName: string): boolean {
|
|
83
|
+
return unitName === HUB_SYSTEMD_UNIT_NAME || unitName.startsWith(CLOUDFLARED_SYSTEMD_UNIT_PREFIX);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Is this launchd label one the supervised model legitimately owns? The hub
|
|
88
|
+
* label + any cloudflared connector label (`computer.parachute.cloudflared.*`).
|
|
89
|
+
*/
|
|
90
|
+
function isProtectedLaunchdLabel(label: string): boolean {
|
|
91
|
+
return (
|
|
92
|
+
label === HUB_LAUNCHD_LABEL ||
|
|
93
|
+
label === CLOUDFLARED_LAUNCHD_LABEL_PREFIX ||
|
|
94
|
+
label.startsWith(`${CLOUDFLARED_LAUNCHD_LABEL_PREFIX}.`)
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The module shorts whose stale standalone autostart units the sweep targets.
|
|
100
|
+
* Derived from `knownServices()` (the canonical FIRST_PARTY_FALLBACKS +
|
|
101
|
+
* KNOWN_MODULES list — vault / scribe / runner / surface / notes / channel), so
|
|
102
|
+
* a future module is covered automatically. `hub` is deliberately NOT in that
|
|
103
|
+
* list — the hub unit is the supervised model itself; we never disable it. As a
|
|
104
|
+
* defensive double-check we also drop any short whose derived unit name lands in
|
|
105
|
+
* the protected skip-list (so the sweep can never disable the hub / cloudflared
|
|
106
|
+
* even if a future short collided).
|
|
107
|
+
*/
|
|
108
|
+
export function targetModuleShorts(): string[] {
|
|
109
|
+
return knownServices().filter(
|
|
110
|
+
(short) =>
|
|
111
|
+
!isProtectedSystemdUnit(moduleSystemdUnitName(short)) &&
|
|
112
|
+
!isProtectedLaunchdLabel(moduleLaunchdLabel(short)),
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* systemd `is-enabled` tokens that mean "this unit will autostart" — i.e. the
|
|
118
|
+
* stale-unit problem we're disabling. `disabled` / `masked` / `not-found` (and a
|
|
119
|
+
* nonzero exit with empty stdout) mean it won't, so they're a no-op.
|
|
120
|
+
*
|
|
121
|
+
* `static` and `indirect` units have no [Install] section / are pulled in by
|
|
122
|
+
* another unit; a standalone leftover `parachute-vault.service` written by the
|
|
123
|
+
* old per-module autostall path always carried `[Install] WantedBy=…` so reads
|
|
124
|
+
* `enabled` — but we treat `static`/`indirect` as "present + active intent" too
|
|
125
|
+
* so an oddly-written leftover still gets cleaned. `linked`/`generated` likewise.
|
|
126
|
+
*/
|
|
127
|
+
const SYSTEMD_ENABLED_TOKENS = new Set([
|
|
128
|
+
"enabled",
|
|
129
|
+
"enabled-runtime",
|
|
130
|
+
"static",
|
|
131
|
+
"indirect",
|
|
132
|
+
"linked",
|
|
133
|
+
"linked-runtime",
|
|
134
|
+
"generated",
|
|
135
|
+
"alias",
|
|
136
|
+
]);
|
|
137
|
+
|
|
138
|
+
/** Outcome of one unit's detect-and-disable attempt. */
|
|
139
|
+
export interface StaleUnitAction {
|
|
140
|
+
/** The module short the unit belongs to. */
|
|
141
|
+
short: string;
|
|
142
|
+
/** "launchd" | "systemd-user" | "systemd-system". */
|
|
143
|
+
kind: "launchd" | "systemd-user" | "systemd-system";
|
|
144
|
+
/** The unit/label name acted on. */
|
|
145
|
+
unit: string;
|
|
146
|
+
/**
|
|
147
|
+
* "disabled" → we disabled it (report it; the operator sees what changed).
|
|
148
|
+
* "warn-system" → a system-level systemd unit we can't disable without sudo;
|
|
149
|
+
* we warn with the manual command. Non-fatal.
|
|
150
|
+
* "failed" → the disable command failed (perms/quirk); we warn + continue.
|
|
151
|
+
*/
|
|
152
|
+
result: "disabled" | "warn-system" | "failed";
|
|
153
|
+
/** The exact line(s) the caller should surface (report / warning). */
|
|
154
|
+
messages: string[];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface DisableStaleModuleUnitsOpts {
|
|
158
|
+
/** Injectable platform deps (defaults to production). */
|
|
159
|
+
deps?: ManagedUnitDeps;
|
|
160
|
+
/** Sink for human-readable report / warning lines. */
|
|
161
|
+
log?: (line: string) => void;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface DisableStaleModuleUnitsResult {
|
|
165
|
+
/** Every unit we acted on (disabled / warned / failed). Empty = clean no-op. */
|
|
166
|
+
actions: StaleUnitAction[];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Detect + disable any STALE per-module autostart unit on this platform (#522).
|
|
171
|
+
* Idempotent + non-fatal: already-disabled/absent units are silent no-ops, and a
|
|
172
|
+
* failed disable warns + continues. Returns the list of actions taken; the caller
|
|
173
|
+
* surfaces the messages (the cutover threads them through its own `log`).
|
|
174
|
+
*
|
|
175
|
+
* Dispatch mirrors `managed-unit.ts`: darwin → launchctl, linux → systemctl.
|
|
176
|
+
* Other platforms (no per-module unit possible) → empty no-op.
|
|
177
|
+
*/
|
|
178
|
+
export function disableStaleModuleUnits(
|
|
179
|
+
opts: DisableStaleModuleUnitsOpts = {},
|
|
180
|
+
): DisableStaleModuleUnitsResult {
|
|
181
|
+
const deps = opts.deps ?? defaultManagedUnitDeps;
|
|
182
|
+
const log = opts.log ?? (() => {});
|
|
183
|
+
const actions: StaleUnitAction[] = [];
|
|
184
|
+
|
|
185
|
+
const record = (action: StaleUnitAction): void => {
|
|
186
|
+
actions.push(action);
|
|
187
|
+
for (const m of action.messages) log(m);
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
if (deps.platform === "darwin") {
|
|
191
|
+
if (deps.which("launchctl") === null) return { actions };
|
|
192
|
+
const uid = deps.getuid() ?? 0;
|
|
193
|
+
for (const short of targetModuleShorts()) {
|
|
194
|
+
const label = moduleLaunchdLabel(short);
|
|
195
|
+
// Belt-and-suspenders: never touch a protected (hub / cloudflared) label.
|
|
196
|
+
if (isProtectedLaunchdLabel(label)) continue;
|
|
197
|
+
const action = disableStaleLaunchdUnit(short, label, uid, deps);
|
|
198
|
+
if (action) record(action);
|
|
199
|
+
}
|
|
200
|
+
return { actions };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (deps.platform === "linux") {
|
|
204
|
+
if (deps.which("systemctl") === null) return { actions };
|
|
205
|
+
for (const short of targetModuleShorts()) {
|
|
206
|
+
const unit = moduleSystemdUnitName(short);
|
|
207
|
+
if (isProtectedSystemdUnit(unit)) continue;
|
|
208
|
+
const action = disableStaleSystemdUnit(short, unit, deps);
|
|
209
|
+
if (action) record(action);
|
|
210
|
+
}
|
|
211
|
+
return { actions };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// No per-platform manager (container / init-less / Windows) → nothing to do.
|
|
215
|
+
return { actions };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* launchd arm: probe `launchctl print gui/<uid>/<label>`. The label is LOADED
|
|
220
|
+
* (a stale KeepAlive LaunchAgent) when the print succeeds with non-empty output;
|
|
221
|
+
* we then `launchctl bootout` it (unload + stop → KeepAlive can't resurrect it).
|
|
222
|
+
* An unloaded/absent label prints empty/nonzero → clean no-op (returns undefined).
|
|
223
|
+
*/
|
|
224
|
+
function disableStaleLaunchdUnit(
|
|
225
|
+
short: string,
|
|
226
|
+
label: string,
|
|
227
|
+
uid: number,
|
|
228
|
+
deps: ManagedUnitDeps,
|
|
229
|
+
): StaleUnitAction | undefined {
|
|
230
|
+
let printed: { code: number; stdout: string; stderr: string };
|
|
231
|
+
try {
|
|
232
|
+
printed = deps.run(["launchctl", "print", `gui/${uid}/${label}`]);
|
|
233
|
+
} catch {
|
|
234
|
+
// launchctl threw (ENOENT between which() and run, or a quirk) — non-fatal.
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
|
237
|
+
// Not loaded → nothing to disable. `launchctl print` is nonzero + empty when
|
|
238
|
+
// the label isn't bootstrapped.
|
|
239
|
+
if (printed.stdout.trim().length === 0) return undefined;
|
|
240
|
+
|
|
241
|
+
let booted: { code: number; stdout: string; stderr: string };
|
|
242
|
+
try {
|
|
243
|
+
booted = deps.run(["launchctl", "bootout", `gui/${uid}/${label}`]);
|
|
244
|
+
} catch (err) {
|
|
245
|
+
return {
|
|
246
|
+
short,
|
|
247
|
+
kind: "launchd",
|
|
248
|
+
unit: label,
|
|
249
|
+
result: "failed",
|
|
250
|
+
messages: [
|
|
251
|
+
` ⚠ Could not disable the stale LaunchAgent ${label} (${err instanceof Error ? err.message : String(err)}).`,
|
|
252
|
+
` Run it yourself: launchctl bootout gui/${uid}/${label}`,
|
|
253
|
+
],
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
if (booted.code !== 0) {
|
|
257
|
+
const detail = booted.stderr.trim() || booted.stdout.trim() || "unknown error";
|
|
258
|
+
return {
|
|
259
|
+
short,
|
|
260
|
+
kind: "launchd",
|
|
261
|
+
unit: label,
|
|
262
|
+
result: "failed",
|
|
263
|
+
messages: [
|
|
264
|
+
` ⚠ Could not disable the stale LaunchAgent ${label} (${detail}).`,
|
|
265
|
+
` Run it yourself: launchctl bootout gui/${uid}/${label}`,
|
|
266
|
+
],
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
return {
|
|
270
|
+
short,
|
|
271
|
+
kind: "launchd",
|
|
272
|
+
unit: label,
|
|
273
|
+
result: "disabled",
|
|
274
|
+
messages: [
|
|
275
|
+
` ✓ Disabled stale ${label} (it was fighting the supervised hub for ${short}'s port).`,
|
|
276
|
+
],
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* systemd arm: a stale standalone module unit can live at USER scope (the common
|
|
282
|
+
* pre-supervisor leftover, no sudo to write) or SYSTEM scope (rarer). We probe
|
|
283
|
+
* both:
|
|
284
|
+
* - USER (`systemctl --user is-enabled <unit>`): if enabled → `--user disable
|
|
285
|
+
* --now`. This is the path migrate can actually fix.
|
|
286
|
+
* - SYSTEM (`systemctl is-enabled <unit>`): if enabled but USER wasn't → migrate
|
|
287
|
+
* has no sudo, so WARN with the exact `sudo systemctl disable --now …` command
|
|
288
|
+
* (never attempt sudo).
|
|
289
|
+
* An absent/disabled unit at both scopes → clean no-op (returns undefined).
|
|
290
|
+
*/
|
|
291
|
+
function disableStaleSystemdUnit(
|
|
292
|
+
short: string,
|
|
293
|
+
unit: string,
|
|
294
|
+
deps: ManagedUnitDeps,
|
|
295
|
+
): StaleUnitAction | undefined {
|
|
296
|
+
// --- USER scope first (what migrate can actually disable). ---
|
|
297
|
+
if (systemdUnitEnabled(unit, ["--user"], deps)) {
|
|
298
|
+
let res: { code: number; stdout: string; stderr: string };
|
|
299
|
+
try {
|
|
300
|
+
res = deps.run(["systemctl", "--user", "disable", "--now", unit]);
|
|
301
|
+
} catch (err) {
|
|
302
|
+
return {
|
|
303
|
+
short,
|
|
304
|
+
kind: "systemd-user",
|
|
305
|
+
unit,
|
|
306
|
+
result: "failed",
|
|
307
|
+
messages: [
|
|
308
|
+
` ⚠ Could not disable the stale user unit ${unit} (${err instanceof Error ? err.message : String(err)}).`,
|
|
309
|
+
` Run it yourself: systemctl --user disable --now ${unit}`,
|
|
310
|
+
],
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
if (res.code !== 0) {
|
|
314
|
+
const detail = res.stderr.trim() || res.stdout.trim() || "unknown error";
|
|
315
|
+
return {
|
|
316
|
+
short,
|
|
317
|
+
kind: "systemd-user",
|
|
318
|
+
unit,
|
|
319
|
+
result: "failed",
|
|
320
|
+
messages: [
|
|
321
|
+
` ⚠ Could not disable the stale user unit ${unit} (${detail}).`,
|
|
322
|
+
` Run it yourself: systemctl --user disable --now ${unit}`,
|
|
323
|
+
],
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
return {
|
|
327
|
+
short,
|
|
328
|
+
kind: "systemd-user",
|
|
329
|
+
unit,
|
|
330
|
+
result: "disabled",
|
|
331
|
+
messages: [
|
|
332
|
+
` ✓ Disabled stale ${unit} (it was fighting the supervised hub for ${short}'s port).`,
|
|
333
|
+
],
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// --- SYSTEM scope: detect-only + warn (no sudo in migrate). ---
|
|
338
|
+
if (systemdUnitEnabled(unit, [], deps)) {
|
|
339
|
+
return {
|
|
340
|
+
short,
|
|
341
|
+
kind: "systemd-system",
|
|
342
|
+
unit,
|
|
343
|
+
result: "warn-system",
|
|
344
|
+
messages: [
|
|
345
|
+
` ⚠ A SYSTEM-level ${unit} is enabled and may fight the supervised hub for ${short}'s port.`,
|
|
346
|
+
" Migrate can't disable a system unit (it needs root). Disable it yourself:",
|
|
347
|
+
` sudo systemctl disable --now ${unit}`,
|
|
348
|
+
],
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return undefined;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* `systemctl [--user] is-enabled <unit>` → true iff the printed token means the
|
|
357
|
+
* unit will autostart (see `SYSTEMD_ENABLED_TOKENS`). `is-enabled` exits nonzero
|
|
358
|
+
* for non-enabled states and prints the token to stdout regardless of exit, so
|
|
359
|
+
* we classify from the stdout token. A throw (ENOENT/quirk) → treated as
|
|
360
|
+
* not-enabled (non-fatal; the sweep continues).
|
|
361
|
+
*/
|
|
362
|
+
function systemdUnitEnabled(unit: string, scope: string[], deps: ManagedUnitDeps): boolean {
|
|
363
|
+
let res: { code: number; stdout: string; stderr: string };
|
|
364
|
+
try {
|
|
365
|
+
res = deps.run(["systemctl", ...scope, "is-enabled", unit]);
|
|
366
|
+
} catch {
|
|
367
|
+
return false;
|
|
368
|
+
}
|
|
369
|
+
const token = res.stdout.trim() || res.stderr.trim();
|
|
370
|
+
if (token.length === 0) return false;
|
|
371
|
+
// `is-enabled` can print the token then a hint on a second line; read line 1.
|
|
372
|
+
const first = token.split("\n")[0]?.trim() ?? "";
|
|
373
|
+
return SYSTEMD_ENABLED_TOKENS.has(first);
|
|
374
|
+
}
|