feat: service machines (CRUD, test-connection, détection OS)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,24 @@
|
|||||||
|
// server/services/machines.test.ts
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
|
||||||
|
// Mock all modules with side-effects at import time (DB, SSH, crypto)
|
||||||
|
vi.mock("../db/client.js", () => ({ db: {}, schema: { machines: {} } }));
|
||||||
|
vi.mock("../crypto/secrets.js", () => ({ encryptSecret: vi.fn(), decryptSecret: vi.fn() }));
|
||||||
|
vi.mock("../env.js", () => ({ env: { requireMasterKey: vi.fn(), reportsDir: "/tmp" } }));
|
||||||
|
vi.mock("../ssh/client.js", () => ({ runPlain: vi.fn() }));
|
||||||
|
|
||||||
|
import { parseOsRelease } from "./machines.js";
|
||||||
|
|
||||||
|
describe("parseOsRelease", () => {
|
||||||
|
it("détecte Debian", () => {
|
||||||
|
const r = parseOsRelease('ID=debian\nVERSION_ID="11"\nPRETTY_NAME="Debian 11"');
|
||||||
|
expect(r).toEqual({ family: "debian", version: "11" });
|
||||||
|
});
|
||||||
|
it("détecte Ubuntu", () => {
|
||||||
|
const r = parseOsRelease('ID=ubuntu\nVERSION_ID="22.04"');
|
||||||
|
expect(r).toEqual({ family: "ubuntu", version: "22.04" });
|
||||||
|
});
|
||||||
|
it("retombe sur unknown", () => {
|
||||||
|
expect(parseOsRelease("ID=arch").family).toBe("unknown");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
// server/services/machines.ts
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { db, schema } from "../db/client.js";
|
||||||
|
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";
|
||||||
|
|
||||||
|
export interface CreateMachineInput {
|
||||||
|
name: string;
|
||||||
|
hostname: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
sudoPassword?: string | null;
|
||||||
|
aptProxyMode?: "direct" | "runtime";
|
||||||
|
aptProxyUrl?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MachineRow = typeof schema.machines.$inferSelect;
|
||||||
|
|
||||||
|
function toView(m: MachineRow): MachineView {
|
||||||
|
return {
|
||||||
|
id: m.id,
|
||||||
|
name: m.name,
|
||||||
|
hostname: m.hostname,
|
||||||
|
port: m.port,
|
||||||
|
osFamily: m.osFamily as OsFamily,
|
||||||
|
username: m.username,
|
||||||
|
aptProxyMode: m.aptProxyMode as "direct" | "runtime",
|
||||||
|
aptProxyUrl: m.aptProxyUrl,
|
||||||
|
status: m.status as MachineView["status"],
|
||||||
|
lastCheckedAt: m.lastCheckedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCreds(m: MachineRow): SshCreds {
|
||||||
|
const key = env.requireMasterKey();
|
||||||
|
return {
|
||||||
|
hostname: m.hostname,
|
||||||
|
port: m.port,
|
||||||
|
username: m.username,
|
||||||
|
password: decryptSecret(m.encPassword, key),
|
||||||
|
sudoPassword: m.encSudoPassword ? decryptSecret(m.encSudoPassword, key) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMachineRow(id: string): MachineRow | undefined {
|
||||||
|
return db.select().from(schema.machines).where(eq(schema.machines.id, id)).get();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listMachines(): MachineView[] {
|
||||||
|
return db.select().from(schema.machines).all().map(toView);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse /etc/os-release pour déduire family + version. */
|
||||||
|
export function parseOsRelease(content: string): { family: OsFamily; version: string } {
|
||||||
|
const fields: Record<string, string> = {};
|
||||||
|
for (const line of content.split("\n")) {
|
||||||
|
const m = /^([A-Z_]+)=(.*)$/.exec(line.trim());
|
||||||
|
if (m) fields[m[1]!] = m[2]!.replace(/^"|"$/g, "");
|
||||||
|
}
|
||||||
|
const id = (fields.ID ?? "").toLowerCase();
|
||||||
|
const family: OsFamily = id === "ubuntu" ? "ubuntu" : id === "debian" ? "debian" : "unknown";
|
||||||
|
return { family, version: fields.VERSION_ID ?? fields.VERSION ?? "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testConnection(creds: SshCreds): Promise<{ family: OsFamily; version: string }> {
|
||||||
|
const res = await runPlain(creds, "cat /etc/os-release");
|
||||||
|
return parseOsRelease(res.stdout);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createMachine(input: CreateMachineInput): Promise<MachineView> {
|
||||||
|
const key = env.requireMasterKey();
|
||||||
|
const creds: SshCreds = {
|
||||||
|
hostname: input.hostname,
|
||||||
|
port: input.port,
|
||||||
|
username: input.username,
|
||||||
|
password: input.password,
|
||||||
|
sudoPassword: input.sudoPassword ?? null,
|
||||||
|
};
|
||||||
|
const os = await testConnection(creds); // lève si la connexion échoue
|
||||||
|
const id = randomUUID();
|
||||||
|
const row: MachineRow = {
|
||||||
|
id,
|
||||||
|
name: input.name,
|
||||||
|
hostname: input.hostname,
|
||||||
|
port: input.port,
|
||||||
|
osFamily: os.family,
|
||||||
|
username: input.username,
|
||||||
|
encPassword: encryptSecret(input.password, key),
|
||||||
|
encSudoPassword: input.sudoPassword ? encryptSecret(input.sudoPassword, key) : null,
|
||||||
|
aptProxyMode: input.aptProxyMode ?? "direct",
|
||||||
|
aptProxyUrl: input.aptProxyUrl ?? null,
|
||||||
|
status: "unknown",
|
||||||
|
lastCheckedAt: null,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
db.insert(schema.machines).values(row).run();
|
||||||
|
return toView(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteMachine(id: string): void {
|
||||||
|
db.delete(schema.machines).where(eq(schema.machines.id, id)).run();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user