|
6 | 6 | // Serves the Hono app via @hono/node-server, drives the queue with the same processJob, ticks the same |
7 | 7 | // scheduled handler on a timer, exposes /health /ready /metrics, and shuts down gracefully. The Cloudflare |
8 | 8 | // Worker (src/index.ts) is untouched — this is a parallel entry the self-host esbuild build bundles. |
9 | | -import { existsSync, writeFileSync } from "node:fs"; |
| 9 | +import { existsSync, readdirSync, writeFileSync } from "node:fs"; |
10 | 10 | import { delimiter, join } from "node:path"; |
11 | 11 | import { randomUUID } from "node:crypto"; |
12 | 12 | import { DatabaseSync } from "node:sqlite"; |
@@ -48,6 +48,8 @@ import { |
48 | 48 | backupAcknowledgedGaugeValue, |
49 | 49 | buildHealthBody, |
50 | 50 | codexAuthReadinessProbe, |
| 51 | + emptyConfigDirAcknowledgedGaugeValue, |
| 52 | + emptyConfigDirAdvisory, |
51 | 53 | githubAppReadinessProbe, |
52 | 54 | publicOriginAcknowledgedGaugeValue, |
53 | 55 | publicOriginReachabilityAdvisory, |
@@ -113,6 +115,16 @@ function nonBlank(value: string | undefined): string | undefined { |
113 | 115 | return trimmed ? trimmed : undefined; |
114 | 116 | } |
115 | 117 |
|
| 118 | +/** Top-level entry count of `dir` (files + subdirectories, dotfiles included), or 0 on any read error -- |
| 119 | + * a missing/unreadable directory is reported the same as an empty one rather than crashing boot. */ |
| 120 | +function safeReaddirCount(dir: string): number { |
| 121 | + try { |
| 122 | + return readdirSync(dir).length; |
| 123 | + } catch { |
| 124 | + return 0; |
| 125 | + } |
| 126 | +} |
| 127 | + |
116 | 128 |
|
117 | 129 | interface Backend { |
118 | 130 | db: D1Database; |
@@ -298,15 +310,36 @@ async function main(): Promise<void> { |
298 | 310 | // Boot-time visibility (config-drift guardrail): state which config dir is actually in effect, unconditionally |
299 | 311 | // -- neither reader above logs anything, so an operator previously had no way to confirm from the logs alone |
300 | 312 | // which directory (if any) was live, which is exactly the ambiguity that let a stale, no-longer-mounted config |
301 | | - // path get mistaken for the real one during a past incident. Never touches or validates any file; this is |
302 | | - // purely a log line, same "state what's in effect" shape as the sentry/otel boot logs below. |
| 313 | + // path get mistaken for the real one during a past incident. `entryCount` is a cheap, one-time top-level |
| 314 | + // listing (never recursive, never touches file contents) so a SECOND incident of the same shape -- the mount |
| 315 | + // resolving but landing on an empty directory -- is visible in the log line itself, not just "some path is |
| 316 | + // configured" (see emptyConfigDirAdvisory below for the loud version of this same signal). |
| 317 | + const configDirOpts = { |
| 318 | + configured: Boolean(repoConfigDir), |
| 319 | + // A missing (as opposed to merely empty) directory is treated the same as zero entries -- both mean "no |
| 320 | + // local config was actually read" -- rather than letting a bad path crash the whole server at boot. |
| 321 | + entryCount: repoConfigDir ? safeReaddirCount(repoConfigDir) : 0, |
| 322 | + acknowledged: process.env.CONFIG_DIR_EMPTY_ACKNOWLEDGED === "true", |
| 323 | + }; |
303 | 324 | console.log( |
304 | 325 | JSON.stringify({ |
305 | 326 | event: "selfhost_config_dir", |
306 | | - configured: Boolean(repoConfigDir), |
| 327 | + configured: configDirOpts.configured, |
307 | 328 | dir: repoConfigDir ?? null, |
| 329 | + entryCount: repoConfigDir ? configDirOpts.entryCount : null, |
308 | 330 | }), |
309 | 331 | ); |
| 332 | + // Config-drift advisory: warn LOUDLY (not just the log line above) when the mount resolves but is empty -- |
| 333 | + // see emptyConfigDirAdvisory's own doc comment for the incident this guards against. |
| 334 | + const configDirAdvisory = emptyConfigDirAdvisory(configDirOpts); |
| 335 | + if (configDirAdvisory) |
| 336 | + console.warn( |
| 337 | + JSON.stringify({ |
| 338 | + level: "warn", |
| 339 | + event: "selfhost_config_dir_empty_advisory", |
| 340 | + message: configDirAdvisory, |
| 341 | + }), |
| 342 | + ); |
310 | 343 | // Error tracking (#1468): opt-in via SENTRY_DSN — a complete no-op when unset. When on, capture uncaught crashes |
311 | 344 | // + unhandled rejections (flush before exit for the fatal case); per-subsystem captures (queue dead-letter, |
312 | 345 | // review failures) are wired at their sites. |
@@ -781,6 +814,7 @@ async function main(): Promise<void> { |
781 | 814 | ); |
782 | 815 | gauge("loopover_backup_acknowledged", () => backupAcknowledgedGaugeValue(sqliteBackupOpts)); |
783 | 816 | gauge("loopover_public_origin_acknowledged", () => publicOriginAcknowledgedGaugeValue(publicOriginOpts)); |
| 817 | + gauge("loopover_config_dir_empty_acknowledged", () => emptyConfigDirAcknowledgedGaugeValue(configDirOpts)); |
784 | 818 | // Pre-initialize job counters to 0 so they appear in the first Prometheus scrape (lazy counters |
785 | 819 | // created on first use would otherwise cause "No data" in Grafana until the first job event). |
786 | 820 | for (const c of [ |
|
0 commit comments