diff --git a/bastion/src/labd/src/routes/bastions.ts b/bastion/src/labd/src/routes/bastions.ts index 1903534..d77a417 100644 --- a/bastion/src/labd/src/routes/bastions.ts +++ b/bastion/src/labd/src/routes/bastions.ts @@ -84,7 +84,6 @@ export function registerBastionRoutes(app: FastifyInstance, db: DbClient): void app.get("/api/machines", async () => { const live = bastionRegistry.getAggregatedState(); - // Merge DB records for machines not currently in any bastion's live state try { const dbServers = (await db.server.findMany({})) as Array<{ mac: string | null; hostname: string; role: string; ip: string | null; @@ -93,9 +92,49 @@ export function registerBastionRoutes(app: FastifyInstance, db: DbClient): void for (const s of dbServers) { if (!s.mac) continue; const mac = s.mac.toLowerCase(); - // Only add from DB if not already in live state + + // DB knows this machine has been installed at some point if it has a real + // hostname+role (not just product-name-as-hostname and role="unknown"). + // Status alone is unreliable: a rediscovery can re-set it without erasing the + // install identity. If the bastion restarted and lost its installed map, the + // machine will only show up in live.discovered — promote it here so the CLI + // still sees hostname/role/IP. + const dbKnowsInstalled = + s.role !== "unknown" && s.role !== "" && + s.hostname !== "" && s.hostname !== s.mac; + + if (dbKnowsInstalled && !(mac in live.installed) && !(mac in live.install_queue)) { + const hw = live.discovered[mac]; + live.installed[mac] = { + hostname: s.hostname, + role: s.role, + ip: s.ip ?? "", + installed_at: "", + bastionId: hw?.bastionId ?? "db", + ...(hw ? { + product: hw.product, + manufacturer: hw.manufacturer, + cpu_model: hw.cpu_model, + cpu_cores: hw.cpu_cores, + memory_gb: hw.memory_gb, + arch: hw.arch, + } : {}), + }; + delete live.discovered[mac]; + continue; + } + + // Unknown-to-live MAC: fall back to whatever the DB says. if (!(mac in live.discovered) && !(mac in live.install_queue) && !(mac in live.installed)) { - if (s.status === "discovered") { + if (s.status === "online" || s.status === "offline") { + live.installed[mac] = { + hostname: s.hostname, + role: s.role, + ip: s.ip ?? "", + installed_at: "", + bastionId: "db", + }; + } else { live.discovered[mac] = { mac, product: String(s.labels?.product ?? "unknown"), @@ -112,14 +151,6 @@ export function registerBastionRoutes(app: FastifyInstance, db: DbClient): void last_seen: "", bastionId: "db", }; - } else if (s.status === "online" || s.status === "offline") { - live.installed[mac] = { - hostname: s.hostname, - role: s.role, - ip: s.ip ?? "", - installed_at: "", - bastionId: "db", - }; } } } diff --git a/bastion/src/labd/src/server.ts b/bastion/src/labd/src/server.ts index b4ac77a..9da02bb 100644 --- a/bastion/src/labd/src/server.ts +++ b/bastion/src/labd/src/server.ts @@ -192,7 +192,9 @@ export async function createApp(_config: LabdConfig, db: DbClient): Promise<{ labels: { cpu: hw.cpu_model, cores: hw.cpu_cores, memory_gb: hw.memory_gb, arch: hw.arch, product: hw.product, manufacturer: hw.manufacturer }, }, update: { - status: "discovered", + // Leave status alone — a previously "online"/"offline" record + // must not be downgraded to "discovered" just because the bastion + // restarted and re-discovered the MAC via DHCP/PXE. lastHeartbeat: new Date(), labels: { cpu: hw.cpu_model, cores: hw.cpu_cores, memory_gb: hw.memory_gb, arch: hw.arch, product: hw.product, manufacturer: hw.manufacturer }, }, diff --git a/bastion/src/labd/tests/bastions-machines.test.ts b/bastion/src/labd/tests/bastions-machines.test.ts new file mode 100644 index 0000000..2b52cc7 --- /dev/null +++ b/bastion/src/labd/tests/bastions-machines.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import Fastify from "fastify"; +import { registerBastionRoutes } from "../src/routes/bastions.js"; +import { bastionRegistry } from "../src/services/bastion-registry.js"; +import type { DbClient } from "../src/server.js"; +import type { BastionState } from "@lab/shared"; + +function createMockDb(servers: unknown[] = []): DbClient { + return { + $queryRaw: vi.fn().mockResolvedValue([{ "?column?": 1 }]), + server: { + findMany: vi.fn().mockResolvedValue(servers), + findUnique: vi.fn().mockResolvedValue(null), + upsert: vi.fn().mockResolvedValue({}), + }, + joinToken: { + findUnique: vi.fn().mockResolvedValue(null), + findMany: vi.fn().mockResolvedValue([]), + create: vi.fn().mockResolvedValue({ id: "t" }), + update: vi.fn().mockResolvedValue({}), + }, + bastion: { + upsert: vi.fn().mockResolvedValue({}), + findMany: vi.fn().mockResolvedValue([]), + findUnique: vi.fn().mockResolvedValue(null), + update: vi.fn().mockResolvedValue({}), + }, + }; +} + +function registerFakeBastion(bastionId: string, state: BastionState): void { + bastionRegistry.register({ + bastionId, + hostname: "fake", + network: "192.168.8.0/24", + serverIp: "192.168.8.11", + // socket is referenced only on commands, not during aggregation + socket: { on: () => undefined, off: () => undefined, send: () => undefined, close: () => undefined } as never, + connectedAt: new Date(), + lastHeartbeat: new Date(), + state, + }); +} + +describe("GET /api/machines aggregation", () => { + beforeEach(() => { + for (const b of bastionRegistry.getAll()) bastionRegistry.unregister(b.bastionId); + }); + + it("promotes a live-discovered MAC to installed when the DB has a real hostname+role for it", async () => { + // Simulates the worker0-k8s0 bug: bastion restarted, lost its installed map, + // rediscovered the machine via DHCP/PXE. DB still has hostname=worker0-k8s0, + // role=infra, ip=192.168.8.23. Without the fix, the CLI sees a "discovered" + // row with no hostname/role/IP. With the fix, the row is promoted to + // "installed" with full identity preserved. + const mac = "78:55:36:08:28:fb"; + registerFakeBastion("b1", { + discovered: { + [mac]: { + mac, product: "SER", board: "SER", serial: "x", manufacturer: "AZW", + cpu_model: "AMD Ryzen 7 255", cpu_cores: 16, memory_gb: 58, arch: "x86_64", + disks: [], nics: [], first_seen: "", last_seen: "", + }, + }, + install_queue: {}, + installed: {}, + debug: {}, + }); + + const app = Fastify({ logger: false }); + const db = createMockDb([ + { mac, hostname: "worker0-k8s0", role: "infra", ip: "192.168.8.23", status: "discovered", labels: {} }, + ]); + registerBastionRoutes(app, db); + + const res = await app.inject({ method: "GET", url: "/api/machines" }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + + expect(body.discovered[mac]).toBeUndefined(); + expect(body.installed[mac]).toMatchObject({ + hostname: "worker0-k8s0", + role: "infra", + ip: "192.168.8.23", + cpu_model: "AMD Ryzen 7 255", + cpu_cores: 16, + memory_gb: 58, + }); + + await app.close(); + }); + + it("leaves a fresh-discovery MAC in discovered when DB only has a discovery-shaped record", async () => { + const mac = "aa:bb:cc:dd:ee:ff"; + registerFakeBastion("b1", { + discovered: { + [mac]: { + mac, product: "SER", board: "SER", serial: "x", manufacturer: "AZW", + cpu_model: "AMD Ryzen 7", cpu_cores: 8, memory_gb: 32, arch: "x86_64", + disks: [], nics: [], first_seen: "", last_seen: "", + }, + }, + install_queue: {}, + installed: {}, + debug: {}, + }); + + const app = Fastify({ logger: false }); + // Matches what labd writes on first discovery: hostname=product, role="unknown" + const db = createMockDb([ + { mac, hostname: "SER", role: "unknown", ip: null, status: "discovered", labels: {} }, + ]); + registerBastionRoutes(app, db); + + const res = await app.inject({ method: "GET", url: "/api/machines" }); + const body = JSON.parse(res.body); + + expect(body.discovered[mac]).toBeDefined(); + expect(body.installed[mac]).toBeUndefined(); + + await app.close(); + }); + + it("falls back to DB for MACs not in any live bucket", async () => { + const mac = "11:22:33:44:55:66"; + // No bastions connected + const app = Fastify({ logger: false }); + const db = createMockDb([ + { mac, hostname: "worker1-k8s0", role: "infra", ip: "192.168.8.13", status: "online", labels: {} }, + ]); + registerBastionRoutes(app, db); + + const res = await app.inject({ method: "GET", url: "/api/machines" }); + const body = JSON.parse(res.body); + + expect(body.installed[mac]).toMatchObject({ + hostname: "worker1-k8s0", + role: "infra", + ip: "192.168.8.13", + }); + + await app.close(); + }); +});