feat: socle BDD (tâche 1.9 Phase 1-2) + moteur APT (tâche 2 SJ-0→3) + WIP capabilities/auth/Rust
Checkpoint multi-chantiers (arbre vert : tsc 0 erreur, 70 tests, build OK). - tâche 1.9 Phase 1 : schéma socle (machine_state/events/reports/raw_artifacts/ hardware/metrics + colonnes étendues) + wiring refresh/execute. Migration 0002. - tâche 1.9 Phase 2 : machine_credentials + machine_host_keys (non destructif, dual-read + backfill). Migration 0003. Fix séquence journal de migration. - tâche 2 : SJ-0 (types étendus rétro-compatibles, réducteur Docker, resolveTemplate), SJ-1 (update-analyze enrichi), SJ-2 (apply + diff dpkg + timeout inactivité SSH), SJ-3 (reboot vérifié boot_id). - WIP parallèle inclus : /api/capabilities, auth/apiTokens/apiClients, system metrics, scaffold app_rust, ajustements frontend. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
===SU:APT_UPDATE===
|
||||
Hit:1 http://deb.debian.org/debian bookworm InRelease
|
||||
Reading package lists...
|
||||
===SU:APT_SIM_UPGRADE===
|
||||
Reading package lists...
|
||||
Building dependency tree...
|
||||
Inst libc6 [2.31-13] (2.31-13+deb11u5 Debian:11.6/stable [amd64])
|
||||
Conf libc6 (2.31-13+deb11u5 Debian:11.6/stable [amd64])
|
||||
===SU:APT_SIM_DISTUPGRADE===
|
||||
Reading package lists...
|
||||
Inst libc6 [2.31-13] (2.31-13+deb11u5 Debian:11.6/stable [amd64])
|
||||
Inst newdep (1.0.0 Debian:11.6/stable [all])
|
||||
Remv oldpkg [3.2-1]
|
||||
Conf libc6 (2.31-13+deb11u5 Debian:11.6/stable [amd64])
|
||||
===SU:APT_HELD===
|
||||
frozenpkg
|
||||
===SU:REBOOT===
|
||||
REBOOT_REQUIRED=1
|
||||
PKG=linux-image-amd64
|
||||
===SU:EXIT=0===
|
||||
@@ -0,0 +1,64 @@
|
||||
// server/services/apiClients.test.ts
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../db/client.js", () => ({
|
||||
db: {},
|
||||
schema: { apiClients: {} },
|
||||
}));
|
||||
|
||||
vi.mock("../env.js", () => ({ env: { requireMasterKey: vi.fn() } }));
|
||||
|
||||
import { apiClientInternals } from "./apiClients.js";
|
||||
|
||||
describe("apiClientInternals", () => {
|
||||
it("retombe sur read quand aucun scope n'est fourni", () => {
|
||||
expect(apiClientInternals.normalizeScopes([])).toEqual(["read"]);
|
||||
});
|
||||
|
||||
it("déduplique les scopes en gardant l'ordre", () => {
|
||||
expect(apiClientInternals.normalizeScopes(["read", "operate", "read"])).toEqual([
|
||||
"read",
|
||||
"operate",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejette un scope inconnu", () => {
|
||||
expect(() => apiClientInternals.normalizeScopes(["root" as never])).toThrow(
|
||||
"Scope API inconnu: root",
|
||||
);
|
||||
});
|
||||
|
||||
it("convertit une ligne DB en vue sans token hash", () => {
|
||||
const view = apiClientInternals.toView({
|
||||
id: "client_1",
|
||||
name: "App locale",
|
||||
tokenPrefix: "su_abcdefghi",
|
||||
tokenHash: "hash-secret",
|
||||
scopesJson: '["read","operate"]',
|
||||
createdAt: "2026-06-05T08:00:00.000Z",
|
||||
lastUsedAt: null,
|
||||
revokedAt: null,
|
||||
});
|
||||
|
||||
expect(view).toEqual({
|
||||
id: "client_1",
|
||||
name: "App locale",
|
||||
tokenPrefix: "su_abcdefghi",
|
||||
scopes: ["read", "operate"],
|
||||
createdAt: "2026-06-05T08:00:00.000Z",
|
||||
lastUsedAt: null,
|
||||
revokedAt: null,
|
||||
});
|
||||
expect(JSON.stringify(view)).not.toContain("hash-secret");
|
||||
});
|
||||
|
||||
it("applique les scopes par capacité", () => {
|
||||
expect(apiClientInternals.hasApiScope(["read"], "read")).toBe(true);
|
||||
expect(apiClientInternals.hasApiScope(["read"], "operate")).toBe(false);
|
||||
expect(apiClientInternals.hasApiScope(["operate"], "read")).toBe(true);
|
||||
expect(apiClientInternals.hasApiScope(["operate"], "operate")).toBe(true);
|
||||
expect(apiClientInternals.hasApiScope(["debug"], "debug")).toBe(true);
|
||||
expect(apiClientInternals.hasApiScope(["admin"], "debug")).toBe(true);
|
||||
expect(apiClientInternals.hasApiScope(["admin"], "admin")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
// server/services/apiClients.ts
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { ApiClientScope, ApiClientView, CreatedApiClient } from "@shared/types.js";
|
||||
import { db, schema } from "../db/client.js";
|
||||
import { env } from "../env.js";
|
||||
import {
|
||||
generateApiToken,
|
||||
hashApiToken,
|
||||
tokenPrefix,
|
||||
verifyApiToken,
|
||||
} from "../crypto/apiTokens.js";
|
||||
|
||||
type ApiClientRow = typeof schema.apiClients.$inferSelect;
|
||||
|
||||
const ALLOWED_SCOPES = new Set<ApiClientScope>(["read", "operate", "admin", "debug"]);
|
||||
|
||||
export interface CreateApiClientInput {
|
||||
name: string;
|
||||
scopes: ApiClientScope[];
|
||||
}
|
||||
|
||||
function normalizeScopes(scopes: ApiClientScope[]): ApiClientScope[] {
|
||||
const unique = [...new Set(scopes)];
|
||||
if (unique.length === 0) return ["read"];
|
||||
for (const scope of unique) {
|
||||
if (!ALLOWED_SCOPES.has(scope)) throw new Error(`Scope API inconnu: ${scope}`);
|
||||
}
|
||||
return unique;
|
||||
}
|
||||
|
||||
function scopesFromJson(json: string): ApiClientScope[] {
|
||||
const parsed = JSON.parse(json) as ApiClientScope[];
|
||||
return normalizeScopes(parsed);
|
||||
}
|
||||
|
||||
function toView(row: ApiClientRow): ApiClientView {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
tokenPrefix: row.tokenPrefix,
|
||||
scopes: scopesFromJson(row.scopesJson),
|
||||
createdAt: row.createdAt,
|
||||
lastUsedAt: row.lastUsedAt,
|
||||
revokedAt: row.revokedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function createApiClient(input: CreateApiClientInput, now = new Date()): CreatedApiClient {
|
||||
const name = input.name.trim();
|
||||
if (!name) throw new Error("Le nom du client API est obligatoire");
|
||||
|
||||
const scopes = normalizeScopes(input.scopes);
|
||||
const token = generateApiToken();
|
||||
const pepper = env.requireMasterKey();
|
||||
const row: ApiClientRow = {
|
||||
id: randomUUID(),
|
||||
name,
|
||||
tokenPrefix: tokenPrefix(token),
|
||||
tokenHash: hashApiToken(token, pepper),
|
||||
scopesJson: JSON.stringify(scopes),
|
||||
createdAt: now.toISOString(),
|
||||
lastUsedAt: null,
|
||||
revokedAt: null,
|
||||
};
|
||||
|
||||
db.insert(schema.apiClients).values(row).run();
|
||||
return { client: toView(row), token };
|
||||
}
|
||||
|
||||
export function listApiClients(): ApiClientView[] {
|
||||
return db.select().from(schema.apiClients).all().map(toView);
|
||||
}
|
||||
|
||||
export function revokeApiClient(id: string, now = new Date()): ApiClientView | null {
|
||||
const existing = db.select().from(schema.apiClients).where(eq(schema.apiClients.id, id)).get();
|
||||
if (!existing) return null;
|
||||
|
||||
const revokedAt = now.toISOString();
|
||||
db.update(schema.apiClients).set({ revokedAt }).where(eq(schema.apiClients.id, id)).run();
|
||||
return toView({ ...existing, revokedAt });
|
||||
}
|
||||
|
||||
export function authenticateApiToken(token: string, now = new Date()): ApiClientView | null {
|
||||
const pepper = env.requireMasterKey();
|
||||
const tokenHash = hashApiToken(token, pepper);
|
||||
const row = db
|
||||
.select()
|
||||
.from(schema.apiClients)
|
||||
.where(eq(schema.apiClients.tokenHash, tokenHash))
|
||||
.get();
|
||||
|
||||
if (!row || row.revokedAt) return null;
|
||||
if (!verifyApiToken(token, row.tokenHash, pepper)) return null;
|
||||
|
||||
const lastUsedAt = now.toISOString();
|
||||
db.update(schema.apiClients)
|
||||
.set({ lastUsedAt })
|
||||
.where(eq(schema.apiClients.id, row.id))
|
||||
.run();
|
||||
|
||||
return toView({ ...row, lastUsedAt });
|
||||
}
|
||||
|
||||
export function hasApiScope(scopes: ApiClientScope[], required: ApiClientScope): boolean {
|
||||
if (scopes.includes("admin")) return true;
|
||||
if (required === "read") return scopes.length > 0;
|
||||
if (required === "operate") return scopes.includes("operate");
|
||||
if (required === "debug") return scopes.includes("debug");
|
||||
return false;
|
||||
}
|
||||
|
||||
export const apiClientInternals = {
|
||||
normalizeScopes,
|
||||
scopesFromJson,
|
||||
toView,
|
||||
hasApiScope,
|
||||
};
|
||||
@@ -2,10 +2,17 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parseAptSimulate, parseRebootRequired } from "./aptParse.js";
|
||||
import { parseAptSimulate, parseRebootRequired, parseAptRemovals, parseHeld, parseRebootDetail, buildAptSnapshotDetail, parseDpkgList, buildAptExecutionResult } from "./aptParse.js";
|
||||
|
||||
const raw = readFileSync(fileURLToPath(new URL("./__fixtures__/apt-simulate.txt", import.meta.url)), "utf8");
|
||||
|
||||
const ua = readFileSync(fileURLToPath(new URL("./__fixtures__/apt-update-analyze.txt", import.meta.url)), "utf8");
|
||||
function section(rawInput: string, start: string, end: string): string {
|
||||
const s = rawInput.indexOf(start); if (s === -1) return "";
|
||||
const from = s + start.length; const e = rawInput.indexOf(end, from);
|
||||
return rawInput.slice(from, e === -1 ? undefined : e).trim();
|
||||
}
|
||||
|
||||
describe("parseAptSimulate", () => {
|
||||
it("extrait les paquets upgradables avec versions et origine", () => {
|
||||
const pkgs = parseAptSimulate(raw);
|
||||
@@ -28,3 +35,74 @@ describe("parseRebootRequired", () => {
|
||||
expect(parseRebootRequired("rien")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseAptRemovals", () => {
|
||||
it("extrait les suppressions Remv", () => {
|
||||
expect(parseAptRemovals("Remv oldpkg [3.2-1]\nInst x [1] (2 Y [amd64])"))
|
||||
.toEqual([{ name: "oldpkg", currentVersion: "3.2-1" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseHeld", () => {
|
||||
it("liste les paquets retenus non vides", () => {
|
||||
expect(parseHeld("frozenpkg\n\n other ")).toEqual(["frozenpkg", "other"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseRebootDetail", () => {
|
||||
it("lit le flag et les paquets reboot", () => {
|
||||
expect(parseRebootDetail("REBOOT_REQUIRED=1\nPKG=linux-image-amd64\nPKG=foo"))
|
||||
.toEqual({ rebootRequired: true, pkgs: ["linux-image-amd64", "foo"] });
|
||||
expect(parseRebootDetail("REBOOT_REQUIRED=0")).toEqual({ rebootRequired: false, pkgs: [] });
|
||||
});
|
||||
});
|
||||
|
||||
const BEFORE = "libc6\t2.31-13\tamd64\noldpkg\t3.2-1\tamd64\nstable\t1.0\tamd64";
|
||||
const AFTER = "libc6\t2.31-14\tamd64\nnewpkg\t1.0.0\tall\nstable\t1.0\tamd64";
|
||||
|
||||
describe("parseDpkgList", () => {
|
||||
it("indexe par package:arch", () => {
|
||||
const m = parseDpkgList("libc6\t2.31-13\tamd64");
|
||||
expect(m["libc6:amd64"]).toEqual({ version: "2.31-13", arch: "amd64" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildAptExecutionResult", () => {
|
||||
it("calcule le diff réel before/after", () => {
|
||||
const r = buildAptExecutionResult(BEFORE, AFTER, "REBOOT_REQUIRED=1");
|
||||
expect(r.applied.find((c) => c.name === "libc6")).toMatchObject({ operation: "upgraded", fromVersion: "2.31-13", toVersion: "2.31-14" });
|
||||
expect(r.installed.map((c) => c.name)).toEqual(["newpkg"]);
|
||||
expect(r.removed.map((c) => c.name)).toEqual(["oldpkg"]);
|
||||
expect(r.applied.some((c) => c.name === "stable")).toBe(false); // unchanged exclu
|
||||
expect(r.rebootRequiredAfterRun).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildAptSnapshotDetail", () => {
|
||||
it("construit le détail enrichi depuis les sections", () => {
|
||||
const detail = buildAptSnapshotDetail({
|
||||
upgradeSim: section(ua, "===SU:APT_SIM_UPGRADE===", "===SU:APT_SIM_DISTUPGRADE==="),
|
||||
distUpgradeSim: section(ua, "===SU:APT_SIM_DISTUPGRADE===", "===SU:APT_HELD==="),
|
||||
heldRaw: section(ua, "===SU:APT_HELD===", "===SU:REBOOT==="),
|
||||
rebootRaw: section(ua, "===SU:REBOOT===", "===SU:EXIT"),
|
||||
updateFailed: false,
|
||||
});
|
||||
expect(detail.enabled).toBe(true);
|
||||
expect(detail.count).toBe(2); // 2 Inst en dist-upgrade
|
||||
expect(detail.upgradeCount).toBe(1); // 1 Inst en upgrade
|
||||
expect(detail.distUpgradeCount).toBe(2);
|
||||
expect(detail.rebootRequired).toBe(true);
|
||||
expect(detail.rebootPkgs).toEqual(["linux-image-amd64"]);
|
||||
expect(detail.held).toEqual(["frozenpkg"]);
|
||||
expect(detail.removed?.map((r) => r.name)).toEqual(["oldpkg"]);
|
||||
expect(detail.installed?.map((p) => p.name)).toEqual(["newdep"]);
|
||||
expect(detail.status).toBe("warning"); // car removed + held non vides
|
||||
});
|
||||
|
||||
it("status=updates_available sans removed/held, error si update échoue", () => {
|
||||
const ok = buildAptSnapshotDetail({ upgradeSim: "Inst a [1] (2 Y [amd64])", distUpgradeSim: "Inst a [1] (2 Y [amd64])", heldRaw: "", rebootRaw: "REBOOT_REQUIRED=0", updateFailed: false });
|
||||
expect(ok.status).toBe("updates_available");
|
||||
const err = buildAptSnapshotDetail({ upgradeSim: "", distUpgradeSim: "", heldRaw: "", rebootRaw: "REBOOT_REQUIRED=0", updateFailed: true });
|
||||
expect(err.status).toBe("error");
|
||||
});
|
||||
});
|
||||
|
||||
+115
-1
@@ -1,5 +1,5 @@
|
||||
// server/services/aptParse.ts
|
||||
import type { AptPackage } from "@shared/types.js";
|
||||
import type { AptPackage, AptSnapshotDetail, SnapshotStatus, AptChange, AptExecutionResult } from "@shared/types.js";
|
||||
|
||||
// Exemple de ligne:
|
||||
// Inst pve-manager [8.4-1] (8.4-3 Proxmox VE:8.x [amd64])
|
||||
@@ -24,3 +24,117 @@ export function parseAptSimulate(raw: string): AptPackage[] {
|
||||
export function parseRebootRequired(raw: string): boolean {
|
||||
return /REBOOT_REQUIRED=1/.test(raw);
|
||||
}
|
||||
|
||||
const REMV_RE = /^Remv (\S+)(?: \[([^\]]+)\])?/;
|
||||
export function parseAptRemovals(raw: string): { name: string; currentVersion: string | null }[] {
|
||||
const out: { name: string; currentVersion: string | null }[] = [];
|
||||
for (const line of raw.split("\n")) {
|
||||
const m = REMV_RE.exec(line.trimEnd());
|
||||
if (m) out.push({ name: m[1]!, currentVersion: m[2] ?? null });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseHeld(raw: string): string[] {
|
||||
return raw.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
|
||||
}
|
||||
|
||||
export function parseRebootDetail(raw: string): { rebootRequired: boolean; pkgs: string[] } {
|
||||
const rebootRequired = /REBOOT_REQUIRED=1/.test(raw);
|
||||
const pkgs: string[] = [];
|
||||
for (const line of raw.split("\n")) {
|
||||
const m = /^PKG=(.+)$/.exec(line.trim());
|
||||
if (m) pkgs.push(m[1]!.trim());
|
||||
}
|
||||
return { rebootRequired, pkgs };
|
||||
}
|
||||
|
||||
export interface AptSections {
|
||||
upgradeSim: string;
|
||||
distUpgradeSim: string;
|
||||
heldRaw: string;
|
||||
rebootRaw: string;
|
||||
updateFailed: boolean;
|
||||
}
|
||||
|
||||
export function parseDpkgList(raw: string): Record<string, { version: string; arch: string }> {
|
||||
const out: Record<string, { version: string; arch: string }> = {};
|
||||
for (const line of raw.split("\n")) {
|
||||
const parts = line.split("\t");
|
||||
if (parts.length < 3) continue;
|
||||
const [name, version, arch] = [parts[0]!.trim(), parts[1]!.trim(), parts[2]!.trim()];
|
||||
if (!name) continue;
|
||||
out[`${name}:${arch}`] = { version, arch };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Diff dpkg réel before/after → AptExecutionResult (planned/held vides : portés par le snapshot). */
|
||||
export function buildAptExecutionResult(beforeRaw: string, afterRaw: string, rebootRaw: string): AptExecutionResult {
|
||||
const before = parseDpkgList(beforeRaw);
|
||||
const after = parseDpkgList(afterRaw);
|
||||
const applied: AptChange[] = [];
|
||||
const installed: AptChange[] = [];
|
||||
const removed: AptChange[] = [];
|
||||
|
||||
for (const key of Object.keys(after)) {
|
||||
const [name] = key.split(":");
|
||||
const a = after[key]!;
|
||||
const b = before[key];
|
||||
if (!b) {
|
||||
const change: AptChange = { name: name!, arch: a.arch, fromVersion: null, toVersion: a.version, operation: "installed" };
|
||||
installed.push(change); applied.push(change);
|
||||
} else if (b.version !== a.version) {
|
||||
applied.push({ name: name!, arch: a.arch, fromVersion: b.version, toVersion: a.version, operation: "upgraded" });
|
||||
}
|
||||
}
|
||||
for (const key of Object.keys(before)) {
|
||||
if (!after[key]) {
|
||||
const [name] = key.split(":");
|
||||
const b = before[key]!;
|
||||
const change: AptChange = { name: name!, arch: b.arch, fromVersion: b.version, toVersion: null, operation: "removed" };
|
||||
removed.push(change); applied.push(change);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
planned: [],
|
||||
applied,
|
||||
installed,
|
||||
removed,
|
||||
held: [],
|
||||
rebootRequiredAfterRun: /REBOOT_REQUIRED=1/.test(rebootRaw),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAptSnapshotDetail(s: AptSections): AptSnapshotDetail {
|
||||
const upgradePkgs = parseAptSimulate(s.upgradeSim);
|
||||
const distPkgs = parseAptSimulate(s.distUpgradeSim);
|
||||
const installed: AptPackage[] = distPkgs
|
||||
.filter((p) => p.currentVersion === null)
|
||||
.map((p) => ({ ...p, operation: "install" as const }));
|
||||
const removed: AptPackage[] = parseAptRemovals(s.distUpgradeSim).map((r) => ({
|
||||
name: r.name, currentVersion: r.currentVersion, targetVersion: "", origin: null, operation: "remove" as const,
|
||||
}));
|
||||
const held = parseHeld(s.heldRaw);
|
||||
const { rebootRequired, pkgs: rebootPkgs } = parseRebootDetail(s.rebootRaw);
|
||||
|
||||
let status: SnapshotStatus = "ok";
|
||||
if (s.updateFailed) status = "error";
|
||||
else if (removed.length > 0 || held.length > 0) status = "warning";
|
||||
else if (distPkgs.length > 0) status = "updates_available";
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
count: distPkgs.length,
|
||||
rebootRequired,
|
||||
packages: distPkgs,
|
||||
status,
|
||||
upgradeCount: upgradePkgs.length,
|
||||
distUpgradeCount: distPkgs.length,
|
||||
installed,
|
||||
removed,
|
||||
held,
|
||||
rebootPkgs,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// server/services/capabilities.test.ts
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getServerCapabilities } from "./capabilities.js";
|
||||
|
||||
describe("getServerCapabilities", () => {
|
||||
it("publie un contrat stable sans annoncer les fonctions futures non implémentées", () => {
|
||||
const caps = getServerCapabilities(new Date("2026-06-05T08:00:00.000Z"));
|
||||
|
||||
expect(caps).toMatchObject({
|
||||
app: "system_update",
|
||||
apiVersion: "1",
|
||||
generatedAt: "2026-06-05T08:00:00.000Z",
|
||||
features: {
|
||||
machines: true,
|
||||
actions: true,
|
||||
terminalOutput: true,
|
||||
docker: false,
|
||||
hermes: false,
|
||||
interactiveSsh: false,
|
||||
authTokens: false,
|
||||
},
|
||||
endpoints: {
|
||||
capabilities: "GET /api/capabilities",
|
||||
systemStatus: "GET /api/system/status",
|
||||
systemMetrics: "GET /api/system/metrics",
|
||||
machines: "GET /api/machines",
|
||||
terminalOutputWs: "WS /api/ws/machines/:id/output",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
// server/services/capabilities.ts
|
||||
import type { ServerCapabilities } from "@shared/types.js";
|
||||
|
||||
export function getServerCapabilities(now = new Date()): ServerCapabilities {
|
||||
return {
|
||||
app: "system_update",
|
||||
apiVersion: "1",
|
||||
generatedAt: now.toISOString(),
|
||||
features: {
|
||||
machines: true,
|
||||
machineSnapshots: true,
|
||||
actions: true,
|
||||
aptFullUpgrade: true,
|
||||
reboot: true,
|
||||
reports: true,
|
||||
terminalOutput: true,
|
||||
interactiveSsh: false,
|
||||
docker: false,
|
||||
postInstall: false,
|
||||
hermes: false,
|
||||
settings: false,
|
||||
scheduledJobs: false,
|
||||
authTokens: false,
|
||||
},
|
||||
endpoints: {
|
||||
capabilities: "GET /api/capabilities",
|
||||
systemStatus: "GET /api/system/status",
|
||||
systemMetrics: "GET /api/system/metrics",
|
||||
machines: "GET /api/machines",
|
||||
machineSnapshot: "GET /api/machines/:id/snapshot",
|
||||
machineRefresh: "POST /api/machines/:id/refresh",
|
||||
machineActions: "POST /api/machines/:id/actions",
|
||||
machineExecutions: "GET /api/machines/:id/executions",
|
||||
executionReport: "GET /api/machines/:id/executions/:execId/report",
|
||||
terminalOutputWs: "WS /api/ws/machines/:id/output",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { resolveCreds } from "./credentials.js";
|
||||
|
||||
describe("resolveCreds", () => {
|
||||
it("préfère la ligne machine_credentials", () => {
|
||||
const out = resolveCreds(
|
||||
{ encPassword: "M_PWD", encSudoPassword: null }, // machines (legacy)
|
||||
{ encPassword: "C_PWD", encSudoPassword: "C_SUDO" }, // machine_credentials
|
||||
);
|
||||
expect(out).toEqual({ encPassword: "C_PWD", encSudoPassword: "C_SUDO" });
|
||||
});
|
||||
it("retombe sur machines si pas de credentials", () => {
|
||||
const out = resolveCreds({ encPassword: "M_PWD", encSudoPassword: "M_SUDO" }, null);
|
||||
expect(out).toEqual({ encPassword: "M_PWD", encSudoPassword: "M_SUDO" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
// server/services/credentials.ts
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db, schema } from "../db/client.js";
|
||||
|
||||
interface EncPair { encPassword: string | null; encSudoPassword: string | null; }
|
||||
|
||||
/** Résout la source des secrets : machine_credentials prioritaire, sinon legacy machines (fonction pure). */
|
||||
export function resolveCreds(legacy: EncPair, creds: EncPair | null): EncPair {
|
||||
if (creds && creds.encPassword) return { encPassword: creds.encPassword, encSudoPassword: creds.encSudoPassword };
|
||||
return { encPassword: legacy.encPassword, encSudoPassword: legacy.encSudoPassword };
|
||||
}
|
||||
|
||||
/** Écrit (insert/replace) la ligne machine_credentials pour une machine (secrets déjà chiffrés). */
|
||||
export function writeCredentials(input: {
|
||||
machineId: string;
|
||||
encPassword: string | null;
|
||||
encSudoPassword: string | null;
|
||||
}): void {
|
||||
const now = new Date().toISOString();
|
||||
db.insert(schema.machineCredentials)
|
||||
.values({
|
||||
machineId: input.machineId,
|
||||
authMethod: "password",
|
||||
encPassword: input.encPassword,
|
||||
encSudoPassword: input.encSudoPassword,
|
||||
sudoMode: input.encSudoPassword ? "separate" : "same_as_ssh",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
status: "unknown",
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: schema.machineCredentials.machineId,
|
||||
set: { encPassword: input.encPassword, encSudoPassword: input.encSudoPassword, updatedAt: now },
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
/** Lit la ligne machine_credentials (ou null). */
|
||||
export function readCredentials(machineId: string): EncPair | null {
|
||||
const row = db.select().from(schema.machineCredentials)
|
||||
.where(eq(schema.machineCredentials.machineId, machineId)).get();
|
||||
return row ? { encPassword: row.encPassword, encSudoPassword: row.encSudoPassword } : null;
|
||||
}
|
||||
|
||||
/** Backfill idempotent : crée une ligne machine_credentials pour chaque machine qui n'en a pas. */
|
||||
export function backfillCredentials(): number {
|
||||
const machines = db.select().from(schema.machines).all();
|
||||
let created = 0;
|
||||
for (const m of machines) {
|
||||
if (readCredentials(m.id)) continue;
|
||||
writeCredentials({ machineId: m.id, encPassword: m.encPassword, encSudoPassword: m.encSudoPassword });
|
||||
created++;
|
||||
}
|
||||
return created;
|
||||
}
|
||||
+102
-7
@@ -1,7 +1,7 @@
|
||||
// server/services/execute.ts
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { mkdirSync, writeFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { db, schema } from "../db/client.js";
|
||||
import { env } from "../env.js";
|
||||
@@ -9,15 +9,22 @@ 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 { parseRebootRequired } from "./aptParse.js";
|
||||
import { parseRebootRequired, buildAptExecutionResult } from "./aptParse.js";
|
||||
import { parseBootIdBefore, verifyReboot } from "./rebootVerify.js";
|
||||
import type { RebootResult } from "@shared/types.js";
|
||||
import { extractSection } from "./refresh.js";
|
||||
import { buildReportMarkdown } from "./report.js";
|
||||
import { outputHub } from "../ws/outputHub.js";
|
||||
import type { ActionType, ExecutionResult, ExecutionStatus } from "@shared/types.js";
|
||||
import { upsertMachineState, recordEvent } from "./machineState.js";
|
||||
import type { ActionType, AptExecutionResult, ExecutionResult, ExecutionStatus } from "@shared/types.js";
|
||||
|
||||
const TEMPLATE_FOR: Record<ActionType, string> = {
|
||||
const TEMPLATE_FOR: Partial<Record<ActionType, string>> = {
|
||||
apt_full_upgrade: "apt/full-upgrade.sh.tpl",
|
||||
apt_upgrade: "apt/upgrade.sh.tpl",
|
||||
apt_autoremove: "apt/autoremove.sh.tpl",
|
||||
apt_clean: "apt/clean.sh.tpl",
|
||||
reboot: "apt/reboot.sh.tpl",
|
||||
reboot_verified: "apt/reboot.sh.tpl",
|
||||
};
|
||||
|
||||
export async function runAction(machineId: string, action: ActionType): Promise<ExecutionResult> {
|
||||
@@ -31,9 +38,14 @@ export async function runAction(machineId: string, action: ActionType): Promise<
|
||||
db.insert(schema.executions).values({
|
||||
id: executionId, machineId, action, mode: "manual", startedAt, status: "running",
|
||||
}).run();
|
||||
upsertMachineState(machineId, { status: "running", runningJobId: executionId });
|
||||
|
||||
const proxy = m.aptProxyMode === "runtime" ? m.aptProxyUrl : null;
|
||||
const script = renderTemplate(TEMPLATE_FOR[action], { aptProxy: proxy });
|
||||
const rel = TEMPLATE_FOR[action];
|
||||
if (!rel) throw new Error("Action sans template: " + action);
|
||||
const script = renderTemplate(rel, { aptProxy: proxy });
|
||||
|
||||
const inactivity = action === "reboot" ? 0 : 600000;
|
||||
|
||||
let raw = "";
|
||||
let status: ExecutionStatus = "ok";
|
||||
@@ -41,7 +53,7 @@ export async function runAction(machineId: string, action: ActionType): Promise<
|
||||
const res = await runScriptSudo(getCreds(m), script, (c) => {
|
||||
raw += c;
|
||||
outputHub.publish(machineId, c);
|
||||
});
|
||||
}, inactivity);
|
||||
raw = res.stdout;
|
||||
if (/===SU:EXIT=\d+===/.test(raw) && !/===SU:EXIT=0===/.test(raw)) {
|
||||
status = "error";
|
||||
@@ -51,9 +63,41 @@ export async function runAction(machineId: string, action: ActionType): Promise<
|
||||
raw += `\n[ERREUR] ${(err as Error).message}\n`;
|
||||
}
|
||||
|
||||
// Vérification réseau du reboot (nouvelle action reboot_verified, jalon SJ-3).
|
||||
let rebootResult: RebootResult | undefined;
|
||||
if (action === "reboot_verified") {
|
||||
const beforeBootId = parseBootIdBefore(raw);
|
||||
outputHub.publish(machineId, "\n[reboot] attente du redémarrage...\n");
|
||||
rebootResult = await verifyReboot(getCreds(m), { beforeBootId, requestedAt: startedAt });
|
||||
if (rebootResult.status !== "ok") status = "error";
|
||||
if (rebootResult.status === "ok") {
|
||||
recordEvent({
|
||||
machineId,
|
||||
eventType: "reboot_verified",
|
||||
severity: "info",
|
||||
executionId,
|
||||
message: `Reboot vérifié en ${rebootResult.waitedSeconds}s (boot_id changé)`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const finishedAt = new Date().toISOString();
|
||||
const rebootRequired = parseRebootRequired(extractSection(raw, "===SU:REBOOT===", "===SU:EXIT") || raw);
|
||||
|
||||
// Diff dpkg réel (si le template a émis DPKG_BEFORE + DPKG_AFTER).
|
||||
let aptResult: AptExecutionResult | undefined;
|
||||
if (raw.includes("===SU:DPKG_BEFORE===") && raw.includes("===SU:DPKG_AFTER===")) {
|
||||
const afterBeforeMarker =
|
||||
raw.includes("===SU:APT_FULLUPGRADE===") ? "===SU:APT_FULLUPGRADE===" :
|
||||
raw.includes("===SU:APT_UPGRADE===") ? "===SU:APT_UPGRADE===" :
|
||||
"===SU:APT_AUTOREMOVE===";
|
||||
aptResult = buildAptExecutionResult(
|
||||
extractSection(raw, "===SU:DPKG_BEFORE===", afterBeforeMarker),
|
||||
extractSection(raw, "===SU:DPKG_AFTER===", "===SU:REBOOT==="),
|
||||
extractSection(raw, "===SU:REBOOT===", "===SU:EXIT"),
|
||||
);
|
||||
}
|
||||
|
||||
// Archivage log brut + rapport.
|
||||
const dir = join(env.reportsDir, machineId);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
@@ -66,15 +110,66 @@ export async function runAction(machineId: string, action: ActionType): Promise<
|
||||
rebootRequiredAfterRun: rebootRequired,
|
||||
importantLogLines: reduceAptLines(raw),
|
||||
rawLogRef: rawLogPath, reportRef: reportPath,
|
||||
...(aptResult ? { apt: aptResult } : {}),
|
||||
...(rebootResult ? { reboot: rebootResult } : {}),
|
||||
};
|
||||
writeFileSync(reportPath, buildReportMarkdown(result, m.name), "utf8");
|
||||
|
||||
const reportId = randomUUID();
|
||||
const exitMatch = /===SU:EXIT=(\d+)===/.exec(raw);
|
||||
db.update(schema.executions).set({
|
||||
finishedAt, status, resultJson: JSON.stringify(result), reportPath, rawLogPath,
|
||||
finishedAt,
|
||||
status,
|
||||
schemaVersion: 1,
|
||||
resultJson: JSON.stringify(result),
|
||||
importantJson: JSON.stringify(result.importantLogLines),
|
||||
reportPath,
|
||||
rawLogPath,
|
||||
reportId,
|
||||
exitCode: exitMatch ? Number(exitMatch[1]) : null,
|
||||
errorKind: status === "error" ? "execution_failed" : null,
|
||||
errorMessage: status === "error" ? (result.importantLogLines.at(-1) ?? null) : null,
|
||||
}).where(eq(schema.executions.id, executionId)).run();
|
||||
db.update(schema.machines).set({ status: status === "error" ? "error" : "unknown" })
|
||||
.where(eq(schema.machines.id, machineId)).run();
|
||||
|
||||
db.insert(schema.reports).values({
|
||||
id: reportId,
|
||||
machineId,
|
||||
executionId,
|
||||
kind: "machine",
|
||||
title: `${m.name} — ${action}`,
|
||||
path: reportPath,
|
||||
createdAt: finishedAt,
|
||||
}).run();
|
||||
|
||||
db.insert(schema.rawArtifacts).values({
|
||||
id: randomUUID(),
|
||||
machineId,
|
||||
kind: "raw_log",
|
||||
path: rawLogPath,
|
||||
bytes: statSync(rawLogPath).size,
|
||||
createdAt: finishedAt,
|
||||
retentionPolicy: status === "error" ? "failed" : "default",
|
||||
}).run();
|
||||
|
||||
upsertMachineState(machineId, {
|
||||
status: status === "error" ? "error" : "unknown",
|
||||
runningJobId: null,
|
||||
lastErrorKind: status === "error" ? "execution_failed" : null,
|
||||
lastErrorMessage: status === "error" ? (result.importantLogLines.at(-1) ?? null) : null,
|
||||
});
|
||||
|
||||
const execSeverity: "info" | "warning" | "error" =
|
||||
status === "error" ? "error" : (status as string) === "warning" ? "warning" : "info";
|
||||
recordEvent({
|
||||
machineId,
|
||||
eventType: `action_${action}`,
|
||||
severity: execSeverity,
|
||||
executionId,
|
||||
message: `Action ${action} : ${status}`,
|
||||
});
|
||||
|
||||
outputHub.publish(machineId, `\n===SU:DONE status=${status}===\n`);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// server/services/machineState.test.ts
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { deriveAptState } from "./machineState.js";
|
||||
import type { UpdateSnapshot } from "@shared/types.js";
|
||||
|
||||
const snap: UpdateSnapshot = {
|
||||
machineId: "m1", hostname: "h", os: { family: "debian", version: "12" },
|
||||
checkedAt: "2026-06-05T10:00:00Z", status: "updates_available",
|
||||
apt: { enabled: true, count: 3, rebootRequired: true, packages: [] },
|
||||
};
|
||||
|
||||
describe("deriveAptState", () => {
|
||||
it("dérive le bloc APT de machine_state depuis un snapshot", () => {
|
||||
expect(deriveAptState(snap)).toEqual({
|
||||
status: "updates_available",
|
||||
aptStatus: "updates_available",
|
||||
aptUpdatesCount: 3,
|
||||
aptRebootRequired: 1,
|
||||
aptLastAnalyzeAt: "2026-06-05T10:00:00Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("met rebootRequired à 0 quand absent", () => {
|
||||
const s = { ...snap, status: "ok" as const, apt: { ...snap.apt, count: 0, rebootRequired: false } };
|
||||
expect(deriveAptState(s)).toMatchObject({ aptUpdatesCount: 0, aptRebootRequired: 0, status: "ok" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
// server/services/machineState.ts
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { db, schema } from "../db/client.js";
|
||||
import type { UpdateSnapshot } from "@shared/types.js";
|
||||
|
||||
export interface AptDerivedState {
|
||||
status: string;
|
||||
aptStatus: string;
|
||||
aptUpdatesCount: number;
|
||||
aptRebootRequired: number;
|
||||
aptLastAnalyzeAt: string;
|
||||
}
|
||||
|
||||
/** Dérive le bloc APT de l'état courant depuis un snapshot (fonction pure). */
|
||||
export function deriveAptState(snapshot: UpdateSnapshot): AptDerivedState {
|
||||
return {
|
||||
status: snapshot.status,
|
||||
aptStatus: snapshot.status,
|
||||
aptUpdatesCount: snapshot.apt.count,
|
||||
aptRebootRequired: snapshot.apt.rebootRequired ? 1 : 0,
|
||||
aptLastAnalyzeAt: snapshot.checkedAt,
|
||||
};
|
||||
}
|
||||
|
||||
type MachineStateInsert = typeof schema.machineState.$inferInsert;
|
||||
|
||||
/** Insère ou met à jour les champs fournis de machine_state pour une machine. */
|
||||
export function upsertMachineState(
|
||||
machineId: string,
|
||||
fields: Partial<Omit<MachineStateInsert, "machineId" | "updatedAt" | "status">> & { status: string },
|
||||
): void {
|
||||
const now = new Date().toISOString();
|
||||
db.insert(schema.machineState)
|
||||
.values({ machineId, updatedAt: now, ...fields })
|
||||
.onConflictDoUpdate({
|
||||
target: schema.machineState.machineId,
|
||||
set: { ...fields, updatedAt: now },
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
/** Ajoute une ligne à la timeline machine_events. */
|
||||
export function recordEvent(input: {
|
||||
machineId: string;
|
||||
eventType: string;
|
||||
severity: "info" | "warning" | "error";
|
||||
actorType?: string;
|
||||
snapshotId?: string;
|
||||
executionId?: string;
|
||||
message?: string;
|
||||
}): void {
|
||||
db.insert(schema.machineEvents).values({
|
||||
id: randomUUID(),
|
||||
machineId: input.machineId,
|
||||
eventType: input.eventType,
|
||||
severity: input.severity,
|
||||
createdAt: new Date().toISOString(),
|
||||
actorType: input.actorType ?? "system",
|
||||
snapshotId: input.snapshotId,
|
||||
executionId: input.executionId,
|
||||
message: input.message,
|
||||
}).run();
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { encryptSecret, decryptSecret } from "../crypto/secrets.js";
|
||||
import { env } from "../env.js";
|
||||
import { runPlain, type SshCreds } from "../ssh/client.js";
|
||||
import type { MachineView, OsFamily } from "@shared/types.js";
|
||||
import { writeCredentials, readCredentials, resolveCreds } from "./credentials.js";
|
||||
|
||||
export interface CreateMachineInput {
|
||||
name: string;
|
||||
@@ -37,12 +38,17 @@ function toView(m: MachineRow): MachineView {
|
||||
|
||||
export function getCreds(m: MachineRow): SshCreds {
|
||||
const key = env.requireMasterKey();
|
||||
const { encPassword, encSudoPassword } = resolveCreds(
|
||||
{ encPassword: m.encPassword, encSudoPassword: m.encSudoPassword },
|
||||
readCredentials(m.id),
|
||||
);
|
||||
if (!encPassword) throw new Error("Aucun secret pour cette machine");
|
||||
return {
|
||||
hostname: m.hostname,
|
||||
port: m.port,
|
||||
username: m.username,
|
||||
password: decryptSecret(m.encPassword, key),
|
||||
sudoPassword: m.encSudoPassword ? decryptSecret(m.encSudoPassword, key) : null,
|
||||
password: decryptSecret(encPassword, key),
|
||||
sudoPassword: encSudoPassword ? decryptSecret(encSudoPassword, key) : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -82,12 +88,19 @@ export async function createMachine(input: CreateMachineInput): Promise<MachineV
|
||||
};
|
||||
const os = await testConnection(creds); // lève si la connexion échoue
|
||||
const id = randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
const row: MachineRow = {
|
||||
id,
|
||||
name: input.name,
|
||||
hostname: input.hostname,
|
||||
port: input.port,
|
||||
osFamily: os.family,
|
||||
osVersion: os.version || null,
|
||||
osCodename: null,
|
||||
arch: null,
|
||||
machineKind: null,
|
||||
virtualization: null,
|
||||
hardwareProfile: null,
|
||||
username: input.username,
|
||||
encPassword: encryptSecret(input.password, key),
|
||||
encSudoPassword: input.sudoPassword ? encryptSecret(input.sudoPassword, key) : null,
|
||||
@@ -95,9 +108,13 @@ export async function createMachine(input: CreateMachineInput): Promise<MachineV
|
||||
aptProxyUrl: input.aptProxyUrl ?? null,
|
||||
status: "unknown",
|
||||
lastCheckedAt: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastSeenAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
deletedAt: null,
|
||||
};
|
||||
db.insert(schema.machines).values(row).run();
|
||||
writeCredentials({ machineId: id, encPassword: row.encPassword, encSudoPassword: row.encSudoPassword });
|
||||
return toView(row);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { classifyReboot, parseBootIdBefore } from "./rebootVerify.js";
|
||||
|
||||
describe("parseBootIdBefore", () => {
|
||||
it("extrait le boot_id de la sortie du template", () => {
|
||||
const raw = "===SU:BOOT_ID_BEFORE===\nabcd-1234\n===SU:REBOOT_NOW===\nreboot planifié";
|
||||
expect(parseBootIdBefore(raw)).toBe("abcd-1234");
|
||||
});
|
||||
it("retourne null si absent", () => {
|
||||
expect(parseBootIdBefore("rien")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyReboot", () => {
|
||||
it("ok si la machine revient avec un boot_id différent", () => {
|
||||
expect(classifyReboot({ beforeBootId: "A", afterBootId: "B", wentDown: true, cameBack: true }).status).toBe("ok");
|
||||
});
|
||||
it("boot_id_unchanged si même boot_id", () => {
|
||||
expect(classifyReboot({ beforeBootId: "A", afterBootId: "A", wentDown: true, cameBack: true }).status).toBe("boot_id_unchanged");
|
||||
});
|
||||
it("ssh_never_went_down si la coupure n'a pas été observée", () => {
|
||||
expect(classifyReboot({ beforeBootId: "A", afterBootId: null, wentDown: false, cameBack: false }).status).toBe("ssh_never_went_down");
|
||||
});
|
||||
it("machine_did_not_return si coupure mais pas de retour", () => {
|
||||
expect(classifyReboot({ beforeBootId: "A", afterBootId: null, wentDown: true, cameBack: false }).status).toBe("machine_did_not_return");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
// server/services/rebootVerify.ts
|
||||
import { runPlain, type SshCreds } from "../ssh/client.js";
|
||||
import type { RebootResult } from "@shared/types.js";
|
||||
|
||||
export function parseBootIdBefore(raw: string): string | null {
|
||||
const s = raw.indexOf("===SU:BOOT_ID_BEFORE===");
|
||||
if (s === -1) return null;
|
||||
const from = s + "===SU:BOOT_ID_BEFORE===".length;
|
||||
const e = raw.indexOf("===SU:REBOOT_NOW===", from);
|
||||
const id = raw.slice(from, e === -1 ? undefined : e).trim();
|
||||
return id || null;
|
||||
}
|
||||
|
||||
export interface RebootSignals {
|
||||
beforeBootId: string | null;
|
||||
afterBootId: string | null;
|
||||
wentDown: boolean;
|
||||
cameBack: boolean;
|
||||
}
|
||||
|
||||
/** Détermine le statut d'un reboot vérifié (fonction pure). */
|
||||
export function classifyReboot(s: RebootSignals): { status: RebootResult["status"] } {
|
||||
if (!s.wentDown) return { status: "ssh_never_went_down" };
|
||||
if (!s.cameBack || s.afterBootId === null) return { status: "machine_did_not_return" };
|
||||
if (s.beforeBootId !== null && s.afterBootId === s.beforeBootId) return { status: "boot_id_unchanged" };
|
||||
return { status: "ok" };
|
||||
}
|
||||
|
||||
async function readBootId(creds: SshCreds): Promise<string | null> {
|
||||
try {
|
||||
const res = await runPlain(creds, "cat /proc/sys/kernel/random/boot_id");
|
||||
const id = res.stdout.trim();
|
||||
return id || null;
|
||||
} catch {
|
||||
return null; // connexion impossible (machine down)
|
||||
}
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
export interface VerifyOptions {
|
||||
beforeBootId: string | null;
|
||||
requestedAt: string;
|
||||
downTimeoutMs?: number; // détection de la coupure
|
||||
upTimeoutMs?: number; // attente du retour
|
||||
pollMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orchestration : attend la coupure SSH (machine qui reboote) puis le retour,
|
||||
* relit le boot_id, et classe le résultat. Réseau ; non testé unitairement.
|
||||
*/
|
||||
export async function verifyReboot(creds: SshCreds, opt: VerifyOptions): Promise<RebootResult> {
|
||||
const downTimeoutMs = opt.downTimeoutMs ?? 60000;
|
||||
const upTimeoutMs = opt.upTimeoutMs ?? 600000;
|
||||
const pollMs = opt.pollMs ?? 5000;
|
||||
const t0 = Date.now();
|
||||
|
||||
// Phase A : attendre que la machine devienne injoignable.
|
||||
let wentDown = false;
|
||||
let sshWentDownAt: string | null = null;
|
||||
while (Date.now() - t0 < downTimeoutMs) {
|
||||
const id = await readBootId(creds);
|
||||
if (id === null) { wentDown = true; sshWentDownAt = new Date().toISOString(); break; }
|
||||
await sleep(pollMs);
|
||||
}
|
||||
|
||||
// Phase B : attendre le retour (seulement si on a vu la coupure).
|
||||
let cameBack = false;
|
||||
let sshCameBackAt: string | null = null;
|
||||
let afterBootId: string | null = null;
|
||||
if (wentDown) {
|
||||
const tB = Date.now();
|
||||
while (Date.now() - tB < upTimeoutMs) {
|
||||
const id = await readBootId(creds);
|
||||
if (id !== null) { cameBack = true; sshCameBackAt = new Date().toISOString(); afterBootId = id; break; }
|
||||
await sleep(pollMs);
|
||||
}
|
||||
}
|
||||
|
||||
const { status } = classifyReboot({ beforeBootId: opt.beforeBootId, afterBootId, wentDown, cameBack });
|
||||
const waitedSeconds = Math.round((Date.now() - t0) / 1000);
|
||||
return {
|
||||
beforeBootId: opt.beforeBootId,
|
||||
afterBootId,
|
||||
requestedAt: opt.requestedAt,
|
||||
sshWentDownAt,
|
||||
sshCameBackAt,
|
||||
waitedSeconds,
|
||||
status,
|
||||
lastRebootDurationSeconds: status === "ok" ? waitedSeconds : undefined,
|
||||
nextRecommendedWaitSeconds: status === "ok" ? Math.round(waitedSeconds * 1.5) + 30 : undefined,
|
||||
};
|
||||
}
|
||||
+35
-12
@@ -3,12 +3,13 @@ import { randomUUID } from "node:crypto";
|
||||
import { eq, desc } from "drizzle-orm";
|
||||
import { db, schema } from "../db/client.js";
|
||||
import { getMachineRow, getCreds } from "./machines.js";
|
||||
import { renderTemplate } from "../templates/render.js";
|
||||
import { renderTemplate, resolveTemplate } from "../templates/render.js";
|
||||
import { reduceAptLines } from "../templates/aptReduce.js";
|
||||
import { runScriptSudo } from "../ssh/client.js";
|
||||
import { parseAptSimulate, parseRebootRequired } from "./aptParse.js";
|
||||
import { buildAptSnapshotDetail } from "./aptParse.js";
|
||||
import { outputHub } from "../ws/outputHub.js";
|
||||
import type { UpdateSnapshot, MachineStatus } from "@shared/types.js";
|
||||
import { deriveAptState, upsertMachineState, recordEvent } from "./machineState.js";
|
||||
import type { UpdateSnapshot, MachineStatus, AptSnapshotDetail } 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 {
|
||||
@@ -27,7 +28,7 @@ export async function refreshMachine(machineId: string): Promise<UpdateSnapshot>
|
||||
outputHub.clear(machineId);
|
||||
|
||||
const proxy = m.aptProxyMode === "runtime" ? m.aptProxyUrl : null;
|
||||
const script = renderTemplate("apt/check.sh.tpl", { aptProxy: proxy });
|
||||
const script = renderTemplate(resolveTemplate("update-analyze", m.osFamily), { aptProxy: proxy });
|
||||
|
||||
let raw = "";
|
||||
try {
|
||||
@@ -41,32 +42,54 @@ export async function refreshMachine(machineId: string): Promise<UpdateSnapshot>
|
||||
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 updateExit = /===SU:EXIT=(\d+)===/.exec(raw);
|
||||
const detail: AptSnapshotDetail = buildAptSnapshotDetail({
|
||||
upgradeSim: extractSection(raw, "===SU:APT_SIM_UPGRADE===", "===SU:APT_SIM_DISTUPGRADE==="),
|
||||
distUpgradeSim: extractSection(raw, "===SU:APT_SIM_DISTUPGRADE===", "===SU:APT_HELD==="),
|
||||
heldRaw: extractSection(raw, "===SU:APT_HELD===", "===SU:REBOOT==="),
|
||||
rebootRaw: extractSection(raw, "===SU:REBOOT===", "===SU:EXIT"),
|
||||
updateFailed: updateExit ? Number(updateExit[1]) !== 0 : false,
|
||||
});
|
||||
|
||||
// MachineStatus n'a pas "warning" : warning => updates_available côté machine.
|
||||
const status: MachineStatus =
|
||||
detail.status === "error" ? "error" : detail.count > 0 || detail.status === "warning" ? "updates_available" : "ok";
|
||||
const checkedAt = new Date().toISOString();
|
||||
|
||||
const snapshot: UpdateSnapshot = {
|
||||
machineId,
|
||||
hostname: m.hostname,
|
||||
os: { family: m.osFamily as UpdateSnapshot["os"]["family"], version: "" },
|
||||
os: { family: m.osFamily as UpdateSnapshot["os"]["family"], version: m.osVersion ?? "" },
|
||||
checkedAt,
|
||||
status,
|
||||
apt: { enabled: true, count: packages.length, rebootRequired, packages },
|
||||
apt: detail,
|
||||
schemaVersion: 1,
|
||||
kind: "apt_update_analyze",
|
||||
rawHints: { logImportantLines: reduceAptLines(raw) },
|
||||
};
|
||||
|
||||
const snapshotId = randomUUID();
|
||||
db.insert(schema.snapshots).values({
|
||||
id: randomUUID(),
|
||||
id: snapshotId,
|
||||
machineId,
|
||||
kind: "apt_update_analyze",
|
||||
schemaVersion: 1,
|
||||
checkedAt,
|
||||
status,
|
||||
payloadJson: JSON.stringify(snapshot),
|
||||
importantJson: JSON.stringify(snapshot.rawHints?.logImportantLines ?? []),
|
||||
}).run();
|
||||
db.update(schema.machines).set({ status, lastCheckedAt: checkedAt }).where(eq(schema.machines.id, machineId)).run();
|
||||
|
||||
upsertMachineState(machineId, deriveAptState(snapshot));
|
||||
recordEvent({
|
||||
machineId,
|
||||
eventType: "apt_refresh",
|
||||
severity: "info",
|
||||
snapshotId,
|
||||
message: `Refresh APT : ${snapshot.apt.count} mise(s) à jour`,
|
||||
});
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// server/services/system.test.ts
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getSystemMetrics, getSystemStatus, systemInternals } from "./system.js";
|
||||
|
||||
describe("system service", () => {
|
||||
it("publie un status serveur stable", () => {
|
||||
const status = getSystemStatus(new Date("2026-06-05T10:00:00.000Z"));
|
||||
|
||||
expect(status).toMatchObject({
|
||||
app: "system_update",
|
||||
version: "0.1.0",
|
||||
apiVersion: "1",
|
||||
serverTime: "2026-06-05T10:00:00.000Z",
|
||||
});
|
||||
expect(status.uptimeSeconds).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("publie des métriques serveur sans secret", () => {
|
||||
const metrics = getSystemMetrics(new Date("2026-06-05T10:00:00.000Z"));
|
||||
|
||||
expect(metrics.collectedAt).toBe("2026-06-05T10:00:00.000Z");
|
||||
expect(metrics.process.rssMb).toBeGreaterThan(0);
|
||||
expect(metrics.host.totalMemoryMb).toBeGreaterThan(0);
|
||||
expect(JSON.stringify(metrics)).not.toContain("password");
|
||||
});
|
||||
|
||||
it("arrondit à deux décimales", () => {
|
||||
expect(systemInternals.round2(12.345)).toBe(12.35);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
// server/services/system.ts
|
||||
import os from "node:os";
|
||||
import type { SystemMetrics, SystemStatus } from "@shared/types.js";
|
||||
|
||||
const APP_VERSION = "0.1.0";
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
function round2(value: number): number {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
export function getSystemStatus(now = new Date()): SystemStatus {
|
||||
return {
|
||||
app: "system_update",
|
||||
version: APP_VERSION,
|
||||
apiVersion: "1",
|
||||
serverTime: now.toISOString(),
|
||||
uptimeSeconds: Math.round(process.uptime()),
|
||||
};
|
||||
}
|
||||
|
||||
export function getSystemMetrics(now = new Date()): SystemMetrics {
|
||||
const memory = process.memoryUsage();
|
||||
const load = os.loadavg();
|
||||
|
||||
return {
|
||||
collectedAt: now.toISOString(),
|
||||
process: {
|
||||
uptimeSeconds: Math.round(process.uptime()),
|
||||
rssMb: round2(memory.rss / MB),
|
||||
heapUsedMb: round2(memory.heapUsed / MB),
|
||||
heapTotalMb: round2(memory.heapTotal / MB),
|
||||
},
|
||||
host: {
|
||||
loadAverage1m: round2(load[0] ?? 0),
|
||||
loadAverage5m: round2(load[1] ?? 0),
|
||||
loadAverage15m: round2(load[2] ?? 0),
|
||||
totalMemoryMb: round2(os.totalmem() / MB),
|
||||
freeMemoryMb: round2(os.freemem() / MB),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const systemInternals = { round2 };
|
||||
Reference in New Issue
Block a user