@openparachute/hub 0.7.3 → 0.7.4-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/package.json +1 -1
- package/src/__tests__/cli.test.ts +22 -0
- package/src/__tests__/init.test.ts +354 -1
- package/src/__tests__/status-supervisor.test.ts +152 -3
- package/src/cli.ts +27 -5
- package/src/commands/init.ts +116 -37
- package/src/commands/status.ts +108 -5
- package/src/help.ts +6 -0
package/package.json
CHANGED
|
@@ -103,6 +103,28 @@ describe("cli", () => {
|
|
|
103
103
|
expect(code).toBe(1);
|
|
104
104
|
expect(stderr).toMatch(/invalid --hub-origin/);
|
|
105
105
|
});
|
|
106
|
+
|
|
107
|
+
// hub#694 bug 2: `init --channel` mirrors `install --channel` — rejected at
|
|
108
|
+
// the arg layer (exit 1) before any daemon work, so these are hermetic.
|
|
109
|
+
test("init --channel without a value exits 1 (hub#694)", async () => {
|
|
110
|
+
const { code, stderr } = await runCli(["init", "--channel"]);
|
|
111
|
+
expect(code).toBe(1);
|
|
112
|
+
expect(stderr).toMatch(/--channel requires a value/);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("init --channel with an invalid value exits 1 (hub#694)", async () => {
|
|
116
|
+
const { code, stderr } = await runCli(["init", "--channel", "banana"]);
|
|
117
|
+
expect(code).toBe(1);
|
|
118
|
+
expect(stderr).toMatch(/--channel must be "rc" or "latest"/);
|
|
119
|
+
expect(stderr).toMatch(/banana/);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("init --help documents --channel (hub#694)", async () => {
|
|
123
|
+
const { code, stdout } = await runCli(["init", "--help"]);
|
|
124
|
+
expect(code).toBe(0);
|
|
125
|
+
expect(stdout).toMatch(/--channel/);
|
|
126
|
+
expect(stdout).toMatch(/rc\|latest/);
|
|
127
|
+
});
|
|
106
128
|
});
|
|
107
129
|
|
|
108
130
|
describe("cli per-subcommand help", () => {
|
|
@@ -2,7 +2,13 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
hasNoDisplay,
|
|
7
|
+
init,
|
|
8
|
+
looksLikeServer,
|
|
9
|
+
resolveAdminUrl,
|
|
10
|
+
resolveInitChannel,
|
|
11
|
+
} from "../commands/init.ts";
|
|
6
12
|
import type { ExposeState } from "../expose-state.ts";
|
|
7
13
|
import { writeHubPort } from "../hub-control.ts";
|
|
8
14
|
import { writePid } from "../process-state.ts";
|
|
@@ -1865,5 +1871,352 @@ describe("init exposure chain", () => {
|
|
|
1865
1871
|
});
|
|
1866
1872
|
});
|
|
1867
1873
|
|
|
1874
|
+
// ---------------------------------------------------------------------------
|
|
1875
|
+
// hub#694 — first-run robustness.
|
|
1876
|
+
//
|
|
1877
|
+
// Bug 1 (the spawn race): the supervisor scans services.json EXACTLY ONCE at
|
|
1878
|
+
// hub-unit boot. So the vault module MUST be seeded into services.json BEFORE
|
|
1879
|
+
// the hub unit starts (ensureHub) — otherwise the boot scan finds no vault row
|
|
1880
|
+
// and vault never spawns until a manual `parachute restart`. These tests pin the
|
|
1881
|
+
// ordering: vault-install precedes ensureHub on both the laptop (no --hub-origin)
|
|
1882
|
+
// and the DO (--hub-origin) path, and Step-0 origin-persist stays first.
|
|
1883
|
+
//
|
|
1884
|
+
// Bug 2 (channel): init must install the vault module from the chosen channel
|
|
1885
|
+
// (`--channel rc` / PARACHUTE_CHANNEL=rc), not always @latest — otherwise an rc
|
|
1886
|
+
// box downgrades vault below the rc-tracking hub.
|
|
1887
|
+
// ---------------------------------------------------------------------------
|
|
1888
|
+
|
|
1889
|
+
describe("init hub#694 — vault seeded before the supervisor boot scan (bug 1)", () => {
|
|
1890
|
+
test("laptop (no --hub-origin): installs+seeds vault BEFORE ensureHub", async () => {
|
|
1891
|
+
const h = makeHarness();
|
|
1892
|
+
try {
|
|
1893
|
+
const order: string[] = [];
|
|
1894
|
+
const code = await init({
|
|
1895
|
+
configDir: h.configDir,
|
|
1896
|
+
ensureHubVersion: async () => ({
|
|
1897
|
+
outcome: "match" as const,
|
|
1898
|
+
installedVersion: "test",
|
|
1899
|
+
messages: [],
|
|
1900
|
+
}),
|
|
1901
|
+
manifestPath: h.manifestPath,
|
|
1902
|
+
log: () => {},
|
|
1903
|
+
alive: () => false,
|
|
1904
|
+
// The load-bearing assertion: the vault module install (which seeds the
|
|
1905
|
+
// services.json row) MUST happen before the hub unit boots, so the
|
|
1906
|
+
// supervisor's one-time boot scan finds + spawns vault on the first pass.
|
|
1907
|
+
installVaultModuleImpl: async () => {
|
|
1908
|
+
order.push("install-vault");
|
|
1909
|
+
return 0;
|
|
1910
|
+
},
|
|
1911
|
+
ensureHub: async () => {
|
|
1912
|
+
order.push("ensureHub");
|
|
1913
|
+
writeHubPort(1939, h.configDir);
|
|
1914
|
+
return { pid: 0, port: 1939, started: true };
|
|
1915
|
+
},
|
|
1916
|
+
readExposeStateFn: () => undefined,
|
|
1917
|
+
isTty: false,
|
|
1918
|
+
platform: "linux",
|
|
1919
|
+
});
|
|
1920
|
+
expect(code).toBe(0);
|
|
1921
|
+
expect(order).toEqual(["install-vault", "ensureHub"]);
|
|
1922
|
+
} finally {
|
|
1923
|
+
h.cleanup();
|
|
1924
|
+
}
|
|
1925
|
+
});
|
|
1926
|
+
|
|
1927
|
+
test("--hub-origin (DO path): order is set-origin → install vault → ensureHub", async () => {
|
|
1928
|
+
const h = makeHarness();
|
|
1929
|
+
try {
|
|
1930
|
+
const order: string[] = [];
|
|
1931
|
+
const code = await init({
|
|
1932
|
+
configDir: h.configDir,
|
|
1933
|
+
ensureHubVersion: async () => ({
|
|
1934
|
+
outcome: "match" as const,
|
|
1935
|
+
installedVersion: "test",
|
|
1936
|
+
messages: [],
|
|
1937
|
+
}),
|
|
1938
|
+
manifestPath: h.manifestPath,
|
|
1939
|
+
log: () => {},
|
|
1940
|
+
alive: () => false,
|
|
1941
|
+
hubOrigin: "https://box.sslip.io",
|
|
1942
|
+
// hub#693 Step-0 origin persist must remain FIRST (boot issuer + child
|
|
1943
|
+
// env read it); the hub#694 vault seed slots in AFTER it but BEFORE the
|
|
1944
|
+
// hub unit boots, so the freshly-spawned vault comes up with the public
|
|
1945
|
+
// origin in its accepted-iss set AND is present in the boot scan — in one
|
|
1946
|
+
// pass, no restart.
|
|
1947
|
+
setHubOriginImpl: (_dir, origin) => {
|
|
1948
|
+
order.push(`set-origin:${origin}`);
|
|
1949
|
+
},
|
|
1950
|
+
installVaultModuleImpl: async () => {
|
|
1951
|
+
order.push("install-vault");
|
|
1952
|
+
return 0;
|
|
1953
|
+
},
|
|
1954
|
+
ensureHub: async () => {
|
|
1955
|
+
order.push("ensureHub");
|
|
1956
|
+
writeHubPort(1939, h.configDir);
|
|
1957
|
+
return { pid: 0, port: 1939, started: true };
|
|
1958
|
+
},
|
|
1959
|
+
readExposeStateFn: () => undefined,
|
|
1960
|
+
isTty: false,
|
|
1961
|
+
platform: "linux",
|
|
1962
|
+
});
|
|
1963
|
+
expect(code).toBe(0);
|
|
1964
|
+
expect(order).toEqual(["set-origin:https://box.sslip.io", "install-vault", "ensureHub"]);
|
|
1965
|
+
} finally {
|
|
1966
|
+
h.cleanup();
|
|
1967
|
+
}
|
|
1968
|
+
});
|
|
1969
|
+
|
|
1970
|
+
test("real seed: vault row is in services.json by the time ensureHub runs", async () => {
|
|
1971
|
+
// End-to-end-ish: drive the REAL defaultInstallVaultModule (bun-linked vault
|
|
1972
|
+
// short-circuits the bun-add) and assert the services.json row exists when
|
|
1973
|
+
// ensureHub is invoked — i.e. the boot scan would find it.
|
|
1974
|
+
const h = makeHarness();
|
|
1975
|
+
try {
|
|
1976
|
+
let vaultRowAtEnsure: boolean | undefined;
|
|
1977
|
+
const code = await init({
|
|
1978
|
+
configDir: h.configDir,
|
|
1979
|
+
ensureHubVersion: async () => ({
|
|
1980
|
+
outcome: "match" as const,
|
|
1981
|
+
installedVersion: "test",
|
|
1982
|
+
messages: [],
|
|
1983
|
+
}),
|
|
1984
|
+
manifestPath: h.manifestPath,
|
|
1985
|
+
log: () => {},
|
|
1986
|
+
alive: () => false,
|
|
1987
|
+
ensureHub: async () => {
|
|
1988
|
+
// At hub-unit-boot time the services.json must already carry vault.
|
|
1989
|
+
const { findService } = await import("../services-manifest.ts");
|
|
1990
|
+
vaultRowAtEnsure = findService("parachute-vault", h.manifestPath) !== undefined;
|
|
1991
|
+
writeHubPort(1939, h.configDir);
|
|
1992
|
+
return { pid: 0, port: 1939, started: true };
|
|
1993
|
+
},
|
|
1994
|
+
readExposeStateFn: () => undefined,
|
|
1995
|
+
isTty: false,
|
|
1996
|
+
platform: "linux",
|
|
1997
|
+
// NOTE: no installVaultModuleImpl override — exercise the real install
|
|
1998
|
+
// so we prove the seed write lands before ensureHub, not just the stub.
|
|
1999
|
+
});
|
|
2000
|
+
expect(code).toBe(0);
|
|
2001
|
+
expect(vaultRowAtEnsure).toBe(true);
|
|
2002
|
+
} finally {
|
|
2003
|
+
h.cleanup();
|
|
2004
|
+
}
|
|
2005
|
+
});
|
|
2006
|
+
|
|
2007
|
+
test("already-seeded vault: install is skipped, ensureHub still runs", async () => {
|
|
2008
|
+
const h = makeHarness();
|
|
2009
|
+
try {
|
|
2010
|
+
seedVault(h.manifestPath);
|
|
2011
|
+
const order: string[] = [];
|
|
2012
|
+
const code = await init({
|
|
2013
|
+
configDir: h.configDir,
|
|
2014
|
+
ensureHubVersion: async () => ({
|
|
2015
|
+
outcome: "match" as const,
|
|
2016
|
+
installedVersion: "test",
|
|
2017
|
+
messages: [],
|
|
2018
|
+
}),
|
|
2019
|
+
manifestPath: h.manifestPath,
|
|
2020
|
+
log: () => {},
|
|
2021
|
+
alive: () => false,
|
|
2022
|
+
installVaultModuleImpl: async () => {
|
|
2023
|
+
order.push("install-vault");
|
|
2024
|
+
return 0;
|
|
2025
|
+
},
|
|
2026
|
+
ensureHub: async () => {
|
|
2027
|
+
order.push("ensureHub");
|
|
2028
|
+
writeHubPort(1939, h.configDir);
|
|
2029
|
+
return { pid: 0, port: 1939, started: true };
|
|
2030
|
+
},
|
|
2031
|
+
readExposeStateFn: () => undefined,
|
|
2032
|
+
isTty: false,
|
|
2033
|
+
platform: "linux",
|
|
2034
|
+
});
|
|
2035
|
+
expect(code).toBe(0);
|
|
2036
|
+
// Vault already present → install short-circuits; only ensureHub fires.
|
|
2037
|
+
expect(order).toEqual(["ensureHub"]);
|
|
2038
|
+
} finally {
|
|
2039
|
+
h.cleanup();
|
|
2040
|
+
}
|
|
2041
|
+
});
|
|
2042
|
+
});
|
|
2043
|
+
|
|
2044
|
+
describe("init hub#694 — install channel (bug 2)", () => {
|
|
2045
|
+
test("--channel rc propagates to the vault module install", async () => {
|
|
2046
|
+
const h = makeHarness();
|
|
2047
|
+
try {
|
|
2048
|
+
const channels: (string | undefined)[] = [];
|
|
2049
|
+
const code = await init({
|
|
2050
|
+
configDir: h.configDir,
|
|
2051
|
+
ensureHubVersion: async () => ({
|
|
2052
|
+
outcome: "match" as const,
|
|
2053
|
+
installedVersion: "test",
|
|
2054
|
+
messages: [],
|
|
2055
|
+
}),
|
|
2056
|
+
manifestPath: h.manifestPath,
|
|
2057
|
+
log: () => {},
|
|
2058
|
+
alive: () => false,
|
|
2059
|
+
channel: "rc",
|
|
2060
|
+
installVaultModuleImpl: async (_d, _m, channel) => {
|
|
2061
|
+
channels.push(channel);
|
|
2062
|
+
return 0;
|
|
2063
|
+
},
|
|
2064
|
+
ensureHub: async () => {
|
|
2065
|
+
writeHubPort(1939, h.configDir);
|
|
2066
|
+
return { pid: 0, port: 1939, started: true };
|
|
2067
|
+
},
|
|
2068
|
+
readExposeStateFn: () => undefined,
|
|
2069
|
+
isTty: false,
|
|
2070
|
+
platform: "linux",
|
|
2071
|
+
});
|
|
2072
|
+
expect(code).toBe(0);
|
|
2073
|
+
expect(channels).toEqual(["rc"]);
|
|
2074
|
+
} finally {
|
|
2075
|
+
h.cleanup();
|
|
2076
|
+
}
|
|
2077
|
+
});
|
|
2078
|
+
|
|
2079
|
+
test("PARACHUTE_CHANNEL=rc env propagates when no --channel flag", async () => {
|
|
2080
|
+
const h = makeHarness();
|
|
2081
|
+
try {
|
|
2082
|
+
const channels: (string | undefined)[] = [];
|
|
2083
|
+
const code = await init({
|
|
2084
|
+
configDir: h.configDir,
|
|
2085
|
+
ensureHubVersion: async () => ({
|
|
2086
|
+
outcome: "match" as const,
|
|
2087
|
+
installedVersion: "test",
|
|
2088
|
+
messages: [],
|
|
2089
|
+
}),
|
|
2090
|
+
manifestPath: h.manifestPath,
|
|
2091
|
+
log: () => {},
|
|
2092
|
+
alive: () => false,
|
|
2093
|
+
// No `channel` opt — exercise the env fallback the DO cloud-init script
|
|
2094
|
+
// relies on (it exports PARACHUTE_CHANNEL but passes no --channel flag).
|
|
2095
|
+
env: { PARACHUTE_CHANNEL: "rc" } as NodeJS.ProcessEnv,
|
|
2096
|
+
installVaultModuleImpl: async (_d, _m, channel) => {
|
|
2097
|
+
channels.push(channel);
|
|
2098
|
+
return 0;
|
|
2099
|
+
},
|
|
2100
|
+
ensureHub: async () => {
|
|
2101
|
+
writeHubPort(1939, h.configDir);
|
|
2102
|
+
return { pid: 0, port: 1939, started: true };
|
|
2103
|
+
},
|
|
2104
|
+
readExposeStateFn: () => undefined,
|
|
2105
|
+
isTty: false,
|
|
2106
|
+
platform: "linux",
|
|
2107
|
+
});
|
|
2108
|
+
expect(code).toBe(0);
|
|
2109
|
+
expect(channels).toEqual(["rc"]);
|
|
2110
|
+
} finally {
|
|
2111
|
+
h.cleanup();
|
|
2112
|
+
}
|
|
2113
|
+
});
|
|
2114
|
+
|
|
2115
|
+
test("default (no flag, no env): vault install gets undefined → install resolves latest", async () => {
|
|
2116
|
+
const h = makeHarness();
|
|
2117
|
+
try {
|
|
2118
|
+
const channels: (string | undefined)[] = [];
|
|
2119
|
+
const code = await init({
|
|
2120
|
+
configDir: h.configDir,
|
|
2121
|
+
ensureHubVersion: async () => ({
|
|
2122
|
+
outcome: "match" as const,
|
|
2123
|
+
installedVersion: "test",
|
|
2124
|
+
messages: [],
|
|
2125
|
+
}),
|
|
2126
|
+
manifestPath: h.manifestPath,
|
|
2127
|
+
log: () => {},
|
|
2128
|
+
alive: () => false,
|
|
2129
|
+
env: {} as NodeJS.ProcessEnv,
|
|
2130
|
+
installVaultModuleImpl: async (_d, _m, channel) => {
|
|
2131
|
+
channels.push(channel);
|
|
2132
|
+
return 0;
|
|
2133
|
+
},
|
|
2134
|
+
ensureHub: async () => {
|
|
2135
|
+
writeHubPort(1939, h.configDir);
|
|
2136
|
+
return { pid: 0, port: 1939, started: true };
|
|
2137
|
+
},
|
|
2138
|
+
readExposeStateFn: () => undefined,
|
|
2139
|
+
isTty: false,
|
|
2140
|
+
platform: "linux",
|
|
2141
|
+
});
|
|
2142
|
+
expect(code).toBe(0);
|
|
2143
|
+
expect(channels).toEqual([undefined]);
|
|
2144
|
+
} finally {
|
|
2145
|
+
h.cleanup();
|
|
2146
|
+
}
|
|
2147
|
+
});
|
|
2148
|
+
|
|
2149
|
+
test("--channel flag overrides PARACHUTE_CHANNEL env", async () => {
|
|
2150
|
+
const h = makeHarness();
|
|
2151
|
+
try {
|
|
2152
|
+
const channels: (string | undefined)[] = [];
|
|
2153
|
+
const code = await init({
|
|
2154
|
+
configDir: h.configDir,
|
|
2155
|
+
ensureHubVersion: async () => ({
|
|
2156
|
+
outcome: "match" as const,
|
|
2157
|
+
installedVersion: "test",
|
|
2158
|
+
messages: [],
|
|
2159
|
+
}),
|
|
2160
|
+
manifestPath: h.manifestPath,
|
|
2161
|
+
log: () => {},
|
|
2162
|
+
alive: () => false,
|
|
2163
|
+
channel: "latest",
|
|
2164
|
+
env: { PARACHUTE_CHANNEL: "rc" } as NodeJS.ProcessEnv,
|
|
2165
|
+
installVaultModuleImpl: async (_d, _m, channel) => {
|
|
2166
|
+
channels.push(channel);
|
|
2167
|
+
return 0;
|
|
2168
|
+
},
|
|
2169
|
+
ensureHub: async () => {
|
|
2170
|
+
writeHubPort(1939, h.configDir);
|
|
2171
|
+
return { pid: 0, port: 1939, started: true };
|
|
2172
|
+
},
|
|
2173
|
+
readExposeStateFn: () => undefined,
|
|
2174
|
+
isTty: false,
|
|
2175
|
+
platform: "linux",
|
|
2176
|
+
});
|
|
2177
|
+
expect(code).toBe(0);
|
|
2178
|
+
expect(channels).toEqual(["latest"]);
|
|
2179
|
+
} finally {
|
|
2180
|
+
h.cleanup();
|
|
2181
|
+
}
|
|
2182
|
+
});
|
|
2183
|
+
});
|
|
2184
|
+
|
|
2185
|
+
describe("resolveInitChannel (hub#694 bug 2)", () => {
|
|
2186
|
+
const empty = {} as NodeJS.ProcessEnv;
|
|
2187
|
+
test("explicit rc/latest wins", () => {
|
|
2188
|
+
expect(resolveInitChannel("rc", empty)).toBe("rc");
|
|
2189
|
+
expect(resolveInitChannel("latest", { PARACHUTE_CHANNEL: "rc" } as NodeJS.ProcessEnv)).toBe(
|
|
2190
|
+
"latest",
|
|
2191
|
+
);
|
|
2192
|
+
});
|
|
2193
|
+
test("PARACHUTE_CHANNEL env when no explicit", () => {
|
|
2194
|
+
expect(resolveInitChannel(undefined, { PARACHUTE_CHANNEL: "rc" } as NodeJS.ProcessEnv)).toBe(
|
|
2195
|
+
"rc",
|
|
2196
|
+
);
|
|
2197
|
+
});
|
|
2198
|
+
test("PARACHUTE_INSTALL_CHANNEL env when no explicit + no PARACHUTE_CHANNEL", () => {
|
|
2199
|
+
expect(
|
|
2200
|
+
resolveInitChannel(undefined, { PARACHUTE_INSTALL_CHANNEL: "rc" } as NodeJS.ProcessEnv),
|
|
2201
|
+
).toBe("rc");
|
|
2202
|
+
});
|
|
2203
|
+
test("PARACHUTE_CHANNEL wins over PARACHUTE_INSTALL_CHANNEL", () => {
|
|
2204
|
+
expect(
|
|
2205
|
+
resolveInitChannel(undefined, {
|
|
2206
|
+
PARACHUTE_CHANNEL: "latest",
|
|
2207
|
+
PARACHUTE_INSTALL_CHANNEL: "rc",
|
|
2208
|
+
} as NodeJS.ProcessEnv),
|
|
2209
|
+
).toBe("latest");
|
|
2210
|
+
});
|
|
2211
|
+
test("garbage env → undefined (install falls back to latest)", () => {
|
|
2212
|
+
expect(
|
|
2213
|
+
resolveInitChannel(undefined, { PARACHUTE_CHANNEL: "banana" } as NodeJS.ProcessEnv),
|
|
2214
|
+
).toBe(undefined);
|
|
2215
|
+
});
|
|
2216
|
+
test("nothing set → undefined", () => {
|
|
2217
|
+
expect(resolveInitChannel(undefined, empty)).toBe(undefined);
|
|
2218
|
+
});
|
|
2219
|
+
});
|
|
2220
|
+
|
|
1868
2221
|
// Type alias used only inside this test file for the heuristic test.
|
|
1869
2222
|
type ExposeChoice = "none" | "tailnet" | "cloudflare";
|
|
@@ -80,6 +80,12 @@ interface SupervisorArmOpts {
|
|
|
80
80
|
hubHealthy: boolean;
|
|
81
81
|
moduleStates?: ModuleStatesResult;
|
|
82
82
|
fetchModuleStatesImpl?: () => Promise<ModuleStatesResult>;
|
|
83
|
+
/**
|
|
84
|
+
* Inject the unauthenticated module-liveness probe (#700). Defaults to "every
|
|
85
|
+
* module is down" so the degraded-read tests don't accidentally hit the
|
|
86
|
+
* network; specific tests override to mark a module live.
|
|
87
|
+
*/
|
|
88
|
+
probeModuleHealth?: (port: number, health: string) => Promise<boolean>;
|
|
83
89
|
}
|
|
84
90
|
|
|
85
91
|
/** Drive `status` through the supervisor arm with fully stubbed seams. */
|
|
@@ -96,6 +102,7 @@ function supervisorOpts(configDir: string, path: string, o: SupervisorArmOpts) {
|
|
|
96
102
|
fetchModuleStates:
|
|
97
103
|
o.fetchModuleStatesImpl ??
|
|
98
104
|
(async () => o.moduleStates ?? { supervisorAvailable: true, modules: [] }),
|
|
105
|
+
probeModuleHealth: o.probeModuleHealth ?? (async () => false),
|
|
99
106
|
openDb: fakeOpenDb as unknown as (configDir: string) => import("bun:sqlite").Database,
|
|
100
107
|
},
|
|
101
108
|
};
|
|
@@ -377,7 +384,7 @@ describe("status — Phase 3c supervisor arm: module rows", () => {
|
|
|
377
384
|
}
|
|
378
385
|
});
|
|
379
386
|
|
|
380
|
-
test("no operator token
|
|
387
|
+
test("no operator token (fresh box, no admin) → note targets set-password, NOT rotate-operator (#700)", async () => {
|
|
381
388
|
const { path, configDir, cleanup } = makeTempPath();
|
|
382
389
|
try {
|
|
383
390
|
upsertService(
|
|
@@ -392,15 +399,121 @@ describe("status — Phase 3c supervisor arm: module rows", () => {
|
|
|
392
399
|
fetchModuleStatesImpl: async () => {
|
|
393
400
|
throw new NoOperatorTokenError();
|
|
394
401
|
},
|
|
402
|
+
// No probe-live module here → row stays inactive (exit 0).
|
|
403
|
+
probeModuleHealth: async () => false,
|
|
395
404
|
}),
|
|
396
405
|
print: (l) => lines.push(l),
|
|
397
406
|
});
|
|
398
407
|
// We could not read run-state, but didn't crash. The module row falls back
|
|
399
|
-
// to `inactive` (no supervisor snapshot) — a stopped row is exit 0.
|
|
408
|
+
// to `inactive` (no supervisor snapshot, probe down) — a stopped row is exit 0.
|
|
400
409
|
expect(code).toBe(0);
|
|
401
410
|
const out = lines.join("\n");
|
|
402
411
|
expect(out).toMatch(/parachute-vault/);
|
|
403
|
-
|
|
412
|
+
// #700: a fresh box has no admin, so rotate-operator would itself error.
|
|
413
|
+
// The note must point at set-password and must NOT be the bare
|
|
414
|
+
// rotate-operator guidance.
|
|
415
|
+
expect(out).toMatch(/parachute auth set-password/);
|
|
416
|
+
expect(out).not.toMatch(/run `parachute auth rotate-operator` to mint an operator token/);
|
|
417
|
+
const vaultLine = lines.find((l) => l.includes("parachute-vault"));
|
|
418
|
+
expect(vaultLine).toMatch(/\binactive\b/);
|
|
419
|
+
} finally {
|
|
420
|
+
cleanup();
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test("no operator token + module answers /health probe → LIVE (active), not inactive (#700)", async () => {
|
|
425
|
+
const { path, configDir, cleanup } = makeTempPath();
|
|
426
|
+
try {
|
|
427
|
+
upsertService(
|
|
428
|
+
{ name: "parachute-vault", port: 1940, paths: ["/"], health: "/health", version: "0.6.2" },
|
|
429
|
+
path,
|
|
430
|
+
);
|
|
431
|
+
const probed: Array<{ port: number; health: string }> = [];
|
|
432
|
+
const lines: string[] = [];
|
|
433
|
+
const code = await status({
|
|
434
|
+
...supervisorOpts(configDir, path, {
|
|
435
|
+
managerState: { state: "active" },
|
|
436
|
+
hubHealthy: true,
|
|
437
|
+
fetchModuleStatesImpl: async () => {
|
|
438
|
+
throw new NoOperatorTokenError();
|
|
439
|
+
},
|
|
440
|
+
// vault is genuinely up — its /health answers (2xx or 401 → live).
|
|
441
|
+
probeModuleHealth: async (port, health) => {
|
|
442
|
+
probed.push({ port, health });
|
|
443
|
+
return true;
|
|
444
|
+
},
|
|
445
|
+
}),
|
|
446
|
+
print: (l) => lines.push(l),
|
|
447
|
+
});
|
|
448
|
+
expect(code).toBe(0);
|
|
449
|
+
// The probe targeted the module's own port + health path from the manifest.
|
|
450
|
+
expect(probed).toEqual([{ port: 1940, health: "/health" }]);
|
|
451
|
+
const vaultLine = lines.find((l) => l.includes("parachute-vault"));
|
|
452
|
+
expect(vaultLine).toMatch(/\bactive\b/);
|
|
453
|
+
expect(vaultLine).not.toMatch(/\binactive\b/);
|
|
454
|
+
const out = lines.join("\n");
|
|
455
|
+
// The row is labelled as probe-derived so the operator knows it's thin.
|
|
456
|
+
expect(out).toMatch(/live via unauthenticated health probe/);
|
|
457
|
+
// The degraded-read hint still appears (why PID/uptime are absent).
|
|
458
|
+
expect(out).toMatch(/parachute auth set-password/);
|
|
459
|
+
} finally {
|
|
460
|
+
cleanup();
|
|
461
|
+
}
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
test("degraded read + module probe FAILS → row stays inactive (#700)", async () => {
|
|
465
|
+
const { path, configDir, cleanup } = makeTempPath();
|
|
466
|
+
try {
|
|
467
|
+
upsertService(
|
|
468
|
+
{ name: "parachute-vault", port: 1940, paths: ["/"], health: "/health", version: "0.6.2" },
|
|
469
|
+
path,
|
|
470
|
+
);
|
|
471
|
+
const lines: string[] = [];
|
|
472
|
+
const code = await status({
|
|
473
|
+
...supervisorOpts(configDir, path, {
|
|
474
|
+
managerState: { state: "active" },
|
|
475
|
+
hubHealthy: true,
|
|
476
|
+
fetchModuleStatesImpl: async () => {
|
|
477
|
+
throw new NoOperatorTokenError();
|
|
478
|
+
},
|
|
479
|
+
probeModuleHealth: async () => false,
|
|
480
|
+
}),
|
|
481
|
+
print: (l) => lines.push(l),
|
|
482
|
+
});
|
|
483
|
+
expect(code).toBe(0);
|
|
484
|
+
const vaultLine = lines.find((l) => l.includes("parachute-vault"));
|
|
485
|
+
expect(vaultLine).toMatch(/\binactive\b/);
|
|
486
|
+
const out = lines.join("\n");
|
|
487
|
+
expect(out).not.toMatch(/live via unauthenticated health probe/);
|
|
488
|
+
} finally {
|
|
489
|
+
cleanup();
|
|
490
|
+
}
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
test("a throwing module probe never crashes status — row degrades to inactive (#700)", async () => {
|
|
494
|
+
const { path, configDir, cleanup } = makeTempPath();
|
|
495
|
+
try {
|
|
496
|
+
upsertService(
|
|
497
|
+
{ name: "parachute-vault", port: 1940, paths: ["/"], health: "/health", version: "0.6.2" },
|
|
498
|
+
path,
|
|
499
|
+
);
|
|
500
|
+
const lines: string[] = [];
|
|
501
|
+
const code = await status({
|
|
502
|
+
...supervisorOpts(configDir, path, {
|
|
503
|
+
managerState: { state: "active" },
|
|
504
|
+
hubHealthy: true,
|
|
505
|
+
fetchModuleStatesImpl: async () => {
|
|
506
|
+
throw new NoOperatorTokenError();
|
|
507
|
+
},
|
|
508
|
+
probeModuleHealth: async () => {
|
|
509
|
+
throw new Error("probe exploded");
|
|
510
|
+
},
|
|
511
|
+
}),
|
|
512
|
+
print: (l) => lines.push(l),
|
|
513
|
+
});
|
|
514
|
+
expect(code).toBe(0);
|
|
515
|
+
const vaultLine = lines.find((l) => l.includes("parachute-vault"));
|
|
516
|
+
expect(vaultLine).toMatch(/\binactive\b/);
|
|
404
517
|
} finally {
|
|
405
518
|
cleanup();
|
|
406
519
|
}
|
|
@@ -433,6 +546,42 @@ describe("status — Phase 3c supervisor arm: module rows", () => {
|
|
|
433
546
|
}
|
|
434
547
|
});
|
|
435
548
|
|
|
549
|
+
test("expired operator token + module answers /health probe → LIVE (active) (#700)", async () => {
|
|
550
|
+
// Symmetry with the no-token case: the unauthenticated probe fallback fires
|
|
551
|
+
// on ANY degraded read where the hub is up + run-state is missing, so an
|
|
552
|
+
// expired-token box still shows a genuinely-serving module as `active`.
|
|
553
|
+
const { path, configDir, cleanup } = makeTempPath();
|
|
554
|
+
try {
|
|
555
|
+
upsertService(
|
|
556
|
+
{ name: "parachute-vault", port: 1940, paths: ["/"], health: "/health", version: "0.6.2" },
|
|
557
|
+
path,
|
|
558
|
+
);
|
|
559
|
+
const lines: string[] = [];
|
|
560
|
+
const code = await status({
|
|
561
|
+
...supervisorOpts(configDir, path, {
|
|
562
|
+
managerState: { state: "active" },
|
|
563
|
+
hubHealthy: true,
|
|
564
|
+
fetchModuleStatesImpl: async () => {
|
|
565
|
+
throw new OperatorTokenExpiredError(
|
|
566
|
+
"token expired — run `parachute auth rotate-operator`",
|
|
567
|
+
);
|
|
568
|
+
},
|
|
569
|
+
probeModuleHealth: async () => true,
|
|
570
|
+
}),
|
|
571
|
+
print: (l) => lines.push(l),
|
|
572
|
+
});
|
|
573
|
+
expect(code).toBe(0);
|
|
574
|
+
const vaultLine = lines.find((l) => l.includes("parachute-vault"));
|
|
575
|
+
expect(vaultLine).toMatch(/\bactive\b/);
|
|
576
|
+
const out = lines.join("\n");
|
|
577
|
+
expect(out).toMatch(/live via unauthenticated health probe/);
|
|
578
|
+
// The expired-token degraded-read hint still points at rotate-operator.
|
|
579
|
+
expect(out).toMatch(/rotate-operator/);
|
|
580
|
+
} finally {
|
|
581
|
+
cleanup();
|
|
582
|
+
}
|
|
583
|
+
});
|
|
584
|
+
|
|
436
585
|
test("API error reading module states → degrade with the message, no crash", async () => {
|
|
437
586
|
const { path, configDir, cleanup } = makeTempPath();
|
|
438
587
|
try {
|
package/src/cli.ts
CHANGED
|
@@ -402,22 +402,41 @@ async function main(argv: string[]): Promise<number> {
|
|
|
402
402
|
);
|
|
403
403
|
return 1;
|
|
404
404
|
}
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
const
|
|
405
|
+
// hub#694 bug 2: `--channel rc|latest` picks the dist-tag init installs the
|
|
406
|
+
// vault module from. Without it, init always resolved @latest — DOWNGRADING
|
|
407
|
+
// vault below an rc-tracking hub. Mirrors `parachute install --channel`.
|
|
408
|
+
const channelExtract = extractNamedFlag(exposeExtract.rest, "--channel");
|
|
409
|
+
if (channelExtract.error) {
|
|
410
|
+
console.error(`parachute init: ${channelExtract.error}`);
|
|
411
|
+
return 1;
|
|
412
|
+
}
|
|
413
|
+
if (
|
|
414
|
+
channelExtract.value !== undefined &&
|
|
415
|
+
channelExtract.value !== "rc" &&
|
|
416
|
+
channelExtract.value !== "latest"
|
|
417
|
+
) {
|
|
418
|
+
console.error(
|
|
419
|
+
`parachute init: --channel must be "rc" or "latest" (got "${channelExtract.value}")`,
|
|
420
|
+
);
|
|
421
|
+
return 1;
|
|
422
|
+
}
|
|
423
|
+
const noBrowser = channelExtract.rest.includes("--no-browser");
|
|
424
|
+
const noExposePrompt = channelExtract.rest.includes("--no-expose-prompt");
|
|
425
|
+
const cliWizard = channelExtract.rest.includes("--cli-wizard");
|
|
426
|
+
const browserWizard = channelExtract.rest.includes("--browser-wizard");
|
|
409
427
|
const known = new Set([
|
|
410
428
|
"--no-browser",
|
|
411
429
|
"--no-expose-prompt",
|
|
412
430
|
"--cli-wizard",
|
|
413
431
|
"--browser-wizard",
|
|
414
432
|
]);
|
|
415
|
-
const unknown =
|
|
433
|
+
const unknown = channelExtract.rest.find((a) => !known.has(a));
|
|
416
434
|
if (unknown !== undefined) {
|
|
417
435
|
console.error(`parachute init: unknown argument "${unknown}"`);
|
|
418
436
|
console.error(
|
|
419
437
|
"usage: parachute init [--no-browser] [--no-expose-prompt]\n" +
|
|
420
438
|
" [--expose none|tailnet|cloudflare]\n" +
|
|
439
|
+
" [--channel rc|latest]\n" +
|
|
421
440
|
" [--hub-origin <url>]\n" +
|
|
422
441
|
" [--cli-wizard | --browser-wizard]",
|
|
423
442
|
);
|
|
@@ -434,6 +453,9 @@ async function main(argv: string[]): Promise<number> {
|
|
|
434
453
|
if (exposeExtract.value) {
|
|
435
454
|
initOpts.exposeChoice = exposeExtract.value as "none" | "tailnet" | "cloudflare";
|
|
436
455
|
}
|
|
456
|
+
if (channelExtract.value === "rc" || channelExtract.value === "latest") {
|
|
457
|
+
initOpts.channel = channelExtract.value;
|
|
458
|
+
}
|
|
437
459
|
if (cliWizard) initOpts.wizardChoice = "cli";
|
|
438
460
|
else if (browserWizard) initOpts.wizardChoice = "browser";
|
|
439
461
|
const mod = await loadCommand("init", () => import("./commands/init.ts"));
|
package/src/commands/init.ts
CHANGED
|
@@ -154,14 +154,31 @@ export interface InitOpts {
|
|
|
154
154
|
* stub to record the call without shelling out to `cloudflared`.
|
|
155
155
|
*/
|
|
156
156
|
exposeCloudflareImpl?: () => Promise<number>;
|
|
157
|
+
/**
|
|
158
|
+
* Install channel for the vault module (hub#694 bug 2). `"rc"` makes init's
|
|
159
|
+
* vault install resolve `@openparachute/vault@rc` instead of `@latest`, so an
|
|
160
|
+
* rc-channel box (hub installed from `@rc`) doesn't DOWNGRADE vault below the
|
|
161
|
+
* hub on `parachute init`. Default `"latest"` (npm default; back-compat for
|
|
162
|
+
* every existing operator). Precedence in the CLI: `--channel <v>` flag >
|
|
163
|
+
* `PARACHUTE_CHANNEL` / `PARACHUTE_INSTALL_CHANNEL` env > `"latest"`. Threaded
|
|
164
|
+
* verbatim into `install()`'s existing `channel` plumbing
|
|
165
|
+
* (`resolveInstallChannel`).
|
|
166
|
+
*/
|
|
167
|
+
channel?: "latest" | "rc";
|
|
157
168
|
/**
|
|
158
169
|
* Test seam: shim for the vault-module install step (hub#168 Cut 1).
|
|
159
170
|
* Production calls `install("vault", { noCreate: true, noStart: true, …})`
|
|
160
171
|
* to put `@openparachute/vault` on PATH without creating a first-vault
|
|
161
172
|
* instance — the wizard's vault step decides Create/Import/Skip. Tests
|
|
162
|
-
* pass a stub to record the call without shelling out.
|
|
173
|
+
* pass a stub to record the call without shelling out. The `channel` arg
|
|
174
|
+
* (hub#694) forwards into `install()`'s `channel` option so an rc box installs
|
|
175
|
+
* vault from `@rc`.
|
|
163
176
|
*/
|
|
164
|
-
installVaultModuleImpl?: (
|
|
177
|
+
installVaultModuleImpl?: (
|
|
178
|
+
configDir: string,
|
|
179
|
+
manifestPath: string,
|
|
180
|
+
channel?: "latest" | "rc",
|
|
181
|
+
) => Promise<number>;
|
|
165
182
|
/**
|
|
166
183
|
* Override the wizard-choice prompt (hub#168 Cut 4). When set, the
|
|
167
184
|
* "Continue setup in the browser or CLI?" question is answered without
|
|
@@ -509,8 +526,18 @@ async function defaultGuaranteeOperatorToken(ctx: {
|
|
|
509
526
|
* shim that re-emits each line under an `[install vault] ` prefix so the
|
|
510
527
|
* init log stays grep-able. Idempotent — `install` short-circuits the
|
|
511
528
|
* bun-add when vault is already linked / installed.
|
|
529
|
+
*
|
|
530
|
+
* The `channel` arg (hub#694) forwards into `install()`'s `channel` option so
|
|
531
|
+
* an rc-channel box installs `@openparachute/vault@rc` instead of `@latest` —
|
|
532
|
+
* otherwise init silently DOWNGRADES vault below the rc-tracking hub. Undefined
|
|
533
|
+
* → install's own resolution (`--tag` > `channel` > `PARACHUTE_INSTALL_CHANNEL`
|
|
534
|
+
* env > `"latest"`) applies, preserving today's behavior.
|
|
512
535
|
*/
|
|
513
|
-
async function defaultInstallVaultModule(
|
|
536
|
+
async function defaultInstallVaultModule(
|
|
537
|
+
configDir: string,
|
|
538
|
+
manifestPath: string,
|
|
539
|
+
channel?: "latest" | "rc",
|
|
540
|
+
): Promise<number> {
|
|
514
541
|
const installOpts: InstallOpts = {
|
|
515
542
|
configDir,
|
|
516
543
|
manifestPath,
|
|
@@ -518,6 +545,7 @@ async function defaultInstallVaultModule(configDir: string, manifestPath: string
|
|
|
518
545
|
noStart: true,
|
|
519
546
|
log: (line) => console.log(`[install vault] ${line}`),
|
|
520
547
|
};
|
|
548
|
+
if (channel) installOpts.channel = channel;
|
|
521
549
|
return await defaultInstall("vault", installOpts);
|
|
522
550
|
}
|
|
523
551
|
|
|
@@ -637,6 +665,32 @@ async function promptExposeChoice(
|
|
|
637
665
|
return defaultChoice;
|
|
638
666
|
}
|
|
639
667
|
|
|
668
|
+
/**
|
|
669
|
+
* Resolve the install channel for init's vault module step (hub#694 bug 2).
|
|
670
|
+
*
|
|
671
|
+
* Precedence (highest → lowest):
|
|
672
|
+
* 1. explicit `--channel rc|latest` (parsed in cli.ts → `opts.channel`)
|
|
673
|
+
* 2. `PARACHUTE_CHANNEL` env (the DigitalOcean cloud-init script's var)
|
|
674
|
+
* 3. `PARACHUTE_INSTALL_CHANNEL` env (the install layer's own platform cascade)
|
|
675
|
+
* 4. `undefined` → `install()` falls back to its own "latest" default
|
|
676
|
+
*
|
|
677
|
+
* Returns `"latest"` / `"rc"` when one is resolved, or `undefined` to defer to
|
|
678
|
+
* `install()`'s resolution. A non-`rc`/`latest` env value is ignored (returns
|
|
679
|
+
* undefined) so a typo degrades to "latest" rather than crashing init — the
|
|
680
|
+
* same forgiving posture `resolveInstallChannel` takes for the install command.
|
|
681
|
+
*/
|
|
682
|
+
export function resolveInitChannel(
|
|
683
|
+
explicit: "latest" | "rc" | undefined,
|
|
684
|
+
env: NodeJS.ProcessEnv,
|
|
685
|
+
): "latest" | "rc" | undefined {
|
|
686
|
+
if (explicit === "rc" || explicit === "latest") return explicit;
|
|
687
|
+
for (const key of ["PARACHUTE_CHANNEL", "PARACHUTE_INSTALL_CHANNEL"]) {
|
|
688
|
+
const v = env[key];
|
|
689
|
+
if (v === "rc" || v === "latest") return v;
|
|
690
|
+
}
|
|
691
|
+
return undefined;
|
|
692
|
+
}
|
|
693
|
+
|
|
640
694
|
export async function init(opts: InitOpts = {}): Promise<number> {
|
|
641
695
|
const configDir = opts.configDir ?? CONFIG_DIR;
|
|
642
696
|
const manifestPath = opts.manifestPath ?? SERVICES_MANIFEST_PATH;
|
|
@@ -668,6 +722,16 @@ export async function init(opts: InitOpts = {}): Promise<number> {
|
|
|
668
722
|
const exposeTailnetImpl = opts.exposeTailnetImpl ?? defaultExposeTailnet;
|
|
669
723
|
const exposeCloudflareImpl = opts.exposeCloudflareImpl ?? defaultExposeCloudflare;
|
|
670
724
|
const installVaultModuleImpl = opts.installVaultModuleImpl ?? defaultInstallVaultModule;
|
|
725
|
+
// hub#694 bug 2: resolve the channel for the vault module install. Precedence:
|
|
726
|
+
// explicit `--channel <v>` (opts.channel) > `PARACHUTE_CHANNEL` /
|
|
727
|
+
// `PARACHUTE_INSTALL_CHANNEL` env > undefined (install's own "latest"
|
|
728
|
+
// fallback). The env fallback is what makes the DigitalOcean cloud-init
|
|
729
|
+
// script's `PARACHUTE_CHANNEL=rc` cascade into init's vault install with zero
|
|
730
|
+
// extra flags — init never received a `--channel` from that script, but it
|
|
731
|
+
// reads the env the script already exports. A garbage env value falls through
|
|
732
|
+
// to undefined → install resolves "latest" (matching resolveInstallChannel's
|
|
733
|
+
// own garbage-handling), so an operator typo can't break init.
|
|
734
|
+
const installChannel: "latest" | "rc" | undefined = resolveInitChannel(opts.channel, env);
|
|
671
735
|
const runCliWizardImpl = opts.runCliWizardImpl ?? defaultRunCliWizard;
|
|
672
736
|
const fetchBootstrapTokenImpl = opts.fetchBootstrapTokenImpl ?? defaultFetchBootstrapToken;
|
|
673
737
|
const setHubOriginImpl = opts.setHubOriginImpl ?? defaultSetHubOrigin;
|
|
@@ -696,6 +760,48 @@ export async function init(opts: InitOpts = {}): Promise<number> {
|
|
|
696
760
|
}
|
|
697
761
|
}
|
|
698
762
|
|
|
763
|
+
// Step 0.5 (hub#694 bug 1 — the spawn race): install + seed the vault module
|
|
764
|
+
// into services.json BEFORE the hub unit starts (Step 1 below). The hub unit's
|
|
765
|
+
// `serve` runs the in-process Supervisor, which scans services.json EXACTLY
|
|
766
|
+
// ONCE at boot (`bootSupervisedModules`) and never rescans. If we seed vault
|
|
767
|
+
// AFTER the unit boots (the old Step 2.5 ordering), that single scan reads a
|
|
768
|
+
// services.json with no vault row → vault is registered-but-never-spawned, so
|
|
769
|
+
// `/vault/*` 502s until a manual `parachute restart` re-triggers a per-module
|
|
770
|
+
// start. On a slow box (1GB droplet) the scan reliably wins that race. Seeding
|
|
771
|
+
// first means the boot scan finds vault and spawns it on the first pass — no
|
|
772
|
+
// restart needed.
|
|
773
|
+
//
|
|
774
|
+
// `install("vault", { noCreate: true, noStart: true, … })` only does the
|
|
775
|
+
// on-disk work — `bun add -g` (idempotent; short-circuits when vault is
|
|
776
|
+
// bun-linked / already installed) + an `upsertService` seed write. It does NOT
|
|
777
|
+
// need a running hub: the start path, the stale-unit sweep, and the
|
|
778
|
+
// supervised-hub guidance probe are all gated off under `noCreate`/`noStart`,
|
|
779
|
+
// so running it before the unit exists is safe. The wizard's vault step still
|
|
780
|
+
// owns Create / Import / Skip (noCreate defers first-vault creation); the
|
|
781
|
+
// supervisor (not install.ts) owns spawning (noStart).
|
|
782
|
+
//
|
|
783
|
+
// Idempotent: if a vault row already exists (re-run, or a prior install), this
|
|
784
|
+
// short-circuits past the bun-add and the row is left intact. We don't block
|
|
785
|
+
// init on a non-zero exit — the wizard can retry from /admin/setup.
|
|
786
|
+
const findVaultEntry = (): boolean => {
|
|
787
|
+
try {
|
|
788
|
+
return findService("parachute-vault", manifestPath) !== undefined;
|
|
789
|
+
} catch {
|
|
790
|
+
return false;
|
|
791
|
+
}
|
|
792
|
+
};
|
|
793
|
+
const vaultAlreadyInstalled = findVaultEntry();
|
|
794
|
+
if (!vaultAlreadyInstalled) {
|
|
795
|
+
log("Installing the vault module so the wizard can offer create / import / skip…");
|
|
796
|
+
const installCode = await installVaultModuleImpl(configDir, manifestPath, installChannel);
|
|
797
|
+
if (installCode !== 0) {
|
|
798
|
+
log(
|
|
799
|
+
`⚠ vault module install returned ${installCode}; the wizard can retry from /admin/setup.`,
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
log("");
|
|
803
|
+
}
|
|
804
|
+
|
|
699
805
|
// Step 1: hub running?
|
|
700
806
|
// NB: under the Phase 3a unit-managed hub there is no pidfile, so
|
|
701
807
|
// `processState(HUB_SVC)` reports not-running on EVERY init re-run even when
|
|
@@ -831,41 +937,14 @@ export async function init(opts: InitOpts = {}): Promise<number> {
|
|
|
831
937
|
}
|
|
832
938
|
}
|
|
833
939
|
|
|
834
|
-
//
|
|
835
|
-
//
|
|
836
|
-
//
|
|
837
|
-
//
|
|
838
|
-
//
|
|
839
|
-
//
|
|
840
|
-
// would auto-create a `default` vault) is skipped. The wizard's
|
|
841
|
-
// vault step then either Creates / Imports / Skips.
|
|
842
|
-
//
|
|
843
|
-
// Idempotent: install short-circuits the bun-add when vault is
|
|
844
|
-
// already linked (`bun link`) or already globally installed. If the
|
|
845
|
-
// operator already has a vault row, this is a no-op past the
|
|
846
|
-
// already-installed log line. We don't block init on this step;
|
|
847
|
-
// a non-zero exit code is logged but treated as a warning, since the
|
|
848
|
-
// wizard can re-attempt the install itself from /admin/setup.
|
|
849
|
-
const findVaultEntry = (): boolean => {
|
|
850
|
-
try {
|
|
851
|
-
return findService("parachute-vault", manifestPath) !== undefined;
|
|
852
|
-
} catch {
|
|
853
|
-
return false;
|
|
854
|
-
}
|
|
855
|
-
};
|
|
856
|
-
const vaultAlreadyInstalled = findVaultEntry();
|
|
857
|
-
if (!vaultAlreadyInstalled) {
|
|
858
|
-
log("");
|
|
859
|
-
log("Installing the vault module so the wizard can offer create / import / skip…");
|
|
860
|
-
const installCode = await installVaultModuleImpl(configDir, manifestPath);
|
|
861
|
-
if (installCode !== 0) {
|
|
862
|
-
log(
|
|
863
|
-
`⚠ vault module install returned ${installCode}; the wizard can retry from /admin/setup.`,
|
|
864
|
-
);
|
|
865
|
-
}
|
|
866
|
-
}
|
|
940
|
+
// (The vault module install + seed now runs at Step 0.5, BEFORE the hub unit
|
|
941
|
+
// starts — see hub#694 bug 1. It used to live here, after exposure; moving it
|
|
942
|
+
// ahead of Step 1's hub bringup is what lets the supervisor's one-time boot
|
|
943
|
+
// scan find + spawn vault instead of registering it after the scan already
|
|
944
|
+
// ran. The "always install the vault module" directive — Aaron 2026-05-28,
|
|
945
|
+
// hub#168 Cut 1 — and the noCreate/noStart split are unchanged.)
|
|
867
946
|
|
|
868
|
-
// Step 3: vault configured? (After the module install above, this may
|
|
947
|
+
// Step 3: vault configured? (After the Step 0.5 module install above, this may
|
|
869
948
|
// have flipped from false to true on a fresh box. The wizard reads
|
|
870
949
|
// services.json on every request, so the "configured" answer here is
|
|
871
950
|
// best-effort — it only shapes the next-step log message below.)
|
package/src/commands/status.ts
CHANGED
|
@@ -19,8 +19,8 @@ import {
|
|
|
19
19
|
} from "../install-source.ts";
|
|
20
20
|
import {
|
|
21
21
|
type DriveModuleOpDeps,
|
|
22
|
-
type ModuleStatesResult,
|
|
23
22
|
type ModuleStateSnapshot,
|
|
23
|
+
type ModuleStatesResult,
|
|
24
24
|
NoOperatorTokenError,
|
|
25
25
|
OperatorTokenExpiredError,
|
|
26
26
|
fetchModuleStates as fetchModuleStatesImpl,
|
|
@@ -71,6 +71,17 @@ export interface StatusOpts {
|
|
|
71
71
|
probeHubHealth?: (port: number) => Promise<boolean>;
|
|
72
72
|
/** Read the running supervisor's module states (§6.4 module rows). */
|
|
73
73
|
fetchModuleStates?: (deps: DriveModuleOpDeps) => Promise<ModuleStatesResult>;
|
|
74
|
+
/**
|
|
75
|
+
* Unauthenticated module-liveness probe (#700). Used ONLY on the degraded
|
|
76
|
+
* path where the supervisor run-state read couldn't run (no/expired/invalid
|
|
77
|
+
* operator token, or any API error) but the hub itself is up: probes a
|
|
78
|
+
* module's own `/health` directly on its loopback port. Treats 2xx AND 401
|
|
79
|
+
* as live (mirrors the "auth-gated health = healthy" rule, #423: a module
|
|
80
|
+
* that answers 401 is authenticated-but-alive, not down). Bounded; never
|
|
81
|
+
* throws. Production reuses the same bounded fetch shape as the hub probe;
|
|
82
|
+
* tests inject so they don't hit the network.
|
|
83
|
+
*/
|
|
84
|
+
probeModuleHealth?: (port: number, health: string) => Promise<boolean>;
|
|
74
85
|
/**
|
|
75
86
|
* Open the hub DB used to validate/auto-rotate the operator token in
|
|
76
87
|
* `fetchModuleStates`. Production opens `<configDir>/hub.db`; tests inject a
|
|
@@ -162,6 +173,15 @@ interface StatusRow {
|
|
|
162
173
|
* Printed on a continuation line like the other notes.
|
|
163
174
|
*/
|
|
164
175
|
managerNote?: string;
|
|
176
|
+
/**
|
|
177
|
+
* Set on a module row whose STATE was derived from an unauthenticated
|
|
178
|
+
* `/health` probe rather than the supervisor's run-state (#700) — the
|
|
179
|
+
* degraded-read fallback (no/expired operator token, or an API error) where
|
|
180
|
+
* the module is genuinely serving. Tells the operator the row is live-but-
|
|
181
|
+
* thin: no PID/uptime/structured run-state until they sign in. Printed on a
|
|
182
|
+
* continuation line like the other notes.
|
|
183
|
+
*/
|
|
184
|
+
probeNote?: string;
|
|
165
185
|
}
|
|
166
186
|
|
|
167
187
|
/**
|
|
@@ -319,6 +339,7 @@ function renderRows(rows: StatusRow[], print: (line: string) => void): void {
|
|
|
319
339
|
print(` ! probe: ${row.healthDetail}`);
|
|
320
340
|
}
|
|
321
341
|
if (row.managerNote) print(` ! ${row.managerNote}`);
|
|
342
|
+
if (row.probeNote) print(` → ${row.probeNote}`);
|
|
322
343
|
if (row.driftWarning) print(` ! ${row.driftWarning}`);
|
|
323
344
|
if (row.staleNote) print(` ! ${row.staleNote}`);
|
|
324
345
|
if (row.startErrorNote) print(` ! ${row.startErrorNote}`);
|
|
@@ -336,12 +357,33 @@ function renderRows(rows: StatusRow[], print: (line: string) => void): void {
|
|
|
336
357
|
// in Phase 5b.
|
|
337
358
|
// ---------------------------------------------------------------------------
|
|
338
359
|
|
|
360
|
+
/**
|
|
361
|
+
* Default unauthenticated module-liveness probe (#700). A bounded `fetch` to the
|
|
362
|
+
* module's own `http://127.0.0.1:<port><health>`. Treats 2xx AND 401 as live —
|
|
363
|
+
* an auth-gated `/health` that answers 401 is authenticated-but-alive, not down
|
|
364
|
+
* (the "auth-gated health = healthy" rule, #423). Any other status / network
|
|
365
|
+
* error / timeout → false. 1.5s timeout, mirroring hub-unit's `defaultProbeHealth`.
|
|
366
|
+
*/
|
|
367
|
+
async function defaultProbeModuleHealth(port: number, health: string): Promise<boolean> {
|
|
368
|
+
try {
|
|
369
|
+
const res = await fetch(`http://127.0.0.1:${port}${health}`, {
|
|
370
|
+
signal: AbortSignal.timeout(1500),
|
|
371
|
+
// Loopback-only target, but never chase a redirect off-box (defensive).
|
|
372
|
+
redirect: "manual",
|
|
373
|
+
});
|
|
374
|
+
return res.ok || res.status === 401;
|
|
375
|
+
} catch {
|
|
376
|
+
return false;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
339
380
|
/** Resolved supervisor-path seams (see `StatusOpts.supervisor`). */
|
|
340
381
|
interface ResolvedStatusSupervisor {
|
|
341
382
|
hubUnitDeps: HubUnitDeps;
|
|
342
383
|
queryHubUnitState: (deps: HubUnitDeps) => HubUnitStateResult;
|
|
343
384
|
probeHubHealth: (port: number) => Promise<boolean>;
|
|
344
385
|
fetchModuleStates: (deps: DriveModuleOpDeps) => Promise<ModuleStatesResult>;
|
|
386
|
+
probeModuleHealth: (port: number, health: string) => Promise<boolean>;
|
|
345
387
|
openDb: (configDir: string) => Database;
|
|
346
388
|
baseUrl: string | undefined;
|
|
347
389
|
}
|
|
@@ -357,6 +399,7 @@ function resolveStatusSupervisor(opts: StatusOpts["supervisor"]): ResolvedStatus
|
|
|
357
399
|
queryHubUnitState: opts?.queryHubUnitState ?? queryHubUnitStateImpl,
|
|
358
400
|
probeHubHealth: opts?.probeHubHealth ?? hubUnitDeps.probeHealth,
|
|
359
401
|
fetchModuleStates: opts?.fetchModuleStates ?? fetchModuleStatesImpl,
|
|
402
|
+
probeModuleHealth: opts?.probeModuleHealth ?? defaultProbeModuleHealth,
|
|
360
403
|
openDb: opts?.openDb ?? ((configDir) => openHubDb(hubDbPath(configDir))),
|
|
361
404
|
baseUrl: opts?.baseUrl,
|
|
362
405
|
};
|
|
@@ -471,10 +514,17 @@ async function buildSupervisorRows(args: BuildSupervisorRowsArgs): Promise<Statu
|
|
|
471
514
|
...(sup.baseUrl !== undefined ? { baseUrl: sup.baseUrl } : {}),
|
|
472
515
|
});
|
|
473
516
|
} catch (err) {
|
|
474
|
-
if (err instanceof NoOperatorTokenError
|
|
475
|
-
// No
|
|
476
|
-
//
|
|
477
|
-
//
|
|
517
|
+
if (err instanceof NoOperatorTokenError) {
|
|
518
|
+
// No operator token AND none can be minted yet — on a fresh box the
|
|
519
|
+
// first admin doesn't exist, so `rotate-operator` would itself hard-error
|
|
520
|
+
// ("no hub users yet"). Point at `set-password` (create the first admin),
|
|
521
|
+
// the actual unblocking step. We still can't read run-state, but the hub
|
|
522
|
+
// is up — degrade gracefully (§6.4), do NOT 401-crash status (#700).
|
|
523
|
+
moduleReadNote =
|
|
524
|
+
"couldn't read live module state — run `parachute auth set-password` to create the first admin (then `parachute auth rotate-operator`)";
|
|
525
|
+
} else if (err instanceof OperatorTokenExpiredError) {
|
|
526
|
+
// Token exists but is stale: an admin already exists, so re-minting works.
|
|
527
|
+
// Keep the rotate-operator guidance.
|
|
478
528
|
moduleReadNote =
|
|
479
529
|
"couldn't read live module state — run `parachute auth rotate-operator` to mint an operator token";
|
|
480
530
|
} else {
|
|
@@ -500,6 +550,26 @@ async function buildSupervisorRows(args: BuildSupervisorRowsArgs): Promise<Statu
|
|
|
500
550
|
if (m.short && !stateByShort.has(m.short)) stateByShort.set(m.short, m);
|
|
501
551
|
}
|
|
502
552
|
|
|
553
|
+
// Unauthenticated-liveness fallback (#700). On the degraded path — the hub is
|
|
554
|
+
// up but we couldn't read supervisor run-state (no/expired operator token, or
|
|
555
|
+
// an API error) — probe each module's own `/health` directly so a module that
|
|
556
|
+
// is genuinely serving reads LIVE instead of being mapped null→`inactive`
|
|
557
|
+
// (which falsely told fresh-box operators a working install was broken). Keyed
|
|
558
|
+
// by the unique `entry.name`; probed concurrently, bounded, never throws.
|
|
559
|
+
const probeAlive = new Map<string, boolean>();
|
|
560
|
+
if (hubHealthy && !states) {
|
|
561
|
+
await Promise.all(
|
|
562
|
+
manifest.services.map(async (entry) => {
|
|
563
|
+
try {
|
|
564
|
+
const alive = await sup.probeModuleHealth(entry.port, entry.health);
|
|
565
|
+
if (alive) probeAlive.set(entry.name, true);
|
|
566
|
+
} catch {
|
|
567
|
+
// Probe must never crash status — absent from the map = treated as down.
|
|
568
|
+
}
|
|
569
|
+
}),
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
|
|
503
573
|
const rows: StatusRow[] = manifest.services.map((entry) => {
|
|
504
574
|
const base = manifestRowBase(entry, installSourceDeps);
|
|
505
575
|
const snap = base.short ? stateByShort.get(base.short) : undefined;
|
|
@@ -526,6 +596,39 @@ async function buildSupervisorRows(args: BuildSupervisorRowsArgs): Promise<Statu
|
|
|
526
596
|
};
|
|
527
597
|
}
|
|
528
598
|
|
|
599
|
+
// Degraded read, but the module answered an unauthenticated `/health` probe
|
|
600
|
+
// (#700): show it LIVE instead of null→`inactive`. We can't surface PID/
|
|
601
|
+
// uptime/structured run-state (those need the operator token), so keep the
|
|
602
|
+
// degraded `moduleReadNote` AND add a probe-derived continuation note so the
|
|
603
|
+
// operator understands the row is from a liveness probe, not full supervisor
|
|
604
|
+
// state. `skipped: true` keeps a working install at exit 0.
|
|
605
|
+
if (!snap && probeAlive.get(entry.name)) {
|
|
606
|
+
const row: StatusRow = {
|
|
607
|
+
service: entry.name,
|
|
608
|
+
port: String(entry.port),
|
|
609
|
+
version: entry.version,
|
|
610
|
+
stateLabel: "active",
|
|
611
|
+
pidLabel: "-",
|
|
612
|
+
uptimeLabel: "-",
|
|
613
|
+
healthDetail: "-",
|
|
614
|
+
latencyLabel: "-",
|
|
615
|
+
sourceLabel: base.sourceLabel,
|
|
616
|
+
url: base.url,
|
|
617
|
+
healthy: true,
|
|
618
|
+
skipped: true,
|
|
619
|
+
};
|
|
620
|
+
row.probeNote = "live via unauthenticated health probe — sign in for full supervisor state";
|
|
621
|
+
if (base.driftWarning) row.driftWarning = base.driftWarning;
|
|
622
|
+
if (base.staleNote) row.staleNote = base.staleNote;
|
|
623
|
+
if (base.manifestStartErrorNote) row.startErrorNote = base.manifestStartErrorNote;
|
|
624
|
+
// Surface the degraded-read note ONCE (first module row), same as below.
|
|
625
|
+
if (moduleReadNote) {
|
|
626
|
+
row.managerNote = moduleReadNote;
|
|
627
|
+
moduleReadNote = undefined;
|
|
628
|
+
}
|
|
629
|
+
return row;
|
|
630
|
+
}
|
|
631
|
+
|
|
529
632
|
const { stateLabel, healthy, skipped } = mapSupervisorStatus(snap?.supervisor_status ?? null);
|
|
530
633
|
// Prefer the supervisor's structured start-error (live), else the persisted
|
|
531
634
|
// services.json note — same friendly surface either way (#188).
|
package/src/help.ts
CHANGED
|
@@ -132,6 +132,7 @@ export function initHelp(): string {
|
|
|
132
132
|
Usage:
|
|
133
133
|
parachute init [--no-browser] [--no-expose-prompt]
|
|
134
134
|
[--expose none|tailnet|cloudflare]
|
|
135
|
+
[--channel rc|latest]
|
|
135
136
|
[--hub-origin <url>]
|
|
136
137
|
[--cli-wizard | --browser-wizard]
|
|
137
138
|
|
|
@@ -168,6 +169,10 @@ Flags:
|
|
|
168
169
|
none — stay loopback-only
|
|
169
170
|
tailnet — set up Tailscale serve (private to your tailnet)
|
|
170
171
|
cloudflare — set up Cloudflare Tunnel (your own domain)
|
|
172
|
+
--channel <rc|latest> npm dist-tag for the vault module install (default: latest).
|
|
173
|
+
Use \`rc\` on an rc-channel box so init doesn't downgrade
|
|
174
|
+
vault below the hub. Also honors PARACHUTE_CHANNEL /
|
|
175
|
+
PARACHUTE_INSTALL_CHANNEL env when the flag is absent.
|
|
171
176
|
--hub-origin <url> set the canonical public origin (OAuth issuer) BEFORE
|
|
172
177
|
the hub + modules start, so vault/scribe come up
|
|
173
178
|
accepting it in one pass. For reverse-proxy /
|
|
@@ -185,6 +190,7 @@ Examples:
|
|
|
185
190
|
parachute init --expose tailnet # CI/scripted: chain straight into Tailscale
|
|
186
191
|
parachute init --no-browser # don't shell out to open / xdg-open
|
|
187
192
|
parachute init --cli-wizard # walk the wizard in this terminal (hub#168)
|
|
193
|
+
parachute init --channel rc # rc box: install the vault module from @rc
|
|
188
194
|
`;
|
|
189
195
|
}
|
|
190
196
|
|