@openparachute/hub 0.5.14-rc.5 → 0.5.14-rc.7
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__/api-ready.test.ts +135 -0
- package/src/__tests__/hub-server.test.ts +299 -10
- package/src/__tests__/init.test.ts +451 -1
- package/src/__tests__/proxy-error-ui.test.ts +212 -0
- package/src/__tests__/proxy-state.test.ts +192 -0
- package/src/api-ready.ts +102 -0
- package/src/cli.ts +27 -3
- package/src/commands/init.ts +204 -21
- package/src/help.ts +31 -14
- package/src/hub-server.ts +67 -13
- package/src/proxy-error-ui.ts +506 -0
- package/src/proxy-state.ts +131 -0
package/package.json
CHANGED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { handleApiReady } from "../api-ready.ts";
|
|
3
|
+
import type { ModuleState, Supervisor } from "../supervisor.ts";
|
|
4
|
+
|
|
5
|
+
function stubSupervisor(states: ModuleState[]): Supervisor {
|
|
6
|
+
return {
|
|
7
|
+
list: () => states,
|
|
8
|
+
get: (short: string) => states.find((s) => s.short === short),
|
|
9
|
+
start: async () => {
|
|
10
|
+
throw new Error("not implemented");
|
|
11
|
+
},
|
|
12
|
+
stop: async () => undefined,
|
|
13
|
+
restart: async () => undefined,
|
|
14
|
+
} as unknown as Supervisor;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function req(): Request {
|
|
18
|
+
return new Request("http://127.0.0.1/api/ready", {
|
|
19
|
+
headers: { accept: "application/json" },
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function moduleState(partial: Partial<ModuleState> & { short: string }): ModuleState {
|
|
24
|
+
return {
|
|
25
|
+
status: "running",
|
|
26
|
+
restartsInWindow: 0,
|
|
27
|
+
...partial,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("handleApiReady — no supervisor (CLI mode)", () => {
|
|
32
|
+
test("returns ready=true + empty arrays when supervisor absent", async () => {
|
|
33
|
+
const res = handleApiReady(req());
|
|
34
|
+
expect(res.status).toBe(200);
|
|
35
|
+
const body = (await res.json()) as {
|
|
36
|
+
ready: boolean;
|
|
37
|
+
ready_modules: string[];
|
|
38
|
+
transient_modules: string[];
|
|
39
|
+
persistent_modules: string[];
|
|
40
|
+
};
|
|
41
|
+
expect(body.ready).toBe(true);
|
|
42
|
+
expect(body.ready_modules).toEqual([]);
|
|
43
|
+
expect(body.transient_modules).toEqual([]);
|
|
44
|
+
expect(body.persistent_modules).toEqual([]);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("handleApiReady — supervisor mode", () => {
|
|
49
|
+
test("all modules running past boot window → ready=true", async () => {
|
|
50
|
+
const now = 1_700_000_000_000;
|
|
51
|
+
const startedAt = new Date(now - 60_000).toISOString();
|
|
52
|
+
const sup = stubSupervisor([
|
|
53
|
+
moduleState({ short: "vault", status: "running", startedAt }),
|
|
54
|
+
moduleState({ short: "scribe", status: "running", startedAt }),
|
|
55
|
+
]);
|
|
56
|
+
const res = handleApiReady(req(), { supervisor: sup, now: () => now });
|
|
57
|
+
const body = (await res.json()) as {
|
|
58
|
+
ready: boolean;
|
|
59
|
+
ready_modules: string[];
|
|
60
|
+
transient_modules: string[];
|
|
61
|
+
persistent_modules: string[];
|
|
62
|
+
};
|
|
63
|
+
expect(body.ready).toBe(true);
|
|
64
|
+
expect(body.ready_modules.sort()).toEqual(["scribe", "vault"]);
|
|
65
|
+
expect(body.transient_modules).toEqual([]);
|
|
66
|
+
expect(body.persistent_modules).toEqual([]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("module inside boot window → transient, ready=false", async () => {
|
|
70
|
+
const now = 1_700_000_000_000;
|
|
71
|
+
const sup = stubSupervisor([
|
|
72
|
+
moduleState({
|
|
73
|
+
short: "vault",
|
|
74
|
+
status: "running",
|
|
75
|
+
startedAt: new Date(now - 10_000).toISOString(),
|
|
76
|
+
}),
|
|
77
|
+
]);
|
|
78
|
+
const res = handleApiReady(req(), { supervisor: sup, now: () => now });
|
|
79
|
+
const body = (await res.json()) as {
|
|
80
|
+
ready: boolean;
|
|
81
|
+
ready_modules: string[];
|
|
82
|
+
transient_modules: string[];
|
|
83
|
+
};
|
|
84
|
+
expect(body.ready).toBe(false);
|
|
85
|
+
expect(body.transient_modules).toEqual(["vault"]);
|
|
86
|
+
expect(body.ready_modules).toEqual([]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("starting + restarting + crashed all classified correctly", async () => {
|
|
90
|
+
const now = 1_700_000_000_000;
|
|
91
|
+
const sup = stubSupervisor([
|
|
92
|
+
moduleState({ short: "vault", status: "starting" }),
|
|
93
|
+
moduleState({ short: "scribe", status: "restarting" }),
|
|
94
|
+
moduleState({ short: "notes", status: "crashed" }),
|
|
95
|
+
moduleState({ short: "channel", status: "stopped" }),
|
|
96
|
+
moduleState({
|
|
97
|
+
short: "runner",
|
|
98
|
+
status: "running",
|
|
99
|
+
startedAt: new Date(now - 60_000).toISOString(),
|
|
100
|
+
}),
|
|
101
|
+
]);
|
|
102
|
+
const res = handleApiReady(req(), { supervisor: sup, now: () => now });
|
|
103
|
+
const body = (await res.json()) as {
|
|
104
|
+
ready: boolean;
|
|
105
|
+
ready_modules: string[];
|
|
106
|
+
transient_modules: string[];
|
|
107
|
+
persistent_modules: string[];
|
|
108
|
+
};
|
|
109
|
+
expect(body.ready).toBe(false);
|
|
110
|
+
expect(body.ready_modules).toEqual(["runner"]);
|
|
111
|
+
expect(body.transient_modules.sort()).toEqual(["scribe", "vault"]);
|
|
112
|
+
expect(body.persistent_modules.sort()).toEqual(["channel", "notes"]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("only crashed/stopped + nothing transient → ready=false (still failing)", async () => {
|
|
116
|
+
const sup = stubSupervisor([moduleState({ short: "vault", status: "crashed" })]);
|
|
117
|
+
const res = handleApiReady(req(), { supervisor: sup });
|
|
118
|
+
const body = (await res.json()) as { ready: boolean };
|
|
119
|
+
expect(body.ready).toBe(false);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("handleApiReady — method check", () => {
|
|
124
|
+
test("rejects non-GET", () => {
|
|
125
|
+
const r = new Request("http://127.0.0.1/api/ready", { method: "POST" });
|
|
126
|
+
const res = handleApiReady(r);
|
|
127
|
+
expect(res.status).toBe(405);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("accepts HEAD", () => {
|
|
131
|
+
const r = new Request("http://127.0.0.1/api/ready", { method: "HEAD" });
|
|
132
|
+
const res = handleApiReady(r);
|
|
133
|
+
expect(res.status).toBe(200);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -17,6 +17,7 @@ import { clearNotesRedirectLogState } from "../notes-redirect.ts";
|
|
|
17
17
|
import { pidPath } from "../process-state.ts";
|
|
18
18
|
import { type ServiceEntry, writeManifest } from "../services-manifest.ts";
|
|
19
19
|
import { rotateSigningKey } from "../signing-keys.ts";
|
|
20
|
+
import type { ModuleState, Supervisor } from "../supervisor.ts";
|
|
20
21
|
import { createUser } from "../users.ts";
|
|
21
22
|
|
|
22
23
|
interface Harness {
|
|
@@ -42,6 +43,33 @@ function mkdirIfMissing(dir: string): void {
|
|
|
42
43
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Minimal stub of the Supervisor surface that proxyRequest's classifier
|
|
48
|
+
* reads from. We don't drive real Bun.spawn — just hand back hand-crafted
|
|
49
|
+
* ModuleState values so the test can pin "vault is starting" / "vault has
|
|
50
|
+
* been running for a minute" exactly. See `supervisor.test.ts` for the
|
|
51
|
+
* real lifecycle tests.
|
|
52
|
+
*/
|
|
53
|
+
function stubSupervisor(states: ModuleState[]): Supervisor {
|
|
54
|
+
return {
|
|
55
|
+
list: () => states,
|
|
56
|
+
get: (short: string) => states.find((s) => s.short === short),
|
|
57
|
+
start: async () => {
|
|
58
|
+
throw new Error("not implemented");
|
|
59
|
+
},
|
|
60
|
+
stop: async () => undefined,
|
|
61
|
+
restart: async () => undefined,
|
|
62
|
+
} as unknown as Supervisor;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function moduleState(partial: Partial<ModuleState> & { short: string }): ModuleState {
|
|
66
|
+
return {
|
|
67
|
+
status: "running",
|
|
68
|
+
restartsInWindow: 0,
|
|
69
|
+
...partial,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
45
73
|
function vaultEntry(name: string): ServiceEntry {
|
|
46
74
|
return {
|
|
47
75
|
name: `parachute-vault-${name}`,
|
|
@@ -1912,10 +1940,12 @@ describe("hubFetch /vault/<name>/* dynamic proxy (#144)", () => {
|
|
|
1912
1940
|
}
|
|
1913
1941
|
});
|
|
1914
1942
|
|
|
1915
|
-
test("returns 502 when the matching vault upstream is unreachable", async () => {
|
|
1943
|
+
test("returns 502 with persistent-state JSON when the matching vault upstream is unreachable", async () => {
|
|
1916
1944
|
// Vault is in services.json but the port has nothing listening — vault
|
|
1917
1945
|
// crashed, port shifted, or the user is mid-restart. We owe the caller a
|
|
1918
|
-
// useful error instead of a hang or a silent 404.
|
|
1946
|
+
// useful error instead of a hang or a silent 404. No supervisor +
|
|
1947
|
+
// no pidfile → classifier returns "persistent" → 502 + admin_url
|
|
1948
|
+
// (hub#443 boot-readiness gating).
|
|
1919
1949
|
const h = makeHarness();
|
|
1920
1950
|
try {
|
|
1921
1951
|
writeManifest(
|
|
@@ -1934,10 +1964,261 @@ describe("hubFetch /vault/<name>/* dynamic proxy (#144)", () => {
|
|
|
1934
1964
|
h.manifestPath,
|
|
1935
1965
|
);
|
|
1936
1966
|
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1937
|
-
const res = await fetcher(
|
|
1967
|
+
const res = await fetcher(
|
|
1968
|
+
req("/vault/default/health", { headers: { accept: "application/json" } }),
|
|
1969
|
+
);
|
|
1970
|
+
expect(res.status).toBe(502);
|
|
1971
|
+
const body = (await res.json()) as {
|
|
1972
|
+
error: string;
|
|
1973
|
+
error_type: string;
|
|
1974
|
+
admin_url?: string;
|
|
1975
|
+
service: string;
|
|
1976
|
+
};
|
|
1977
|
+
expect(body.error_type).toBe("upstream_unreachable");
|
|
1978
|
+
expect(body.error).toBe("upstream_unreachable");
|
|
1979
|
+
expect(body.admin_url).toBe("/admin/modules");
|
|
1980
|
+
expect(body.service).toBe("vault");
|
|
1981
|
+
} finally {
|
|
1982
|
+
h.cleanup();
|
|
1983
|
+
}
|
|
1984
|
+
});
|
|
1985
|
+
|
|
1986
|
+
test("transient state (supervisor reports starting) → 503 + Retry-After when fetch fails", async () => {
|
|
1987
|
+
// Supervisor says vault is `starting` — the loopback port hasn't bound
|
|
1988
|
+
// yet, fetch fails with ECONNREFUSED. Classifier returns "transient",
|
|
1989
|
+
// proxy responds 503 with a Retry-After hint instead of 502.
|
|
1990
|
+
const h = makeHarness();
|
|
1991
|
+
try {
|
|
1992
|
+
writeManifest(
|
|
1993
|
+
{
|
|
1994
|
+
services: [
|
|
1995
|
+
{
|
|
1996
|
+
name: "parachute-vault",
|
|
1997
|
+
port: await pickClosedPort(),
|
|
1998
|
+
paths: ["/vault/default"],
|
|
1999
|
+
health: "/vault/default/health",
|
|
2000
|
+
version: "0.4.0",
|
|
2001
|
+
},
|
|
2002
|
+
],
|
|
2003
|
+
},
|
|
2004
|
+
h.manifestPath,
|
|
2005
|
+
);
|
|
2006
|
+
const supervisor = stubSupervisor([moduleState({ short: "vault", status: "starting" })]);
|
|
2007
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath, supervisor });
|
|
2008
|
+
const res = await fetcher(
|
|
2009
|
+
req("/vault/default/health", { headers: { accept: "application/json" } }),
|
|
2010
|
+
);
|
|
2011
|
+
expect(res.status).toBe(503);
|
|
2012
|
+
expect(res.headers.get("retry-after")).toBe("2");
|
|
2013
|
+
const body = (await res.json()) as {
|
|
2014
|
+
error_type: string;
|
|
2015
|
+
retry_after_ms: number;
|
|
2016
|
+
max_attempts: number;
|
|
2017
|
+
admin_url?: string;
|
|
2018
|
+
};
|
|
2019
|
+
expect(body.error_type).toBe("upstream_starting");
|
|
2020
|
+
expect(body.retry_after_ms).toBe(2000);
|
|
2021
|
+
expect(body.max_attempts).toBe(5);
|
|
2022
|
+
// Transient JSON MUST NOT carry an admin link.
|
|
2023
|
+
expect(body.admin_url).toBeUndefined();
|
|
2024
|
+
} finally {
|
|
2025
|
+
h.cleanup();
|
|
2026
|
+
}
|
|
2027
|
+
});
|
|
2028
|
+
|
|
2029
|
+
test("transient state + Accept: text/html → 503 HTML page with auto-refresh + poll, no admin link", async () => {
|
|
2030
|
+
const h = makeHarness();
|
|
2031
|
+
try {
|
|
2032
|
+
writeManifest(
|
|
2033
|
+
{
|
|
2034
|
+
services: [
|
|
2035
|
+
{
|
|
2036
|
+
name: "parachute-vault",
|
|
2037
|
+
port: await pickClosedPort(),
|
|
2038
|
+
paths: ["/vault/default"],
|
|
2039
|
+
health: "/vault/default/health",
|
|
2040
|
+
version: "0.4.0",
|
|
2041
|
+
},
|
|
2042
|
+
],
|
|
2043
|
+
},
|
|
2044
|
+
h.manifestPath,
|
|
2045
|
+
);
|
|
2046
|
+
const supervisor = stubSupervisor([moduleState({ short: "vault", status: "starting" })]);
|
|
2047
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath, supervisor });
|
|
2048
|
+
const res = await fetcher(req("/vault/default/health", { headers: { accept: "text/html" } }));
|
|
2049
|
+
expect(res.status).toBe(503);
|
|
2050
|
+
expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
|
2051
|
+
const text = await res.text();
|
|
2052
|
+
expect(text).toContain(`<meta http-equiv="refresh"`);
|
|
2053
|
+
expect(text).toContain("/api/ready");
|
|
2054
|
+
expect(text).toContain("Just a moment");
|
|
2055
|
+
// Aaron design (d): transient page has no admin link.
|
|
2056
|
+
expect(text).not.toContain("/admin/modules");
|
|
2057
|
+
} finally {
|
|
2058
|
+
h.cleanup();
|
|
2059
|
+
}
|
|
2060
|
+
});
|
|
2061
|
+
|
|
2062
|
+
test("persistent state + Accept: text/html → 502 HTML page with admin link, no auto-refresh", async () => {
|
|
2063
|
+
const h = makeHarness();
|
|
2064
|
+
try {
|
|
2065
|
+
writeManifest(
|
|
2066
|
+
{
|
|
2067
|
+
services: [
|
|
2068
|
+
{
|
|
2069
|
+
name: "parachute-vault",
|
|
2070
|
+
port: await pickClosedPort(),
|
|
2071
|
+
paths: ["/vault/default"],
|
|
2072
|
+
health: "/vault/default/health",
|
|
2073
|
+
version: "0.4.0",
|
|
2074
|
+
},
|
|
2075
|
+
],
|
|
2076
|
+
},
|
|
2077
|
+
h.manifestPath,
|
|
2078
|
+
);
|
|
2079
|
+
const supervisor = stubSupervisor([moduleState({ short: "vault", status: "crashed" })]);
|
|
2080
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath, supervisor });
|
|
2081
|
+
const res = await fetcher(req("/vault/default/health", { headers: { accept: "text/html" } }));
|
|
1938
2082
|
expect(res.status).toBe(502);
|
|
1939
|
-
|
|
1940
|
-
expect(
|
|
2083
|
+
expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
|
2084
|
+
expect(res.headers.get("retry-after")).toBeNull();
|
|
2085
|
+
const text = await res.text();
|
|
2086
|
+
expect(text).toContain("Module unreachable");
|
|
2087
|
+
expect(text).toContain("/admin/modules");
|
|
2088
|
+
expect(text).not.toContain(`http-equiv="refresh"`);
|
|
2089
|
+
} finally {
|
|
2090
|
+
h.cleanup();
|
|
2091
|
+
}
|
|
2092
|
+
});
|
|
2093
|
+
|
|
2094
|
+
test("supervisor running-but-fresh-startedAt → transient classification", async () => {
|
|
2095
|
+
// Supervisor says vault is running, but startedAt is recent enough that
|
|
2096
|
+
// we trust the boot-window heuristic over the failed fetch.
|
|
2097
|
+
const h = makeHarness();
|
|
2098
|
+
try {
|
|
2099
|
+
writeManifest(
|
|
2100
|
+
{
|
|
2101
|
+
services: [
|
|
2102
|
+
{
|
|
2103
|
+
name: "parachute-vault",
|
|
2104
|
+
port: await pickClosedPort(),
|
|
2105
|
+
paths: ["/vault/default"],
|
|
2106
|
+
health: "/vault/default/health",
|
|
2107
|
+
version: "0.4.0",
|
|
2108
|
+
},
|
|
2109
|
+
],
|
|
2110
|
+
},
|
|
2111
|
+
h.manifestPath,
|
|
2112
|
+
);
|
|
2113
|
+
const supervisor = stubSupervisor([
|
|
2114
|
+
moduleState({
|
|
2115
|
+
short: "vault",
|
|
2116
|
+
status: "running",
|
|
2117
|
+
startedAt: new Date(Date.now() - 5_000).toISOString(),
|
|
2118
|
+
}),
|
|
2119
|
+
]);
|
|
2120
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath, supervisor });
|
|
2121
|
+
const res = await fetcher(
|
|
2122
|
+
req("/vault/default/health", { headers: { accept: "application/json" } }),
|
|
2123
|
+
);
|
|
2124
|
+
expect(res.status).toBe(503);
|
|
2125
|
+
const body = (await res.json()) as { error_type: string };
|
|
2126
|
+
expect(body.error_type).toBe("upstream_starting");
|
|
2127
|
+
} finally {
|
|
2128
|
+
h.cleanup();
|
|
2129
|
+
}
|
|
2130
|
+
});
|
|
2131
|
+
|
|
2132
|
+
test("supervisor running-but-old-startedAt → persistent (boot window expired)", async () => {
|
|
2133
|
+
const h = makeHarness();
|
|
2134
|
+
try {
|
|
2135
|
+
writeManifest(
|
|
2136
|
+
{
|
|
2137
|
+
services: [
|
|
2138
|
+
{
|
|
2139
|
+
name: "parachute-vault",
|
|
2140
|
+
port: await pickClosedPort(),
|
|
2141
|
+
paths: ["/vault/default"],
|
|
2142
|
+
health: "/vault/default/health",
|
|
2143
|
+
version: "0.4.0",
|
|
2144
|
+
},
|
|
2145
|
+
],
|
|
2146
|
+
},
|
|
2147
|
+
h.manifestPath,
|
|
2148
|
+
);
|
|
2149
|
+
const supervisor = stubSupervisor([
|
|
2150
|
+
moduleState({
|
|
2151
|
+
short: "vault",
|
|
2152
|
+
status: "running",
|
|
2153
|
+
startedAt: new Date(Date.now() - 120_000).toISOString(),
|
|
2154
|
+
}),
|
|
2155
|
+
]);
|
|
2156
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath, supervisor });
|
|
2157
|
+
const res = await fetcher(
|
|
2158
|
+
req("/vault/default/health", { headers: { accept: "application/json" } }),
|
|
2159
|
+
);
|
|
2160
|
+
expect(res.status).toBe(502);
|
|
2161
|
+
const body = (await res.json()) as { error_type: string };
|
|
2162
|
+
expect(body.error_type).toBe("upstream_unreachable");
|
|
2163
|
+
} finally {
|
|
2164
|
+
h.cleanup();
|
|
2165
|
+
}
|
|
2166
|
+
});
|
|
2167
|
+
|
|
2168
|
+
test("non-vault upstream (scribe) classified through supervisor when starting", async () => {
|
|
2169
|
+
// Same logic as vault, but exercising the generic /<svc>/* dispatch path.
|
|
2170
|
+
const h = makeHarness();
|
|
2171
|
+
try {
|
|
2172
|
+
writeManifest(
|
|
2173
|
+
{
|
|
2174
|
+
services: [
|
|
2175
|
+
{
|
|
2176
|
+
name: "scribe",
|
|
2177
|
+
port: await pickClosedPort(),
|
|
2178
|
+
paths: ["/scribe"],
|
|
2179
|
+
health: "/scribe/health",
|
|
2180
|
+
version: "0.1.0",
|
|
2181
|
+
},
|
|
2182
|
+
],
|
|
2183
|
+
},
|
|
2184
|
+
h.manifestPath,
|
|
2185
|
+
);
|
|
2186
|
+
const supervisor = stubSupervisor([moduleState({ short: "scribe", status: "starting" })]);
|
|
2187
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath, supervisor });
|
|
2188
|
+
const res = await fetcher(req("/scribe/health", { headers: { accept: "application/json" } }));
|
|
2189
|
+
expect(res.status).toBe(503);
|
|
2190
|
+
const body = (await res.json()) as { error_type: string; service: string };
|
|
2191
|
+
expect(body.error_type).toBe("upstream_starting");
|
|
2192
|
+
expect(body.service).toBe("scribe");
|
|
2193
|
+
} finally {
|
|
2194
|
+
h.cleanup();
|
|
2195
|
+
}
|
|
2196
|
+
});
|
|
2197
|
+
|
|
2198
|
+
test("/api/ready returns supervisor view + is reachable pre-admin", async () => {
|
|
2199
|
+
// hub#443 endpoint is public + pre-admin (it has to be — the page that
|
|
2200
|
+
// polls it is itself served pre-auth when modules are still booting).
|
|
2201
|
+
const h = makeHarness();
|
|
2202
|
+
try {
|
|
2203
|
+
const supervisor = stubSupervisor([
|
|
2204
|
+
moduleState({ short: "vault", status: "starting" }),
|
|
2205
|
+
moduleState({
|
|
2206
|
+
short: "scribe",
|
|
2207
|
+
status: "running",
|
|
2208
|
+
startedAt: new Date(Date.now() - 60_000).toISOString(),
|
|
2209
|
+
}),
|
|
2210
|
+
]);
|
|
2211
|
+
const fetcher = hubFetch(h.dir, { supervisor });
|
|
2212
|
+
const res = await fetcher(req("/api/ready"));
|
|
2213
|
+
expect(res.status).toBe(200);
|
|
2214
|
+
const body = (await res.json()) as {
|
|
2215
|
+
ready: boolean;
|
|
2216
|
+
ready_modules: string[];
|
|
2217
|
+
transient_modules: string[];
|
|
2218
|
+
};
|
|
2219
|
+
expect(body.ready).toBe(false);
|
|
2220
|
+
expect(body.transient_modules).toEqual(["vault"]);
|
|
2221
|
+
expect(body.ready_modules).toEqual(["scribe"]);
|
|
1941
2222
|
} finally {
|
|
1942
2223
|
h.cleanup();
|
|
1943
2224
|
}
|
|
@@ -2562,9 +2843,10 @@ describe("hubFetch /<svc>/* generic proxy dispatch (#182)", () => {
|
|
|
2562
2843
|
}
|
|
2563
2844
|
});
|
|
2564
2845
|
|
|
2565
|
-
test("returns 502 when the matching upstream is unreachable", async () => {
|
|
2846
|
+
test("returns 502 with persistent-state JSON when the matching upstream is unreachable", async () => {
|
|
2566
2847
|
// Service is in services.json but the port has nothing listening — same
|
|
2567
|
-
// shape as the vault-unreachable test
|
|
2848
|
+
// shape as the vault-unreachable test (hub#443 boot-readiness gating).
|
|
2849
|
+
// No supervisor + no pidfile → persistent → 502 with admin_url.
|
|
2568
2850
|
const h = makeHarness();
|
|
2569
2851
|
try {
|
|
2570
2852
|
writeManifest(
|
|
@@ -2582,10 +2864,17 @@ describe("hubFetch /<svc>/* generic proxy dispatch (#182)", () => {
|
|
|
2582
2864
|
h.manifestPath,
|
|
2583
2865
|
);
|
|
2584
2866
|
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
2585
|
-
const res = await fetcher(req("/scribe/health"));
|
|
2867
|
+
const res = await fetcher(req("/scribe/health", { headers: { accept: "application/json" } }));
|
|
2586
2868
|
expect(res.status).toBe(502);
|
|
2587
|
-
const body = (await res.json()) as {
|
|
2588
|
-
|
|
2869
|
+
const body = (await res.json()) as {
|
|
2870
|
+
error: string;
|
|
2871
|
+
error_type: string;
|
|
2872
|
+
admin_url?: string;
|
|
2873
|
+
service: string;
|
|
2874
|
+
};
|
|
2875
|
+
expect(body.error_type).toBe("upstream_unreachable");
|
|
2876
|
+
expect(body.admin_url).toBe("/admin/modules");
|
|
2877
|
+
expect(body.service).toBe("scribe");
|
|
2589
2878
|
} finally {
|
|
2590
2879
|
h.cleanup();
|
|
2591
2880
|
}
|