@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
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loopback TCP-port readiness probe — the tiny "is something listening on
|
|
3
|
+
* 127.0.0.1:<port>?" primitive shared by the detached `commands/lifecycle.ts`
|
|
4
|
+
* start path and the in-process `supervisor.ts` (design 2026-06-01 §6.5).
|
|
5
|
+
*
|
|
6
|
+
* Factored out of `lifecycle.ts` so the supervisor can reach the probe without
|
|
7
|
+
* importing all of lifecycle's heavy graph (hub-db, operator-token,
|
|
8
|
+
* services-manifest, …) into a module that hub-server / proxy-state / the
|
|
9
|
+
* module-ops API all depend on. `lifecycle.ts` re-exports `defaultPortListening`
|
|
10
|
+
* + `PortListeningFn` so its public API is unchanged; both files share THIS
|
|
11
|
+
* one implementation, so they can't drift.
|
|
12
|
+
*
|
|
13
|
+
* `node:net` rather than `Bun.connect` because the latter has no clean
|
|
14
|
+
* "connection refused → false" without a custom socket handler, and the net
|
|
15
|
+
* Socket's `error`/`connect` events map directly onto the boolean we want.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { Socket } from "node:net";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* "Is something listening on this TCP port on loopback?" seam. Pairs with the
|
|
22
|
+
* spawn-then-die settle to catch the alive-but-never-bound failure shape
|
|
23
|
+
* (hub#487): a service that lives long enough to clear a liveness check but
|
|
24
|
+
* never binds its port (port already held by an orphan / a bun-linked
|
|
25
|
+
* resolution failure that lingers). Tests inject a deterministic stub;
|
|
26
|
+
* production uses {@link defaultPortListening}.
|
|
27
|
+
*/
|
|
28
|
+
export type PortListeningFn = (port: number) => Promise<boolean>;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Connect-probe: open a TCP socket to 127.0.0.1:<port> and see if it's
|
|
32
|
+
* accepted. A successful connect means *something* is listening; we close
|
|
33
|
+
* immediately. Connection refused / timeout means nothing is bound yet.
|
|
34
|
+
*/
|
|
35
|
+
export const defaultPortListening: PortListeningFn = (port) =>
|
|
36
|
+
new Promise((resolve) => {
|
|
37
|
+
const socket = new Socket();
|
|
38
|
+
let settled = false;
|
|
39
|
+
const done = (listening: boolean) => {
|
|
40
|
+
if (settled) return;
|
|
41
|
+
settled = true;
|
|
42
|
+
socket.destroy();
|
|
43
|
+
resolve(listening);
|
|
44
|
+
};
|
|
45
|
+
socket.setTimeout(1000);
|
|
46
|
+
socket.once("connect", () => done(true));
|
|
47
|
+
socket.once("timeout", () => done(false));
|
|
48
|
+
socket.once("error", () => done(false));
|
|
49
|
+
socket.connect(port, "127.0.0.1");
|
|
50
|
+
});
|
package/src/process-state.ts
CHANGED
|
@@ -11,6 +11,13 @@ import { CONFIG_DIR } from "./config.ts";
|
|
|
11
11
|
* `pid file present` + `process.kill(pid, 0)` succeeds. A stale PID file
|
|
12
12
|
* (process died without cleanup) reads as stopped; writers of the PID
|
|
13
13
|
* file own removing it on clean shutdown.
|
|
14
|
+
*
|
|
15
|
+
* Phase 5b retired the detached module/hub spawners that *wrote* per-service
|
|
16
|
+
* pidfiles. The pidfile READERS (`readPid` / `processState`) are deliberately
|
|
17
|
+
* kept (design §7.5) so the migrate detector (`hasPriorDetachedInstall`) can
|
|
18
|
+
* still see a prior detached install for one release. `writePid` / `clearPid`
|
|
19
|
+
* remain too — `serve` (hub-server.ts) writes its own `hub` pidfile so
|
|
20
|
+
* `parachute stop hub` / `migrate` can find a serve-mode hub.
|
|
14
21
|
*/
|
|
15
22
|
|
|
16
23
|
export function serviceDir(svc: string, configDir: string = CONFIG_DIR): string {
|
|
@@ -70,12 +77,21 @@ export const defaultAlive: AliveFn = (pid: number) => {
|
|
|
70
77
|
};
|
|
71
78
|
|
|
72
79
|
/**
|
|
73
|
-
* Three-state
|
|
80
|
+
* Three-state, kept for legacy detection only.
|
|
81
|
+
*
|
|
82
|
+
* Phase 5b retired the detached spawners that wrote per-service pidfiles, so in
|
|
83
|
+
* the steady (supervised) state no module has a pidfile and module run-state
|
|
84
|
+
* comes from the supervisor (`supervisor.list()`), not from here. This reader
|
|
85
|
+
* survives so the `migrate` detector (`hasPriorDetachedInstall`) can still
|
|
86
|
+
* recognize a pre-cutover box, and so `serve` / `parachute stop hub` can find a
|
|
87
|
+
* serve-mode hub's own `hub` pidfile.
|
|
74
88
|
*
|
|
75
89
|
* - `running` — PID file present, `kill(pid, 0)` succeeds.
|
|
76
90
|
* - `stopped` — PID file present, process gone (stale pidfile, or cleanly shut down).
|
|
77
|
-
* - `unknown` — no PID file.
|
|
78
|
-
*
|
|
91
|
+
* - `unknown` — no PID file. In a supervised install this is the normal case
|
|
92
|
+
* for modules (no pidfile is written); the "externally-managed / legacy
|
|
93
|
+
* launchd-era" reading is now purely about detecting a *prior detached*
|
|
94
|
+
* install, not a live signal. Don't claim stopped.
|
|
79
95
|
*/
|
|
80
96
|
export interface ProcessState {
|
|
81
97
|
status: "running" | "stopped" | "unknown";
|
package/src/setup-wizard.ts
CHANGED
|
@@ -67,7 +67,13 @@ import {
|
|
|
67
67
|
} from "./hub-settings.ts";
|
|
68
68
|
import { signAccessToken } from "./jwt-sign.ts";
|
|
69
69
|
import { escapeHtml } from "./oauth-ui.ts";
|
|
70
|
-
import {
|
|
70
|
+
import {
|
|
71
|
+
type IssueOperatorTokenResult,
|
|
72
|
+
type MintOperatorTokenOpts,
|
|
73
|
+
issueOperatorToken,
|
|
74
|
+
mintOperatorToken,
|
|
75
|
+
readOperatorTokenFile,
|
|
76
|
+
} from "./operator-token.ts";
|
|
71
77
|
import { isHttpsRequest } from "./request-protocol.ts";
|
|
72
78
|
import { findService, readManifestLenient } from "./services-manifest.ts";
|
|
73
79
|
import {
|
|
@@ -377,6 +383,22 @@ export interface SetupWizardDeps {
|
|
|
377
383
|
* `readExposeStateFn` seam.
|
|
378
384
|
*/
|
|
379
385
|
readExposeStateFn?: () => ExposeState | undefined;
|
|
386
|
+
/**
|
|
387
|
+
* Test seam for the fresh-box operator-token closure (design §3.1 /
|
|
388
|
+
* Phase 3b Deliverable A). After the wizard creates the first admin, it
|
|
389
|
+
* persists `~/.parachute/operator.token` so the box has a CLI operator
|
|
390
|
+
* credential the moment it gains an admin — without it, the Phase 3b
|
|
391
|
+
* per-module verbs (`parachute start/stop/restart <svc>` driving the
|
|
392
|
+
* supervisor) would 401 on a freshly-bootstrapped box. Production omits
|
|
393
|
+
* this and uses the real {@link issueOperatorToken}; tests inject a stub
|
|
394
|
+
* to assert the call (or to make it throw and prove a token-write failure
|
|
395
|
+
* never fails account creation).
|
|
396
|
+
*/
|
|
397
|
+
issueOperatorToken?: (
|
|
398
|
+
db: Database,
|
|
399
|
+
userId: string,
|
|
400
|
+
opts: MintOperatorTokenOpts & { dir?: string },
|
|
401
|
+
) => Promise<IssueOperatorTokenResult>;
|
|
380
402
|
}
|
|
381
403
|
|
|
382
404
|
/**
|
|
@@ -1888,6 +1910,16 @@ export async function handleSetupAccountPost(
|
|
|
1888
1910
|
// any racer who saw it over the operator's shoulder during the
|
|
1889
1911
|
// window between log-print and form-submit.
|
|
1890
1912
|
if (requireToken) consumeBootstrapToken();
|
|
1913
|
+
// Fresh-box operator-token closure (design §3.1 / Phase 3b Deliverable A).
|
|
1914
|
+
// The box now has its first admin — persist `operator.token` so it has a
|
|
1915
|
+
// CLI operator credential immediately. Without it, the Phase 3b per-module
|
|
1916
|
+
// verbs (start/stop/restart <svc> driving the supervisor over the
|
|
1917
|
+
// module-ops API) would 401 on a box bootstrapped purely through the
|
|
1918
|
+
// wizard. Runs AFTER the admin row + bootstrap-token are committed so a
|
|
1919
|
+
// half-written admin never gains a token; guarded so an existing token is
|
|
1920
|
+
// never clobbered; wrapped so a token-write failure NEVER fails the
|
|
1921
|
+
// account creation the operator just completed.
|
|
1922
|
+
await ensureOperatorTokenForFirstAdmin(deps, user.id);
|
|
1891
1923
|
const session = createSession(deps.db, { userId: user.id });
|
|
1892
1924
|
const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000), {
|
|
1893
1925
|
secure: isHttpsRequest(req),
|
|
@@ -1927,6 +1959,53 @@ export async function handleSetupAccountPost(
|
|
|
1927
1959
|
}
|
|
1928
1960
|
}
|
|
1929
1961
|
|
|
1962
|
+
/**
|
|
1963
|
+
* Persist `~/.parachute/operator.token` for the just-created first admin
|
|
1964
|
+
* (design §3.1 / Phase 3b Deliverable A). The 3a reviewer flagged that a fresh
|
|
1965
|
+
* `init`→wizard flow ends with NO operator token on disk, so the Phase 3b
|
|
1966
|
+
* per-module verbs — `parachute start/stop/restart <svc>`, which now drive the
|
|
1967
|
+
* supervisor over the host-admin-gated module-ops API — would 401 on such a
|
|
1968
|
+
* box. Minting the token here makes the box have a CLI operator credential the
|
|
1969
|
+
* moment it gains an admin.
|
|
1970
|
+
*
|
|
1971
|
+
* Three invariants:
|
|
1972
|
+
* - Mints under the `admin` scope-set (the default), which carries
|
|
1973
|
+
* `parachute:host:admin` — exactly the scope `api-modules-ops.ts` gates on.
|
|
1974
|
+
* `issueOperatorToken` writes it 0600 (`writeOperatorTokenFile`).
|
|
1975
|
+
* - Guarded by `readOperatorTokenFile() === null`: never clobber a token an
|
|
1976
|
+
* operator already minted (`auth set-password` / `rotate-operator`, or a
|
|
1977
|
+
* prior init).
|
|
1978
|
+
* - Wrapped in try/catch so a token-write failure NEVER fails the account
|
|
1979
|
+
* creation the operator just completed — they have an admin row + session
|
|
1980
|
+
* either way, and `parachute auth rotate-operator` is the documented
|
|
1981
|
+
* recovery for a missing token.
|
|
1982
|
+
*
|
|
1983
|
+
* Uses `deps.issuer` as the `iss` claim — the same pre-resolved origin the rest
|
|
1984
|
+
* of the wizard's mints use (`handleSetupExposePost`). The hub-server derives
|
|
1985
|
+
* that origin the same way `commands/auth.ts:resolveHubIssuer` does — semantically
|
|
1986
|
+
* equivalent, structurally different: this path takes a pre-resolved `deps.issuer`
|
|
1987
|
+
* while `auth.ts` reads expose-state inline at call time. `start hub` self-heals a
|
|
1988
|
+
* stale `iss` later if the box is exposed after init (hub#481), so an
|
|
1989
|
+
* init-at-loopback mint is correct here.
|
|
1990
|
+
*/
|
|
1991
|
+
async function ensureOperatorTokenForFirstAdmin(
|
|
1992
|
+
deps: SetupWizardDeps,
|
|
1993
|
+
userId: string,
|
|
1994
|
+
): Promise<void> {
|
|
1995
|
+
const issue = deps.issueOperatorToken ?? issueOperatorToken;
|
|
1996
|
+
try {
|
|
1997
|
+
const existing = await readOperatorTokenFile(deps.configDir);
|
|
1998
|
+
if (existing !== null) return;
|
|
1999
|
+
await issue(deps.db, userId, { issuer: deps.issuer, dir: deps.configDir });
|
|
2000
|
+
} catch (err) {
|
|
2001
|
+
// Non-fatal: the admin + session were already committed. Log for the
|
|
2002
|
+
// operator's debugging; they can recover with `parachute auth
|
|
2003
|
+
// rotate-operator` from a shell on the box.
|
|
2004
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2005
|
+
console.warn(`[setup-wizard] operator-token closure skipped for new admin: ${msg}`);
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
|
|
1930
2009
|
/**
|
|
1931
2010
|
* Static error page surfaced when an `/admin/setup/account` POST arrives
|
|
1932
2011
|
* after the bootstrap token has already been consumed by a successful
|