@celilo/console-server 0.5.1 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/spa/assets/{index-C31pEQ-J.js → index-bR5M5myY.js} +3 -3
- package/spa/index.html +9 -1
- package/src/main.test.ts +38 -0
- package/src/main.ts +26 -7
- package/src/upstream.ts +42 -4
- package/src/verbs.ts +55 -12
package/spa/index.html
CHANGED
|
@@ -4,7 +4,15 @@
|
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
6
|
<title>celilo</title>
|
|
7
|
-
|
|
7
|
+
<!-- Inline so there is no file to ship and no second request. The glyph is
|
|
8
|
+
drawn by the VIEWER's emoji font, so it will not look identical across
|
|
9
|
+
platforms, and a system without the woman-technologist ZWJ sequence
|
|
10
|
+
falls back to two glyphs side by side rather than one. -->
|
|
11
|
+
<link
|
|
12
|
+
rel="icon"
|
|
13
|
+
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>👩💻</text></svg>"
|
|
14
|
+
/>
|
|
15
|
+
<script type="module" crossorigin src="/assets/index-bR5M5myY.js"></script>
|
|
8
16
|
<link rel="stylesheet" crossorigin href="/assets/index-eLF_dgP-.css">
|
|
9
17
|
</head>
|
|
10
18
|
<body>
|
package/src/main.test.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { startFromEnv } from './main';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The console used to fall back to the bare destination `celilo-api@celilo-mgr`
|
|
6
|
+
* when CELILO_API_DEST was unset. That name resolves only on celilo-mgr itself,
|
|
7
|
+
* so on the console's own box every /api read 502'd while `celilo module health`
|
|
8
|
+
* reported VERIFIED — it drives an OIDC login and never touches the upstream.
|
|
9
|
+
* celilo#1453.
|
|
10
|
+
*
|
|
11
|
+
* A silent fallback to an unreachable default is worse than no default: it turns
|
|
12
|
+
* a missing value into a runtime symptom three layers from its cause. So the
|
|
13
|
+
* behaviour under test is the REFUSAL, not the presence of a constant.
|
|
14
|
+
*/
|
|
15
|
+
const OIDC_ENV = {
|
|
16
|
+
OIDC_ISSUER: 'https://auth.example.test/application/o/console/',
|
|
17
|
+
OIDC_CLIENT_ID: 'console-web',
|
|
18
|
+
OIDC_CLIENT_SECRET: 'not-a-real-secret',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
describe('startFromEnv — the upstream destination', () => {
|
|
22
|
+
test('refuses to start when CELILO_API_DEST is unset', () => {
|
|
23
|
+
expect(() => startFromEnv({ ...OIDC_ENV })).toThrow(/CELILO_API_DEST is not set/);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('refuses to start when CELILO_API_DEST is empty', () => {
|
|
27
|
+
expect(() => startFromEnv({ ...OIDC_ENV, CELILO_API_DEST: '' })).toThrow(
|
|
28
|
+
/CELILO_API_DEST is not set/,
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('says what a usable destination looks like, so the message is actionable', () => {
|
|
33
|
+
// An error naming only the variable sends the reader to grep. This one has to
|
|
34
|
+
// carry the shape of the answer, because the shape IS the bug: a bare
|
|
35
|
+
// hostname looks correct and resolves nowhere.
|
|
36
|
+
expect(() => startFromEnv({ ...OIDC_ENV })).toThrow(/\.infra\.<domain>/);
|
|
37
|
+
});
|
|
38
|
+
});
|
package/src/main.ts
CHANGED
|
@@ -23,8 +23,18 @@ import { type AuthGate, type AuthSubject, serve } from './serve';
|
|
|
23
23
|
*/
|
|
24
24
|
const DEFAULT_IDENTITY_FILE = '/etc/celilo-web-console/id_ed25519';
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
// There is deliberately no DEFAULT_DEST. It used to be `celilo-api@celilo-mgr`,
|
|
27
|
+
// borrowed from wellspring's DEFAULT_ENDPOINT — a bare name that resolves only on
|
|
28
|
+
// celilo-mgr itself, via its own /etc/hosts. No system in the fleet carries a DNS
|
|
29
|
+
// search domain, so on the console's own box it resolved nowhere, and the server
|
|
30
|
+
// fell back to it silently whenever CELILO_API_DEST was unset. The result was
|
|
31
|
+
// `ssh: Could not resolve hostname celilo-mgr` twice a second and a 502 on every
|
|
32
|
+
// /api read, while `celilo module health` still reported VERIFIED because it
|
|
33
|
+
// drives a login and never touches the upstream. celilo#1453.
|
|
34
|
+
//
|
|
35
|
+
// A missing destination is a misconfiguration, so it fails loudly. The fleet's
|
|
36
|
+
// real name for a system is `<hostname>.infra.<domain>`, which technitium
|
|
37
|
+
// registers for every system (on-system-event.ts:120).
|
|
28
38
|
|
|
29
39
|
export interface OidcGateOptions {
|
|
30
40
|
/** The `iss` claim exactly — for authentik, `https://auth.<domain>/application/o/<slug>/`. */
|
|
@@ -95,12 +105,17 @@ export function oidcGate(options: OidcGateOptions): AuthGate {
|
|
|
95
105
|
}
|
|
96
106
|
|
|
97
107
|
/** Read one required value, or say which one is missing and who writes it. */
|
|
98
|
-
|
|
108
|
+
const OIDC_UNSET =
|
|
109
|
+
"The console refuses to serve without an identity provider — see /etc/celilo-web-console-discovered.env, which the module's install hook writes once the OIDC client exists.";
|
|
110
|
+
|
|
111
|
+
function required(
|
|
112
|
+
env: Record<string, string | undefined>,
|
|
113
|
+
name: string,
|
|
114
|
+
why: string = OIDC_UNSET,
|
|
115
|
+
): string {
|
|
99
116
|
const value = env[name];
|
|
100
117
|
if (value === undefined || value === '') {
|
|
101
|
-
throw new Error(
|
|
102
|
-
`${name} is not set. The console refuses to serve without an identity provider — see /etc/celilo-web-console-discovered.env, which the module's install hook writes once the OIDC client exists.`,
|
|
103
|
-
);
|
|
118
|
+
throw new Error(`${name} is not set. ${why}`);
|
|
104
119
|
}
|
|
105
120
|
return value;
|
|
106
121
|
}
|
|
@@ -126,7 +141,11 @@ export function startFromEnv(env: Record<string, string | undefined> = process.e
|
|
|
126
141
|
);
|
|
127
142
|
const server = serve({
|
|
128
143
|
port: Number(env.PORT ?? 8443),
|
|
129
|
-
dest:
|
|
144
|
+
dest: required(
|
|
145
|
+
env,
|
|
146
|
+
'CELILO_API_DEST',
|
|
147
|
+
'The console has no celilo to read through. The module writes it into /etc/celilo-web-console.env from its `api_dest` config; it must be a resolvable destination such as celilo-api@celilo-mgr.infra.<domain>, never a bare hostname.',
|
|
148
|
+
),
|
|
130
149
|
identityFile: env.CELILO_IDENTITY_FILE ?? DEFAULT_IDENTITY_FILE,
|
|
131
150
|
...(env.CONSOLE_SPA_DIR ? { spaDir: env.CONSOLE_SPA_DIR } : {}),
|
|
132
151
|
authenticate: login.authenticate,
|
package/src/upstream.ts
CHANGED
|
@@ -66,8 +66,15 @@ export interface UpstreamOptions {
|
|
|
66
66
|
const EXIT_DENIED = 126;
|
|
67
67
|
|
|
68
68
|
interface CacheEntry {
|
|
69
|
+
/** When the value SETTLED. Meaningless while `pending`. */
|
|
69
70
|
at: number;
|
|
70
|
-
|
|
71
|
+
/**
|
|
72
|
+
* The promise, not the resolved value — so concurrent callers for the same
|
|
73
|
+
* argv join one request instead of each starting their own.
|
|
74
|
+
*/
|
|
75
|
+
value: Promise<unknown>;
|
|
76
|
+
/** In flight. A pending entry is joined regardless of the TTL. */
|
|
77
|
+
pending: boolean;
|
|
71
78
|
}
|
|
72
79
|
|
|
73
80
|
export class Upstream {
|
|
@@ -90,10 +97,41 @@ export class Upstream {
|
|
|
90
97
|
async read<T>(argv: readonly string[]): Promise<T> {
|
|
91
98
|
const key = argv.join(' ');
|
|
92
99
|
const hit = this.cache.get(key);
|
|
93
|
-
|
|
100
|
+
// A PENDING entry is joined whatever the TTL says. This cache used to store
|
|
101
|
+
// the resolved value and `await` before setting it, so every caller that
|
|
102
|
+
// arrived while a read was in flight missed and started its own. The console
|
|
103
|
+
// polls every 5s and mounts several panels at once, and each panel opens its
|
|
104
|
+
// OWN ssh connection because ControlMaster is deliberately off (celilo#921),
|
|
105
|
+
// so identical argv went to celilo three or four times per cycle. Measured
|
|
106
|
+
// on the live console 2026-09-27: 28 `console:status` ops against 18
|
|
107
|
+
// `alerts:list` + 10 `backup:list`, with panels at 12-14s against a 15s
|
|
108
|
+
// client timeout.
|
|
109
|
+
//
|
|
110
|
+
// Joining only on a fresh TTL would not fix it: a read takes ~4s here and
|
|
111
|
+
// the TTL is 2s, so the entry expires WHILE IN FLIGHT and the next caller
|
|
112
|
+
// duplicates it anyway. With reads slower than the TTL that is the common
|
|
113
|
+
// case, not an edge one — hence `pending`.
|
|
114
|
+
if (hit && (hit.pending || this.now() - hit.at < this.ttl)) return hit.value as Promise<T>;
|
|
115
|
+
|
|
116
|
+
const value = this.runJson<T>(argv);
|
|
117
|
+
const entry: CacheEntry = { at: this.now(), value, pending: true };
|
|
118
|
+
this.cache.set(key, entry);
|
|
119
|
+
|
|
120
|
+
value.then(
|
|
121
|
+
() => {
|
|
122
|
+
// The TTL runs from when the value SETTLED, not when the request
|
|
123
|
+
// started; otherwise a read slower than the TTL is stale on arrival.
|
|
124
|
+
entry.at = this.now();
|
|
125
|
+
entry.pending = false;
|
|
126
|
+
},
|
|
127
|
+
() => {
|
|
128
|
+
// A failed read must not be served for the rest of the window, and must
|
|
129
|
+
// not leave a permanently-pending entry that every later caller joins.
|
|
130
|
+
// Retry it; do not remember it.
|
|
131
|
+
if (this.cache.get(key) === entry) this.cache.delete(key);
|
|
132
|
+
},
|
|
133
|
+
);
|
|
94
134
|
|
|
95
|
-
const value = await this.runJson<T>(argv);
|
|
96
|
-
this.cache.set(key, { at: this.now(), value });
|
|
97
135
|
return value;
|
|
98
136
|
}
|
|
99
137
|
|
package/src/verbs.ts
CHANGED
|
@@ -81,7 +81,23 @@ interface CliClosure {
|
|
|
81
81
|
truncated: boolean;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
/**
|
|
84
|
+
/**
|
|
85
|
+
* `celilo alerts list --json`, verbatim — which is NOT the protocol's shape.
|
|
86
|
+
*
|
|
87
|
+
* Every timestamp is `unknown` deliberately. celilo stores them as Drizzle
|
|
88
|
+
* `mode: 'timestamp'` columns, so its rows hold `Date` objects and `--json`
|
|
89
|
+
* prints ISO STRINGS, while the protocol declares epoch ms (also deliberate:
|
|
90
|
+
* ages are computed against the server's `asOf`, so a skewed browser clock
|
|
91
|
+
* cannot answer "how long has this been firing" wrongly).
|
|
92
|
+
*
|
|
93
|
+
* Typing them `number` here declared a shape nobody checked. `upstream.read<T>`
|
|
94
|
+
* is a type ASSERTION, so the strings passed through onto the wire, the
|
|
95
|
+
* browser's own protocol validation rejected the payload, and the dock rendered
|
|
96
|
+
* one word — "unavailable" — for a fleet with six firing alerts (celilo#1455).
|
|
97
|
+
* `unknown` forces every one of them through `epochMs` below, which converts and
|
|
98
|
+
* VALIDATES at the ingest boundary, so an upstream shape change fails here
|
|
99
|
+
* naming the field instead of three layers away as a single word.
|
|
100
|
+
*/
|
|
85
101
|
interface CliAlert {
|
|
86
102
|
id: string;
|
|
87
103
|
key: string;
|
|
@@ -90,18 +106,45 @@ interface CliAlert {
|
|
|
90
106
|
monitor: string | null;
|
|
91
107
|
escalationPolicy: string | null;
|
|
92
108
|
escalationStep: number;
|
|
93
|
-
nextEscalationAt:
|
|
94
|
-
firstFiredAt:
|
|
95
|
-
lastSeenAt:
|
|
109
|
+
nextEscalationAt: unknown;
|
|
110
|
+
firstFiredAt: unknown;
|
|
111
|
+
lastSeenAt: unknown;
|
|
96
112
|
suppressedByAlertId: string | null;
|
|
97
113
|
suppressedByWindowId: string | null;
|
|
98
114
|
awaitingConfirmation: boolean;
|
|
99
115
|
ackedBy: string | null;
|
|
100
|
-
ackedAt:
|
|
101
|
-
silencedUntil:
|
|
116
|
+
ackedAt: unknown;
|
|
117
|
+
silencedUntil: unknown;
|
|
102
118
|
message: string;
|
|
103
119
|
}
|
|
104
120
|
|
|
121
|
+
const ALERTS_ARGV = ['alerts', 'list', '--json'] as const;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* An upstream timestamp as epoch ms, or a `malformed` failure naming the field.
|
|
125
|
+
*
|
|
126
|
+
* Accepts both forms on purpose: epoch ms is what the protocol wants and what an
|
|
127
|
+
* older or future celilo may print directly, and an ISO string is what today's
|
|
128
|
+
* Drizzle-serialised rows carry.
|
|
129
|
+
*/
|
|
130
|
+
function epochMs(value: unknown, field: string): number {
|
|
131
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
|
132
|
+
if (typeof value === 'string') {
|
|
133
|
+
const parsed = Date.parse(value);
|
|
134
|
+
if (!Number.isNaN(parsed)) return parsed;
|
|
135
|
+
}
|
|
136
|
+
throw new UpstreamUnavailable(
|
|
137
|
+
'malformed',
|
|
138
|
+
ALERTS_ARGV,
|
|
139
|
+
`\`celilo alerts list --json\` returned ${field}=${JSON.stringify(value)}; expected epoch ms or an ISO timestamp.`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** As `epochMs`, for a field celilo legitimately leaves unset. */
|
|
144
|
+
function epochMsOrNull(value: unknown, field: string): number | null {
|
|
145
|
+
return value === null || value === undefined ? null : epochMs(value, field);
|
|
146
|
+
}
|
|
147
|
+
|
|
105
148
|
/** `celilo person list --json`, verbatim. */
|
|
106
149
|
interface CliPerson {
|
|
107
150
|
name: string;
|
|
@@ -278,17 +321,17 @@ export async function readAlerts(upstream: Upstream, moduleId?: string): Promise
|
|
|
278
321
|
state: alertState(row.state),
|
|
279
322
|
severity: severity(row.severity),
|
|
280
323
|
policy: row.escalationPolicy,
|
|
281
|
-
firstFiredAt: row.firstFiredAt,
|
|
282
|
-
lastSeenAt: row.lastSeenAt,
|
|
324
|
+
firstFiredAt: epochMs(row.firstFiredAt, 'firstFiredAt'),
|
|
325
|
+
lastSeenAt: epochMs(row.lastSeenAt, 'lastSeenAt'),
|
|
283
326
|
suppressedByAlertId: row.suppressedByAlertId,
|
|
284
327
|
suppressedByWindowId: row.suppressedByWindowId,
|
|
285
|
-
silencedUntil: row.silencedUntil,
|
|
328
|
+
silencedUntil: epochMsOrNull(row.silencedUntil, 'silencedUntil'),
|
|
286
329
|
// `alerts --json` resolves the person to a name. There is no id on the
|
|
287
330
|
// wire, so the name serves as both — which is what the console shows.
|
|
288
331
|
ackedBy: row.ackedBy ? { id: row.ackedBy, name: row.ackedBy } : null,
|
|
289
|
-
ackedAt: row.ackedAt,
|
|
332
|
+
ackedAt: epochMsOrNull(row.ackedAt, 'ackedAt'),
|
|
290
333
|
escalationStep: row.escalationStep,
|
|
291
|
-
nextEscalationAt: row.nextEscalationAt,
|
|
334
|
+
nextEscalationAt: epochMsOrNull(row.nextEscalationAt, 'nextEscalationAt'),
|
|
292
335
|
}),
|
|
293
336
|
),
|
|
294
337
|
};
|
|
@@ -467,5 +510,5 @@ export async function ackAlert(
|
|
|
467
510
|
);
|
|
468
511
|
}
|
|
469
512
|
|
|
470
|
-
return { ackedAt: acked.ackedAt, ackedByName: acked.ackedBy ?? person };
|
|
513
|
+
return { ackedAt: epochMs(acked.ackedAt, 'ackedAt'), ackedByName: acked.ackedBy ?? person };
|
|
471
514
|
}
|