@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/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
- <script type="module" crossorigin src="/assets/index-C31pEQ-J.js"></script>
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>
@@ -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
- /** Matches `wellspring`'s `DEFAULT_ENDPOINT`, which is the fleet's convention. */
27
- const DEFAULT_DEST = 'celilo-api@celilo-mgr';
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
- function required(env: Record<string, string | undefined>, name: string): string {
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: env.CELILO_API_DEST ?? DEFAULT_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
- value: unknown;
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
- if (hit && this.now() - hit.at < this.ttl) return hit.value as T;
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
- /** `celilo alerts --json`, verbatim. */
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: number | null;
94
- firstFiredAt: number;
95
- lastSeenAt: number;
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: number | null;
101
- silencedUntil: number | null;
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
  }