From 0576820059e860120d55c52a1aa546161e9006a8 Mon Sep 17 00:00:00 2001 From: Gilles Soulier Date: Fri, 5 Jun 2026 04:04:28 +0200 Subject: [PATCH] feat: service refresh (check APT -> snapshot canonique) Co-Authored-By: Claude Opus 4.8 --- server/services/refresh.test.ts | 24 +++++++++++ server/services/refresh.ts | 76 +++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 server/services/refresh.test.ts create mode 100644 server/services/refresh.ts diff --git a/server/services/refresh.test.ts b/server/services/refresh.test.ts new file mode 100644 index 0000000..9ca9bd3 --- /dev/null +++ b/server/services/refresh.test.ts @@ -0,0 +1,24 @@ +// server/services/refresh.test.ts +import { describe, it, expect, vi } from "vitest"; + +// Mock all modules with side-effects at import time (DB, SSH, templates) +vi.mock("../db/client.js", () => ({ db: {}, schema: { machines: {}, snapshots: {} } })); +vi.mock("../env.js", () => ({ env: { requireMasterKey: vi.fn(), reportsDir: "/tmp" } })); +vi.mock("../ssh/client.js", () => ({ runScriptSudo: vi.fn() })); +vi.mock("../templates/render.js", () => ({ renderTemplate: vi.fn() })); +vi.mock("../templates/aptReduce.js", () => ({ reduceAptLines: vi.fn(() => []) })); +vi.mock("./machines.js", () => ({ getMachineRow: vi.fn(), getCreds: vi.fn() })); +vi.mock("../ws/outputHub.js", () => ({ outputHub: { clear: vi.fn(), publish: vi.fn() } })); + +import { extractSection } from "./refresh.js"; + +const raw = "===SU:UPDATE===\nok\n===SU:SIMULATE===\nInst a [1] (2 X [amd64])\n===SU:REBOOT===\nREBOOT_REQUIRED=0\n===SU:END==="; + +describe("extractSection", () => { + it("extrait le contenu entre deux marqueurs", () => { + expect(extractSection(raw, "===SU:SIMULATE===", "===SU:REBOOT===")).toBe("Inst a [1] (2 X [amd64])"); + }); + it("retourne vide si marqueur absent", () => { + expect(extractSection(raw, "===SU:NOPE===", "===SU:END===")).toBe(""); + }); +}); diff --git a/server/services/refresh.ts b/server/services/refresh.ts new file mode 100644 index 0000000..e28e551 --- /dev/null +++ b/server/services/refresh.ts @@ -0,0 +1,76 @@ +// server/services/refresh.ts +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { db, schema } from "../db/client.js"; +import { getMachineRow, getCreds } from "./machines.js"; +import { renderTemplate } from "../templates/render.js"; +import { reduceAptLines } from "../templates/aptReduce.js"; +import { runScriptSudo } from "../ssh/client.js"; +import { parseAptSimulate, parseRebootRequired } from "./aptParse.js"; +import { outputHub } from "../ws/outputHub.js"; +import type { UpdateSnapshot, MachineStatus } from "@shared/types.js"; + +/** Extrait la section entre deux marqueurs ===SU:X=== d'une sortie de script. */ +export function extractSection(raw: string, start: string, end: string): string { + const s = raw.indexOf(start); + if (s === -1) return ""; + const from = s + start.length; + const e = raw.indexOf(end, from); + return raw.slice(from, e === -1 ? undefined : e).trim(); +} + +export async function refreshMachine(machineId: string): Promise { + const m = getMachineRow(machineId); + if (!m) throw new Error("Machine introuvable"); + + db.update(schema.machines).set({ status: "running" }).where(eq(schema.machines.id, machineId)).run(); + outputHub.clear(machineId); + + const proxy = m.aptProxyMode === "runtime" ? m.aptProxyUrl : null; + const script = renderTemplate("apt/check.sh.tpl", { aptProxy: proxy }); + + let raw = ""; + try { + const res = await runScriptSudo(getCreds(m), script, (c) => { + raw += c; + outputHub.publish(machineId, c); + }); + raw = res.stdout; + } catch (err) { + db.update(schema.machines).set({ status: "error" }).where(eq(schema.machines.id, machineId)).run(); + throw err; + } + + const simulate = extractSection(raw, "===SU:SIMULATE===", "===SU:REBOOT==="); + const rebootSection = extractSection(raw, "===SU:REBOOT===", "===SU:END==="); + const packages = parseAptSimulate(simulate); + const rebootRequired = parseRebootRequired(rebootSection); + const status: MachineStatus = packages.length > 0 ? "updates_available" : "ok"; + const checkedAt = new Date().toISOString(); + + const snapshot: UpdateSnapshot = { + machineId, + hostname: m.hostname, + os: { family: m.osFamily as UpdateSnapshot["os"]["family"], version: "" }, + checkedAt, + status, + apt: { enabled: true, count: packages.length, rebootRequired, packages }, + rawHints: { logImportantLines: reduceAptLines(raw) }, + }; + + db.insert(schema.snapshots).values({ + id: randomUUID(), + machineId, + checkedAt, + status, + payloadJson: JSON.stringify(snapshot), + }).run(); + db.update(schema.machines).set({ status, lastCheckedAt: checkedAt }).where(eq(schema.machines.id, machineId)).run(); + + return snapshot; +} + +export function getLatestSnapshot(machineId: string): UpdateSnapshot | null { + const row = db.select().from(schema.snapshots).where(eq(schema.snapshots.machineId, machineId)).all().at(-1); + return row ? (JSON.parse(row.payloadJson) as UpdateSnapshot) : null; +}