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:
2026-06-05 19:50:25 +02:00
parent 0fbca06d3d
commit 08919752e3
69 changed files with 7785 additions and 102 deletions
+18
View File
@@ -0,0 +1,18 @@
// server/auth/apiAuth.test.ts
import { describe, expect, it } from "vitest";
import { apiAuthInternals } from "./apiAuth.js";
describe("apiAuthInternals", () => {
it("extrait un token bearer", () => {
expect(apiAuthInternals.extractBearerToken("Bearer su_token")).toBe("su_token");
});
it("accepte bearer sans sensibilité à la casse", () => {
expect(apiAuthInternals.extractBearerToken("bearer su_token")).toBe("su_token");
});
it("rejette un header absent ou mal formé", () => {
expect(apiAuthInternals.extractBearerToken(null)).toBeNull();
expect(apiAuthInternals.extractBearerToken("Basic abc")).toBeNull();
});
});
+34
View File
@@ -0,0 +1,34 @@
// server/auth/apiAuth.ts
import type { MiddlewareHandler } from "hono";
import type { ApiClientScope, ApiClientView } from "@shared/types.js";
import { authenticateApiToken, hasApiScope } from "../services/apiClients.js";
export interface ApiAuthVariables {
apiClient: ApiClientView;
}
export function extractBearerToken(authorization: string | null | undefined): string | null {
if (!authorization) return null;
const match = /^Bearer\s+(.+)$/i.exec(authorization.trim());
return match?.[1]?.trim() || null;
}
export function requireApiScope(required: ApiClientScope): MiddlewareHandler<{
Variables: ApiAuthVariables;
}> {
return async (c, next) => {
const token = extractBearerToken(c.req.header("Authorization"));
if (!token) return c.json({ error: "Token API manquant" }, 401);
const client = authenticateApiToken(token);
if (!client) return c.json({ error: "Token API invalide ou révoqué" }, 401);
if (!hasApiScope(client.scopes, required)) {
return c.json({ error: "Scope API insuffisant" }, 403);
}
c.set("apiClient", client);
await next();
};
}
export const apiAuthInternals = { extractBearerToken };
+28
View File
@@ -0,0 +1,28 @@
// server/cli/createApiClient.test.ts
import { describe, expect, it } from "vitest";
import {
createApiClientCliInternals,
parseCreateApiClientArgs,
} from "./createApiClient.js";
describe("createApiClient CLI", () => {
it("parse un nom et des scopes", () => {
expect(
parseCreateApiClientArgs(["--name", "App Rust", "--scopes", "read,operate,read"]),
).toEqual({
name: "App Rust",
scopes: ["read", "operate"],
});
});
it("utilise read par défaut", () => {
expect(parseCreateApiClientArgs(["--name", "Hermes"])).toEqual({
name: "Hermes",
scopes: ["read"],
});
});
it("rejette un scope invalide", () => {
expect(() => createApiClientCliInternals.parseScopes("read,root")).toThrow("Scope invalide");
});
});
+78
View File
@@ -0,0 +1,78 @@
// server/cli/createApiClient.ts
import { pathToFileURL } from "node:url";
import type { ApiClientScope } from "@shared/types.js";
import { runMigrations } from "../db/migrate.js";
import { createApiClient } from "../services/apiClients.js";
export interface CreateApiClientCliOptions {
name: string;
scopes: ApiClientScope[];
}
const ALLOWED_SCOPES: ApiClientScope[] = ["read", "operate", "admin", "debug"];
export function parseCreateApiClientArgs(args: string[]): CreateApiClientCliOptions {
let name = "";
let scopes: ApiClientScope[] = ["read"];
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === "--name") {
i += 1;
name = args[i] ?? "";
} else if (arg === "--scopes") {
i += 1;
scopes = parseScopes(args[i] ?? "");
} else if (arg === "--help" || arg === "-h") {
throw new Error(helpText());
} else {
throw new Error(`Argument inconnu: ${arg}\n\n${helpText()}`);
}
}
if (!name.trim()) throw new Error(`--name est obligatoire\n\n${helpText()}`);
return { name: name.trim(), scopes };
}
function parseScopes(raw: string): ApiClientScope[] {
const scopes = raw
.split(",")
.map((scope) => scope.trim())
.filter(Boolean) as ApiClientScope[];
if (scopes.length === 0) return ["read"];
for (const scope of scopes) {
if (!ALLOWED_SCOPES.includes(scope)) {
throw new Error(`Scope invalide: ${scope}. Scopes valides: ${ALLOWED_SCOPES.join(", ")}`);
}
}
return [...new Set(scopes)];
}
function helpText(): string {
return [
"Usage:",
" pnpm api-client:create -- --name \"App Rust\" --scopes read,operate",
"",
"Variables requises:",
" SU_MASTER_KEY clé hex 64 caractères",
" SU_DB_PATH chemin SQLite optionnel",
].join("\n");
}
async function main(): Promise<void> {
const options = parseCreateApiClientArgs(process.argv.slice(2));
runMigrations();
const created = createApiClient(options);
console.log(JSON.stringify(created, null, 2));
}
const entrypoint = process.argv[1] ? pathToFileURL(process.argv[1]).href : "";
if (import.meta.url === entrypoint) {
main().catch((err) => {
console.error((err as Error).message);
process.exitCode = 1;
});
}
export const createApiClientCliInternals = { parseScopes, helpText };
+25
View File
@@ -0,0 +1,25 @@
// server/crypto/apiTokens.test.ts
import { describe, expect, it } from "vitest";
import { generateApiToken, hashApiToken, tokenPrefix, verifyApiToken } from "./apiTokens.js";
const PEPPER = "b".repeat(64);
describe("apiTokens", () => {
it("génère un token préfixé non trivial", () => {
const token = generateApiToken();
expect(token).toMatch(/^su_[A-Za-z0-9_-]{40,}$/);
});
it("calcule un préfixe court affichable", () => {
expect(tokenPrefix("su_abcdefghijklmnopqrstuvwxyz")).toBe("su_abcdefghi");
});
it("vérifie un token par HMAC sans stocker le token brut", () => {
const token = "su_test_token";
const hash = hashApiToken(token, PEPPER);
expect(hash).not.toContain(token);
expect(verifyApiToken(token, hash, PEPPER)).toBe(true);
expect(verifyApiToken("su_other_token", hash, PEPPER)).toBe(false);
});
});
+24
View File
@@ -0,0 +1,24 @@
// server/crypto/apiTokens.ts
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
const TOKEN_BYTES = 32;
const TOKEN_PREFIX_LENGTH = 12;
export function generateApiToken(): string {
return `su_${randomBytes(TOKEN_BYTES).toString("base64url")}`;
}
export function tokenPrefix(token: string): string {
return token.slice(0, TOKEN_PREFIX_LENGTH);
}
export function hashApiToken(token: string, pepperHex: string): string {
const pepper = Buffer.from(pepperHex, "hex");
return createHmac("sha256", pepper).update(token).digest("base64url");
}
export function verifyApiToken(token: string, expectedHash: string, pepperHex: string): boolean {
const actual = Buffer.from(hashApiToken(token, pepperHex));
const expected = Buffer.from(expectedHash);
return actual.length === expected.length && timingSafeEqual(actual, expected);
}
+3
View File
@@ -1,7 +1,10 @@
// server/db/migrate.ts
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { db } from "./client.js";
import { backfillCredentials } from "../services/credentials.js";
export function runMigrations(): void {
migrate(db, { migrationsFolder: "./server/db/migrations" });
const n = backfillCredentials();
if (n > 0) console.log(`[migrate] backfill credentials: ${n} machine(s)`);
}
+12
View File
@@ -0,0 +1,12 @@
CREATE TABLE `api_clients` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`token_prefix` text NOT NULL,
`token_hash` text NOT NULL,
`scopes_json` text NOT NULL,
`created_at` text NOT NULL,
`last_used_at` text,
`revoked_at` text
);
--> statement-breakpoint
CREATE UNIQUE INDEX `api_clients_token_hash_unique` ON `api_clients` (`token_hash`);
@@ -0,0 +1,150 @@
CREATE TABLE `important_messages` (
`id` text PRIMARY KEY NOT NULL,
`machine_id` text,
`source` text NOT NULL,
`category` text NOT NULL,
`severity` text NOT NULL,
`package_name` text,
`component` text,
`message` text NOT NULL,
`raw_line_ref` text,
`snapshot_id` text,
`execution_id` text,
`first_seen_at` text NOT NULL,
`last_seen_at` text NOT NULL,
`acknowledged` integer DEFAULT 0 NOT NULL,
`acknowledged_at` text,
`acknowledged_by` text,
`payload_json` text,
FOREIGN KEY (`machine_id`) REFERENCES `machines`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `machine_events` (
`id` text PRIMARY KEY NOT NULL,
`machine_id` text,
`event_type` text NOT NULL,
`severity` text NOT NULL,
`created_at` text NOT NULL,
`actor_type` text,
`actor_id` text,
`snapshot_id` text,
`execution_id` text,
`job_id` text,
`message` text,
`payload_json` text,
FOREIGN KEY (`machine_id`) REFERENCES `machines`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `machine_hardware` (
`machine_id` text PRIMARY KEY NOT NULL,
`probe_snapshot_id` text,
`cpu_model` text,
`cpu_cores` integer,
`memory_bytes` integer,
`gpus_json` text,
`disks_json` text,
`network_json` text,
`firmware_json` text,
`driver_json` text,
`warnings_json` text,
`updated_at` text NOT NULL,
FOREIGN KEY (`machine_id`) REFERENCES `machines`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `machine_metrics_latest` (
`machine_id` text PRIMARY KEY NOT NULL,
`snapshot_id` text,
`collected_at` text NOT NULL,
`cpu_load1` real,
`cpu_load5` real,
`cpu_cores` integer,
`memory_total_bytes` integer,
`memory_used_bytes` integer,
`memory_available_bytes` integer,
`memory_used_percent` real,
`filesystems_json` text,
`root_used_percent` real,
`warnings_json` text,
FOREIGN KEY (`machine_id`) REFERENCES `machines`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `machine_state` (
`machine_id` text PRIMARY KEY NOT NULL,
`status` text NOT NULL,
`apt_status` text,
`apt_updates_count` integer DEFAULT 0 NOT NULL,
`apt_reboot_required` integer DEFAULT 0 NOT NULL,
`apt_last_analyze_at` text,
`docker_status` text,
`docker_installed` integer DEFAULT 0 NOT NULL,
`docker_stacks_count` integer DEFAULT 0 NOT NULL,
`docker_updates_count` integer DEFAULT 0 NOT NULL,
`docker_prune_available` integer DEFAULT 0 NOT NULL,
`post_install_status` text,
`metrics_last_collected_at` text,
`cpu_load1` real,
`memory_used_percent` real,
`root_used_percent` real,
`disk_warnings_count` integer DEFAULT 0 NOT NULL,
`hardware_warnings_count` integer DEFAULT 0 NOT NULL,
`running_job_id` text,
`last_error_kind` text,
`last_error_message` text,
`updated_at` text NOT NULL,
FOREIGN KEY (`machine_id`) REFERENCES `machines`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `raw_artifacts` (
`id` text PRIMARY KEY NOT NULL,
`machine_id` text,
`kind` text NOT NULL,
`path` text NOT NULL,
`bytes` integer,
`sha256` text,
`created_at` text NOT NULL,
`expires_at` text,
`pinned` integer DEFAULT 0 NOT NULL,
`redacted` integer DEFAULT 1 NOT NULL,
`retention_policy` text,
`deleted_at` text,
`delete_reason` text,
`metadata_json` text,
FOREIGN KEY (`machine_id`) REFERENCES `machines`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `reports` (
`id` text PRIMARY KEY NOT NULL,
`machine_id` text,
`execution_id` text,
`kind` text NOT NULL,
`title` text NOT NULL,
`path` text NOT NULL,
`created_at` text NOT NULL,
`pinned` integer DEFAULT 0 NOT NULL,
`summary_json` text,
FOREIGN KEY (`machine_id`) REFERENCES `machines`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
ALTER TABLE `executions` ADD `schema_version` integer DEFAULT 1 NOT NULL;--> statement-breakpoint
ALTER TABLE `executions` ADD `request_id` text;--> statement-breakpoint
ALTER TABLE `executions` ADD `job_id` text;--> statement-breakpoint
ALTER TABLE `executions` ADD `important_json` text;--> statement-breakpoint
ALTER TABLE `executions` ADD `report_id` text;--> statement-breakpoint
ALTER TABLE `executions` ADD `exit_code` integer;--> statement-breakpoint
ALTER TABLE `executions` ADD `error_kind` text;--> statement-breakpoint
ALTER TABLE `executions` ADD `error_message` text;--> statement-breakpoint
ALTER TABLE `machines` ADD `os_version` text;--> statement-breakpoint
ALTER TABLE `machines` ADD `os_codename` text;--> statement-breakpoint
ALTER TABLE `machines` ADD `arch` text;--> statement-breakpoint
ALTER TABLE `machines` ADD `machine_kind` text;--> statement-breakpoint
ALTER TABLE `machines` ADD `virtualization` text;--> statement-breakpoint
ALTER TABLE `machines` ADD `hardware_profile` text;--> statement-breakpoint
ALTER TABLE `machines` ADD `last_seen_at` text;--> statement-breakpoint
ALTER TABLE `machines` ADD `updated_at` text;--> statement-breakpoint
ALTER TABLE `machines` ADD `deleted_at` text;--> statement-breakpoint
ALTER TABLE `snapshots` ADD `kind` text DEFAULT 'apt_update_analyze' NOT NULL;--> statement-breakpoint
ALTER TABLE `snapshots` ADD `schema_version` integer DEFAULT 1 NOT NULL;--> statement-breakpoint
ALTER TABLE `snapshots` ADD `important_json` text;--> statement-breakpoint
ALTER TABLE `snapshots` ADD `raw_log_path` text;--> statement-breakpoint
ALTER TABLE `snapshots` ADD `raw_artifact_id` text;--> statement-breakpoint
ALTER TABLE `snapshots` ADD `source_job_id` text;
@@ -0,0 +1,28 @@
CREATE TABLE `machine_credentials` (
`machine_id` text PRIMARY KEY NOT NULL,
`auth_method` text NOT NULL,
`enc_password` text,
`enc_sudo_password` text,
`enc_private_key` text,
`enc_key_passphrase` text,
`sudo_mode` text NOT NULL,
`created_at` text NOT NULL,
`updated_at` text NOT NULL,
`last_test_at` text,
`status` text,
FOREIGN KEY (`machine_id`) REFERENCES `machines`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `machine_host_keys` (
`id` text PRIMARY KEY NOT NULL,
`machine_id` text,
`hostname` text NOT NULL,
`port` integer NOT NULL,
`key_type` text,
`fingerprint_sha256` text NOT NULL,
`public_key` text,
`status` text NOT NULL,
`first_seen_at` text NOT NULL,
`last_seen_at` text NOT NULL,
FOREIGN KEY (`machine_id`) REFERENCES `machines`(`id`) ON UPDATE no action ON DELETE cascade
);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+21
View File
@@ -8,6 +8,27 @@
"when": 1780599514478,
"tag": "0000_brainy_dakota_north",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1780669000000,
"tag": "0001_api_clients",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1780669100000,
"tag": "0002_reflective_lifeguard",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1780669200000,
"tag": "0003_magical_psylocke",
"breakpoints": true
}
]
}
+59
View File
@@ -0,0 +1,59 @@
// server/db/schema.test.ts
import { describe, it, expect } from "vitest";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
function freshMigratedDb() {
const sqlite = new Database(":memory:");
const db = drizzle(sqlite);
migrate(db, { migrationsFolder: "./server/db/migrations" });
return sqlite;
}
function tableNames(sqlite: Database.Database): string[] {
return sqlite.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map((r: any) => r.name);
}
function columnNames(sqlite: Database.Database, table: string): string[] {
return sqlite.prepare(`PRAGMA table_info(${table})`).all().map((r: any) => r.name);
}
describe("schéma Phase 1", () => {
it("crée les tables socle", () => {
const sqlite = freshMigratedDb();
const tables = tableNames(sqlite);
for (const t of [
"machines", "snapshots", "executions",
"machine_state", "machine_hardware", "machine_metrics_latest",
"machine_events", "important_messages", "reports", "raw_artifacts",
]) {
expect(tables, `table ${t}`).toContain(t);
}
});
it("ajoute les colonnes étendues sans casser l'existant", () => {
const sqlite = freshMigratedDb();
expect(columnNames(sqlite, "machines")).toEqual(
expect.arrayContaining(["machine_kind", "virtualization", "hardware_profile", "os_version", "updated_at"]),
);
expect(columnNames(sqlite, "snapshots")).toEqual(
expect.arrayContaining(["kind", "schema_version", "important_json"]),
);
expect(columnNames(sqlite, "executions")).toEqual(
expect.arrayContaining(["schema_version", "error_kind", "error_message", "exit_code"]),
);
// colonnes jalon 1 conservées
expect(columnNames(sqlite, "snapshots")).toContain("checked_at");
expect(columnNames(sqlite, "machines")).toContain("enc_password");
});
});
describe("schéma Phase 2", () => {
it("crée les tables de credentials Phase 2", () => {
const sqlite = freshMigratedDb();
const tables = tableNames(sqlite);
expect(tables).toEqual(expect.arrayContaining(["machine_credentials", "machine_host_keys"]));
// machines conserve ses colonnes secrets legacy (fallback)
expect(columnNames(sqlite, "machines")).toContain("enc_password");
});
});
+190 -1
View File
@@ -1,5 +1,5 @@
// server/db/schema.ts
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
import { sqliteTable, text, integer, real, uniqueIndex } from "drizzle-orm/sqlite-core";
export const machines = sqliteTable("machines", {
id: text("id").primaryKey(),
@@ -7,6 +7,12 @@ export const machines = sqliteTable("machines", {
hostname: text("hostname").notNull(),
port: integer("port").notNull().default(22),
osFamily: text("os_family").notNull().default("unknown"),
osVersion: text("os_version"),
osCodename: text("os_codename"),
arch: text("arch"),
machineKind: text("machine_kind"), // physical | vm | proxmox_host | lxc | raspberry_pi | workstation | unknown
virtualization: text("virtualization"), // none | qemu | kvm | lxc | docker | vmware | ...
hardwareProfile: text("hardware_profile"), // generic_vm | baremetal_server | raspberry_pi | gpu_server | proxmox_host | ...
username: text("username").notNull(),
encPassword: text("enc_password").notNull(),
encSudoPassword: text("enc_sudo_password"),
@@ -14,15 +20,24 @@ export const machines = sqliteTable("machines", {
aptProxyUrl: text("apt_proxy_url"),
status: text("status").notNull().default("unknown"),
lastCheckedAt: text("last_checked_at"),
lastSeenAt: text("last_seen_at"),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at"),
deletedAt: text("deleted_at"),
});
export const snapshots = sqliteTable("snapshots", {
id: text("id").primaryKey(),
machineId: text("machine_id").notNull().references(() => machines.id, { onDelete: "cascade" }),
kind: text("kind").notNull().default("apt_update_analyze"),
schemaVersion: integer("schema_version").notNull().default(1),
checkedAt: text("checked_at").notNull(),
status: text("status").notNull(),
payloadJson: text("payload_json").notNull(),
importantJson: text("important_json"),
rawLogPath: text("raw_log_path"),
rawArtifactId: text("raw_artifact_id"),
sourceJobId: text("source_job_id"),
});
export const executions = sqliteTable("executions", {
@@ -30,10 +45,184 @@ export const executions = sqliteTable("executions", {
machineId: text("machine_id").notNull().references(() => machines.id, { onDelete: "cascade" }),
action: text("action").notNull(),
mode: text("mode").notNull().default("manual"),
schemaVersion: integer("schema_version").notNull().default(1),
startedAt: text("started_at").notNull(),
finishedAt: text("finished_at"),
status: text("status").notNull(),
requestId: text("request_id"),
jobId: text("job_id"),
resultJson: text("result_json"),
importantJson: text("important_json"),
reportPath: text("report_path"),
rawLogPath: text("raw_log_path"),
reportId: text("report_id"),
exitCode: integer("exit_code"),
errorKind: text("error_kind"),
errorMessage: text("error_message"),
});
export const machineState = sqliteTable("machine_state", {
machineId: text("machine_id").primaryKey().references(() => machines.id, { onDelete: "cascade" }),
status: text("status").notNull(),
aptStatus: text("apt_status"),
aptUpdatesCount: integer("apt_updates_count").notNull().default(0),
aptRebootRequired: integer("apt_reboot_required").notNull().default(0),
aptLastAnalyzeAt: text("apt_last_analyze_at"),
dockerStatus: text("docker_status"),
dockerInstalled: integer("docker_installed").notNull().default(0),
dockerStacksCount: integer("docker_stacks_count").notNull().default(0),
dockerUpdatesCount: integer("docker_updates_count").notNull().default(0),
dockerPruneAvailable: integer("docker_prune_available").notNull().default(0),
postInstallStatus: text("post_install_status"),
metricsLastCollectedAt: text("metrics_last_collected_at"),
cpuLoad1: real("cpu_load1"),
memoryUsedPercent: real("memory_used_percent"),
rootUsedPercent: real("root_used_percent"),
diskWarningsCount: integer("disk_warnings_count").notNull().default(0),
hardwareWarningsCount: integer("hardware_warnings_count").notNull().default(0),
runningJobId: text("running_job_id"),
lastErrorKind: text("last_error_kind"),
lastErrorMessage: text("last_error_message"),
updatedAt: text("updated_at").notNull(),
});
export const machineHardware = sqliteTable("machine_hardware", {
machineId: text("machine_id").primaryKey().references(() => machines.id, { onDelete: "cascade" }),
probeSnapshotId: text("probe_snapshot_id"),
cpuModel: text("cpu_model"),
cpuCores: integer("cpu_cores"),
memoryBytes: integer("memory_bytes"),
gpusJson: text("gpus_json"),
disksJson: text("disks_json"),
networkJson: text("network_json"),
firmwareJson: text("firmware_json"),
driverJson: text("driver_json"),
warningsJson: text("warnings_json"),
updatedAt: text("updated_at").notNull(),
});
export const machineMetricsLatest = sqliteTable("machine_metrics_latest", {
machineId: text("machine_id").primaryKey().references(() => machines.id, { onDelete: "cascade" }),
snapshotId: text("snapshot_id"),
collectedAt: text("collected_at").notNull(),
cpuLoad1: real("cpu_load1"),
cpuLoad5: real("cpu_load5"),
cpuCores: integer("cpu_cores"),
memoryTotalBytes: integer("memory_total_bytes"),
memoryUsedBytes: integer("memory_used_bytes"),
memoryAvailableBytes: integer("memory_available_bytes"),
memoryUsedPercent: real("memory_used_percent"),
filesystemsJson: text("filesystems_json"),
rootUsedPercent: real("root_used_percent"),
warningsJson: text("warnings_json"),
});
export const machineEvents = sqliteTable("machine_events", {
id: text("id").primaryKey(),
machineId: text("machine_id").references(() => machines.id, { onDelete: "cascade" }),
eventType: text("event_type").notNull(),
severity: text("severity").notNull(), // info | warning | error
createdAt: text("created_at").notNull(),
actorType: text("actor_type"), // user | system | schedule | hermes
actorId: text("actor_id"),
snapshotId: text("snapshot_id"),
executionId: text("execution_id"),
jobId: text("job_id"),
message: text("message"),
payloadJson: text("payload_json"),
});
export const importantMessages = sqliteTable("important_messages", {
id: text("id").primaryKey(),
machineId: text("machine_id").references(() => machines.id, { onDelete: "cascade" }),
source: text("source").notNull(), // apt | docker | post_install | ssh | system
category: text("category").notNull(), // error | warning | future_major_change | ...
severity: text("severity").notNull(),
packageName: text("package_name"),
component: text("component"),
message: text("message").notNull(),
rawLineRef: text("raw_line_ref"),
snapshotId: text("snapshot_id"),
executionId: text("execution_id"),
firstSeenAt: text("first_seen_at").notNull(),
lastSeenAt: text("last_seen_at").notNull(),
acknowledged: integer("acknowledged").notNull().default(0),
acknowledgedAt: text("acknowledged_at"),
acknowledgedBy: text("acknowledged_by"),
payloadJson: text("payload_json"),
});
export const reports = sqliteTable("reports", {
id: text("id").primaryKey(),
machineId: text("machine_id").references(() => machines.id, { onDelete: "cascade" }),
executionId: text("execution_id"),
kind: text("kind").notNull(), // machine | global | cleanup | hermes
title: text("title").notNull(),
path: text("path").notNull(),
createdAt: text("created_at").notNull(),
pinned: integer("pinned").notNull().default(0),
summaryJson: text("summary_json"),
});
export const rawArtifacts = sqliteTable("raw_artifacts", {
id: text("id").primaryKey(),
machineId: text("machine_id").references(() => machines.id, { onDelete: "cascade" }),
kind: text("kind").notNull(), // raw_log | rendered_template | export | screenshot
path: text("path").notNull(),
bytes: integer("bytes"),
sha256: text("sha256"),
createdAt: text("created_at").notNull(),
expiresAt: text("expires_at"),
pinned: integer("pinned").notNull().default(0),
redacted: integer("redacted").notNull().default(1),
retentionPolicy: text("retention_policy"), // default | failed | pinned | short
deletedAt: text("deleted_at"),
deleteReason: text("delete_reason"),
metadataJson: text("metadata_json"),
});
// --- Préexistant (WIP api_clients) : NE PAS supprimer ---
export const apiClients = sqliteTable(
"api_clients",
{
id: text("id").primaryKey(),
name: text("name").notNull(),
tokenPrefix: text("token_prefix").notNull(),
tokenHash: text("token_hash").notNull(),
scopesJson: text("scopes_json").notNull(),
createdAt: text("created_at").notNull(),
lastUsedAt: text("last_used_at"),
revokedAt: text("revoked_at"),
},
(table) => ({
tokenHashIdx: uniqueIndex("api_clients_token_hash_unique").on(table.tokenHash),
}),
);
// --- Phase 2 : credentials isolés (non destructif) ---
export const machineCredentials = sqliteTable("machine_credentials", {
machineId: text("machine_id").primaryKey().references(() => machines.id, { onDelete: "cascade" }),
authMethod: text("auth_method").notNull(), // password | ssh_key
encPassword: text("enc_password"),
encSudoPassword: text("enc_sudo_password"),
encPrivateKey: text("enc_private_key"),
encKeyPassphrase: text("enc_key_passphrase"),
sudoMode: text("sudo_mode").notNull(), // same_as_ssh | separate | none
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull(),
lastTestAt: text("last_test_at"),
status: text("status"), // ok | error | unknown
});
export const machineHostKeys = sqliteTable("machine_host_keys", {
id: text("id").primaryKey(),
machineId: text("machine_id").references(() => machines.id, { onDelete: "cascade" }),
hostname: text("hostname").notNull(),
port: integer("port").notNull(),
keyType: text("key_type"),
fingerprintSha256: text("fingerprint_sha256").notNull(),
publicKey: text("public_key"),
status: text("status").notNull(), // approved | changed | rejected | unknown
firstSeenAt: text("first_seen_at").notNull(),
lastSeenAt: text("last_seen_at").notNull(),
});
+4
View File
@@ -14,6 +14,10 @@ env.requireMasterKey();
runMigrations();
const app = new Hono();
app.onError((err, c) => {
console.error("[api]", err.message);
return c.json({ error: err.message || "Erreur serveur" }, 500);
});
app.route("/api", api);
app.get("/health", (c) => c.json({ ok: true }));
+5
View File
@@ -2,7 +2,12 @@
import { Hono } from "hono";
import { machinesRoutes } from "./machines.js";
import { actionsRoutes } from "./actions.js";
import { getServerCapabilities } from "../services/capabilities.js";
import { getSystemMetrics, getSystemStatus } from "../services/system.js";
export const api = new Hono();
api.get("/capabilities", (c) => c.json(getServerCapabilities()));
api.get("/system/status", (c) => c.json(getSystemStatus()));
api.get("/system/metrics", (c) => c.json(getSystemMetrics()));
api.route("/machines", machinesRoutes);
api.route("/machines", actionsRoutes);
@@ -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===
+64
View File
@@ -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);
});
});
+118
View File
@@ -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,
};
+79 -1
View File
@@ -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
View File
@@ -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,
};
}
+31
View File
@@ -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",
},
});
});
});
+38
View File
@@ -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",
},
};
}
+16
View File
@@ -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" });
});
});
+55
View File
@@ -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
View File
@@ -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;
}
+27
View File
@@ -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" });
});
});
+63
View File
@@ -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();
}
+20 -3
View File
@@ -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);
}
+27
View File
@@ -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");
});
});
+94
View File
@@ -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
View File
@@ -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;
}
+30
View File
@@ -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);
});
});
+44
View File
@@ -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 };
+24 -2
View File
@@ -44,17 +44,20 @@ export async function runPlain(creds: SshCreds, command: string): Promise<RunRes
* Exécute un script shell sous sudo. Le script est encodé en base64 pour éviter
* tout problème de quoting; le mot de passe sudo est poussé sur stdin (sudo -S -p '').
* `onData` reçoit chaque chunk de sortie pour le streaming live.
* `inactivityTimeoutMs` (défaut 0 = désactivé) : si aucune sortie n'est reçue pendant
* cette durée, la connexion est fermée et une erreur `human_interaction_required` est levée.
*/
export async function runScriptSudo(
creds: SshCreds,
script: string,
onData: (chunk: string) => void,
inactivityTimeoutMs = 0,
): Promise<RunResult> {
const conn = await connect(creds);
try {
const b64 = Buffer.from(script, "utf8").toString("base64");
const cmd = `sudo -S -p '' sh -c "$(printf '%s' '${b64}' | base64 -d)"`;
return await execStream(conn, cmd, (creds.sudoPassword ?? creds.password) + "\n", onData);
return await execStream(conn, cmd, (creds.sudoPassword ?? creds.password) + "\n", onData, inactivityTimeoutMs);
} finally {
conn.end();
}
@@ -65,26 +68,45 @@ function execStream(
command: string,
stdinData: string | null,
onData: (chunk: string) => void,
inactivityTimeoutMs = 0,
): Promise<RunResult> {
return new Promise((resolve, reject) => {
conn.exec(command, { pty: false }, (err, stream) => {
if (err) return reject(err);
let stdout = "";
let code = 0;
let timer: ReturnType<typeof setTimeout> | undefined;
const arm = () => {
if (!inactivityTimeoutMs) return;
clearTimeout(timer);
timer = setTimeout(() => {
stream.close();
conn.end();
reject(new Error(`human_interaction_required: aucune sortie depuis ${Math.round(inactivityTimeoutMs / 1000)}s`));
}, inactivityTimeoutMs);
};
arm();
if (stdinData) {
stream.write(stdinData);
}
stream
.on("close", (c: number) => resolve({ stdout, code: c ?? code }))
.on("close", (c: number) => {
clearTimeout(timer);
resolve({ stdout, code: c ?? code });
})
.on("data", (d: Buffer) => {
const s = d.toString("utf8");
stdout += s;
onData(s);
arm();
});
stream.stderr.on("data", (d: Buffer) => {
const s = d.toString("utf8");
stdout += s;
onData(s);
arm();
});
});
});
+19 -1
View File
@@ -1,6 +1,6 @@
// server/templates/aptReduce.test.ts
import { describe, it, expect } from "vitest";
import { reduceAptLines } from "./aptReduce.js";
import { reduceAptLines, reduceLines } from "./aptReduce.js";
describe("reduceAptLines", () => {
it("ne garde que les lignes utiles", () => {
@@ -26,4 +26,22 @@ describe("reduceAptLines", () => {
it("retourne un tableau vide si rien d'utile", () => {
expect(reduceAptLines("Reading package lists...\nDone")).toEqual([]);
});
it("garde aussi les lignes Docker utiles", () => {
const raw = [
"Pulling jellyfin ...",
"Status: Downloaded newer image for jellyfin/jellyfin:latest",
"Recreating jellyfin ...",
"Started jellyfin",
"blabla inutile",
"Total reclaimed space: 1.2GB",
].join("\n");
expect(reduceLines(raw)).toEqual([
"Pulling jellyfin ...",
"Status: Downloaded newer image for jellyfin/jellyfin:latest",
"Recreating jellyfin ...",
"Started jellyfin",
"Total reclaimed space: 1.2GB",
]);
});
});
+15 -4
View File
@@ -1,11 +1,22 @@
// server/templates/aptReduce.ts
const PREFIXES = ["Inst ", "Conf ", "Remv ", "Err ", "E:", "W:", "dpkg:"];
const CONTAINS = ["reboot-required", "REBOOT_REQUIRED"];
const PREFIXES = [
// APT / dpkg (jalon 1)
"Inst ", "Conf ", "Remv ", "Err ", "E:", "W:", "dpkg:",
// Docker (SJ-0)
"Pulling", "Digest", "Status", "Downloaded newer image", "Recreating", "Started", "Error",
];
const CONTAINS = [
"reboot-required", "REBOOT_REQUIRED",
"deleted", "Total reclaimed space",
];
/** Garde uniquement les lignes informatives d'une sortie APT brute. */
export function reduceAptLines(raw: string): string[] {
/** Garde uniquement les lignes informatives (APT + Docker) d'une sortie brute. */
export function reduceLines(raw: string): string[] {
return raw
.split("\n")
.map((l) => l.trimEnd())
.filter((l) => PREFIXES.some((p) => l.startsWith(p)) || CONTAINS.some((c) => l.includes(c)));
}
/** Alias rétro-compatible (jalon 1) : même comportement, conserve les imports existants. */
export const reduceAptLines = reduceLines;
+6
View File
@@ -14,4 +14,10 @@ describe("renderTemplate", () => {
const out = renderTemplate("apt/check.sh.tpl", { aptProxy: "http://cache:3142" });
expect(out).toContain('http_proxy="http://cache:3142"');
});
it("rend update-analyze.sh.tpl avec les sections attendues", () => {
const out = renderTemplate("apt/update-analyze.sh.tpl", { aptProxy: null });
expect(out).toContain("===SU:APT_SIM_DISTUPGRADE===");
expect(out).toContain("apt-mark showhold");
});
});
+21 -1
View File
@@ -1,6 +1,6 @@
// server/templates/render.ts
import Mustache from "mustache";
import { readFileSync } from "node:fs";
import { readFileSync, existsSync } from "node:fs";
import { resolve } from "node:path";
const TEMPLATES_ROOT = resolve(process.cwd(), "templates");
@@ -14,3 +14,23 @@ export function renderTemplate(relPath: string, vars: TemplateVars): string {
// Mustache échappe le HTML par défaut; on désactive (ce sont des scripts shell).
return Mustache.render(tpl, vars, {}, { escape: (s) => s });
}
/** Existence par défaut d'un template relatif à templates/. */
function defaultExists(rel: string): boolean {
return existsSync(resolve(TEMPLATES_ROOT, rel));
}
/**
* Résout le chemin de template le plus spécifique pour (action, OS) :
* `<osFamily>/<action>.sh.tpl` s'il existe, sinon fallback base `apt/<action>.sh.tpl`.
* `exists` est injectable pour les tests.
*/
export function resolveTemplate(
action: string,
osFamily: string,
exists: (rel: string) => boolean = defaultExists,
): string {
const specific = `${osFamily}/${action}.sh.tpl`;
if (osFamily !== "unknown" && osFamily !== "apt" && exists(specific)) return specific;
return `apt/${action}.sh.tpl`;
}
+20
View File
@@ -0,0 +1,20 @@
import { describe, it, expect } from "vitest";
import { resolveTemplate } from "./render.js";
describe("resolveTemplate", () => {
it("retombe sur apt/ quand aucun dossier OS spécifique n'existe (fonction exists fournie)", () => {
const noneExist = () => false;
expect(resolveTemplate("full-upgrade", "proxmox", noneExist)).toBe("apt/full-upgrade.sh.tpl");
expect(resolveTemplate("update-analyze", "debian", noneExist)).toBe("apt/update-analyze.sh.tpl");
});
it("choisit le template OS spécifique quand il existe", () => {
const proxmoxExists = (rel: string) => rel === "proxmox/full-upgrade.sh.tpl";
expect(resolveTemplate("full-upgrade", "proxmox", proxmoxExists)).toBe("proxmox/full-upgrade.sh.tpl");
});
it("unknown retombe toujours sur apt/", () => {
const all = () => true;
expect(resolveTemplate("clean", "unknown", all)).toBe("apt/clean.sh.tpl");
});
});