diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index d74bf96..b4bcb62 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,8 +1,9 @@ import "dotenv/config"; +import { statSync } from "node:fs"; import Fastify from "fastify"; import fastifyCookie from "@fastify/cookie"; import fastifySession from "@fastify/session"; -import { loadConfig } from "./config/index.js"; +import { loadConfig, type AppConfig } from "./config/index.js"; import { openDb, upsertSnapshots, pruneOldSnapshots } from "./db/index.js"; import { seedAdminUser } from "./auth/local.js"; import { ProxmoxCollector } from "./collectors/proxmox.js"; @@ -17,15 +18,8 @@ import { initOidc } from "./auth/oidc.js"; const HOSTS_CONFIG_PATH = process.env.HOSTS_CONFIG_PATH ?? "../../config/hosts.yaml"; -async function main() { - const cfg = loadConfig(HOSTS_CONFIG_PATH); - const db = openDb(cfg.dbPath); - - if (cfg.adminPassword) { - seedAdminUser(db, cfg.adminUsername, cfg.adminPassword); - } - - const collectors: Collector[] = [ +function buildCollectors(cfg: AppConfig): Collector[] { + return [ new ProxmoxCollector({ host: cfg.proxmox.host, node: cfg.proxmox.node, @@ -34,8 +28,50 @@ async function main() { }), ...cfg.sshHosts.map((h) => new SshHostCollector(h)), ]; +} + +async function main() { + const cfg = loadConfig(HOSTS_CONFIG_PATH); + const db = openDb(cfg.dbPath); + + if (cfg.adminPassword) { + seedAdminUser(db, cfg.adminUsername, cfg.adminPassword); + } + + // Proxmox-monitored hosts (the node + every LXC) auto-discover on every poll + // already — no config needed there. sshHosts and knownDevices, though, are + // declared in hosts.yaml and used to need a container restart to pick up + // changes. Re-checking the file's mtime on each poll cycle (already running + // every 30s) avoids that without adding a separate file-watcher/admin UI. + let knownDevices = cfg.knownDevices; + let collectors = buildCollectors(cfg); + let hostsConfigMtimeMs = statSync(HOSTS_CONFIG_PATH).mtimeMs; + + function reloadHostsConfigIfChanged() { + let mtimeMs: number; + try { + mtimeMs = statSync(HOSTS_CONFIG_PATH).mtimeMs; + } catch { + return; + } + if (mtimeMs === hostsConfigMtimeMs) return; + hostsConfigMtimeMs = mtimeMs; + try { + const reloaded = loadConfig(HOSTS_CONFIG_PATH); + collectors = buildCollectors(reloaded); + knownDevices = reloaded.knownDevices; + app.log.info( + { sshHosts: reloaded.sshHosts.length, knownDevices: reloaded.knownDevices.size }, + "config/hosts.yaml changed, reloaded" + ); + } catch (err) { + app.log.error({ err }, "failed to reload config/hosts.yaml, keeping previous config"); + } + } async function pollOnce() { + reloadHostsConfigIfChanged(); + for (const collector of collectors) { try { const snapshots = await collector.collect(); @@ -47,7 +83,7 @@ async function main() { pruneOldSnapshots(db, cfg.snapshotRetentionHours); try { - await refreshDevices(db, cfg.discoveryFilePath, cfg.knownDevices); + await refreshDevices(db, cfg.discoveryFilePath, knownDevices); } catch (err) { app.log.error({ err }, "device discovery refresh failed"); } diff --git a/docs/hot-reload.md b/docs/hot-reload.md new file mode 100644 index 0000000..96b3cec --- /dev/null +++ b/docs/hot-reload.md @@ -0,0 +1,18 @@ +# Editing monitored hosts without a redeploy + +`config/hosts.yaml`'s `sshHosts` and `knownDevices` lists are re-read on the next poll +cycle (checks the file's mtime, reloads if changed) — no container restart needed. + +- **Proxmox-monitored hosts** (the node + every LXC) need no config at all — they're + auto-discovered from the Proxmox API on every poll already. +- **SSH-monitored hosts** (`sshHosts`): edit `config/hosts.yaml` on the deploy host + directly (`/opt/homelab-monitor/config/hosts.yaml` on CT122), save, wait up to one + poll interval (`POLL_INTERVAL_SECONDS`, default 30s). Adding a *new* SSH host still + needs a one-time manual step first — installing the forced-command key on the target + (see `docs/ssh-collector-key-setup.md`) — that part can't be automated away, so a web + form wouldn't make this fully self-service regardless of hot-reload. +- **`knownDevices`** (device discovery labeling): same file, same reload path, no + manual step needed since it's just a label list. + +If the file fails to parse (bad YAML), the error is logged and the **previous** +in-memory config keeps running rather than crashing the poller.