Compare commits
26 Commits
08919752e3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| fa73ab07b0 | |||
| a93a43e1c8 | |||
| ff9cfaa9e1 | |||
| bdbe7af55c | |||
| 1530409d3b | |||
| 3ea2e66359 | |||
| aaf1b4988d | |||
| d1b0290e3b | |||
| e3e824185f | |||
| c390addadb | |||
| 58abebf687 | |||
| 3b16fdd52a | |||
| 4eb0335900 | |||
| e6f4ae470b | |||
| faa654c95a | |||
| 2b684da9cd | |||
| 0ab6b1d392 | |||
| bafb085995 | |||
| b5ec14dcd8 | |||
| c79c3e5ccb | |||
| 2c15b8c06b | |||
| 47fe952240 | |||
| edb22a59c7 | |||
| b1c81ba518 | |||
| 2af8e74079 | |||
| 434a149f1f |
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Clé maître de chiffrement des credentials (32 octets en hex = 64 caractères).
|
||||
# Générer avec: openssl rand -hex 32
|
||||
SU_MASTER_KEY=0000000000000000000000000000000000000000000000000000000000000000
|
||||
SU_MASTER_KEY=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
# Chemin du fichier SQLite
|
||||
SU_DB_PATH=./data/system-update.db
|
||||
# Répertoire d'archivage des rapports + logs
|
||||
|
||||
+39
-1
@@ -1,2 +1,40 @@
|
||||
- dans l onglet terminal, il n y a pas de separation franche entre 2 machines distincte ou totalement separe?
|
||||
- dans le champ host on peut mettre ip ou nostname .local ou .home ?
|
||||
- dans le champ host on peut mettre ip ou nostname .local ou .home ?
|
||||
- apres un apt upgrade, ne met pas a jours les paquet dans la webui
|
||||
- dans parametre ajouter d'un bacup et restore de la bdd
|
||||
- le bouton ajouter sera deplacer dans le header
|
||||
- ajout de bouton dans le header (toggle entre mode tuilenet mode listing)
|
||||
- dans le header ajouter bouton pour un mode update all qui permet d executer update sur chacune des machine ( mettre en la machine qui est en cours d 'update via un style --shadow-press)
|
||||
- petite modification sur le mode listing 2 ascenseurs verticaux independant: si ma souris survol la zone de listing , lascenseur agit sur la liste des machines, si je survol la zone detail, ma souris agit sur la zone detail seulment
|
||||
- si sudo n'est pas installer utiliser "su -"
|
||||
- les script peuvent etre configurable via parametre ex: Preview — Docker (dépôt officiel)
|
||||
|
||||
#!/bin/sh
|
||||
# Docker Engine depuis le dépôt officiel Debian (docs.docker.com/engine/install/debian).
|
||||
export LC_ALL=C
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
echo "===SU:CUSTOM_DOCKER==="
|
||||
apt-get update -qq 2>&1
|
||||
apt-get install -y ca-certificates curl 2>&1
|
||||
install -m 0755 -d /etc/apt/keyrings
|
||||
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc 2>&1
|
||||
chmod a+r /etc/apt/keyrings/docker.asc
|
||||
. /etc/os-release
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian ${VERSION_CODENAME} stable" > /etc/apt/sources.list.d/docker.list && echo "FILE_MODIFIED=/etc/apt/sources.list.d/docker.list"
|
||||
apt-get update -qq 2>&1
|
||||
if apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin 2>&1; then
|
||||
for p in docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin; do echo "PKG_INSTALLED=$p"; done
|
||||
echo "SERVICE_ENABLED=docker"
|
||||
CODE=0
|
||||
else
|
||||
echo "ERR=docker_install_failed"
|
||||
CODE=1
|
||||
fi
|
||||
usermod -aG docker "gilles" 2>&1 && echo "GROUP_ADDED=docker:gilles" || echo "ERR=docker_group_failed"
|
||||
mkdir -p "/home/gilles/docker" 2>&1 && echo "FILE_MODIFIED=/home/gilles/docker"
|
||||
echo "DOCKER_GROUP_RELOGIN_REQUIRED=1"
|
||||
echo "REBOOT_REQUESTED=1"
|
||||
echo "===SU:EXIT=${CODE}==="
|
||||
( il faut integrer un editeur de texte avec coloration syntaxique et validation du code?)
|
||||
- script partage reseau ajouter le dossier partagé gilles a samba avec les parametre : accessible en temps que guest ( lecture ecriture sans mot de pass) samba : workgroup: home
|
||||
- script partage reseau: nfs : ajoute le partage home/gilles en lecture ecriture
|
||||
@@ -4,6 +4,12 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>System Update</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="alternate icon" href="/favicon.ico" sizes="16x16 32x32 48x48" />
|
||||
<link rel="mask-icon" href="/favicon.svg" color="#fe8019" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<meta name="theme-color" content="#fe8019" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32" role="img" aria-label="System Update">
|
||||
<rect x="1" y="1" width="30" height="30" rx="6" fill="#2a231d" stroke="#fe8019" stroke-width="2"/>
|
||||
<rect x="7" y="8" width="18" height="6" rx="1.5" fill="none" stroke="#f2e5c7" stroke-width="1.6"/>
|
||||
<circle cx="10" cy="11" r="1.3" fill="#4dbb26"/>
|
||||
<line x1="14" y1="11" x2="22" y2="11" stroke="#d5c4a1" stroke-width="1.4" stroke-linecap="round"/>
|
||||
<rect x="7" y="18" width="18" height="6" rx="1.5" fill="none" stroke="#f2e5c7" stroke-width="1.6"/>
|
||||
<circle cx="10" cy="21" r="1.3" fill="#fe8019"/>
|
||||
<line x1="14" y1="21" x2="22" y2="21" stroke="#d5c4a1" stroke-width="1.4" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 737 B |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "System Update",
|
||||
"short_name": "SysUpdate",
|
||||
"description": "Dashboard de mise à jour distante de machines Linux (SSH agentless).",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#2a231d",
|
||||
"theme_color": "#fe8019",
|
||||
"icons": [
|
||||
{ "src": "/favicon.svg", "type": "image/svg+xml", "sizes": "any", "purpose": "any" },
|
||||
{ "src": "/web-app-manifest-192x192.png", "type": "image/png", "sizes": "192x192", "purpose": "any maskable" },
|
||||
{ "src": "/web-app-manifest-512x512.png", "type": "image/png", "sizes": "512x512", "purpose": "any maskable" }
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 170 KiB |
+27
-2
@@ -2,11 +2,12 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { SystemMetrics } from "@shared/types.js";
|
||||
import { api } from "./lib/api.js";
|
||||
import type { DashboardSummary } from "./panels/Dashboard.js";
|
||||
import type { DashboardSummary, ViewMode } from "./panels/Dashboard.js";
|
||||
import { HermesPanel } from "./panels/HermesPanel.js";
|
||||
import { Dashboard } from "./panels/Dashboard.js";
|
||||
import { TerminalPanel } from "./panels/TerminalPanel.js";
|
||||
import { SettingsModal } from "./panels/SettingsModal.js";
|
||||
import { Icon } from "./components/ui-kit.js";
|
||||
import { applyTheme, getInitialTheme, nextTheme, type Theme } from "./lib/theme.js";
|
||||
|
||||
const EMPTY_SUMMARY: DashboardSummary = { machines: 0, updates: 0, errors: 0, running: 0 };
|
||||
@@ -17,6 +18,14 @@ export function App() {
|
||||
const [metrics, setMetrics] = useState<SystemMetrics | null>(null);
|
||||
const [theme, setTheme] = useState<Theme>(() => getInitialTheme());
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [view, setView] = useState<ViewMode>(
|
||||
() => (localStorage.getItem("su-view") as ViewMode) ?? "grid",
|
||||
);
|
||||
function changeView(mode: ViewMode) {
|
||||
setView(mode);
|
||||
localStorage.setItem("su-view", mode);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
applyTheme(theme);
|
||||
@@ -57,6 +66,15 @@ export function App() {
|
||||
<span>{summary.errors} erreurs</span>
|
||||
</div>
|
||||
<div className="su-spacer" />
|
||||
<div className="su-viewtoggle" role="group" aria-label="Mode d'affichage">
|
||||
<button className={`interactive su-viewtoggle-btn ${view === "grid" ? "active" : ""}`} onClick={() => changeView("grid")}>
|
||||
<Icon name="grid" size={13} style={undefined} /> Tuiles
|
||||
</button>
|
||||
<button className={`interactive su-viewtoggle-btn ${view === "list" ? "active" : ""}`} onClick={() => changeView("list")}>
|
||||
<Icon name="list" size={13} style={undefined} /> Liste
|
||||
</button>
|
||||
</div>
|
||||
<button className="interactive su-header-button" onClick={() => setAdding(true)}>+ Ajouter</button>
|
||||
<button className="interactive su-header-button" onClick={() => setTheme(nextTheme(theme))}>
|
||||
{theme === "dark" ? "Light" : "Dark"}
|
||||
</button>
|
||||
@@ -66,7 +84,14 @@ export function App() {
|
||||
</header>
|
||||
<div className="su-row">
|
||||
<HermesPanel />
|
||||
<Dashboard onSelect={setSelected} onSummaryChange={setSummary} />
|
||||
<Dashboard
|
||||
selectedId={selected}
|
||||
onSelect={setSelected}
|
||||
onSummaryChange={setSummary}
|
||||
view={view}
|
||||
adding={adding}
|
||||
onAddingChange={setAdding}
|
||||
/>
|
||||
<TerminalPanel machineId={selected} />
|
||||
</div>
|
||||
<footer className="su-statusbar">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// @ts-nocheck
|
||||
import React from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
/* ============================================================
|
||||
ui-kit.jsx
|
||||
Composants haute-fid Gruvbox Seventies.
|
||||
@@ -45,6 +46,10 @@ const ICON_MAP = {
|
||||
plus: 'plus',
|
||||
filter: 'filter',
|
||||
download: 'download',
|
||||
upload: 'upload',
|
||||
database: 'database',
|
||||
trash: 'trash',
|
||||
check: 'check',
|
||||
folder: 'folder',
|
||||
docker: 'boxes-stacked',
|
||||
package: 'box-open',
|
||||
@@ -429,9 +434,11 @@ function BigRadialGauge({ value = 87, label = 'score santé · stable' }) {
|
||||
============================================================ */
|
||||
function Popup({ open, onClose, title, children, footer, width = 460 }) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
// Portail vers <body> : échappe aux contextes d'empilement des tuiles (backdrop-filter
|
||||
// glass piège même position:fixed) pour rester au premier plan global.
|
||||
return createPortal((
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, zIndex: 100,
|
||||
position: 'fixed', inset: 0, zIndex: 1000,
|
||||
background: 'rgba(0,0,0,0.45)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
@@ -468,7 +475,7 @@ function Popup({ open, onClose, title, children, footer, width = 460 }) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
), document.body);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
|
||||
@@ -1,19 +1,60 @@
|
||||
// client/src/features/machines/AddMachineModal.tsx
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { AptProxyMode, MachineKind, OsFamily } from "@shared/types.js";
|
||||
import type { DefaultAptProxy } from "../../lib/api.js";
|
||||
import { api } from "../../lib/api.js";
|
||||
|
||||
interface Props { onClose: () => void; onCreated: () => void; }
|
||||
|
||||
const OS_OPTIONS: { value: OsFamily; label: string }[] = [
|
||||
{ value: "debian", label: "Debian" },
|
||||
{ value: "ubuntu", label: "Ubuntu" },
|
||||
{ value: "proxmox", label: "Proxmox VE" },
|
||||
{ value: "raspbian", label: "Raspberry Pi OS" },
|
||||
{ value: "unknown", label: "Autre / auto" },
|
||||
];
|
||||
const KIND_OPTIONS: { value: MachineKind; label: string }[] = [
|
||||
{ value: "vm", label: "VM" },
|
||||
{ value: "physical", label: "Physique" },
|
||||
{ value: "proxmox_host", label: "Hôte Proxmox" },
|
||||
{ value: "lxc", label: "LXC / conteneur" },
|
||||
{ value: "raspberry_pi", label: "Raspberry Pi" },
|
||||
{ value: "workstation", label: "Workstation / GPU" },
|
||||
{ value: "unknown", label: "Inconnu" },
|
||||
];
|
||||
|
||||
export function AddMachineModal({ onClose, onCreated }: Props) {
|
||||
const [form, setForm] = useState({ name: "", hostname: "", port: 22, username: "", password: "", sudoPassword: "" });
|
||||
const [form, setForm] = useState({
|
||||
name: "", hostname: "", port: 22, username: "", password: "", sudoPassword: "",
|
||||
osFamily: "debian" as OsFamily, machineKind: "vm" as MachineKind,
|
||||
});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [proxyDefault, setProxyDefault] = useState<DefaultAptProxy | null>(null);
|
||||
const [useProxy, setUseProxy] = useState(false);
|
||||
const set = (k: string, v: string | number) => setForm({ ...form, [k]: v });
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const s = await api.getSettings();
|
||||
if (s.defaultAptProxy.url) {
|
||||
setProxyDefault(s.defaultAptProxy);
|
||||
setUseProxy(true);
|
||||
}
|
||||
} catch {
|
||||
/* pas de défaut configuré */
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
async function submit() {
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
await api.createMachine({ ...form, port: Number(form.port), sudoPassword: form.sudoPassword || null });
|
||||
const proxy = useProxy && proxyDefault?.url
|
||||
? { aptProxyMode: proxyDefault.mode === "direct" ? "runtime" : proxyDefault.mode, aptProxyUrl: proxyDefault.url }
|
||||
: {};
|
||||
await api.createMachine({ ...form, port: Number(form.port), sudoPassword: form.sudoPassword || null, ...proxy });
|
||||
onCreated(); onClose();
|
||||
} catch (e) { setError((e as Error).message); } finally { setBusy(false); }
|
||||
}
|
||||
@@ -26,8 +67,29 @@ export function AddMachineModal({ onClose, onCreated }: Props) {
|
||||
<input key={k} placeholder={k} value={form[k]} onChange={(e) => set(k, e.target.value)} />
|
||||
))}
|
||||
<input placeholder="port" type="number" value={form.port} onChange={(e) => set("port", e.target.value)} />
|
||||
<label style={{ display: "grid", gap: 4 }}>
|
||||
<span className="label">OS</span>
|
||||
<select value={form.osFamily} onChange={(e) => setForm({ ...form, osFamily: e.target.value as OsFamily })}>
|
||||
{OS_OPTIONS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label style={{ display: "grid", gap: 4 }}>
|
||||
<span className="label">Type machine</span>
|
||||
<select value={form.machineKind} onChange={(e) => setForm({ ...form, machineKind: e.target.value as MachineKind })}>
|
||||
{KIND_OPTIONS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<input placeholder="password" type="password" value={form.password} onChange={(e) => set("password", e.target.value)} />
|
||||
<input placeholder="sudo password (optionnel)" type="password" value={form.sudoPassword} onChange={(e) => set("sudoPassword", e.target.value)} />
|
||||
{proxyDefault?.url && (
|
||||
<label style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 12, color: "var(--ink-2)" }}>
|
||||
<input type="checkbox" checked={useProxy} onChange={(e) => setUseProxy(e.target.checked)} />
|
||||
<span>Proxy APT par défaut <span className="mono">{proxyDefault.url}</span></span>
|
||||
</label>
|
||||
)}
|
||||
<div style={{ fontSize: 11, color: "var(--ink-3)" }}>
|
||||
« Autre / auto » détecte l'OS via os-release. Détection complète (type, virt) ensuite via ⚙ Sonder.
|
||||
</div>
|
||||
{error && <div style={{ color: "var(--err)", fontSize: 12 }}>{error}</div>}
|
||||
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
|
||||
<button onClick={onClose}>Annuler</button>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+278
-3
@@ -1,5 +1,5 @@
|
||||
// client/src/lib/api.ts
|
||||
import type { ActionType, MachineView, SystemMetrics, UpdateSnapshot } from "@shared/types.js";
|
||||
import type { ActionType, AptProxyMode, AptRepositoriesAnalysis, MachineKind, MachineMetricsSimple, MachineView, OsFamily, SystemMetrics, UpdateSnapshot } from "@shared/types.js";
|
||||
|
||||
async function readJsonBody(res: Response): Promise<unknown> {
|
||||
const text = await res.text();
|
||||
@@ -35,7 +35,282 @@ export const api = {
|
||||
createMachine: (body: unknown) => req<MachineView>("/machines", { method: "POST", body: JSON.stringify(body) }),
|
||||
refresh: (id: string) => req<UpdateSnapshot>(`/machines/${id}/refresh`, { method: "POST" }),
|
||||
snapshot: (id: string) => req<UpdateSnapshot>(`/machines/${id}/snapshot`),
|
||||
runAction: (id: string, action: ActionType) =>
|
||||
req<{ ok: boolean }>(`/machines/${id}/actions`, { method: "POST", body: JSON.stringify({ action }) }),
|
||||
runAction: (id: string, action: ActionType, stackId?: string) =>
|
||||
req<{ ok: boolean }>(`/machines/${id}/actions`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(stackId ? { action, stackId } : { action }),
|
||||
}),
|
||||
deleteMachine: (id: string) => req<{ ok: boolean }>(`/machines/${id}`, { method: "DELETE" }),
|
||||
|
||||
// --- Post-install (profils) ---
|
||||
getProfiles: () => req<ProfileManifestView[]>("/profiles"),
|
||||
previewProfile: (id: string, profileId: string, values: ProfileValues) =>
|
||||
req<ProfilePreview>(`/machines/${id}/profiles/${profileId}/preview`, { method: "POST", body: JSON.stringify({ values }) }),
|
||||
runProfile: (id: string, profileId: string, values: ProfileValues) =>
|
||||
req<RunProfileResult>(`/machines/${id}/profiles/${profileId}/run`, { method: "POST", body: JSON.stringify({ values }) }),
|
||||
|
||||
// --- Réglages globaux ---
|
||||
getSettings: () => req<AppSettingsView>("/settings"),
|
||||
setDefaultAptProxy: (body: DefaultAptProxy) =>
|
||||
req<DefaultAptProxy>("/settings/apt-proxy", { method: "PUT", body: JSON.stringify(body) }),
|
||||
applyProxyToAll: () => req<{ ok: boolean; updated: number }>("/settings/apt-proxy/apply-all", { method: "POST" }),
|
||||
|
||||
// --- Automatisations planifiées ---
|
||||
getSchedules: () => req<ScheduleView[]>("/schedules"),
|
||||
createSchedule: (body: ScheduleInput) => req<ScheduleView>("/schedules", { method: "POST", body: JSON.stringify(body) }),
|
||||
updateSchedule: (id: string, body: Partial<ScheduleInput>) => req<ScheduleView>(`/schedules/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
deleteSchedule: (id: string) => req<{ ok: boolean }>(`/schedules/${id}`, { method: "DELETE" }),
|
||||
runScheduleNow: (id: string) => req<{ ok: boolean }>(`/schedules/${id}/run`, { method: "POST" }),
|
||||
|
||||
// --- Profil machine (SJ-7) ---
|
||||
updateMachine: (id: string, body: UpdateMachineBody) =>
|
||||
req<MachineView>(`/machines/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
probe: (id: string) => req<ProbeResultView>(`/machines/${id}/probe`, { method: "POST" }),
|
||||
machineHardware: (id: string) => req<MachineHardwareView>(`/machines/${id}/hardware`),
|
||||
machineMessages: (id: string) => req<ImportantMessageView[]>(`/machines/${id}/messages`),
|
||||
machineEvents: (id: string) => req<MachineEventView[]>(`/machines/${id}/events`),
|
||||
ackMessage: (id: string, msgId: string) => req<{ ok: boolean }>(`/machines/${id}/messages/${msgId}/ack`, { method: "POST" }),
|
||||
latestMetrics: (id: string) => req<MachineMetricsSimple | null>(`/machines/${id}/metrics`),
|
||||
collectMetrics: (id: string) => req<MachineMetricsSimple>(`/machines/${id}/metrics/collect`, { method: "POST" }),
|
||||
analyzeRepositories: (id: string) => req<AptRepositoriesAnalysis>(`/machines/${id}/apt-repositories`, { method: "POST" }),
|
||||
|
||||
// --- Docker ---
|
||||
dockerSettings: (id: string) => req<DockerSettingsView>(`/machines/${id}/docker/settings`),
|
||||
dockerSetRoots: (id: string, paths: string[], scanDepth = 4) =>
|
||||
req<DockerSettingsView>(`/machines/${id}/docker/roots`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ paths, scanDepth }),
|
||||
}),
|
||||
dockerScan: (id: string) => req<{ ok: boolean }>(`/machines/${id}/docker/scan`, { method: "POST" }),
|
||||
dockerStacks: (id: string) => req<DockerStackRow[]>(`/machines/${id}/docker/stacks`),
|
||||
setStackStatus: (id: string, stackId: string, status: StackStatus) =>
|
||||
req<DockerStackRow>(`/machines/${id}/docker/stacks/${stackId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ status }),
|
||||
}),
|
||||
|
||||
// --- Demandes d'action destructive ---
|
||||
createActionRequest: (id: string, body: { action: ActionType; stackId?: string; aggressive?: boolean; summary?: string }) =>
|
||||
req<ActionRequestRow>(`/machines/${id}/action-requests`, { method: "POST", body: JSON.stringify(body) }),
|
||||
approveActionRequest: (reqId: string, approvedBy = "ui") =>
|
||||
req<ActionRequestRow>(`/action-requests/${reqId}/approve`, { method: "POST", body: JSON.stringify({ approvedBy }) }),
|
||||
|
||||
// --- Sauvegarde / restauration de la base ---
|
||||
dbInfo: () => req<DbInfo>("/system/db/info"),
|
||||
/** Télécharge l'archive de la base et déclenche le téléchargement navigateur. */
|
||||
dbBackup: async (): Promise<void> => {
|
||||
const res = await fetch("/api/system/db/backup");
|
||||
if (!res.ok) throw new Error("Échec de la sauvegarde");
|
||||
const blob = await res.blob();
|
||||
const cd = res.headers.get("Content-Disposition") ?? "";
|
||||
const filename = /filename="([^"]+)"/.exec(cd)?.[1] ?? "system-update-backup.db";
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
/** Envoie une archive `.db` à restaurer (appliquée au redémarrage). */
|
||||
dbRestore: async (file: File): Promise<DbRestoreResult> => {
|
||||
const res = await fetch("/api/system/db/restore", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/octet-stream" },
|
||||
body: file,
|
||||
});
|
||||
const body = (await readJsonBody(res)) as DbRestoreResult & { error?: string };
|
||||
if (!res.ok) throw new Error(body?.error ?? "Échec de la restauration");
|
||||
return body;
|
||||
},
|
||||
};
|
||||
|
||||
export interface DbInfo {
|
||||
sizeBytes: number;
|
||||
modifiedAt: string | null;
|
||||
restorePending: boolean;
|
||||
}
|
||||
|
||||
export interface DbRestoreResult {
|
||||
ok: boolean;
|
||||
restartRequired: boolean;
|
||||
safetyBackup: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type ProfileValues = Record<string, string | number | boolean>;
|
||||
|
||||
export interface ProfileFieldView {
|
||||
name: string;
|
||||
type: "string" | "hostname" | "ipv4" | "ipv4_cidr" | "ipv4_list" | "select" | "bool" | "int" | "path" | "secret";
|
||||
required: boolean;
|
||||
label?: string;
|
||||
default?: string | number | boolean;
|
||||
defaultFrom?: string;
|
||||
options?: string[];
|
||||
}
|
||||
|
||||
export interface ProfileManifestView {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
risk: "low" | "medium" | "network_change";
|
||||
requiresConfirmation: boolean;
|
||||
fields: ProfileFieldView[];
|
||||
}
|
||||
|
||||
export interface ProfileValidation {
|
||||
ok: boolean;
|
||||
errors: { field: string; message: string }[];
|
||||
}
|
||||
|
||||
export interface ProfilePreview {
|
||||
script: string;
|
||||
validation: ProfileValidation;
|
||||
requiresConfirmation: boolean;
|
||||
}
|
||||
|
||||
export interface RunProfileResult {
|
||||
ok?: boolean;
|
||||
action?: string;
|
||||
profileId?: string;
|
||||
requiresConfirmation?: boolean;
|
||||
actionRequest?: ActionRequestRow;
|
||||
}
|
||||
|
||||
export interface DefaultAptProxy {
|
||||
mode: AptProxyMode;
|
||||
url: string | null;
|
||||
}
|
||||
|
||||
export interface AppSettingsView {
|
||||
defaultAptProxy: DefaultAptProxy;
|
||||
}
|
||||
|
||||
export interface UpdateMachineBody {
|
||||
osFamily?: OsFamily;
|
||||
machineKind?: MachineKind;
|
||||
virtualization?: string | null;
|
||||
aptProxyMode?: AptProxyMode;
|
||||
aptProxyUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface MachineEventView {
|
||||
id: string;
|
||||
eventType: string;
|
||||
severity: "info" | "warning" | "error";
|
||||
createdAt: string;
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
export interface ImportantMessageView {
|
||||
id: string;
|
||||
source: string;
|
||||
category: string;
|
||||
severity: "error" | "warning" | "info";
|
||||
message: string;
|
||||
packageName: string | null;
|
||||
lastSeenAt: string;
|
||||
}
|
||||
|
||||
export interface MachineHardwareView {
|
||||
osFamily: string;
|
||||
osVersion: string | null;
|
||||
arch: string | null;
|
||||
machineKind: string | null;
|
||||
virtualization: string | null;
|
||||
gpus: string[];
|
||||
network: { iface: string; addr: string }[];
|
||||
probed: boolean;
|
||||
}
|
||||
|
||||
export interface ProbeResultView {
|
||||
probe: {
|
||||
osId: string | null;
|
||||
osVersion: string | null;
|
||||
osCodename: string | null;
|
||||
arch: string | null;
|
||||
dpkgArch: string | null;
|
||||
virt: string | null;
|
||||
isProxmox: boolean;
|
||||
isRpi: boolean;
|
||||
gpus: string[];
|
||||
net: { iface: string; addr: string }[];
|
||||
cpuModel: string | null;
|
||||
cpuCores: number | null;
|
||||
memoryBytes: number | null;
|
||||
disks: { name: string; sizeBytes: number }[];
|
||||
};
|
||||
proposal: { osFamily: OsFamily; machineKind: MachineKind; virtualization: string };
|
||||
recommendations: { profileId: string; reason: string }[];
|
||||
changes: string[];
|
||||
}
|
||||
|
||||
export type ScheduleAction = "apt_update_analyze" | "machine_metrics_simple" | "docker_scan";
|
||||
|
||||
export interface ScheduleView {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
cron: string;
|
||||
timezone: string | null;
|
||||
scope: { machineIds: "all" | string[] };
|
||||
actions: ScheduleAction[];
|
||||
concurrency: number;
|
||||
lastRunAt: string | null;
|
||||
lastStatus: string | null;
|
||||
}
|
||||
|
||||
export interface ScheduleInput {
|
||||
name: string;
|
||||
cron: string;
|
||||
timezone?: string | null;
|
||||
enabled?: boolean;
|
||||
scope?: { machineIds: "all" | string[] };
|
||||
actions: ScheduleAction[];
|
||||
concurrency?: number;
|
||||
}
|
||||
|
||||
export type StackStatus = "candidate" | "enabled" | "ignored" | "error";
|
||||
|
||||
export interface DockerSettingsView {
|
||||
settings: { machineId: string; enabled: number; scanDepth: number; pruneMode: string; lastScanAt: string | null; lastPullCheckAt: string | null } | null;
|
||||
roots: Array<{ id: string; path: string; enabled: number }>;
|
||||
}
|
||||
|
||||
export interface DockerServiceRow {
|
||||
id: string;
|
||||
stackId: string;
|
||||
serviceName: string;
|
||||
imageRef: string | null;
|
||||
currentImageId: string | null;
|
||||
currentDigest: string | null;
|
||||
candidateImageId: string | null;
|
||||
candidateDigest: string | null;
|
||||
versionLabel: string | null;
|
||||
status: string | null;
|
||||
}
|
||||
|
||||
export interface DockerStackRow {
|
||||
id: string;
|
||||
machineId: string;
|
||||
name: string;
|
||||
workingDir: string;
|
||||
status: StackStatus;
|
||||
detectedBy: string | null;
|
||||
lastScanAt: string | null;
|
||||
lastUpdateAt: string | null;
|
||||
composeFiles: string[];
|
||||
services: DockerServiceRow[];
|
||||
}
|
||||
|
||||
export interface ActionRequestRow {
|
||||
id: string;
|
||||
machineId: string;
|
||||
action: ActionType;
|
||||
risk: string | null;
|
||||
status: "pending" | "approved" | "rejected" | "executed" | "expired";
|
||||
summary: string | null;
|
||||
executionId: string | null;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { MachineView } from "@shared/types.js";
|
||||
import { api } from "../lib/api.js";
|
||||
import { MachineTile } from "../features/machines/MachineTile.js";
|
||||
import { MachineTile, MachineRow, MachineDetailPanel } from "../features/machines/MachineTile.js";
|
||||
import { AddMachineModal } from "../features/machines/AddMachineModal.js";
|
||||
import { sumUpdates } from "../lib/stats.js";
|
||||
|
||||
@@ -13,15 +13,20 @@ export interface DashboardSummary {
|
||||
running: number;
|
||||
}
|
||||
|
||||
export type ViewMode = "grid" | "list";
|
||||
|
||||
interface Props {
|
||||
selectedId?: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
onSummaryChange?: (summary: DashboardSummary) => void;
|
||||
view: ViewMode;
|
||||
adding: boolean;
|
||||
onAddingChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function Dashboard({ onSelect, onSummaryChange }: Props) {
|
||||
export function Dashboard({ selectedId, onSelect, onSummaryChange, view, adding, onAddingChange }: Props) {
|
||||
const [machines, setMachines] = useState<MachineView[]>([]);
|
||||
const [counts, setCounts] = useState<Record<string, number>>({});
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -56,6 +61,11 @@ export function Dashboard({ onSelect, onSummaryChange }: Props) {
|
||||
onSummaryChange?.(summary);
|
||||
}, [onSummaryChange, summary]);
|
||||
|
||||
const onRefresh = (id: string) => { onSelect(id); void api.refresh(id).then(load); };
|
||||
const onUpgrade = (id: string) => { onSelect(id); void api.runAction(id, "apt_full_upgrade"); };
|
||||
const onReboot = (id: string) => { onSelect(id); void api.runAction(id, "reboot"); };
|
||||
const detail = machines.find((m) => m.id === selectedId) ?? machines[0] ?? null;
|
||||
|
||||
return (
|
||||
<main className="su-center">
|
||||
<div className="su-dashboard-head">
|
||||
@@ -63,22 +73,42 @@ export function Dashboard({ onSelect, onSummaryChange }: Props) {
|
||||
<h2>Machines</h2>
|
||||
<p>{summary.machines} machines · {summary.updates} updates · {summary.errors} erreurs</p>
|
||||
</div>
|
||||
<button className="interactive su-add-button" onClick={() => setAdding(true)}>+ Ajouter</button>
|
||||
</div>
|
||||
{error && <p style={{ color: "var(--err)" }}>{error}</p>}
|
||||
{!error && loading && <p style={{ color: "var(--ink-3)" }}>Chargement des machines…</p>}
|
||||
{!error && !loading && machines.length === 0 && <p style={{ color: "var(--ink-3)" }}>Aucune machine. Clique sur « + Ajouter ».</p>}
|
||||
<div className="su-tiles">
|
||||
{machines.map((m) => (
|
||||
<MachineTile
|
||||
key={m.id} machine={m} packageCount={counts[m.id] ?? 0} onSelect={onSelect}
|
||||
onRefresh={(id) => { onSelect(id); void api.refresh(id).then(load); }}
|
||||
onUpgrade={(id) => { onSelect(id); void api.runAction(id, "apt_full_upgrade"); }}
|
||||
onReboot={(id) => { onSelect(id); void api.runAction(id, "reboot"); }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{adding && <AddMachineModal onClose={() => setAdding(false)} onCreated={load} />}
|
||||
|
||||
{view === "grid" ? (
|
||||
<div className="su-tiles">
|
||||
{machines.map((m) => (
|
||||
<MachineTile
|
||||
key={m.id} machine={m} packageCount={counts[m.id] ?? 0} onSelect={onSelect}
|
||||
onRefresh={onRefresh} onUpgrade={onUpgrade} onReboot={onReboot} onChanged={load}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
machines.length > 0 && (
|
||||
<div className="machine-listing">
|
||||
<div className="machine-list">
|
||||
{machines.map((m) => (
|
||||
<MachineRow
|
||||
key={m.id} machine={m} packageCount={counts[m.id] ?? 0}
|
||||
selected={detail?.id === m.id} onClick={() => onSelect(m.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{detail && (
|
||||
<MachineDetailPanel
|
||||
machine={detail} packageCount={counts[detail.id] ?? 0} onSelect={onSelect}
|
||||
onRefresh={onRefresh} onUpgrade={onUpgrade} onReboot={onReboot} onChanged={load}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{adding && <AddMachineModal onClose={() => onAddingChange(false)} onCreated={load} />}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// client/src/panels/SettingsModal.tsx
|
||||
import { useState } from "react";
|
||||
import { Icon } from "../components/ui-kit.js";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { AptProxyMode } from "@shared/types.js";
|
||||
import { Icon, Popup, Button } from "../components/ui-kit.js";
|
||||
import { api, type DbInfo, type ScheduleView, type ScheduleAction } from "../lib/api.js";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@@ -11,21 +13,27 @@ type SettingsTab =
|
||||
| "appearance"
|
||||
| "tiles"
|
||||
| "layout"
|
||||
| "proxy"
|
||||
| "automation"
|
||||
| "docker"
|
||||
| "scripts"
|
||||
| "hermes"
|
||||
| "terminal"
|
||||
| "retention";
|
||||
| "retention"
|
||||
| "database";
|
||||
|
||||
const TABS: Array<{ id: SettingsTab; label: string; icon: string }> = [
|
||||
{ id: "appearance", label: "Apparence", icon: "cog" },
|
||||
{ id: "tiles", label: "Tuiles", icon: "grid" },
|
||||
{ id: "layout", label: "Volets", icon: "collapse" },
|
||||
{ id: "proxy", label: "Proxy APT", icon: "network" },
|
||||
{ id: "automation", label: "Automatisations", icon: "clock" },
|
||||
{ id: "docker", label: "Docker", icon: "docker" },
|
||||
{ id: "scripts", label: "Scripts", icon: "script" },
|
||||
{ id: "hermes", label: "Hermes", icon: "node" },
|
||||
{ id: "terminal", label: "Terminal", icon: "terminal" },
|
||||
{ id: "retention", label: "Nettoyage", icon: "logs" },
|
||||
{ id: "database", label: "Base de données", icon: "database" },
|
||||
];
|
||||
|
||||
export function SettingsModal({ open, onClose }: Props) {
|
||||
@@ -64,11 +72,14 @@ export function SettingsModal({ open, onClose }: Props) {
|
||||
{active === "appearance" && <AppearanceSettings />}
|
||||
{active === "tiles" && <TileSettings />}
|
||||
{active === "layout" && <LayoutSettings />}
|
||||
{active === "proxy" && <ProxyDefaultSettings />}
|
||||
{active === "automation" && <AutomationSettings />}
|
||||
{active === "docker" && <DockerSettings />}
|
||||
{active === "scripts" && <ScriptsSettings />}
|
||||
{active === "hermes" && <HermesSettings />}
|
||||
{active === "terminal" && <TerminalSettings />}
|
||||
{active === "retention" && <RetentionSettings />}
|
||||
{active === "database" && <DatabaseSettings />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -231,6 +242,310 @@ function RetentionSettings() {
|
||||
);
|
||||
}
|
||||
|
||||
const SCHEDULE_ACTIONS: { id: ScheduleAction; label: string }[] = [
|
||||
{ id: "apt_update_analyze", label: "Analyse APT" },
|
||||
{ id: "machine_metrics_simple", label: "Métriques" },
|
||||
{ id: "docker_scan", label: "Scan Docker" },
|
||||
];
|
||||
|
||||
function AutomationSettings() {
|
||||
const [schedules, setSchedules] = useState<ScheduleView[]>([]);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [msg, setMsg] = useState<{ kind: "ok" | "error"; text: string } | null>(null);
|
||||
const [name, setName] = useState("Analyse quotidienne");
|
||||
const [cron, setCron] = useState("0 6 * * *");
|
||||
const [actions, setActions] = useState<ScheduleAction[]>(["apt_update_analyze", "machine_metrics_simple"]);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
setSchedules(await api.getSchedules());
|
||||
} catch (e) {
|
||||
setMsg({ kind: "error", text: (e as Error).message });
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
async function withBusy(key: string, fn: () => Promise<void>) {
|
||||
setBusy(key);
|
||||
setMsg(null);
|
||||
try {
|
||||
await fn();
|
||||
} catch (e) {
|
||||
setMsg({ kind: "error", text: (e as Error).message });
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
const toggleAction = (a: ScheduleAction) =>
|
||||
setActions((prev) => (prev.includes(a) ? prev.filter((x) => x !== a) : [...prev, a]));
|
||||
|
||||
const create = () =>
|
||||
withBusy("create", async () => {
|
||||
if (!actions.length) throw new Error("Sélectionne au moins une action.");
|
||||
await api.createSchedule({ name, cron, actions, scope: { machineIds: "all" } });
|
||||
await load();
|
||||
setMsg({ kind: "ok", text: "Automatisation créée." });
|
||||
});
|
||||
|
||||
return (
|
||||
<SettingsSection title="Automatisations planifiées">
|
||||
<div className="machine-list" style={{ gap: 8 }}>
|
||||
{schedules.length === 0 && <span className="machine-placeholder">Aucune automatisation. Crée-en une ci-dessous.</span>}
|
||||
{schedules.map((s) => (
|
||||
<div key={s.id} className="docker-stack">
|
||||
<div className="docker-stack-head">
|
||||
<span className="docker-stack-name">{s.name}</span>
|
||||
<span className={`docker-badge docker-badge-${s.enabled ? "ok" : "off"}`}>{s.enabled ? "actif" : "off"}</span>
|
||||
<span className="docker-stack-by mono">{s.cron}</span>
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 11, color: "var(--ink-3)" }}>
|
||||
{s.actions.join(" · ")} · toutes machines{s.lastRunAt ? ` · dernier ${new Date(s.lastRunAt).toLocaleString("fr-FR")} (${s.lastStatus})` : ""}
|
||||
</div>
|
||||
<div className="docker-stack-actions">
|
||||
<Button icon="play" size="sm" onClick={busy ? undefined : () => withBusy(`run:${s.id}`, async () => { await api.runScheduleNow(s.id); setMsg({ kind: "ok", text: `${s.name} lancé.` }); })}>
|
||||
{busy === `run:${s.id}` ? "…" : "Lancer"}
|
||||
</Button>
|
||||
<Button icon="check" size="sm" variant="ghost" onClick={busy ? undefined : () => withBusy(`tog:${s.id}`, async () => { await api.updateSchedule(s.id, { enabled: !s.enabled }); await load(); })}>
|
||||
{s.enabled ? "Désactiver" : "Activer"}
|
||||
</Button>
|
||||
<Button icon="trash" size="sm" variant="danger" onClick={busy ? undefined : () => withBusy(`del:${s.id}`, async () => { await api.deleteSchedule(s.id); await load(); })}>
|
||||
Supprimer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="cfg-block" style={{ marginTop: 14 }}>
|
||||
<span className="label">Nouvelle automatisation</span>
|
||||
<div className="settings-fields">
|
||||
<Field label="Nom"><input className="su-field" value={name} onChange={(e) => setName(e.target.value)} /></Field>
|
||||
<Field label="Cron (min h j m jsem)"><input className="su-field" value={cron} onChange={(e) => setCron(e.target.value)} placeholder="0 6 * * *" /></Field>
|
||||
</div>
|
||||
<Field label="Actions">
|
||||
<div className="settings-checks">
|
||||
{SCHEDULE_ACTIONS.map((a) => (
|
||||
<label key={a.id} className="settings-check">
|
||||
<input type="checkbox" checked={actions.includes(a.id)} onChange={() => toggleAction(a.id)} />
|
||||
<span>{a.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
<div className="settings-actions">
|
||||
<Button icon="check" variant="primary" onClick={busy ? undefined : create}>
|
||||
{busy === "create" ? "Création…" : "Créer (toutes machines)"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{msg && <p className={`settings-note ${msg.kind === "error" ? "settings-note-err" : "settings-note-ok"}`}>{msg.text}</p>}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function ProxyDefaultSettings() {
|
||||
const [mode, setMode] = useState<AptProxyMode>("direct");
|
||||
const [url, setUrl] = useState("");
|
||||
const [busy, setBusy] = useState<null | "save" | "apply">(null);
|
||||
const [msg, setMsg] = useState<{ kind: "ok" | "error"; text: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const s = await api.getSettings();
|
||||
setMode(s.defaultAptProxy.mode);
|
||||
setUrl(s.defaultAptProxy.url ?? "");
|
||||
} catch {
|
||||
/* défaut direct */
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
async function save() {
|
||||
setBusy("save");
|
||||
setMsg(null);
|
||||
try {
|
||||
await api.setDefaultAptProxy({ mode, url: url.trim() || null });
|
||||
setMsg({ kind: "ok", text: "Proxy par défaut enregistré." });
|
||||
} catch (err) {
|
||||
setMsg({ kind: "error", text: (err as Error).message });
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function applyAll() {
|
||||
setBusy("apply");
|
||||
setMsg(null);
|
||||
try {
|
||||
await api.setDefaultAptProxy({ mode, url: url.trim() || null });
|
||||
const res = await api.applyProxyToAll();
|
||||
setMsg({ kind: "ok", text: `Appliqué à ${res.updated} machine(s).` });
|
||||
} catch (err) {
|
||||
setMsg({ kind: "error", text: (err as Error).message });
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection title="Proxy APT par défaut (apt-cacher-ng)">
|
||||
<div className="settings-fields">
|
||||
<Field label="Mode par défaut">
|
||||
<select className="su-field" value={mode} onChange={(e) => setMode(e.target.value as AptProxyMode)}>
|
||||
<option value="direct">Direct (aucun proxy)</option>
|
||||
<option value="runtime">Runtime (le temps d'une exécution)</option>
|
||||
<option value="persistent">Persistant (/etc/apt/apt.conf.d/01proxy)</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="URL apt-cacher-ng">
|
||||
<input className="su-field" value={url} onChange={(e) => setUrl(e.target.value)} placeholder="http://10.0.3.100:3142" />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="settings-actions">
|
||||
<Button icon="check" variant="primary" onClick={busy ? undefined : save}>
|
||||
{busy === "save" ? "Enregistrement…" : "Enregistrer le défaut"}
|
||||
</Button>
|
||||
<Button icon="network" variant="default" onClick={busy ? undefined : applyAll}>
|
||||
{busy === "apply" ? "Application…" : "Appliquer à toutes les machines"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="settings-note">
|
||||
Ce proxy sert de valeur par défaut à l'ajout d'une machine (apt-cacher-ng mutualise le cache des paquets). « Appliquer à toutes les machines » écrase le réglage proxy de chaque machine existante. Le mode <span className="mono">persistant</span> n'est écrit sur disque que via l'action dédiée par machine.
|
||||
</p>
|
||||
|
||||
{msg && (
|
||||
<p className={`settings-note ${msg.kind === "error" ? "settings-note-err" : "settings-note-ok"}`}>{msg.text}</p>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function DatabaseSettings() {
|
||||
const [info, setInfo] = useState<DbInfo | null>(null);
|
||||
const [busy, setBusy] = useState<null | "backup" | "restore">(null);
|
||||
const [message, setMessage] = useState<{ kind: "ok" | "error"; text: string } | null>(null);
|
||||
const [pendingFile, setPendingFile] = useState<File | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
async function loadInfo() {
|
||||
try {
|
||||
setInfo(await api.dbInfo());
|
||||
} catch {
|
||||
setInfo(null);
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
void loadInfo();
|
||||
}, []);
|
||||
|
||||
async function onBackup() {
|
||||
setBusy("backup");
|
||||
setMessage(null);
|
||||
try {
|
||||
await api.dbBackup();
|
||||
setMessage({ kind: "ok", text: "Archive téléchargée." });
|
||||
} catch (err) {
|
||||
setMessage({ kind: "error", text: (err as Error).message });
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
function onPickFile(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0] ?? null;
|
||||
if (file) setPendingFile(file);
|
||||
event.target.value = "";
|
||||
}
|
||||
|
||||
async function confirmRestore() {
|
||||
if (!pendingFile) return;
|
||||
setBusy("restore");
|
||||
setMessage(null);
|
||||
try {
|
||||
const res = await api.dbRestore(pendingFile);
|
||||
setMessage({ kind: "ok", text: res.message });
|
||||
void loadInfo();
|
||||
} catch (err) {
|
||||
setMessage({ kind: "error", text: (err as Error).message });
|
||||
} finally {
|
||||
setBusy(null);
|
||||
setPendingFile(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection title="Base de données">
|
||||
<div className="settings-fields">
|
||||
<Field label="Taille actuelle">
|
||||
<span className="mono">{info ? formatBytes(info.sizeBytes) : "--"}</span>
|
||||
</Field>
|
||||
<Field label="Dernière modification">
|
||||
<span className="mono">{info?.modifiedAt ? new Date(info.modifiedAt).toLocaleString("fr-FR") : "--"}</span>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{info?.restorePending && (
|
||||
<p className="settings-note settings-note-warn">
|
||||
<Icon name="alert" size={13} style={undefined} /> Une restauration est en attente : redémarrez le serveur pour l'appliquer.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="settings-actions">
|
||||
<Button icon="download" variant="primary" onClick={busy ? undefined : onBackup}>
|
||||
{busy === "backup" ? "Sauvegarde…" : "Télécharger la sauvegarde"}
|
||||
</Button>
|
||||
<Button icon="upload" variant="default" onClick={busy ? undefined : () => fileRef.current?.click()}>
|
||||
Restaurer une archive…
|
||||
</Button>
|
||||
<input ref={fileRef} type="file" accept=".db,application/octet-stream" hidden onChange={onPickFile} />
|
||||
</div>
|
||||
|
||||
<p className="settings-note">
|
||||
La sauvegarde produit un instantané cohérent <span className="mono">.db</span> (machines, credentials chiffrés, exécutions, rapports). La restauration remplace toute la base au prochain démarrage ; une sauvegarde de sécurité est créée automatiquement avant.
|
||||
</p>
|
||||
|
||||
{message && (
|
||||
<p className={`settings-note ${message.kind === "error" ? "settings-note-err" : "settings-note-ok"}`}>
|
||||
{message.text}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Popup
|
||||
open={pendingFile !== null}
|
||||
onClose={() => setPendingFile(null)}
|
||||
title="Confirmer la restauration"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" icon="close" onClick={() => setPendingFile(null)}>Annuler</Button>
|
||||
<Button variant="danger" icon="upload" onClick={busy === "restore" ? undefined : confirmRestore}>
|
||||
{busy === "restore" ? "Restauration…" : "Remplacer la base"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p>
|
||||
La base actuelle sera <strong>entièrement remplacée</strong> par
|
||||
<span className="mono">{pendingFile?.name}</span> au prochain démarrage du serveur.
|
||||
</p>
|
||||
<p>Une sauvegarde de sécurité de la base actuelle est créée automatiquement.</p>
|
||||
</Popup>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} o`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} Ko`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} Mo`;
|
||||
}
|
||||
|
||||
function SettingsSection({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="settings-section">
|
||||
|
||||
@@ -66,6 +66,62 @@ body {
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
|
||||
/* --- Toggle d'affichage Tuiles / Liste --- */
|
||||
.su-head-actions { display: flex; align-items: center; gap: 10px; }
|
||||
.su-viewtoggle { display: inline-flex; border: 1px solid var(--border-2); border-radius: 8px; overflow: hidden; background: var(--bg-2); }
|
||||
.su-viewtoggle-btn {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 7px 12px; font-size: 12px; font-family: var(--font-ui);
|
||||
color: var(--ink-2); background: transparent; border: none;
|
||||
}
|
||||
.su-viewtoggle-btn.active { background: var(--accent); color: var(--bg-1); }
|
||||
|
||||
/* --- Mode Listing : liste compacte + panneau détail --- */
|
||||
.machine-listing { display: flex; gap: 14px; align-items: flex-start; }
|
||||
.machine-list { flex: 0 0 clamp(280px, 32%, 420px); display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||
.machine-row {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 9px 11px; border-radius: 8px;
|
||||
border: 1px solid var(--border-1); background: var(--bg-2);
|
||||
text-align: left; width: 100%; color: var(--ink-1);
|
||||
}
|
||||
.machine-row.active { border-color: var(--accent); background: var(--accent-tint); }
|
||||
.machine-row-name { font-weight: 600; font-size: 13px; flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.machine-row-ip { color: var(--ink-3); font-size: 11px; flex: 0 0 auto; }
|
||||
.machine-row-os { display: inline-flex; align-items: center; gap: 5px; color: var(--ink-2); font-size: 12px; flex: 0 0 auto; }
|
||||
.machine-row-cell { display: flex; flex-direction: column; align-items: flex-end; gap: 1px; flex: 0 0 auto; min-width: 56px; }
|
||||
.machine-row-cell b { font-size: 13px; }
|
||||
.machine-row-cell .mono { font-size: 11px; color: var(--ink-3); }
|
||||
|
||||
.machine-detail { flex: 1 1 auto; min-width: 0; padding: 16px; border-radius: 10px; display: flex; flex-direction: column; gap: 14px; }
|
||||
.machine-detail-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.machine-detail-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }
|
||||
.machine-detail-card {
|
||||
display: flex; flex-direction: column; gap: 7px;
|
||||
padding: 12px; border-radius: 8px;
|
||||
border: 1px solid var(--border-1); background: var(--bg-1);
|
||||
}
|
||||
.machine-info-row { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
|
||||
.machine-info-k { color: var(--ink-3); font-size: 12px; }
|
||||
.machine-info-v { color: var(--ink-1); font-size: 13px; text-align: right; }
|
||||
|
||||
/* --- Post-install (profils) --- */
|
||||
.pi-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.pi-profile { border: 1px solid var(--border-2); border-radius: 8px; background: var(--bg-2); overflow: hidden; }
|
||||
.pi-profile-head { display: flex; align-items: center; gap: 8px; width: 100%; padding: 9px 10px; background: transparent; border: none; color: var(--ink-1); text-align: left; }
|
||||
.pi-profile-name { font-weight: 600; font-size: 13px; flex: 1 1 auto; }
|
||||
.pi-profile-body { display: flex; flex-direction: column; gap: 8px; padding: 4px 10px 10px; border-top: 1px solid var(--border-1); }
|
||||
.pi-desc { margin: 6px 0 2px; color: var(--ink-3); font-size: 12px; line-height: 1.4; }
|
||||
.pi-field { display: flex; flex-direction: column; gap: 4px; }
|
||||
.pi-bool { padding: 4px 0; }
|
||||
.pi-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 4px; }
|
||||
.pi-preview {
|
||||
margin: 0; max-height: 50vh; overflow: auto;
|
||||
padding: 12px; border-radius: 8px;
|
||||
background: var(--bg-0); border: 1px solid var(--border-1);
|
||||
color: var(--ink-2); font-size: 12px; line-height: 1.45; white-space: pre-wrap; word-break: break-word;
|
||||
}
|
||||
|
||||
.machine-tile {
|
||||
min-width: 0;
|
||||
padding: 14px;
|
||||
@@ -158,6 +214,89 @@ body {
|
||||
.machine-section-row { justify-content: space-between; gap: 8px; }
|
||||
.machine-placeholder { color: var(--ink-3); font-size: 12px; line-height: 1.35; }
|
||||
.machine-check-row { gap: 8px; color: var(--ink-2); font-size: 13px; }
|
||||
|
||||
/* --- Docker section --- */
|
||||
.docker-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.docker-laststamp { color: var(--ink-3); font-size: 11px; margin-left: auto; }
|
||||
.docker-roots {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-2);
|
||||
background: var(--bg-2);
|
||||
}
|
||||
.docker-roots .settings-textarea { min-height: 60px; }
|
||||
.docker-stacks { display: flex; flex-direction: column; gap: 8px; }
|
||||
.docker-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 9px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-2);
|
||||
background: var(--bg-2);
|
||||
}
|
||||
.docker-stack-head { display: flex; align-items: center; gap: 8px; }
|
||||
.docker-stack-name { font-weight: 600; color: var(--ink-1); font-size: 13px; }
|
||||
.docker-stack-by { color: var(--ink-3); font-size: 11px; margin-left: auto; }
|
||||
.docker-stack-actions { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.docker-services {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid var(--border-1);
|
||||
}
|
||||
.docker-service { display: flex; align-items: center; gap: 8px; font-size: 12px; }
|
||||
.docker-service-name { color: var(--ink-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.docker-service-diff { color: var(--ink-3); font-size: 11px; margin-left: auto; }
|
||||
.docker-badge {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 2px 7px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.docker-badge-ok { color: var(--ok); border-color: var(--ok); }
|
||||
.docker-badge-warn { color: var(--warn); border-color: var(--warn); }
|
||||
.docker-badge-err { color: var(--err); border-color: var(--err); }
|
||||
.docker-badge-info { color: var(--accent); border-color: var(--accent-soft); }
|
||||
.docker-badge-off { color: var(--ink-3); }
|
||||
.docker-msg { font-size: 12px; margin: 4px 0 0; }
|
||||
.docker-msg-ok { color: var(--ok); }
|
||||
.docker-msg-err { color: var(--err); }
|
||||
.docker-confirm-note { display: flex; align-items: center; gap: 7px; color: var(--ink-3); font-size: 12px; margin-top: 10px; }
|
||||
|
||||
/* --- Popup config machine (SJ-7) --- */
|
||||
.cfg { display: flex; flex-direction: column; gap: 14px; }
|
||||
.cfg-current { display: flex; flex-direction: column; gap: 4px; }
|
||||
.cfg-current .mono { color: var(--ink-1); font-size: 13px; }
|
||||
.cfg-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-2);
|
||||
background: var(--bg-2);
|
||||
}
|
||||
.cfg-block-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.cfg-probe { display: flex; flex-direction: column; gap: 6px; }
|
||||
.cfg-facts { color: var(--ink-2); font-size: 12px; }
|
||||
.cfg-proposal { color: var(--accent); font-size: 12px; }
|
||||
.cfg-changes { margin: 0; padding-left: 16px; display: flex; flex-direction: column; gap: 2px; }
|
||||
.cfg-changes li { color: var(--warn); font-size: 12px; }
|
||||
.cfg-nochange { color: var(--ok); font-size: 12px; }
|
||||
.cfg-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.machine-check-row input { accent-color: var(--accent); }
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
@@ -285,6 +424,31 @@ body {
|
||||
min-height: 96px;
|
||||
resize: vertical;
|
||||
}
|
||||
.settings-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin: 18px 0 4px;
|
||||
}
|
||||
.settings-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 10px 0 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.settings-note .mono { color: var(--ink-1); }
|
||||
.settings-note-ok { color: var(--ok); }
|
||||
.settings-note-err { color: var(--err); }
|
||||
.settings-note-warn {
|
||||
color: var(--warn);
|
||||
padding: 9px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--warn);
|
||||
background: var(--bg-1);
|
||||
}
|
||||
.settings-checks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
# Tâche 2 — SJ-4 (Docker scan + inspect, passifs) — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: subagent-driven-development / executing-plans. Étapes checkbox.
|
||||
|
||||
**Goal:** Ouvrir le volet Docker (passif) : tables Docker (`docker_settings`, `docker_compose_roots`, `docker_compose_stacks`, `docker_stack_services`), templates `docker/scan-compose.sh.tpl` + `docker/inspect-compose.sh.tpl` (avec délimiteurs Mustache custom pour cohabiter avec les Go-templates Docker), parsing du scan, service de configuration + scan qui persiste les stacks candidats, et branchement des actions `docker_scan` / `docker_inspect_current`.
|
||||
|
||||
**Architecture:** Référence `docs/design/tache2/20-docker.md §1-4` + `40-contrats-json.md §3` (`DockerSnapshot*`). **Découverte par racines déclarées** (`composeRoots`) scannées en profondeur bornée, validées par `docker compose config --quiet` ; labels Compose en complément. Cycle stack `candidate`→`enabled`. **Conflit de délimiteurs résolu** : `renderTemplate` accepte des tags Mustache custom ; les templates Docker utilisent `<% %>` pour les variables, laissant les Go-templates `{{...}}` intacts. Réutilise `runScriptSudo`/`executions`/terminal/`rawLogPath` (pas de moteur parallèle). Passif : aucun `pull`/`up`/`prune` ici (SJ-5/6).
|
||||
|
||||
**Tech Stack:** Drizzle/SQLite, Mustache, ssh2, vitest.
|
||||
|
||||
---
|
||||
|
||||
## Invariants
|
||||
- **Passif** : SJ-4 ne télécharge/recrée/supprime rien (scan + inspect lecture seule).
|
||||
- Additif : `MachineView` inchangé ; nouvelles tables ; actions `docker_scan`/`docker_inspect_current` déjà dans l'union `ActionType` (SJ-0).
|
||||
- Délimiteurs : `renderTemplate` reste rétro-compatible (`{{ }}` par défaut) ; seuls les templates `docker/*` passent `tags: ['<%','%>']`.
|
||||
- Tree partagé / WIP concurrent : ne toucher QUE `server/db/schema.ts` (+migration), `server/templates/render.ts` (+test), `templates/docker/{scan-compose,inspect-compose}.sh.tpl`, `server/services/dockerScan.ts` (+test), `server/services/execute.ts`. **Ne pas committer.**
|
||||
|
||||
## File Structure
|
||||
```
|
||||
server/db/schema.ts # MODIF : +docker_settings/compose_roots/compose_stacks/stack_services
|
||||
server/db/migrations/0004_*.sql # généré
|
||||
server/db/schema.test.ts # MODIF : +assert tables docker
|
||||
server/templates/render.ts # MODIF : tags Mustache custom (optionnels)
|
||||
server/templates/render.test.ts # MODIF : +cas délimiteurs custom
|
||||
templates/docker/scan-compose.sh.tpl # NOUVEAU (délimiteurs <% %>)
|
||||
templates/docker/inspect-compose.sh.tpl # NOUVEAU
|
||||
server/services/dockerScan.ts # NOUVEAU : config + parseDockerScan + scanDockerStacks
|
||||
server/services/dockerScan.test.ts # NOUVEAU : parseDockerScan (TDD)
|
||||
server/services/execute.ts # MODIF : actions docker_scan / docker_inspect_current
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1 : Tables Docker (migration)
|
||||
|
||||
**Files:** Modify `server/db/schema.ts` ; generate migration ; extend `server/db/schema.test.ts`.
|
||||
|
||||
- [ ] **Step 1 : Relire `schema.ts`** (préserver tout l'existant).
|
||||
|
||||
- [ ] **Step 2 : Ajouter les tables** (fin de fichier)
|
||||
|
||||
```ts
|
||||
export const dockerSettings = sqliteTable("docker_settings", {
|
||||
machineId: text("machine_id").primaryKey().references(() => machines.id, { onDelete: "cascade" }),
|
||||
enabled: integer("enabled").notNull().default(0),
|
||||
scanDepth: integer("scan_depth").notNull().default(4),
|
||||
pruneMode: text("prune_mode").notNull().default("safe"),
|
||||
lastScanAt: text("last_scan_at"),
|
||||
lastPullCheckAt: text("last_pull_check_at"),
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
});
|
||||
export const dockerComposeRoots = sqliteTable("docker_compose_roots", {
|
||||
id: text("id").primaryKey(),
|
||||
machineId: text("machine_id").notNull().references(() => machines.id, { onDelete: "cascade" }),
|
||||
path: text("path").notNull(),
|
||||
enabled: integer("enabled").notNull().default(1),
|
||||
scanDepth: integer("scan_depth"),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
});
|
||||
export const dockerComposeStacks = sqliteTable("docker_compose_stacks", {
|
||||
id: text("id").primaryKey(),
|
||||
machineId: text("machine_id").notNull().references(() => machines.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
workingDir: text("working_dir").notNull(),
|
||||
composeFilesJson: text("compose_files_json").notNull(),
|
||||
projectName: text("project_name"),
|
||||
envFile: text("env_file"),
|
||||
status: text("status").notNull(), // candidate | enabled | ignored | error
|
||||
detectedBy: text("detected_by"), // root_scan | label | manual
|
||||
lastScanAt: text("last_scan_at"),
|
||||
lastUpdateAt: text("last_update_at"),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
});
|
||||
export const dockerStackServices = sqliteTable("docker_stack_services", {
|
||||
id: text("id").primaryKey(),
|
||||
stackId: text("stack_id").notNull().references(() => dockerComposeStacks.id, { onDelete: "cascade" }),
|
||||
serviceName: text("service_name").notNull(),
|
||||
imageRef: text("image_ref"),
|
||||
currentImageId: text("current_image_id"),
|
||||
currentDigest: text("current_digest"),
|
||||
candidateImageId: text("candidate_image_id"),
|
||||
candidateDigest: text("candidate_digest"),
|
||||
versionLabel: text("version_label"),
|
||||
status: text("status"), // up_to_date | updates_available | error
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Générer la migration** — `rtk pnpm db:generate` → `server/db/migrations/0004_*.sql` (4 CREATE TABLE, aucun DROP des tables existantes). Vérifier le SQL.
|
||||
|
||||
- [ ] **Step 4 : Étendre `schema.test.ts`** — ajouter un test asserttant la présence de `docker_settings`, `docker_compose_roots`, `docker_compose_stacks`, `docker_stack_services`.
|
||||
|
||||
- [ ] **Step 5 :** `rtk pnpm vitest run server/db/schema.test.ts` → PASS ; `rtk pnpm check` → 0 erreur. (pas de commit)
|
||||
|
||||
---
|
||||
|
||||
## Task 2 : Délimiteurs Mustache custom + templates Docker
|
||||
|
||||
**Files:** Modify `server/templates/render.ts`, `server/templates/render.test.ts` ; Create `templates/docker/scan-compose.sh.tpl`, `templates/docker/inspect-compose.sh.tpl`.
|
||||
|
||||
- [ ] **Step 1 : Étendre `renderTemplate`** (tags optionnels, rétro-compatible)
|
||||
|
||||
```ts
|
||||
export function renderTemplate(
|
||||
relPath: string,
|
||||
vars: TemplateVars,
|
||||
opts?: { tags?: [string, string] },
|
||||
): string {
|
||||
const tpl = readFileSync(resolve(TEMPLATES_ROOT, relPath), "utf8");
|
||||
// Les templates Docker contiennent des Go-templates {{...}} : on bascule les
|
||||
// délimiteurs Mustache sur <% %> pour ne pas les interpréter.
|
||||
const tags = opts?.tags ?? (relPath.startsWith("docker/") ? (["<%", "%>"] as [string, string]) : undefined);
|
||||
return Mustache.render(tpl, vars, {}, { escape: (s) => s, ...(tags ? { tags } : {}) });
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Test délimiteurs** — ajouter à `render.test.ts`
|
||||
|
||||
```ts
|
||||
it("rend les variables Docker en <% %> sans toucher aux Go-templates {{...}}", () => {
|
||||
const out = renderTemplate("docker/scan-compose.sh.tpl", { composeRoots: "/opt/stacks", composeScanDepth: 3 });
|
||||
expect(out).toContain("/opt/stacks");
|
||||
expect(out).toContain("{{.ID}}"); // Go-template Docker resté littéral
|
||||
expect(out).not.toContain("<%composeRoots%>");
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Créer `templates/docker/scan-compose.sh.tpl`** (variables en `<% %>`, Go-templates en `{{ }}` littéraux)
|
||||
|
||||
```sh
|
||||
#!/bin/sh
|
||||
export LC_ALL=C
|
||||
echo "===SU:DOCKER_SCAN==="
|
||||
ROOTS="<%composeRoots%>"
|
||||
DEPTH="<%composeScanDepth%>"
|
||||
for root in $ROOTS; do
|
||||
[ -d "$root" ] || continue
|
||||
find "$root" -maxdepth "$DEPTH" -type f \
|
||||
\( -name 'compose.yaml' -o -name 'compose.yml' \
|
||||
-o -name 'docker-compose.yaml' -o -name 'docker-compose.yml' \) \
|
||||
-not -path '*/.git/*' -not -path '*/node_modules/*' \
|
||||
-not -path '*/backup/*' -not -path '*/old/*' -not -path '*/archive/*' \
|
||||
2>/dev/null | while IFS= read -r f; do
|
||||
dir=$(dirname "$f")
|
||||
if docker compose -f "$f" config --quiet >/dev/null 2>&1; then
|
||||
echo "STACK_OK\tdir=$dir\tfile=$f"
|
||||
else
|
||||
echo "STACK_INVALID\tdir=$dir\tfile=$f"
|
||||
fi
|
||||
done
|
||||
done
|
||||
echo "===SU:DOCKER_LABELS==="
|
||||
docker ps --format '{{.ID}}' 2>/dev/null | while read -r id; do
|
||||
proj=$(docker inspect --format '{{index .Config.Labels "com.docker.compose.project"}}' "$id" 2>/dev/null)
|
||||
wd=$(docker inspect --format '{{index .Config.Labels "com.docker.compose.project.working_dir"}}' "$id" 2>/dev/null)
|
||||
[ -n "$proj" ] && echo "ACTIVE\tproject=$proj\tworking_dir=$wd"
|
||||
done
|
||||
echo "===SU:EXIT=0==="
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Créer `templates/docker/inspect-compose.sh.tpl`**
|
||||
|
||||
```sh
|
||||
#!/bin/sh
|
||||
export LC_ALL=C
|
||||
cd "<%stackDir%>" || { echo "===SU:DOCKER_ERR==="; echo "compose_not_found"; echo "===SU:EXIT=2==="; exit 2; }
|
||||
echo "===SU:DOCKER_CONFIG_IMAGES==="
|
||||
docker compose config --images 2>&1
|
||||
echo "===SU:DOCKER_PS==="
|
||||
docker compose ps --format json 2>&1
|
||||
echo "===SU:DOCKER_IMAGES==="
|
||||
docker compose images --format json 2>&1
|
||||
echo "===SU:DOCKER_INSPECT==="
|
||||
docker compose config --images 2>/dev/null | while IFS= read -r img; do
|
||||
docker image inspect "$img" \
|
||||
--format 'IMG\t{{.Id}}\t{{join .RepoDigests ","}}\t{{index .Config.Labels "org.opencontainers.image.version"}}\t{{index .Config.Labels "org.opencontainers.image.source"}}' 2>/dev/null \
|
||||
|| echo "IMG_MISSING\t$img"
|
||||
done
|
||||
echo "===SU:EXIT=0==="
|
||||
```
|
||||
|
||||
- [ ] **Step 5 :** `rtk pnpm vitest run server/templates/render.test.ts` → PASS. `rtk pnpm check` → 0 erreur. (pas de commit)
|
||||
|
||||
---
|
||||
|
||||
## Task 3 : Parsing du scan + service (TDD)
|
||||
|
||||
**Files:** Create `server/services/dockerScan.ts`, `server/services/dockerScan.test.ts`.
|
||||
|
||||
- [ ] **Step 1 : Test (échec attendu)** — `server/services/dockerScan.test.ts`
|
||||
|
||||
```ts
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseDockerScan } from "./dockerScan.js";
|
||||
|
||||
const raw = [
|
||||
"===SU:DOCKER_SCAN===",
|
||||
"STACK_OK\tdir=/opt/stacks/media\tfile=/opt/stacks/media/compose.yaml",
|
||||
"STACK_INVALID\tdir=/opt/stacks/broken\tfile=/opt/stacks/broken/compose.yml",
|
||||
"===SU:DOCKER_LABELS===",
|
||||
"ACTIVE\tproject=media\tworking_dir=/opt/stacks/media",
|
||||
"===SU:EXIT=0===",
|
||||
].join("\n");
|
||||
|
||||
describe("parseDockerScan", () => {
|
||||
it("extrait stacks valides/invalides et actifs", () => {
|
||||
const r = parseDockerScan(raw);
|
||||
expect(r.stacks).toEqual([
|
||||
{ workingDir: "/opt/stacks/media", composeFile: "/opt/stacks/media/compose.yaml", valid: true },
|
||||
{ workingDir: "/opt/stacks/broken", composeFile: "/opt/stacks/broken/compose.yml", valid: false },
|
||||
]);
|
||||
expect(r.active).toEqual([{ project: "media", workingDir: "/opt/stacks/media" }]);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Lancer (échec)** — `rtk pnpm vitest run server/services/dockerScan.test.ts` → FAIL.
|
||||
|
||||
- [ ] **Step 3 : Implémenter `server/services/dockerScan.ts`**
|
||||
|
||||
```ts
|
||||
// server/services/dockerScan.ts
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { basename } from "node:path";
|
||||
import { db, schema } from "../db/client.js";
|
||||
import { getMachineRow, getCreds } from "./machines.js";
|
||||
import { renderTemplate } from "../templates/render.js";
|
||||
import { runScriptSudo } from "../ssh/client.js";
|
||||
import { outputHub } from "../ws/outputHub.js";
|
||||
|
||||
export interface DockerScanResult {
|
||||
stacks: { workingDir: string; composeFile: string; valid: boolean }[];
|
||||
active: { project: string; workingDir: string }[];
|
||||
}
|
||||
|
||||
function fields(line: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const part of line.split("\t")) {
|
||||
const i = part.indexOf("=");
|
||||
if (i > 0) out[part.slice(0, i)] = part.slice(i + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseDockerScan(raw: string): DockerScanResult {
|
||||
const stacks: DockerScanResult["stacks"] = [];
|
||||
const active: DockerScanResult["active"] = [];
|
||||
for (const line of raw.split("\n")) {
|
||||
const l = line.trimEnd();
|
||||
if (l.startsWith("STACK_OK\t") || l.startsWith("STACK_INVALID\t")) {
|
||||
const f = fields(l);
|
||||
stacks.push({ workingDir: f.dir ?? "", composeFile: f.file ?? "", valid: l.startsWith("STACK_OK") });
|
||||
} else if (l.startsWith("ACTIVE\t")) {
|
||||
const f = fields(l);
|
||||
active.push({ project: f.project ?? "", workingDir: f.working_dir ?? "" });
|
||||
}
|
||||
}
|
||||
return { stacks, active };
|
||||
}
|
||||
|
||||
/** Racines Compose déclarées (enabled) d'une machine. */
|
||||
export function getComposeRoots(machineId: string): string[] {
|
||||
return db.select().from(schema.dockerComposeRoots)
|
||||
.where(eq(schema.dockerComposeRoots.machineId, machineId)).all()
|
||||
.filter((r) => r.enabled).map((r) => r.path);
|
||||
}
|
||||
|
||||
/** Déclare/active Docker pour une machine + ses racines Compose (idempotent). */
|
||||
export function setDockerRoots(machineId: string, paths: string[], scanDepth = 4): void {
|
||||
const now = new Date().toISOString();
|
||||
db.insert(schema.dockerSettings)
|
||||
.values({ machineId, enabled: 1, scanDepth, pruneMode: "safe", updatedAt: now })
|
||||
.onConflictDoUpdate({ target: schema.dockerSettings.machineId, set: { enabled: 1, scanDepth, updatedAt: now } })
|
||||
.run();
|
||||
db.delete(schema.dockerComposeRoots).where(eq(schema.dockerComposeRoots.machineId, machineId)).run();
|
||||
for (const path of paths) {
|
||||
db.insert(schema.dockerComposeRoots).values({
|
||||
id: randomUUID(), machineId, path, enabled: 1, createdAt: now, updatedAt: now,
|
||||
}).run();
|
||||
}
|
||||
}
|
||||
|
||||
/** Scanne les racines déclarées et upsert les stacks candidats. Renvoie le résultat parsé. */
|
||||
export async function scanDockerStacks(machineId: string): Promise<DockerScanResult> {
|
||||
const m = getMachineRow(machineId);
|
||||
if (!m) throw new Error("Machine introuvable");
|
||||
const roots = getComposeRoots(machineId);
|
||||
const settings = db.select().from(schema.dockerSettings)
|
||||
.where(eq(schema.dockerSettings.machineId, machineId)).get();
|
||||
const depth = settings?.scanDepth ?? 4;
|
||||
if (roots.length === 0) return { stacks: [], active: [] };
|
||||
|
||||
const script = renderTemplate("docker/scan-compose.sh.tpl", {
|
||||
composeRoots: roots.join(" "),
|
||||
composeScanDepth: depth,
|
||||
});
|
||||
let raw = "";
|
||||
const res = await runScriptSudo(getCreds(m), script, (c) => { raw += c; outputHub.publish(machineId, c); });
|
||||
raw = res.stdout;
|
||||
const parsed = parseDockerScan(raw);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const activeDirs = new Set(parsed.active.map((a) => a.workingDir));
|
||||
for (const s of parsed.stacks) {
|
||||
if (!s.valid) continue;
|
||||
const name = basename(s.workingDir);
|
||||
const existing = db.select().from(schema.dockerComposeStacks)
|
||||
.where(eq(schema.dockerComposeStacks.workingDir, s.workingDir)).get();
|
||||
const detectedBy = activeDirs.has(s.workingDir) ? "label" : "root_scan";
|
||||
if (existing) {
|
||||
db.update(schema.dockerComposeStacks).set({ lastScanAt: now, detectedBy, updatedAt: now })
|
||||
.where(eq(schema.dockerComposeStacks.id, existing.id)).run();
|
||||
} else {
|
||||
db.insert(schema.dockerComposeStacks).values({
|
||||
id: randomUUID(), machineId, name, workingDir: s.workingDir,
|
||||
composeFilesJson: JSON.stringify([s.composeFile]), status: "candidate",
|
||||
detectedBy, lastScanAt: now, createdAt: now, updatedAt: now,
|
||||
}).run();
|
||||
}
|
||||
}
|
||||
db.update(schema.dockerSettings).set({ lastScanAt: now, updatedAt: now })
|
||||
.where(eq(schema.dockerSettings.machineId, machineId)).run();
|
||||
return parsed;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4 :** `rtk pnpm vitest run server/services/dockerScan.test.ts` → PASS. `rtk pnpm check` → 0 erreur. (pas de commit)
|
||||
|
||||
---
|
||||
|
||||
## Task 4 : Brancher `docker_scan` / `docker_inspect_current`
|
||||
|
||||
**Files:** Modify `server/services/execute.ts`.
|
||||
|
||||
- [ ] **Step 1 : Relire `execute.ts`**.
|
||||
|
||||
- [ ] **Step 2 : `TEMPLATE_FOR`** — ajouter
|
||||
```ts
|
||||
docker_scan: "docker/scan-compose.sh.tpl",
|
||||
docker_inspect_current: "docker/inspect-compose.sh.tpl",
|
||||
```
|
||||
> `docker_inspect_current` requiert un `stackDir` (variable de rendu). Au MVP, `runAction` ne porte pas de paramètre de stack ; `docker_inspect_current` reste donc déclaré mais **son orchestration par stack viendra avec SJ-5** (qui itère les stacks `enabled`). Pour SJ-4, **seul `docker_scan` est réellement exécutable** via `runAction`.
|
||||
|
||||
- [ ] **Step 3 : Spécialiser `docker_scan` dans `runAction`** — après obtention de `raw` (le script de scan a tourné via le flux normal), persister les stacks via le parseur. Le plus simple : router `docker_scan` vers le service dédié plutôt que le flux générique. Ajouter en début de `runAction`, juste après le `getMachineRow` et la création de l'`executionId`/insert execution :
|
||||
```ts
|
||||
if (action === "docker_scan") {
|
||||
// Le rendu Docker nécessite les délimiteurs custom + les racines déclarées :
|
||||
// on délègue au service de scan qui rend le template et persiste les stacks.
|
||||
const { scanDockerStacks } = await import("./dockerScan.js");
|
||||
try {
|
||||
const parsed = await scanDockerStacks(machineId);
|
||||
outputHub.publish(machineId, `\n===SU:DONE status=ok stacks=${parsed.stacks.length}===\n`);
|
||||
} catch (err) {
|
||||
outputHub.publish(machineId, `\n[ERREUR] ${(err as Error).message}\n`);
|
||||
}
|
||||
}
|
||||
```
|
||||
> ⚠️ Implémentation propre attendue : plutôt que de laisser le flux générique re-rendre `docker/scan-compose.sh.tpl` SANS racines (ce qui produirait un scan vide), faire en sorte que pour `action === "docker_scan"` le flux générique NE rende PAS le template lui-même. Option recommandée : extraire la logique commune, ou faire un `early return` après le scan pour `docker_scan` en construisant un `ExecutionResult` minimal (status ok, importantLogLines = lignes réduites, report/log archivés comme les autres actions). **Préférer** : router `docker_scan` AVANT le rendu générique et construire son propre `ExecutionResult` (réutiliser les helpers d'archivage). Le sous-agent doit choisir l'implémentation la plus propre qui évite un double rendu.
|
||||
|
||||
- [ ] **Step 4 : Vérifier** — `rtk pnpm check && rtk pnpm test` → 0 erreur, tests verts. Les blocs Phase 1 et les actions APT restent intacts.
|
||||
|
||||
- [ ] **Step 5 : (pas de commit)**
|
||||
|
||||
---
|
||||
|
||||
## Task 5 : Vérification finale SJ-4
|
||||
|
||||
- [ ] **Step 1 :** `rtk pnpm check && rtk pnpm test && rtk pnpm build` → tout vert.
|
||||
- [ ] **Step 2 : Boot smoke** (DB jetable) → `/health` OK + tables `docker_*` créées. Nettoyer.
|
||||
- [ ] **Step 3 :** Reporter. Vérif live : `setDockerRoots(machineId, ["/opt/stacks"])` puis action `docker_scan` réelle sur une machine avec Docker → vérifier la détection des stacks. **Ne pas committer.**
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (couverture SJ-4)
|
||||
- `docker/scan-compose.sh.tpl` + `inspect-compose.sh.tpl` (passifs) → Task 2. ✓
|
||||
- Conflit délimiteurs Mustache/Go-template résolu (`<% %>` pour Docker) → Task 2. ✓
|
||||
- Config machine `composeRoots`/`scanDepth` + tables `docker_*` → Task 1 + Task 3 (`setDockerRoots`/`getComposeRoots`). ✓
|
||||
- Cycle `candidate` (détecté) + détection labels en complément → `scanDockerStacks`. ✓
|
||||
- Action `docker_scan` exécutable → Task 4. ✓
|
||||
- Validation `docker compose config --quiet` (valid/invalid) → template + parser. ✓
|
||||
|
||||
Décisions : `docker_inspect_current` déclaré mais orchestré par stack en SJ-5 (nécessite `stackDir`). Pas d'API/UI de configuration des roots en SJ-4 (tâche 3/5) ; `setDockerRoots` est le point d'entrée backend. Aucun pull/up/prune (passif). Noms cohérents : `parseDockerScan`/`getComposeRoots`/`setDockerRoots`/`scanDockerStacks`.
|
||||
```
|
||||
@@ -0,0 +1,57 @@
|
||||
# Tâche 2 — SJ-5 : Docker pull-check + comparaison déterministe
|
||||
|
||||
> Statut : **implémenté** (2026-06-05). tsc 0 erreur · 85 tests · build OK.
|
||||
> Réf. design : `docs/design/tache2/20-docker.md §4.3`, `40-contrats-json.md §3/§6`, `80-sous-jalons.md` SJ-5.
|
||||
|
||||
## Périmètre livré
|
||||
|
||||
Télécharger les images candidates d'un stack Compose **sans démarrer de conteneur**
|
||||
(`docker compose pull`), comparer avant/après par image ID + repo digest + label OCI,
|
||||
et persister l'état des services — **sans toucher au flux jalon 1** et sans déclencher
|
||||
de pull automatique (action manuelle par stack, non incluse dans `refreshMachine`).
|
||||
|
||||
## Composants
|
||||
|
||||
- **Template** `templates/docker/pull-check.sh.tpl` — délimiteurs Mustache `<% %>`
|
||||
(`<%stackDir%>`), Go-templates `{{.Id}}` / `{{join .RepoDigests ","}}` préservés.
|
||||
Sections `===SU:DOCKER_INSPECT_BEFORE/PULL/INSPECT_AFTER===` + `===SU:EXIT=N===`.
|
||||
- **`server/services/dockerPull.ts`** :
|
||||
- `parseDockerPullCheck(raw)` — lit BEFORE/AFTER (id, digest, version), code de sortie,
|
||||
et extrait les erreurs de pull **nettoyées de tout secret** (URLs, token/bearer/password).
|
||||
- `buildDockerPullResult(stackName, raw)` — comparaison déterministe → `services`
|
||||
(`up_to_date | updates_available | error` par image) + `changes` (`operation:"pulled"`
|
||||
uniquement pour les images modifiées) + `status` global + `errors`.
|
||||
- `dockerDedupKey(image, fromDigest, toDigest, fromId?, toId?)` — empreinte fonctionnelle
|
||||
(digests prioritaires, fallback image IDs), conforme `40 §6`.
|
||||
- `pullCheckStack(machineId, stackId, onData?)` — orchestration SSH + upsert des services
|
||||
dans `docker_stack_services` (par `stackId + serviceName`), maj `lastUpdateAt` du stack
|
||||
et `lastPullCheckAt` des settings. **Refuse un stack non `enabled`.**
|
||||
- **`server/services/dockerPull.test.ts`** — 7 cas (parse, nettoyage secret registry,
|
||||
classement up_to_date/updates_available, change unique, status global, dédup).
|
||||
- **Wiring** :
|
||||
- `runAction(machineId, action, opts?: { stackId })` — branche dédiée `docker_pull_check`
|
||||
(archivage report/log, `ExecutionResult.docker.pull.changes` + `dedupKey`, event).
|
||||
- Chemin générique : injection `stackDir` quand `stackId` fourni → **corrige aussi
|
||||
`docker_inspect_current`** (SJ-4 le déclarait sans orchestration par stack).
|
||||
- `POST /:id/actions` — allowlist élargie aux actions Docker passives/non-applicatives
|
||||
(`docker_scan`, `docker_inspect_current`, `docker_pull_check`) ; `stackId` requis pour
|
||||
les actions par-stack. **Destructives (apply/down/prune agressif) toujours hors API**
|
||||
jusqu'au socle `action_requests` (SJ-6).
|
||||
- **`shared/types.ts`** : `DockerImageChange.dedupKey?` (additif, pour mutualisation Hermes).
|
||||
|
||||
## Pas de migration
|
||||
|
||||
Le schéma SJ-4 (`docker_stack_services` avec `current/candidate_image_id|digest`,
|
||||
`version_label`, `status` ; `docker_settings.last_pull_check_at`) couvrait déjà SJ-5.
|
||||
|
||||
## Sécurité
|
||||
|
||||
- `docker compose pull` sans `up` → aucun conteneur recréé (pré-check pur applicatif).
|
||||
- Erreurs registry (`registry_auth_failed` / `pull_failed`) **nettoyées** : ni URL, ni token,
|
||||
ni mot de passe ne remontent vers UI/MCP (test dédié).
|
||||
- Credentials registry (`~/.docker/config.json`) jamais lus ni renvoyés.
|
||||
|
||||
## Reste pour SJ-6
|
||||
|
||||
`docker_compose_apply` (up -d --remove-orphans), `docker_prune_images`, `docker_compose_down`,
|
||||
table `docker_image_events`, et validation UI explicite via `action_requests`.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Tâche 2 — SJ-6 : Docker apply / prune / down + socle action_requests
|
||||
|
||||
> Statut : **implémenté** (2026-06-06). tsc 0 erreur · 91 tests · build OK · boot OK (migrations 0000→0005).
|
||||
> Réf. design : `docs/design/tache2/20-docker.md §4.4-4.6`, `40-contrats-json.md §4`, `70-securite.md §2`, `80-sous-jalons.md` SJ-6.
|
||||
|
||||
## Périmètre livré
|
||||
|
||||
Actions Docker **destructives** (recrée/supprime) protégées par un socle de
|
||||
**validation explicite** (`action_requests`) : Hermes/UI proposent, l'opérateur approuve,
|
||||
l'exécution part en arrière-plan. Aucune de ces actions n'est accessible directement
|
||||
via `POST /:id/actions` (allowlist passive uniquement).
|
||||
|
||||
## Composants
|
||||
|
||||
- **Migration 0005** (`0005_silent_drax.sql`, timestamp monotone) : tables
|
||||
`docker_image_events` (historique pulled/recreated/pruned + bytes) et
|
||||
`action_requests` (pending|approved|rejected|executed|expired).
|
||||
- **Templates** `docker/apply-compose.sh.tpl` (`up -d --remove-orphans`),
|
||||
`docker/prune-images.sh.tpl` (safe par défaut / `<%#aggressive%>` = `-a --filter until=168h`),
|
||||
`docker/down-compose.sh.tpl` (down simple, **`--volumes`/`--rmi` interdits**).
|
||||
- **`server/services/dockerApply.ts`** :
|
||||
- parsers purs (TDD) : `parseDockerApply` (recreated/running/exited via ps json),
|
||||
`parseDockerPrune` (`imagesDeleted` + `Total reclaimed space` → octets),
|
||||
`parseDockerDown` (removed), `parseHumanBytes` (unités décimales Docker).
|
||||
- orchestration : `applyStack` / `pruneImages` / `downStack` — réservées aux stacks
|
||||
`enabled`, insèrent les `docker_image_events`. Erreurs nettoyées (réutilise `cleanDockerError`).
|
||||
- **`server/services/actionRequests.ts`** : `createActionRequest` (refuse une action non
|
||||
destructive, exige `stackId` pour apply/down), `approve` (→ `runAction` en tâche de fond,
|
||||
pose `executionId`/`executed`), `reject`, `get`, `list`.
|
||||
- **Routes** `server/routes/actionRequests.ts` (montées à la racine `/api`) :
|
||||
`POST /machines/:id/action-requests`, `GET …`, `GET/POST /action-requests/:id[/approve|/reject]`.
|
||||
- **`execute.ts`** : `RunActionOpts.aggressive`, branches `docker_compose_apply` /
|
||||
`docker_prune_images` / `docker_compose_down`, helper `archiveExecution` mutualisant
|
||||
le boilerplate (log/rapport/DB/état/event) + `ExecutionResult.docker.up|prune`.
|
||||
|
||||
## Sécurité
|
||||
|
||||
- Destructives **hors API directe** : passent obligatoirement par un `action_request` approuvé.
|
||||
- `down` sans volumes ni rmi (volumes préservés). Prune agressif = risque distinct (champ `aggressive`).
|
||||
- Erreurs Docker nettoyées (URL/token/password) avant UI/MCP.
|
||||
|
||||
## Reste tâche 2
|
||||
|
||||
SJ-7 (profils Proxmox/RPi + proxy persistent), SJ-8/9 (post-install). UI des boutons
|
||||
validés (Appliquer/Prune/Down) = tâche 3 (frontend, design system).
|
||||
@@ -0,0 +1,39 @@
|
||||
# Tâche 2 — SJ-7 : Profils OS Proxmox/RPi + machine_probe + proxy persistent
|
||||
|
||||
> Statut : **implémenté** (2026-06-06). tsc 0 · 95 tests · build OK. Résolution OS vérifiée.
|
||||
> Réf. design : `docs/design/tache2/60-profils-os-machine.md`, `80-sous-jalons.md` SJ-7.
|
||||
|
||||
## Périmètre livré (additif, fallback base préservé)
|
||||
|
||||
- **Templates OS-spécifiques** :
|
||||
- `templates/proxmox/update-analyze.sh.tpl` (détection dépôts PVE enterprise/no-subscription)
|
||||
+ `full-upgrade.sh.tpl` (dist-upgrade kernel/proxmox-ve/Ceph).
|
||||
- `templates/raspbian/update-analyze.sh.tpl` (contrôle espace disque carte SD)
|
||||
+ `full-upgrade.sh.tpl` (`apt full-upgrade`). `rpi-update` volontairement non utilisé.
|
||||
- **Résolution par profil OS dans `execute.ts`** : les actions APT passent par
|
||||
`resolveTemplate(file, osFamily)` → `proxmox/`/`raspbian/` si dispo, sinon `apt/`.
|
||||
Vérifié : proxmox/raspbian pris ; debian/ubuntu → fallback `apt/` (non-régression jalon 1).
|
||||
`refresh.ts` résolvait déjà `update-analyze`.
|
||||
- **`machine_probe`** (action lecture seule) :
|
||||
- `templates/apt/machine-probe.sh.tpl` (os-release, arch, systemd-detect-virt, /etc/pve,
|
||||
/proc/cpuinfo RPi, lspci GPU, ip addr).
|
||||
- `machineProbe.ts` : `parseProbe` + `proposeCorrections` (TDD, 4 cas : Proxmox/RPi/VM KVM)
|
||||
→ propose `os_family`/`machine_kind`/`virtualization`. `runProbe` persiste les faits
|
||||
matériels (`machine_hardware` gpus/network) et renvoie un diff **jamais appliqué auto**.
|
||||
- Branche `execute` (archiveExecution) + allowlist route.
|
||||
- **Proxy APT persistant** (`apt_proxy_persistent`) :
|
||||
- ActionType ajouté ; `templates/apt/apt-proxy-persistent.sh.tpl` écrit
|
||||
`/etc/apt/apt.conf.d/01proxy` (idempotent, sauvegarde horodatée de l'existant).
|
||||
- `TemplateVars.aptProxyUrl` ; rendu avec `m.aptProxyUrl` ; allowlist route.
|
||||
|
||||
## Sécurité / invariants
|
||||
|
||||
- `machine_probe` ne modifie rien ; les corrections OS/kind sont **proposées**, l'opérateur
|
||||
garde le dernier mot (pas d'application auto).
|
||||
- Proxy persistant = action explicite idempotente avec backup ; l'URL n'est pas un secret.
|
||||
- Aucun secret dans les templates ; fallback `base` garantit la non-régression Debian/Ubuntu.
|
||||
|
||||
## Reste tâche 2
|
||||
|
||||
SJ-8 / SJ-9 (post-install : bootstrap/identité, paquets de base/Docker officiel/partages/VM tools).
|
||||
UI : bouton « Sonder » + affichage des propositions, sélecteur de proxy persistant = tâche 3.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Tâche 2 — SJ-8 : Post-install (moteur de profils + bootstrap + identité/réseau)
|
||||
|
||||
> Statut : **backend implémenté** (2026-06-06). tsc 0 · 101 tests · build OK · boot OK.
|
||||
> Réf. design : `docs/design/tache2/30-scripts-custom.md`, `40-contrats-json.md §4`, `80-sous-jalons.md` SJ-8.
|
||||
> Non testé en live (post-install destructif : modifie sudo/réseau d'une vraie machine).
|
||||
|
||||
## Périmètre livré
|
||||
|
||||
Moteur de profils post-install non interactif : tout choix devient un **champ de
|
||||
formulaire** validé côté backend ; preview avec **masquage des secrets** ; exécution
|
||||
SSH + parsing `PostInstallResult` ; confirmation explicite (`action_request`) pour les
|
||||
profils à risque.
|
||||
|
||||
## Composants
|
||||
|
||||
- **Templates** `templates/custom/bootstrap-root.sh.tpl` (sudo + ca-certificates + curl,
|
||||
ajout groupe sudo) et `identity-network.sh.tpl` (hostname + IP statique, sauvegarde des
|
||||
fichiers, jamais de coupure sans reconnexion planifiée). Sortie structurée parsable
|
||||
(`PKG_INSTALLED=`, `FILE_MODIFIED=`, `OLD/NEW_ENDPOINT=`, `REBOOT_REQUESTED=1`, `ERR=`).
|
||||
- **`server/services/postInstall.ts`** :
|
||||
- registre `PROFILES` (manifestes : `id`, `label`, `risk`, `requiresConfirmation`,
|
||||
`fields[]` avec types `string|hostname|ipv4|ipv4_cidr|ipv4_list|select|bool|int|path|secret`,
|
||||
`default`/`defaultFrom`).
|
||||
- `validateProfileValues` (requis + formats IPv4/CIDR/hostname) — TDD.
|
||||
- `maskSecretValues` (champs `secret` → `********`) — TDD ; `previewProfile` masque avant rendu.
|
||||
- `buildPostInstallResult` (parse → filesModified/packagesInstalled/networkChange/reboot/errors) — TDD.
|
||||
- `renderProfile` / `runPostInstall` (valide puis SSH, statut ok/error).
|
||||
- **`execute.ts`** : `RunActionOpts.profileId/values`, branche `post_install`
|
||||
(archiveExecution + bloc `postInstall`).
|
||||
- **`actionRequests.ts`** : `post_install` accepté ; payload transporte `profileId`/`values` ;
|
||||
`approve` les repasse à `runAction`.
|
||||
- **Routes** : `GET /api/profiles`, `POST /machines/:id/profiles/:id/preview`
|
||||
(script masqué + validation), `POST /machines/:id/profiles/:id/run`
|
||||
(→ `action_request` si `requiresConfirmation`, sinon exécution directe).
|
||||
|
||||
## Sécurité / invariants
|
||||
|
||||
- Aucune question interactive SSH ; échec contrôlé si décision manquante.
|
||||
- Secrets jamais sérialisés : `previewProfile` masque, `variablesUsed` = non sensible only.
|
||||
- `identity_network` (network_change) exige confirmation explicite via `action_request`.
|
||||
- Reconnexion réseau : `OLD/NEW_ENDPOINT` + `RECONNECT_REQUIRED` remontés ; reboot via `reboot_verified` (futur).
|
||||
|
||||
## Reste (SJ-9 + tâche 4)
|
||||
|
||||
SJ-9 : profils `base_tools`, `network_tools`, `docker_official`, `sharing`, `vm_guest_tools`
|
||||
(+ `install-package-groups`). Persistance `install_profiles`/`machine_profile_state`/
|
||||
`script_variables_presets` et catalogue détaillé = tâche 4. UI (formulaires de profils,
|
||||
preview) = tâche 3.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Tâche 2 — SJ-9 : Catalogue post-install (paquets de base, Docker officiel, partages, VM tools)
|
||||
|
||||
> Statut : **implémenté** (2026-06-06). tsc 0 · 104 tests · build OK · boot OK (8 profils servis).
|
||||
> Réf. design : `docs/design/tache2/30-scripts-custom.md §2/§4`, `80-sous-jalons.md` SJ-9.
|
||||
> Clôt la tâche 2. Non testé en live (installe des paquets / Docker sur une vraie machine).
|
||||
|
||||
## Périmètre livré
|
||||
|
||||
6 profils ajoutés au registre + 4 templates. L'UI post-install (générique, SJ-8/tâche 3)
|
||||
les affiche et exécute **sans modification** — manifeste → formulaire → preview → run.
|
||||
|
||||
- **Mécanisme `presetVars`** : variables fixes (non-champs) injectées au rendu, surchargées
|
||||
par les valeurs de formulaire. Permet des listes de paquets prédéfinies sans champ utilisateur.
|
||||
- **Templates** (`templates/custom/`) :
|
||||
- `install-package-groups.sh.tpl` (générique, `{{packages}}` shell-safe).
|
||||
- `docker-official-debian.sh.tpl` (clé GPG keyrings + docker.list par codename + paquets
|
||||
+ groupe docker + dossier compose ; relogin/reboot signalés).
|
||||
- `sharing.sh.tpl` (Samba/NFS/mDNS via sections Mustache selon cases cochées).
|
||||
- `vm-guest-tools.sh.tpl` (`qemu-guest-agent` ou `open-vm-tools`).
|
||||
- **Manifestes** : `base_tools`, `network_tools`, `dev_git` (presetVars, sans champ, low) ;
|
||||
`docker_official` (medium, confirmation, champs dockerUser/composeRoot/reboot) ;
|
||||
`sharing` (medium, confirmation, bools Samba/NFS/mDNS) ; `vm_guest_tools` (low, select agent).
|
||||
- Tous émettent `PKG_INSTALLED=` / `SERVICE_ENABLED=` / `ERR=` → `buildPostInstallResult`
|
||||
(SJ-8) les parse sans changement.
|
||||
|
||||
## Tests
|
||||
|
||||
3 cas ajoutés : `base_tools` injecte bien sa liste fixe (preset), `sharing` ne rend que les
|
||||
paquets cochés (sections Mustache), `docker_official` exige confirmation.
|
||||
|
||||
## Bilan tâche 2
|
||||
|
||||
APT (SJ-0→3) · Docker scan/pull-check/apply-prune-down (SJ-4→6) · profils OS Proxmox/RPi +
|
||||
sonde + proxy persistant (SJ-7) · post-install moteur+bootstrap+identité (SJ-8) · catalogue
|
||||
post-install (SJ-9). **Volet moteur tâche 2 complet.** Catalogue détaillé/config fine
|
||||
(partages, presets réutilisables, `install_profiles`/`machine_profile_state` en base) = tâche 4.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Tâche 5 — Automatisations planifiées (scheduler croner)
|
||||
|
||||
> Statut : **implémenté** (2026-06-06). tsc 0 · 113 tests · build OK · boot OK.
|
||||
> Réf. : `tache5.md §4` (automatisations backend), `validation_tache5.md`.
|
||||
|
||||
## Périmètre livré (1re tranche tâche 5)
|
||||
|
||||
Planificateur piloté par la BDD : exécuter `apt_update_analyze` / `machine_metrics_simple` /
|
||||
`docker_scan` sur un périmètre de machines à heure fixe (cron), avec concurrence + verrou.
|
||||
|
||||
- **Table `schedules`** (migration 0007) : name, enabled, cron, timezone, scope_json,
|
||||
actions_json, concurrency, notify_on_json, last_run_at, last_status.
|
||||
- **`server/services/scheduler.ts`** :
|
||||
- CRUD (validation cron via `new Cron()` à la création/maj).
|
||||
- `runSchedule` : résout le scope (`all` ou liste), exécute les actions par machine avec
|
||||
**pool de concurrence** + **verrou par machine** (in-process Set, évite 2 actions
|
||||
simultanées), met à jour last_run_at/last_status, `recordEvent` sur échec.
|
||||
- mapping actions → `refreshMachine` / `collectMetrics` / `scanDockerStacks`.
|
||||
- `reloadSchedules` : (ré)enregistre les crons actifs via croner (timezone par schedule).
|
||||
- **`worker.ts`** : `startWorker` = `reloadSchedules()` (remplace le refresh 30 min en dur).
|
||||
- **Routes** `/api/schedules` : list / create / get / patch / delete / `:id/run` (lancement immédiat).
|
||||
- **UI** : onglet Paramètres « Automatisations » — liste (cron, actions, actif, dernier run),
|
||||
activer/désactiver, lancer maintenant, supprimer, et formulaire de création (nom, cron,
|
||||
cases d'actions, toutes machines).
|
||||
|
||||
## Vérifié
|
||||
|
||||
CRUD via API, cron invalide rejeté proprement (message croner), init scheduler sans erreur,
|
||||
migration 0007 appliquée. Verrou empêche les exécutions concurrentes sur une même machine.
|
||||
|
||||
## Reste tâche 5 (backlog)
|
||||
|
||||
Notifications (`notifyOn`), tags de scope, retries persistants, extraction structurée des
|
||||
**messages importants** (E:/W:/dépréciations → `important_messages` + tuile), timeline
|
||||
d'événements machine, politiques de rétention. (pg-boss = piste future si jobs distribués.)
|
||||
+2
-2
@@ -8,10 +8,10 @@
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "pnpm run dev:server & pnpm run dev:client",
|
||||
"dev:server": "tsx watch server/index.ts",
|
||||
"dev:server": "tsx watch --env-file=.env server/index.ts",
|
||||
"dev:client": "vite",
|
||||
"build": "vite build && tsup",
|
||||
"start": "node dist/index.js",
|
||||
"start": "node --env-file=.env dist/index.js",
|
||||
"test": "vitest run",
|
||||
"check": "tsc --noEmit",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
|
||||
+14
-2
@@ -1,15 +1,27 @@
|
||||
// server/db/client.ts
|
||||
import Database from "better-sqlite3";
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { mkdirSync, existsSync, rmSync, renameSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { env } from "../env.js";
|
||||
import * as schema from "./schema.js";
|
||||
|
||||
mkdirSync(dirname(env.dbPath), { recursive: true });
|
||||
|
||||
// Restauration en attente : un fichier `<db>.incoming` déposé par /system/db/restore
|
||||
// est appliqué au démarrage (swap hors-ligne = aucune corruption d'une base ouverte).
|
||||
const incoming = `${env.dbPath}.incoming`;
|
||||
if (existsSync(incoming)) {
|
||||
for (const ext of ["", "-wal", "-shm"]) {
|
||||
const p = `${env.dbPath}${ext}`;
|
||||
if (existsSync(p)) rmSync(p, { force: true });
|
||||
}
|
||||
renameSync(incoming, env.dbPath);
|
||||
}
|
||||
|
||||
const sqlite = new Database(env.dbPath);
|
||||
sqlite.pragma("journal_mode = WAL");
|
||||
sqlite.pragma("foreign_keys = ON");
|
||||
|
||||
export const db = drizzle(sqlite, { schema });
|
||||
export { schema };
|
||||
export { schema, sqlite };
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
CREATE TABLE `docker_compose_roots` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`machine_id` text NOT NULL,
|
||||
`path` text NOT NULL,
|
||||
`enabled` integer DEFAULT 1 NOT NULL,
|
||||
`scan_depth` integer,
|
||||
`created_at` text NOT NULL,
|
||||
`updated_at` text NOT NULL,
|
||||
FOREIGN KEY (`machine_id`) REFERENCES `machines`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `docker_compose_stacks` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`machine_id` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`working_dir` text NOT NULL,
|
||||
`compose_files_json` text NOT NULL,
|
||||
`project_name` text,
|
||||
`env_file` text,
|
||||
`status` text NOT NULL,
|
||||
`detected_by` text,
|
||||
`last_scan_at` text,
|
||||
`last_update_at` text,
|
||||
`created_at` text NOT NULL,
|
||||
`updated_at` text NOT NULL,
|
||||
FOREIGN KEY (`machine_id`) REFERENCES `machines`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `docker_settings` (
|
||||
`machine_id` text PRIMARY KEY NOT NULL,
|
||||
`enabled` integer DEFAULT 0 NOT NULL,
|
||||
`scan_depth` integer DEFAULT 4 NOT NULL,
|
||||
`prune_mode` text DEFAULT 'safe' NOT NULL,
|
||||
`last_scan_at` text,
|
||||
`last_pull_check_at` text,
|
||||
`updated_at` text NOT NULL,
|
||||
FOREIGN KEY (`machine_id`) REFERENCES `machines`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `docker_stack_services` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`stack_id` text NOT NULL,
|
||||
`service_name` text NOT NULL,
|
||||
`image_ref` text,
|
||||
`current_image_id` text,
|
||||
`current_digest` text,
|
||||
`candidate_image_id` text,
|
||||
`candidate_digest` text,
|
||||
`version_label` text,
|
||||
`status` text,
|
||||
`updated_at` text NOT NULL,
|
||||
FOREIGN KEY (`stack_id`) REFERENCES `docker_compose_stacks`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
@@ -0,0 +1,34 @@
|
||||
CREATE TABLE `action_requests` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`machine_id` text,
|
||||
`requested_by_type` text NOT NULL,
|
||||
`requested_by_id` text,
|
||||
`action` text NOT NULL,
|
||||
`risk` text,
|
||||
`status` text NOT NULL,
|
||||
`summary` text,
|
||||
`payload_json` text,
|
||||
`created_at` text NOT NULL,
|
||||
`approved_at` text,
|
||||
`approved_by` text,
|
||||
`execution_id` text,
|
||||
`expires_at` text,
|
||||
FOREIGN KEY (`machine_id`) REFERENCES `machines`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `docker_image_events` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`execution_id` text,
|
||||
`machine_id` text NOT NULL,
|
||||
`stack_id` text,
|
||||
`service_name` text,
|
||||
`image_ref` text,
|
||||
`from_image_id` text,
|
||||
`to_image_id` text,
|
||||
`from_digest` text,
|
||||
`to_digest` text,
|
||||
`operation` text,
|
||||
`bytes_reclaimed` integer,
|
||||
`created_at` text NOT NULL,
|
||||
FOREIGN KEY (`execution_id`) REFERENCES `executions`(`id`) ON UPDATE no action ON DELETE set null
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
CREATE TABLE `app_settings` (
|
||||
`key` text PRIMARY KEY NOT NULL,
|
||||
`value` text,
|
||||
`updated_at` text NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE `schedules` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`enabled` integer DEFAULT 1 NOT NULL,
|
||||
`cron` text NOT NULL,
|
||||
`timezone` text,
|
||||
`scope_json` text NOT NULL,
|
||||
`actions_json` text NOT NULL,
|
||||
`concurrency` integer DEFAULT 2 NOT NULL,
|
||||
`notify_on_json` text,
|
||||
`last_run_at` text,
|
||||
`last_status` text,
|
||||
`created_at` text NOT NULL,
|
||||
`updated_at` text NOT NULL
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,34 @@
|
||||
"when": 1780669200000,
|
||||
"tag": "0003_magical_psylocke",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "6",
|
||||
"when": 1780684150263,
|
||||
"tag": "0004_thin_ted_forrester",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "6",
|
||||
"when": 1780718324238,
|
||||
"tag": "0005_silent_drax",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "6",
|
||||
"when": 1780724800966,
|
||||
"tag": "0006_many_northstar",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "6",
|
||||
"when": 1780766513336,
|
||||
"tag": "0007_bizarre_doctor_faustus",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -57,3 +57,39 @@ describe("schéma Phase 2", () => {
|
||||
expect(columnNames(sqlite, "machines")).toContain("enc_password");
|
||||
});
|
||||
});
|
||||
|
||||
describe("schéma SJ-4 Docker", () => {
|
||||
it("crée les tables docker_*", () => {
|
||||
const sqlite = freshMigratedDb();
|
||||
const tables = tableNames(sqlite);
|
||||
for (const t of [
|
||||
"docker_settings",
|
||||
"docker_compose_roots",
|
||||
"docker_compose_stacks",
|
||||
"docker_stack_services",
|
||||
]) {
|
||||
expect(tables, `table ${t}`).toContain(t);
|
||||
}
|
||||
});
|
||||
|
||||
it("docker_settings a les colonnes attendues", () => {
|
||||
const sqlite = freshMigratedDb();
|
||||
expect(columnNames(sqlite, "docker_settings")).toEqual(
|
||||
expect.arrayContaining(["machine_id", "enabled", "scan_depth", "prune_mode", "last_scan_at", "updated_at"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("docker_compose_stacks a les colonnes attendues", () => {
|
||||
const sqlite = freshMigratedDb();
|
||||
expect(columnNames(sqlite, "docker_compose_stacks")).toEqual(
|
||||
expect.arrayContaining(["id", "machine_id", "name", "working_dir", "compose_files_json", "status", "detected_by"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("docker_stack_services a les colonnes attendues", () => {
|
||||
const sqlite = freshMigratedDb();
|
||||
expect(columnNames(sqlite, "docker_stack_services")).toEqual(
|
||||
expect.arrayContaining(["id", "stack_id", "service_name", "image_ref", "current_image_id", "current_digest"]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -226,3 +226,113 @@ export const machineHostKeys = sqliteTable("machine_host_keys", {
|
||||
firstSeenAt: text("first_seen_at").notNull(),
|
||||
lastSeenAt: text("last_seen_at").notNull(),
|
||||
});
|
||||
|
||||
// --- SJ-4 : Docker (passif) ---
|
||||
export const dockerSettings = sqliteTable("docker_settings", {
|
||||
machineId: text("machine_id").primaryKey().references(() => machines.id, { onDelete: "cascade" }),
|
||||
enabled: integer("enabled").notNull().default(0),
|
||||
scanDepth: integer("scan_depth").notNull().default(4),
|
||||
pruneMode: text("prune_mode").notNull().default("safe"),
|
||||
lastScanAt: text("last_scan_at"),
|
||||
lastPullCheckAt: text("last_pull_check_at"),
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const dockerComposeRoots = sqliteTable("docker_compose_roots", {
|
||||
id: text("id").primaryKey(),
|
||||
machineId: text("machine_id").notNull().references(() => machines.id, { onDelete: "cascade" }),
|
||||
path: text("path").notNull(),
|
||||
enabled: integer("enabled").notNull().default(1),
|
||||
scanDepth: integer("scan_depth"),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const dockerComposeStacks = sqliteTable("docker_compose_stacks", {
|
||||
id: text("id").primaryKey(),
|
||||
machineId: text("machine_id").notNull().references(() => machines.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
workingDir: text("working_dir").notNull(),
|
||||
composeFilesJson: text("compose_files_json").notNull(),
|
||||
projectName: text("project_name"),
|
||||
envFile: text("env_file"),
|
||||
status: text("status").notNull(), // candidate | enabled | ignored | error
|
||||
detectedBy: text("detected_by"), // root_scan | label | manual
|
||||
lastScanAt: text("last_scan_at"),
|
||||
lastUpdateAt: text("last_update_at"),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const dockerStackServices = sqliteTable("docker_stack_services", {
|
||||
id: text("id").primaryKey(),
|
||||
stackId: text("stack_id").notNull().references(() => dockerComposeStacks.id, { onDelete: "cascade" }),
|
||||
serviceName: text("service_name").notNull(),
|
||||
imageRef: text("image_ref"),
|
||||
currentImageId: text("current_image_id"),
|
||||
currentDigest: text("current_digest"),
|
||||
candidateImageId: text("candidate_image_id"),
|
||||
candidateDigest: text("candidate_digest"),
|
||||
versionLabel: text("version_label"),
|
||||
status: text("status"), // up_to_date | updates_available | error
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
});
|
||||
|
||||
// SJ-6 : historique pull/apply/prune (tache1.9.md §8).
|
||||
export const dockerImageEvents = sqliteTable("docker_image_events", {
|
||||
id: text("id").primaryKey(),
|
||||
executionId: text("execution_id").references(() => executions.id, { onDelete: "set null" }),
|
||||
machineId: text("machine_id").notNull(),
|
||||
stackId: text("stack_id"),
|
||||
serviceName: text("service_name"),
|
||||
imageRef: text("image_ref"),
|
||||
fromImageId: text("from_image_id"),
|
||||
toImageId: text("to_image_id"),
|
||||
fromDigest: text("from_digest"),
|
||||
toDigest: text("to_digest"),
|
||||
operation: text("operation"), // pulled | recreated | pruned
|
||||
bytesReclaimed: integer("bytes_reclaimed"),
|
||||
createdAt: text("created_at").notNull(),
|
||||
});
|
||||
|
||||
// SJ-6 : demandes d'actions destructives à valider (UI/Hermes) (tache1.9.md §10).
|
||||
export const actionRequests = sqliteTable("action_requests", {
|
||||
id: text("id").primaryKey(),
|
||||
machineId: text("machine_id").references(() => machines.id, { onDelete: "cascade" }),
|
||||
requestedByType: text("requested_by_type").notNull(), // user | hermes | schedule
|
||||
requestedById: text("requested_by_id"),
|
||||
action: text("action").notNull(),
|
||||
risk: text("risk"),
|
||||
status: text("status").notNull(), // pending | approved | rejected | executed | expired
|
||||
summary: text("summary"),
|
||||
payloadJson: text("payload_json"),
|
||||
createdAt: text("created_at").notNull(),
|
||||
approvedAt: text("approved_at"),
|
||||
approvedBy: text("approved_by"),
|
||||
executionId: text("execution_id"),
|
||||
expiresAt: text("expires_at"),
|
||||
});
|
||||
|
||||
// Réglages globaux de l'application (clé/valeur). Ex. proxy APT par défaut.
|
||||
export const appSettings = sqliteTable("app_settings", {
|
||||
key: text("key").primaryKey(),
|
||||
value: text("value"),
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
});
|
||||
|
||||
// Automatisations planifiées (cron) : analyse/metrics/scan sur un périmètre de machines.
|
||||
export const schedules = sqliteTable("schedules", {
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
enabled: integer("enabled").notNull().default(1),
|
||||
cron: text("cron").notNull(),
|
||||
timezone: text("timezone"),
|
||||
scopeJson: text("scope_json").notNull(), // {"machineIds":"all"|string[]}
|
||||
actionsJson: text("actions_json").notNull(), // ["apt_update_analyze","machine_metrics_simple",...]
|
||||
concurrency: integer("concurrency").notNull().default(2),
|
||||
notifyOnJson: text("notify_on_json"),
|
||||
lastRunAt: text("last_run_at"),
|
||||
lastStatus: text("last_status"),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
});
|
||||
|
||||
+4
-17
@@ -1,24 +1,11 @@
|
||||
// server/jobs/worker.ts
|
||||
import { Cron } from "croner";
|
||||
import { listMachines } from "../services/machines.js";
|
||||
import { refreshMachine } from "../services/refresh.js";
|
||||
import { reloadSchedules, stopSchedules } from "../services/scheduler.js";
|
||||
|
||||
let job: Cron | null = null;
|
||||
|
||||
/** Rafraîchit toutes les machines toutes les 30 minutes (tâche de fond). */
|
||||
/** Démarre le planificateur : enregistre les automatisations actives (cron) depuis la BDD. */
|
||||
export function startWorker(): void {
|
||||
job = new Cron("*/30 * * * *", async () => {
|
||||
for (const m of listMachines()) {
|
||||
try {
|
||||
await refreshMachine(m.id);
|
||||
} catch (err) {
|
||||
console.error(`[worker] refresh échoué pour ${m.id}:`, (err as Error).message);
|
||||
}
|
||||
}
|
||||
});
|
||||
reloadSchedules();
|
||||
}
|
||||
|
||||
export function stopWorker(): void {
|
||||
job?.stop();
|
||||
job = null;
|
||||
stopSchedules();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// server/routes/actionRequests.ts
|
||||
import { Hono } from "hono";
|
||||
import {
|
||||
createActionRequest,
|
||||
getActionRequest,
|
||||
listActionRequests,
|
||||
approveActionRequest,
|
||||
rejectActionRequest,
|
||||
} from "../services/actionRequests.js";
|
||||
import type { ActionType } from "@shared/types.js";
|
||||
|
||||
export const actionRequestsRoutes = new Hono();
|
||||
|
||||
// Crée une demande d'action destructive (pending). Hermes/UI proposent ; aucune exécution ici.
|
||||
actionRequestsRoutes.post("/machines/:id/action-requests", async (c) => {
|
||||
const body = (await c.req.json()) as {
|
||||
action: ActionType;
|
||||
stackId?: string;
|
||||
aggressive?: boolean;
|
||||
summary?: string;
|
||||
requestedByType?: "user" | "hermes" | "schedule";
|
||||
};
|
||||
try {
|
||||
const req = createActionRequest({
|
||||
machineId: c.req.param("id"),
|
||||
action: body.action,
|
||||
requestedByType: body.requestedByType,
|
||||
summary: body.summary,
|
||||
payload: { stackId: body.stackId, aggressive: body.aggressive },
|
||||
});
|
||||
return c.json(req, 201);
|
||||
} catch (err) {
|
||||
return c.json({ error: (err as Error).message }, 400);
|
||||
}
|
||||
});
|
||||
|
||||
actionRequestsRoutes.get("/machines/:id/action-requests", (c) =>
|
||||
c.json(listActionRequests(c.req.param("id"))),
|
||||
);
|
||||
|
||||
actionRequestsRoutes.get("/action-requests/:reqId", (c) => {
|
||||
const req = getActionRequest(c.req.param("reqId"));
|
||||
return req ? c.json(req) : c.json({ error: "Demande introuvable" }, 404);
|
||||
});
|
||||
|
||||
// Validation opérateur → déclenche l'exécution en arrière-plan.
|
||||
actionRequestsRoutes.post("/action-requests/:reqId/approve", async (c) => {
|
||||
const body = (await c.req.json().catch(() => ({}))) as { approvedBy?: string };
|
||||
try {
|
||||
return c.json(approveActionRequest(c.req.param("reqId"), body.approvedBy), 202);
|
||||
} catch (err) {
|
||||
return c.json({ error: (err as Error).message }, 400);
|
||||
}
|
||||
});
|
||||
|
||||
actionRequestsRoutes.post("/action-requests/:reqId/reject", async (c) => {
|
||||
const body = (await c.req.json().catch(() => ({}))) as { by?: string };
|
||||
try {
|
||||
return c.json(rejectActionRequest(c.req.param("reqId"), body.by));
|
||||
} catch (err) {
|
||||
return c.json({ error: (err as Error).message }, 400);
|
||||
}
|
||||
});
|
||||
@@ -6,13 +6,33 @@ import type { ActionType } from "@shared/types.js";
|
||||
|
||||
export const actionsRoutes = new Hono();
|
||||
|
||||
// Actions autorisées par l'API. Les actions destructives Docker
|
||||
// (docker_compose_apply/down, docker_prune_images agressif) restent hors API
|
||||
// jusqu'au socle de validation (action_requests, SJ-6).
|
||||
const ALLOWED_ACTIONS: ActionType[] = [
|
||||
"apt_full_upgrade",
|
||||
"reboot",
|
||||
// Docker passifs / non-applicatifs (SJ-4/SJ-5).
|
||||
"docker_scan",
|
||||
"docker_inspect_current",
|
||||
"docker_pull_check",
|
||||
// SJ-7 : sonde (lecture seule) + proxy APT persistant (action explicite idempotente).
|
||||
"machine_probe",
|
||||
"apt_proxy_persistent",
|
||||
];
|
||||
// Actions Docker ciblant un stack précis : stackId obligatoire.
|
||||
const NEED_STACK: ActionType[] = ["docker_inspect_current", "docker_pull_check"];
|
||||
|
||||
actionsRoutes.post("/:id/actions", async (c) => {
|
||||
const { action } = (await c.req.json()) as { action: ActionType };
|
||||
if (action !== "apt_full_upgrade" && action !== "reboot") {
|
||||
const { action, stackId } = (await c.req.json()) as { action: ActionType; stackId?: string };
|
||||
if (!ALLOWED_ACTIONS.includes(action)) {
|
||||
return c.json({ error: "Action non autorisée" }, 400);
|
||||
}
|
||||
if (NEED_STACK.includes(action) && !stackId) {
|
||||
return c.json({ error: "stackId requis pour cette action" }, 400);
|
||||
}
|
||||
// Exécution lancée en arrière-plan; le suivi se fait via WebSocket.
|
||||
runAction(c.req.param("id"), action).catch((err) =>
|
||||
runAction(c.req.param("id"), action, stackId ? { stackId } : undefined).catch((err) =>
|
||||
console.error("[action]", (err as Error).message),
|
||||
);
|
||||
return c.json({ ok: true, action }, 202);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// server/routes/db.ts
|
||||
import { Hono } from "hono";
|
||||
import { createBackup, prepareRestore, dbInfo } from "../services/dbBackup.js";
|
||||
|
||||
export const dbRoutes = new Hono();
|
||||
|
||||
// Métadonnées de la base (taille, date, restauration en attente).
|
||||
dbRoutes.get("/info", (c) => c.json(dbInfo()));
|
||||
|
||||
// Télécharge une archive cohérente de la base courante.
|
||||
dbRoutes.get("/backup", () => {
|
||||
const { buffer, filename } = createBackup();
|
||||
return new Response(new Uint8Array(buffer), {
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
"Content-Length": String(buffer.length),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Restaure depuis une archive uploadée (corps brut). Appliquée au prochain démarrage.
|
||||
dbRoutes.post("/restore", async (c) => {
|
||||
try {
|
||||
const ab = await c.req.arrayBuffer();
|
||||
if (!ab.byteLength) return c.json({ error: "Archive vide" }, 400);
|
||||
const { safetyBackup } = prepareRestore(Buffer.from(ab));
|
||||
return c.json({
|
||||
ok: true,
|
||||
restartRequired: true,
|
||||
safetyBackup,
|
||||
message: "Restauration préparée. Redémarrez le serveur pour l'appliquer.",
|
||||
});
|
||||
} catch (err) {
|
||||
return c.json({ error: (err as Error).message }, 400);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
// server/routes/docker.ts
|
||||
import { Hono } from "hono";
|
||||
import { runAction } from "../services/execute.js";
|
||||
import {
|
||||
getDockerSettings,
|
||||
setDockerRoots,
|
||||
listStacks,
|
||||
setStackStatus,
|
||||
type StackStatus,
|
||||
} from "../services/dockerScan.js";
|
||||
|
||||
export const dockerRoutes = new Hono();
|
||||
|
||||
// Paramètres Docker (settings + racines Compose déclarées).
|
||||
dockerRoutes.get("/:id/docker/settings", (c) => c.json(getDockerSettings(c.req.param("id"))));
|
||||
|
||||
// Déclare/active les racines Compose à scanner.
|
||||
dockerRoutes.post("/:id/docker/roots", async (c) => {
|
||||
const body = (await c.req.json()) as { paths?: string[]; scanDepth?: number };
|
||||
if (!Array.isArray(body.paths)) return c.json({ error: "paths[] requis" }, 400);
|
||||
setDockerRoots(c.req.param("id"), body.paths, body.scanDepth ?? 4);
|
||||
return c.json(getDockerSettings(c.req.param("id")), 201);
|
||||
});
|
||||
|
||||
// Déclenche un scan (passif) en arrière-plan ; suivi via WebSocket.
|
||||
dockerRoutes.post("/:id/docker/scan", (c) => {
|
||||
runAction(c.req.param("id"), "docker_scan").catch((err) =>
|
||||
console.error("[docker_scan]", (err as Error).message),
|
||||
);
|
||||
return c.json({ ok: true, action: "docker_scan" }, 202);
|
||||
});
|
||||
|
||||
// Liste les stacks détectés (+ services).
|
||||
dockerRoutes.get("/:id/docker/stacks", (c) => c.json(listStacks(c.req.param("id"))));
|
||||
|
||||
// Cycle de vie d'un stack : candidate → enabled (validé) → ignored…
|
||||
dockerRoutes.patch("/:id/docker/stacks/:stackId", async (c) => {
|
||||
const body = (await c.req.json()) as { status?: StackStatus };
|
||||
if (!body.status) return c.json({ error: "status requis" }, 400);
|
||||
try {
|
||||
return c.json(setStackStatus(c.req.param("id"), c.req.param("stackId"), body.status));
|
||||
} catch (err) {
|
||||
return c.json({ error: (err as Error).message }, 400);
|
||||
}
|
||||
});
|
||||
@@ -2,6 +2,12 @@
|
||||
import { Hono } from "hono";
|
||||
import { machinesRoutes } from "./machines.js";
|
||||
import { actionsRoutes } from "./actions.js";
|
||||
import { actionRequestsRoutes } from "./actionRequests.js";
|
||||
import { dockerRoutes } from "./docker.js";
|
||||
import { dbRoutes } from "./db.js";
|
||||
import { settingsRoutes } from "./settings.js";
|
||||
import { postInstallRoutes } from "./postInstall.js";
|
||||
import { schedulesRoutes } from "./schedules.js";
|
||||
import { getServerCapabilities } from "../services/capabilities.js";
|
||||
import { getSystemMetrics, getSystemStatus } from "../services/system.js";
|
||||
|
||||
@@ -9,5 +15,11 @@ 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("/system/db", dbRoutes);
|
||||
api.route("/settings", settingsRoutes);
|
||||
api.route("/schedules", schedulesRoutes);
|
||||
api.route("/machines", machinesRoutes);
|
||||
api.route("/machines", actionsRoutes);
|
||||
api.route("/machines", dockerRoutes);
|
||||
api.route("/", actionRequestsRoutes);
|
||||
api.route("/", postInstallRoutes);
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
// server/routes/machines.ts
|
||||
import { Hono } from "hono";
|
||||
import {
|
||||
listMachines, createMachine, deleteMachine, getMachineRow, getCreds, testConnection,
|
||||
type CreateMachineInput,
|
||||
listMachines, createMachine, deleteMachine, updateMachine, getMachineRow, getCreds, testConnection,
|
||||
getMachineHardware,
|
||||
type CreateMachineInput, type UpdateMachineInput,
|
||||
} from "../services/machines.js";
|
||||
import { refreshMachine, getLatestSnapshot } from "../services/refresh.js";
|
||||
import { runProbe } from "../services/machineProbe.js";
|
||||
import { collectMetrics, getLatestMetrics } from "../services/machineMetrics.js";
|
||||
import { analyzeMachineRepositories } from "../services/aptRepositories.js";
|
||||
import { listImportantMessages, acknowledgeMessage } from "../services/importantMessages.js";
|
||||
import { listMachineEvents } from "../services/machineState.js";
|
||||
|
||||
export const machinesRoutes = new Hono();
|
||||
|
||||
@@ -43,6 +49,64 @@ machinesRoutes.post("/:id/refresh", async (c) => {
|
||||
}
|
||||
});
|
||||
|
||||
machinesRoutes.patch("/:id", async (c) => {
|
||||
const body = (await c.req.json()) as UpdateMachineInput;
|
||||
try {
|
||||
return c.json(updateMachine(c.req.param("id"), body));
|
||||
} catch (err) {
|
||||
return c.json({ error: (err as Error).message }, 400);
|
||||
}
|
||||
});
|
||||
|
||||
// Dernières métriques stockées (sans SSH).
|
||||
machinesRoutes.get("/:id/metrics", (c) => c.json(getLatestMetrics(c.req.param("id"))));
|
||||
|
||||
// Collecte fraîche (SSH léger, non destructif).
|
||||
machinesRoutes.post("/:id/metrics/collect", async (c) => {
|
||||
try {
|
||||
return c.json(await collectMetrics(c.req.param("id")));
|
||||
} catch (err) {
|
||||
return c.json({ error: (err as Error).message }, 400);
|
||||
}
|
||||
});
|
||||
|
||||
// Analyse des dépôts APT (lecture seule).
|
||||
machinesRoutes.post("/:id/apt-repositories", async (c) => {
|
||||
try {
|
||||
return c.json(await analyzeMachineRepositories(c.req.param("id")));
|
||||
} catch (err) {
|
||||
return c.json({ error: (err as Error).message }, 400);
|
||||
}
|
||||
});
|
||||
|
||||
// Timeline d'événements machine.
|
||||
machinesRoutes.get("/:id/events", (c) => c.json(listMachineEvents(c.req.param("id"))));
|
||||
|
||||
// Messages importants (warnings/erreurs/évolutions) extraits des sorties.
|
||||
machinesRoutes.get("/:id/messages", (c) => c.json(listImportantMessages(c.req.param("id"))));
|
||||
machinesRoutes.post("/:id/messages/:msgId/ack", (c) => {
|
||||
acknowledgeMessage(c.req.param("msgId"));
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
machinesRoutes.get("/:id/hardware", (c) => {
|
||||
try {
|
||||
return c.json(getMachineHardware(c.req.param("id")));
|
||||
} catch (err) {
|
||||
return c.json({ error: (err as Error).message }, 404);
|
||||
}
|
||||
});
|
||||
|
||||
// Sonde synchrone (lecture seule) : renvoie faits + proposition de correction.
|
||||
machinesRoutes.post("/:id/probe", async (c) => {
|
||||
try {
|
||||
const o = await runProbe(c.req.param("id"));
|
||||
return c.json({ probe: o.probe, proposal: o.proposal, recommendations: o.recommendations, changes: o.changes });
|
||||
} catch (err) {
|
||||
return c.json({ error: (err as Error).message }, 400);
|
||||
}
|
||||
});
|
||||
|
||||
machinesRoutes.delete("/:id", (c) => {
|
||||
deleteMachine(c.req.param("id"));
|
||||
return c.json({ ok: true });
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// server/routes/postInstall.ts
|
||||
import { Hono } from "hono";
|
||||
import { PROFILES, previewProfile, validateProfileValues } from "../services/postInstall.js";
|
||||
import { createActionRequest } from "../services/actionRequests.js";
|
||||
import { runAction } from "../services/execute.js";
|
||||
|
||||
export const postInstallRoutes = new Hono();
|
||||
|
||||
// Catalogue des profils (manifestes, sans secret).
|
||||
postInstallRoutes.get("/profiles", (c) => c.json(Object.values(PROFILES)));
|
||||
|
||||
// Preview du script rendu (secrets masqués) + validation des champs.
|
||||
postInstallRoutes.post("/machines/:id/profiles/:profileId/preview", async (c) => {
|
||||
const profileId = c.req.param("profileId");
|
||||
const manifest = PROFILES[profileId];
|
||||
if (!manifest) return c.json({ error: "Profil inconnu" }, 404);
|
||||
const { values } = (await c.req.json().catch(() => ({}))) as { values?: Record<string, string | number | boolean> };
|
||||
const validation = validateProfileValues(manifest, values ?? {});
|
||||
return c.json({ script: previewProfile(profileId, values ?? {}), validation, requiresConfirmation: manifest.requiresConfirmation });
|
||||
});
|
||||
|
||||
// Exécute un profil : confirmation explicite (action_request) si requise, sinon direct.
|
||||
postInstallRoutes.post("/machines/:id/profiles/:profileId/run", async (c) => {
|
||||
const machineId = c.req.param("id");
|
||||
const profileId = c.req.param("profileId");
|
||||
const manifest = PROFILES[profileId];
|
||||
if (!manifest) return c.json({ error: "Profil inconnu" }, 404);
|
||||
const { values } = (await c.req.json().catch(() => ({}))) as { values?: Record<string, string | number | boolean> };
|
||||
const validation = validateProfileValues(manifest, values ?? {});
|
||||
if (!validation.ok) return c.json({ error: "Champs invalides", validation }, 400);
|
||||
|
||||
if (manifest.requiresConfirmation) {
|
||||
const reqRow = createActionRequest({
|
||||
machineId,
|
||||
action: "post_install",
|
||||
summary: `Profil ${manifest.label}`,
|
||||
payload: { profileId, values: values ?? {} },
|
||||
});
|
||||
return c.json({ actionRequest: reqRow, requiresConfirmation: true }, 202);
|
||||
}
|
||||
|
||||
runAction(machineId, "post_install", { profileId, values: values ?? {} }).catch((err) =>
|
||||
console.error("[post_install]", (err as Error).message),
|
||||
);
|
||||
return c.json({ ok: true, action: "post_install", profileId }, 202);
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
// server/routes/schedules.ts
|
||||
import { Hono } from "hono";
|
||||
import {
|
||||
listSchedules,
|
||||
getSchedule,
|
||||
createSchedule,
|
||||
updateSchedule,
|
||||
deleteSchedule,
|
||||
runSchedule,
|
||||
type ScheduleInput,
|
||||
} from "../services/scheduler.js";
|
||||
|
||||
export const schedulesRoutes = new Hono();
|
||||
|
||||
schedulesRoutes.get("/", (c) => c.json(listSchedules()));
|
||||
|
||||
schedulesRoutes.post("/", async (c) => {
|
||||
const body = (await c.req.json()) as ScheduleInput;
|
||||
try {
|
||||
return c.json(createSchedule(body), 201);
|
||||
} catch (err) {
|
||||
return c.json({ error: (err as Error).message }, 400);
|
||||
}
|
||||
});
|
||||
|
||||
schedulesRoutes.get("/:id", (c) => {
|
||||
const s = getSchedule(c.req.param("id"));
|
||||
return s ? c.json(s) : c.json({ error: "Schedule introuvable" }, 404);
|
||||
});
|
||||
|
||||
schedulesRoutes.patch("/:id", async (c) => {
|
||||
const body = (await c.req.json()) as Partial<ScheduleInput>;
|
||||
try {
|
||||
return c.json(updateSchedule(c.req.param("id"), body));
|
||||
} catch (err) {
|
||||
return c.json({ error: (err as Error).message }, 400);
|
||||
}
|
||||
});
|
||||
|
||||
schedulesRoutes.delete("/:id", (c) => {
|
||||
deleteSchedule(c.req.param("id"));
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// Lancement immédiat (hors planning).
|
||||
schedulesRoutes.post("/:id/run", (c) => {
|
||||
runSchedule(c.req.param("id")).catch((err) => console.error("[schedule run]", (err as Error).message));
|
||||
return c.json({ ok: true }, 202);
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
// server/routes/settings.ts
|
||||
import { Hono } from "hono";
|
||||
import { getDefaultAptProxy, setDefaultAptProxy, type DefaultAptProxy } from "../services/appSettings.js";
|
||||
import { applyProxyToAllMachines } from "../services/machines.js";
|
||||
|
||||
export const settingsRoutes = new Hono();
|
||||
|
||||
// Réglages globaux exposés à l'UI.
|
||||
settingsRoutes.get("/", (c) => c.json({ defaultAptProxy: getDefaultAptProxy() }));
|
||||
|
||||
// Définit le proxy APT par défaut (apt-cacher-ng).
|
||||
settingsRoutes.put("/apt-proxy", async (c) => {
|
||||
const body = (await c.req.json()) as DefaultAptProxy;
|
||||
const mode = body.mode ?? "direct";
|
||||
const url = (body.url ?? "").trim() || null;
|
||||
return c.json(setDefaultAptProxy({ mode, url }));
|
||||
});
|
||||
|
||||
// Applique le proxy par défaut à toutes les machines existantes.
|
||||
settingsRoutes.post("/apt-proxy/apply-all", (c) => {
|
||||
const { mode, url } = getDefaultAptProxy();
|
||||
const updated = applyProxyToAllMachines(mode, url);
|
||||
return c.json({ ok: true, updated });
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
// server/services/actionRequests.ts
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db, schema } from "../db/client.js";
|
||||
import { runAction, type RunActionOpts } from "./execute.js";
|
||||
import { recordEvent } from "./machineState.js";
|
||||
import type { ActionType } from "@shared/types.js";
|
||||
|
||||
// Actions destructives nécessitant une validation explicite (70-securite.md §2).
|
||||
export const DESTRUCTIVE_ACTIONS: Partial<Record<ActionType, "medium" | "high">> = {
|
||||
docker_compose_apply: "medium",
|
||||
docker_prune_images: "medium",
|
||||
docker_compose_down: "high",
|
||||
apt_full_upgrade: "medium",
|
||||
apt_dist_upgrade: "medium",
|
||||
apt_autoremove: "medium",
|
||||
reboot: "high",
|
||||
reboot_verified: "high",
|
||||
post_install: "high", // risque réel porté par le manifeste du profil
|
||||
};
|
||||
|
||||
const NEED_STACK: ActionType[] = ["docker_compose_apply", "docker_compose_down"];
|
||||
|
||||
export interface CreateRequestInput {
|
||||
machineId: string;
|
||||
action: ActionType;
|
||||
requestedByType?: "user" | "hermes" | "schedule";
|
||||
requestedById?: string | null;
|
||||
summary?: string | null;
|
||||
payload?: {
|
||||
stackId?: string;
|
||||
aggressive?: boolean;
|
||||
profileId?: string;
|
||||
values?: Record<string, string | number | boolean | undefined>;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export function createActionRequest(input: CreateRequestInput) {
|
||||
const risk = DESTRUCTIVE_ACTIONS[input.action];
|
||||
if (!risk) throw new Error(`Action non destructive ou inconnue : ${input.action}`);
|
||||
if (NEED_STACK.includes(input.action) && !input.payload?.stackId) {
|
||||
throw new Error("stackId requis pour cette action");
|
||||
}
|
||||
const id = randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
db.insert(schema.actionRequests).values({
|
||||
id,
|
||||
machineId: input.machineId,
|
||||
requestedByType: input.requestedByType ?? "user",
|
||||
requestedById: input.requestedById ?? null,
|
||||
action: input.action,
|
||||
risk,
|
||||
status: "pending",
|
||||
summary: input.summary ?? `Demande ${input.action}`,
|
||||
payloadJson: input.payload ? JSON.stringify(input.payload) : null,
|
||||
createdAt: now,
|
||||
}).run();
|
||||
recordEvent({
|
||||
machineId: input.machineId,
|
||||
eventType: "action_request_created",
|
||||
severity: "info",
|
||||
message: `Demande ${input.action} (risque ${risk}) en attente de validation`,
|
||||
});
|
||||
return getActionRequest(id);
|
||||
}
|
||||
|
||||
export function getActionRequest(id: string) {
|
||||
return db.select().from(schema.actionRequests).where(eq(schema.actionRequests.id, id)).get();
|
||||
}
|
||||
|
||||
export function listActionRequests(machineId?: string) {
|
||||
const q = db.select().from(schema.actionRequests);
|
||||
const rows = machineId
|
||||
? q.where(eq(schema.actionRequests.machineId, machineId)).all()
|
||||
: q.all();
|
||||
return rows.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
}
|
||||
|
||||
export function rejectActionRequest(id: string, by?: string) {
|
||||
const req = getActionRequest(id);
|
||||
if (!req) throw new Error("Demande introuvable");
|
||||
if (req.status !== "pending") throw new Error(`Demande déjà ${req.status}`);
|
||||
db.update(schema.actionRequests)
|
||||
.set({ status: "rejected", approvedAt: new Date().toISOString(), approvedBy: by ?? null })
|
||||
.where(eq(schema.actionRequests.id, id))
|
||||
.run();
|
||||
return getActionRequest(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Approuve une demande et déclenche l'action en arrière-plan. Renvoie immédiatement
|
||||
* la demande passée à `approved` ; `executionId`/`executed` sont posés à la fin du run.
|
||||
*/
|
||||
export function approveActionRequest(id: string, approvedBy?: string) {
|
||||
const req = getActionRequest(id);
|
||||
if (!req) throw new Error("Demande introuvable");
|
||||
if (req.status !== "pending") throw new Error(`Demande déjà ${req.status}`);
|
||||
if (!req.machineId) throw new Error("Demande sans machine");
|
||||
const now = new Date().toISOString();
|
||||
db.update(schema.actionRequests)
|
||||
.set({ status: "approved", approvedAt: now, approvedBy: approvedBy ?? null })
|
||||
.where(eq(schema.actionRequests.id, id))
|
||||
.run();
|
||||
|
||||
const payload = req.payloadJson
|
||||
? (JSON.parse(req.payloadJson) as { stackId?: string; aggressive?: boolean; profileId?: string; values?: Record<string, string | number | boolean | undefined> })
|
||||
: {};
|
||||
const opts: RunActionOpts = {
|
||||
stackId: payload.stackId,
|
||||
aggressive: payload.aggressive,
|
||||
profileId: payload.profileId,
|
||||
values: payload.values,
|
||||
};
|
||||
const machineId = req.machineId;
|
||||
runAction(machineId, req.action as ActionType, opts)
|
||||
.then((result) => {
|
||||
db.update(schema.actionRequests)
|
||||
.set({ status: "executed", executionId: result.executionId })
|
||||
.where(eq(schema.actionRequests.id, id))
|
||||
.run();
|
||||
})
|
||||
.catch((err) => {
|
||||
recordEvent({
|
||||
machineId,
|
||||
eventType: "action_request_failed",
|
||||
severity: "error",
|
||||
message: `Demande ${req.action} échouée : ${(err as Error).message}`,
|
||||
});
|
||||
});
|
||||
return getActionRequest(id);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// server/services/appSettings.ts
|
||||
import { db, schema } from "../db/client.js";
|
||||
import type { AptProxyMode } from "@shared/types.js";
|
||||
|
||||
export const SETTING_KEYS = {
|
||||
defaultAptProxyUrl: "default_apt_proxy_url",
|
||||
defaultAptProxyMode: "default_apt_proxy_mode",
|
||||
} as const;
|
||||
|
||||
export function getAllSettings(): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
db.select().from(schema.appSettings).all().map((r) => [r.key, r.value ?? ""]),
|
||||
);
|
||||
}
|
||||
|
||||
export function setSettings(patch: Record<string, string | null>): void {
|
||||
const now = new Date().toISOString();
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
db.insert(schema.appSettings)
|
||||
.values({ key, value, updatedAt: now })
|
||||
.onConflictDoUpdate({ target: schema.appSettings.key, set: { value, updatedAt: now } })
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
export interface DefaultAptProxy {
|
||||
mode: AptProxyMode;
|
||||
url: string | null;
|
||||
}
|
||||
|
||||
export function getDefaultAptProxy(): DefaultAptProxy {
|
||||
const s = getAllSettings();
|
||||
const mode = (s[SETTING_KEYS.defaultAptProxyMode] as AptProxyMode) || "direct";
|
||||
return { mode, url: s[SETTING_KEYS.defaultAptProxyUrl] || null };
|
||||
}
|
||||
|
||||
export function setDefaultAptProxy(input: DefaultAptProxy): DefaultAptProxy {
|
||||
setSettings({
|
||||
[SETTING_KEYS.defaultAptProxyMode]: input.mode,
|
||||
[SETTING_KEYS.defaultAptProxyUrl]: input.url ?? "",
|
||||
});
|
||||
return getDefaultAptProxy();
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { analyzeRepositories } from "./aptRepositories.js";
|
||||
|
||||
const DEBIAN = [
|
||||
"===SU:REPO_DEB===",
|
||||
"deb http://deb.debian.org/debian bookworm main contrib",
|
||||
"deb http://security.debian.org/debian-security bookworm-security main",
|
||||
"===SU:REPO_DEB822===",
|
||||
"===SU:EXIT=0===",
|
||||
].join("\n");
|
||||
|
||||
const PROXMOX_ENTERPRISE = [
|
||||
"===SU:REPO_DEB===",
|
||||
"deb http://ftp.debian.org/debian bookworm main contrib",
|
||||
"deb https://enterprise.proxmox.com/debian/pve bookworm pve-enterprise",
|
||||
"===SU:REPO_DEB822===",
|
||||
"===SU:EXIT=0===",
|
||||
].join("\n");
|
||||
|
||||
describe("analyzeRepositories", () => {
|
||||
it("Debian : composants détectés et non-free-firmware absent → note", () => {
|
||||
const a = analyzeRepositories("debian", DEBIAN);
|
||||
expect(a.components).toContain("main");
|
||||
expect(a.components).toContain("contrib");
|
||||
expect(a.repos.length).toBeGreaterThanOrEqual(2);
|
||||
expect(a.notes.some((n) => /non-free-firmware/.test(n))).toBe(true);
|
||||
});
|
||||
|
||||
it("Proxmox : dépôt enterprise sans no-subscription → warning", () => {
|
||||
const a = analyzeRepositories("proxmox", PROXMOX_ENTERPRISE);
|
||||
expect(a.proxmox?.enterprise).toBe(true);
|
||||
expect(a.proxmox?.noSubscription).toBe(false);
|
||||
expect(a.warnings.some((w) => w.kind === "pve_enterprise_without_subscription")).toBe(true);
|
||||
});
|
||||
|
||||
it("Proxmox : aucun dépôt PVE → warning", () => {
|
||||
const a = analyzeRepositories("proxmox", DEBIAN);
|
||||
expect(a.warnings.some((w) => w.kind === "pve_repo_missing")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
// server/services/aptRepositories.ts
|
||||
import { getMachineRow, getCreds } from "./machines.js";
|
||||
import { renderTemplate } from "../templates/render.js";
|
||||
import { runScriptSudo } from "../ssh/client.js";
|
||||
import type { AptRepositoriesAnalysis, OsFamily } from "@shared/types.js";
|
||||
|
||||
function section(raw: string, start: string, end?: string): string {
|
||||
const i = raw.indexOf(start);
|
||||
if (i < 0) return "";
|
||||
const from = i + start.length;
|
||||
const j = end ? raw.indexOf(end, from) : -1;
|
||||
return raw.slice(from, j < 0 ? undefined : j).trim();
|
||||
}
|
||||
|
||||
interface Repo {
|
||||
uri: string;
|
||||
suite: string;
|
||||
components: string[];
|
||||
}
|
||||
|
||||
/** Parse les lignes `deb [opts] URI suite comp...` (format une-ligne). */
|
||||
function parseDebLines(block: string): Repo[] {
|
||||
const repos: Repo[] = [];
|
||||
for (const line of block.split("\n")) {
|
||||
const t = line.trim();
|
||||
if (!t.startsWith("deb ") && !t.startsWith("deb\t")) continue;
|
||||
// retire le mot-clé deb et les options [arch=...]
|
||||
const rest = t.replace(/^deb\s+/, "").replace(/^\[[^\]]*\]\s*/, "");
|
||||
const parts = rest.split(/\s+/).filter(Boolean);
|
||||
if (parts.length < 2) continue;
|
||||
const [uri, suite, ...components] = parts;
|
||||
repos.push({ uri: uri!, suite: suite!, components });
|
||||
}
|
||||
return repos;
|
||||
}
|
||||
|
||||
export function analyzeRepositories(osFamily: OsFamily, raw: string): AptRepositoriesAnalysis {
|
||||
const repos = parseDebLines(section(raw, "===SU:REPO_DEB===", "===SU:REPO_DEB822==="));
|
||||
const components = [...new Set(repos.flatMap((r) => r.components))].sort();
|
||||
const warnings: AptRepositoriesAnalysis["warnings"] = [];
|
||||
const notes: string[] = [];
|
||||
|
||||
if (osFamily === "proxmox") {
|
||||
const enterprise = repos.some((r) => /enterprise\.proxmox\.com/.test(r.uri));
|
||||
const noSubscription = repos.some((r) => /download\.proxmox\.com/.test(r.uri) && r.components.includes("pve-no-subscription"));
|
||||
if (enterprise && !noSubscription) {
|
||||
warnings.push({
|
||||
kind: "pve_enterprise_without_subscription",
|
||||
message: "Dépôt PVE entreprise actif sans dépôt no-subscription : `apt update` échouera sans abonnement.",
|
||||
});
|
||||
}
|
||||
if (!enterprise && !noSubscription) {
|
||||
warnings.push({ kind: "pve_repo_missing", message: "Aucun dépôt PVE détecté (ni enterprise ni no-subscription)." });
|
||||
}
|
||||
return { osFamily, components, repos, proxmox: { enterprise, noSubscription }, warnings, notes };
|
||||
}
|
||||
|
||||
if (osFamily === "debian") {
|
||||
for (const comp of ["contrib", "non-free", "non-free-firmware"]) {
|
||||
if (!components.includes(comp)) notes.push(`Composant « ${comp} » absent (requis pour firmware/drivers propriétaires).`);
|
||||
}
|
||||
} else if (osFamily === "ubuntu") {
|
||||
for (const comp of ["universe", "restricted", "multiverse"]) {
|
||||
if (!components.includes(comp)) notes.push(`Composant « ${comp} » absent (drivers/paquets supplémentaires indisponibles).`);
|
||||
}
|
||||
}
|
||||
|
||||
if (repos.length === 0) warnings.push({ kind: "no_sources", message: "Aucune source APT détectée." });
|
||||
|
||||
return { osFamily, components, repos, warnings, notes };
|
||||
}
|
||||
|
||||
/** Analyse les dépôts APT d'une machine via SSH (lecture seule). */
|
||||
export async function analyzeMachineRepositories(machineId: string): Promise<AptRepositoriesAnalysis> {
|
||||
const m = getMachineRow(machineId);
|
||||
if (!m) throw new Error("Machine introuvable");
|
||||
const script = renderTemplate("apt/repositories.sh.tpl", {});
|
||||
const res = await runScriptSudo(getCreds(m), script, () => {});
|
||||
return analyzeRepositories(m.osFamily as OsFamily, res.stdout);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// server/services/dbBackup.ts
|
||||
import Database from "better-sqlite3";
|
||||
import { readFileSync, writeFileSync, rmSync, existsSync, statSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { sqlite } from "../db/client.js";
|
||||
import { env } from "../env.js";
|
||||
|
||||
// En-tête SQLite : 15 octets ASCII + un octet nul terminal.
|
||||
const SQLITE_HEADER = "SQLite format 3";
|
||||
|
||||
function isSqliteHeader(buffer: Buffer): boolean {
|
||||
return (
|
||||
buffer.length >= 16 &&
|
||||
buffer.subarray(0, 15).toString("latin1") === SQLITE_HEADER &&
|
||||
buffer[15] === 0
|
||||
);
|
||||
}
|
||||
|
||||
function stamp(): string {
|
||||
return new Date().toISOString().replace(/[:T]/g, "-").replace(/\..+$/, "");
|
||||
}
|
||||
|
||||
/** Snapshot cohérent de la base courante (VACUUM INTO → fichier unique, sans WAL). */
|
||||
export function createBackup(): { buffer: Buffer; filename: string } {
|
||||
const tmp = join(dirname(env.dbPath), `.backup-${Date.now()}.db`);
|
||||
rmSync(tmp, { force: true });
|
||||
sqlite.exec(`VACUUM INTO '${tmp.replace(/'/g, "''")}'`);
|
||||
try {
|
||||
return { buffer: readFileSync(tmp), filename: `system-update-${stamp()}.db` };
|
||||
} finally {
|
||||
rmSync(tmp, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** Vérifie qu'un buffer est une base SQLite intègre au schéma attendu. */
|
||||
export function validateSqlite(buffer: Buffer): void {
|
||||
if (!isSqliteHeader(buffer)) {
|
||||
throw new Error("Fichier invalide : ce n'est pas une base SQLite.");
|
||||
}
|
||||
const tmp = join(dirname(env.dbPath), `.verify-${Date.now()}.db`);
|
||||
writeFileSync(tmp, buffer);
|
||||
try {
|
||||
const test = new Database(tmp, { readonly: true });
|
||||
try {
|
||||
const integrity = test.pragma("integrity_check", { simple: true });
|
||||
if (integrity !== "ok") throw new Error("Base corrompue (integrity_check).");
|
||||
const hasMachines = test
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='machines'")
|
||||
.get();
|
||||
if (!hasMachines) throw new Error("Archive non reconnue : table 'machines' absente.");
|
||||
} finally {
|
||||
test.close();
|
||||
}
|
||||
} finally {
|
||||
rmSync(tmp, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prépare une restauration : sauvegarde la base courante puis dépose la nouvelle base
|
||||
* en `<db>.incoming`. Le swap réel a lieu au prochain démarrage (db/client.ts) pour
|
||||
* ne jamais écraser une base ouverte. Renvoie le chemin de la sauvegarde de sécurité.
|
||||
*/
|
||||
export function prepareRestore(buffer: Buffer): { safetyBackup: string } {
|
||||
validateSqlite(buffer);
|
||||
const safety = `${env.dbPath}.pre-restore-${stamp()}.bak`;
|
||||
writeFileSync(safety, createBackup().buffer);
|
||||
writeFileSync(`${env.dbPath}.incoming`, buffer);
|
||||
return { safetyBackup: safety };
|
||||
}
|
||||
|
||||
/** Métadonnées de la base courante (pour l'UI). */
|
||||
export function dbInfo(): { sizeBytes: number; modifiedAt: string | null; restorePending: boolean } {
|
||||
const exists = existsSync(env.dbPath);
|
||||
const st = exists ? statSync(env.dbPath) : null;
|
||||
return {
|
||||
sizeBytes: st?.size ?? 0,
|
||||
modifiedAt: st ? st.mtime.toISOString() : null,
|
||||
restorePending: existsSync(`${env.dbPath}.incoming`),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
parseDockerApply,
|
||||
parseDockerPrune,
|
||||
parseDockerDown,
|
||||
parseHumanBytes,
|
||||
} from "./dockerApply.js";
|
||||
|
||||
describe("parseDockerApply", () => {
|
||||
const RAW = [
|
||||
"===SU:DOCKER_APPLY===",
|
||||
" Container media-app-1 Recreate",
|
||||
" Container media-app-1 Recreated",
|
||||
" Container media-worker-1 Created",
|
||||
" Container media-db-1 Running",
|
||||
" Container media-app-1 Started",
|
||||
"===SU:DOCKER_PS_AFTER===",
|
||||
'{"Name":"media-app-1","Service":"app","State":"running","Health":""}',
|
||||
'{"Name":"media-db-1","Service":"db","State":"running","Health":"healthy"}',
|
||||
'{"Name":"media-worker-1","Service":"worker","State":"exited","Health":""}',
|
||||
"===SU:DOCKER_INSPECT_AFTER===",
|
||||
"IMG\tsha256:newapp\tapp@sha256:dapp",
|
||||
"IMG\tsha256:db\tdb@sha256:ddb",
|
||||
"===SU:EXIT=0===",
|
||||
].join("\n");
|
||||
|
||||
it("liste les conteneurs recréés/créés et l'état running/exited", () => {
|
||||
const r = parseDockerApply(RAW);
|
||||
expect(r.recreated.sort()).toEqual(["media-app-1", "media-worker-1"]);
|
||||
expect(r.running.sort()).toEqual(["media-app-1", "media-db-1"]);
|
||||
expect(r.exited).toEqual(["media-worker-1"]);
|
||||
expect(r.errors).toHaveLength(0);
|
||||
expect(r.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it("remonte une erreur d'application nettoyée", () => {
|
||||
const bad = [
|
||||
"===SU:DOCKER_APPLY===",
|
||||
' Container app-1 Error pulling image from https://reg.example/v2 token=SECRET123',
|
||||
"===SU:DOCKER_PS_AFTER===",
|
||||
"===SU:DOCKER_INSPECT_AFTER===",
|
||||
"===SU:EXIT=1===",
|
||||
].join("\n");
|
||||
const r = parseDockerApply(bad);
|
||||
expect(r.errors.length).toBeGreaterThan(0);
|
||||
expect(r.errors[0]!.message).not.toContain("reg.example");
|
||||
expect(r.errors[0]!.message).not.toContain("SECRET123");
|
||||
expect(r.exitCode).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseHumanBytes", () => {
|
||||
it("convertit les unités décimales Docker", () => {
|
||||
expect(parseHumanBytes("0B")).toBe(0);
|
||||
expect(parseHumanBytes("512MB")).toBe(512_000_000);
|
||||
expect(parseHumanBytes("1.234GB")).toBe(Math.round(1.234 * 1e9));
|
||||
expect(parseHumanBytes("1.5kB")).toBe(1500);
|
||||
});
|
||||
it("renvoie 0 pour une entrée illisible", () => {
|
||||
expect(parseHumanBytes("n/a")).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseDockerPrune", () => {
|
||||
const RAW = [
|
||||
"===SU:DOCKER_PRUNE===",
|
||||
"Deleted Images:",
|
||||
"untagged: redis:6",
|
||||
"deleted: sha256:aaa",
|
||||
"deleted: sha256:bbb",
|
||||
"",
|
||||
"Total reclaimed space: 1.234GB",
|
||||
"===SU:EXIT=0===",
|
||||
].join("\n");
|
||||
|
||||
it("liste les images supprimées et l'espace récupéré", () => {
|
||||
const r = parseDockerPrune(RAW);
|
||||
expect(r.imagesDeleted).toEqual(["sha256:aaa", "sha256:bbb"]);
|
||||
expect(r.bytesReclaimed).toBe(Math.round(1.234 * 1e9));
|
||||
expect(r.errors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseDockerDown", () => {
|
||||
it("liste les conteneurs retirés", () => {
|
||||
const RAW = [
|
||||
"===SU:DOCKER_DOWN===",
|
||||
" Container media-app-1 Stopping",
|
||||
" Container media-app-1 Stopped",
|
||||
" Container media-app-1 Removing",
|
||||
" Container media-app-1 Removed",
|
||||
" Network media_default Removed",
|
||||
"===SU:EXIT=0===",
|
||||
].join("\n");
|
||||
const r = parseDockerDown(RAW);
|
||||
expect(r.removed).toEqual(["media-app-1"]);
|
||||
expect(r.errors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,289 @@
|
||||
// server/services/dockerApply.ts
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db, schema } from "../db/client.js";
|
||||
import { getMachineRow, getCreds } from "./machines.js";
|
||||
import { renderTemplate } from "../templates/render.js";
|
||||
import { runScriptSudo } from "../ssh/client.js";
|
||||
import { outputHub } from "../ws/outputHub.js";
|
||||
import { cleanDockerError } from "./dockerPull.js";
|
||||
import type { SnapshotError } from "@shared/types.js";
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Fonctions pures (testables).
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
function section(raw: string, start: string, end?: string): string {
|
||||
const i = raw.indexOf(start);
|
||||
if (i < 0) return "";
|
||||
const from = i + start.length;
|
||||
const j = end ? raw.indexOf(end, from) : -1;
|
||||
return raw.slice(from, j < 0 ? undefined : j);
|
||||
}
|
||||
|
||||
const ERROR_RE = /\b(error|unauthorized|denied|forbidden|failed|no such host|connection refused|timeout|cannot)\b/i;
|
||||
|
||||
function collectErrors(text: string, kind: string): SnapshotError[] {
|
||||
const seen = new Set<string>();
|
||||
const out: SnapshotError[] = [];
|
||||
for (const line of text.split("\n")) {
|
||||
if (!ERROR_RE.test(line)) continue;
|
||||
const message = cleanDockerError(line);
|
||||
if (!message || seen.has(message)) continue;
|
||||
seen.add(message);
|
||||
out.push({ source: "docker", kind, severity: "error", message });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function exitOf(raw: string): number | null {
|
||||
const m = /===SU:EXIT=(\d+)===/.exec(raw);
|
||||
return m ? Number(m[1]) : null;
|
||||
}
|
||||
|
||||
export interface DockerApplyParsed {
|
||||
recreated: string[];
|
||||
running: string[];
|
||||
exited: string[];
|
||||
imagesAfter: { id: string | null; digests: string | null }[];
|
||||
errors: SnapshotError[];
|
||||
exitCode: number | null;
|
||||
}
|
||||
|
||||
export function parseDockerApply(raw: string): DockerApplyParsed {
|
||||
const applySec = section(raw, "===SU:DOCKER_APPLY===", "===SU:DOCKER_PS_AFTER===");
|
||||
const psSec = section(raw, "===SU:DOCKER_PS_AFTER===", "===SU:DOCKER_INSPECT_AFTER===");
|
||||
const inspectSec = section(raw, "===SU:DOCKER_INSPECT_AFTER===", "===SU:EXIT=");
|
||||
|
||||
const recreated = new Set<string>();
|
||||
for (const m of applySec.matchAll(/Container\s+(\S+)\s+(Recreated|Created)\s*$/gm)) {
|
||||
if (m[1]) recreated.add(m[1]);
|
||||
}
|
||||
|
||||
const running: string[] = [];
|
||||
const exited: string[] = [];
|
||||
const psLines = psSec.trim();
|
||||
const records: { Name?: string; State?: string }[] = [];
|
||||
if (psLines.startsWith("[")) {
|
||||
try {
|
||||
records.push(...(JSON.parse(psLines) as typeof records));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
} else {
|
||||
for (const line of psLines.split("\n")) {
|
||||
const t = line.trim();
|
||||
if (!t.startsWith("{")) continue;
|
||||
try {
|
||||
records.push(JSON.parse(t));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const r of records) {
|
||||
if (!r.Name) continue;
|
||||
if (r.State === "running") running.push(r.Name);
|
||||
else if (r.State === "exited") exited.push(r.Name);
|
||||
}
|
||||
|
||||
const imagesAfter: DockerApplyParsed["imagesAfter"] = [];
|
||||
for (const line of inspectSec.split("\n")) {
|
||||
if (!line.startsWith("IMG\t")) continue;
|
||||
const parts = line.split("\t");
|
||||
imagesAfter.push({ id: parts[1] || null, digests: parts[2] || null });
|
||||
}
|
||||
|
||||
return {
|
||||
recreated: [...recreated],
|
||||
running,
|
||||
exited,
|
||||
imagesAfter,
|
||||
errors: collectErrors(applySec, "compose_apply_failed"),
|
||||
exitCode: exitOf(raw),
|
||||
};
|
||||
}
|
||||
|
||||
/** Convertit une taille humaine Docker (décimale) en octets. */
|
||||
export function parseHumanBytes(s: string): number {
|
||||
const m = /([\d.]+)\s*([kKMGTP]?i?B)/.exec(s.trim());
|
||||
if (!m) return 0;
|
||||
const value = Number(m[1]);
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
const unit = (m[2] ?? "B").toUpperCase();
|
||||
const mult: Record<string, number> = {
|
||||
B: 1,
|
||||
KB: 1e3,
|
||||
MB: 1e6,
|
||||
GB: 1e9,
|
||||
TB: 1e12,
|
||||
PB: 1e15,
|
||||
};
|
||||
return Math.round(value * (mult[unit] ?? 1));
|
||||
}
|
||||
|
||||
export interface DockerPruneParsed {
|
||||
imagesDeleted: string[];
|
||||
bytesReclaimed: number;
|
||||
errors: SnapshotError[];
|
||||
exitCode: number | null;
|
||||
}
|
||||
|
||||
export function parseDockerPrune(raw: string): DockerPruneParsed {
|
||||
const sec = section(raw, "===SU:DOCKER_PRUNE===", "===SU:EXIT=");
|
||||
const imagesDeleted: string[] = [];
|
||||
let bytesReclaimed = 0;
|
||||
for (const line of sec.split("\n")) {
|
||||
const del = /^deleted:\s+(\S+)/.exec(line.trim());
|
||||
if (del?.[1]) imagesDeleted.push(del[1]);
|
||||
const total = /Total reclaimed space:\s*(.+)$/.exec(line);
|
||||
if (total?.[1]) bytesReclaimed = parseHumanBytes(total[1]);
|
||||
}
|
||||
return { imagesDeleted, bytesReclaimed, errors: collectErrors(sec, "prune_failed"), exitCode: exitOf(raw) };
|
||||
}
|
||||
|
||||
export interface DockerDownParsed {
|
||||
removed: string[];
|
||||
errors: SnapshotError[];
|
||||
exitCode: number | null;
|
||||
}
|
||||
|
||||
export function parseDockerDown(raw: string): DockerDownParsed {
|
||||
const sec = section(raw, "===SU:DOCKER_DOWN===", "===SU:EXIT=");
|
||||
const removed = new Set<string>();
|
||||
for (const m of sec.matchAll(/Container\s+(\S+)\s+Removed\s*$/gm)) {
|
||||
if (m[1]) removed.add(m[1]);
|
||||
}
|
||||
return { removed: [...removed], errors: collectErrors(sec, "compose_down_failed"), exitCode: exitOf(raw) };
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Orchestration (SSH). Réservé aux stacks `enabled` ; déclenché via action_requests.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
function getEnabledStack(machineId: string, stackId: string) {
|
||||
const stack = db
|
||||
.select()
|
||||
.from(schema.dockerComposeStacks)
|
||||
.where(eq(schema.dockerComposeStacks.id, stackId))
|
||||
.get();
|
||||
if (!stack || stack.machineId !== machineId) throw new Error("Stack introuvable");
|
||||
if (stack.status !== "enabled") throw new Error(`Stack non activé (statut ${stack.status})`);
|
||||
return stack;
|
||||
}
|
||||
|
||||
async function runDockerScript(
|
||||
machineId: string,
|
||||
rel: string,
|
||||
vars: Record<string, unknown>,
|
||||
onData?: (c: string) => void,
|
||||
): Promise<string> {
|
||||
const m = getMachineRow(machineId);
|
||||
if (!m) throw new Error("Machine introuvable");
|
||||
const script = renderTemplate(rel, vars);
|
||||
const res = await runScriptSudo(
|
||||
getCreds(m),
|
||||
script,
|
||||
(c) => {
|
||||
onData?.(c);
|
||||
outputHub.publish(machineId, c);
|
||||
},
|
||||
900000,
|
||||
);
|
||||
return res.stdout;
|
||||
}
|
||||
|
||||
export interface ApplyOutcome {
|
||||
parsed: DockerApplyParsed;
|
||||
raw: string;
|
||||
stackName: string;
|
||||
events: typeof schema.dockerImageEvents.$inferInsert[];
|
||||
}
|
||||
|
||||
/** `docker compose up -d --remove-orphans` sur un stack enabled + persistance des events. */
|
||||
export async function applyStack(
|
||||
machineId: string,
|
||||
stackId: string,
|
||||
executionId: string,
|
||||
onData?: (c: string) => void,
|
||||
): Promise<ApplyOutcome> {
|
||||
const stack = getEnabledStack(machineId, stackId);
|
||||
const raw = await runDockerScript(machineId, "docker/apply-compose.sh.tpl", { stackDir: stack.workingDir }, onData);
|
||||
const parsed = parseDockerApply(raw);
|
||||
const now = new Date().toISOString();
|
||||
const events = parsed.recreated.map((name) => ({
|
||||
id: randomUUID(),
|
||||
executionId,
|
||||
machineId,
|
||||
stackId,
|
||||
serviceName: name,
|
||||
imageRef: null,
|
||||
fromImageId: null,
|
||||
toImageId: null,
|
||||
fromDigest: null,
|
||||
toDigest: null,
|
||||
operation: "recreated",
|
||||
bytesReclaimed: null,
|
||||
createdAt: now,
|
||||
}));
|
||||
for (const ev of events) db.insert(schema.dockerImageEvents).values(ev).run();
|
||||
db.update(schema.dockerComposeStacks)
|
||||
.set({ lastUpdateAt: now, updatedAt: now })
|
||||
.where(eq(schema.dockerComposeStacks.id, stackId))
|
||||
.run();
|
||||
return { parsed, raw, stackName: stack.name, events };
|
||||
}
|
||||
|
||||
export interface PruneOutcome {
|
||||
parsed: DockerPruneParsed;
|
||||
raw: string;
|
||||
}
|
||||
|
||||
/** `docker image prune` (safe par défaut, agressif si demandé) + event pruned. */
|
||||
export async function pruneImages(
|
||||
machineId: string,
|
||||
executionId: string,
|
||||
aggressive: boolean,
|
||||
onData?: (c: string) => void,
|
||||
): Promise<PruneOutcome> {
|
||||
const raw = await runDockerScript(machineId, "docker/prune-images.sh.tpl", { aggressive }, onData);
|
||||
const parsed = parseDockerPrune(raw);
|
||||
if (parsed.imagesDeleted.length > 0 || parsed.bytesReclaimed > 0) {
|
||||
db.insert(schema.dockerImageEvents)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
executionId,
|
||||
machineId,
|
||||
stackId: null,
|
||||
serviceName: null,
|
||||
imageRef: null,
|
||||
fromImageId: null,
|
||||
toImageId: null,
|
||||
fromDigest: null,
|
||||
toDigest: null,
|
||||
operation: "pruned",
|
||||
bytesReclaimed: parsed.bytesReclaimed,
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
.run();
|
||||
}
|
||||
return { parsed, raw };
|
||||
}
|
||||
|
||||
export interface DownOutcome {
|
||||
parsed: DockerDownParsed;
|
||||
raw: string;
|
||||
stackName: string;
|
||||
}
|
||||
|
||||
/** `docker compose down` (sans volumes/rmi) sur un stack enabled. */
|
||||
export async function downStack(
|
||||
machineId: string,
|
||||
stackId: string,
|
||||
onData?: (c: string) => void,
|
||||
): Promise<DownOutcome> {
|
||||
const stack = getEnabledStack(machineId, stackId);
|
||||
const raw = await runDockerScript(machineId, "docker/down-compose.sh.tpl", { stackDir: stack.workingDir }, onData);
|
||||
const parsed = parseDockerDown(raw);
|
||||
return { parsed, raw, stackName: stack.name };
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseDockerPullCheck, buildDockerPullResult, dockerDedupKey } from "./dockerPull.js";
|
||||
|
||||
// Stack à 3 images : nginx inchangé, app mis à jour, redis en échec d'auth registry.
|
||||
const RAW = [
|
||||
"===SU:DOCKER_INSPECT_BEFORE===",
|
||||
"BEFORE\tnginx:latest\tsha256:aaa\tnginx@sha256:d1",
|
||||
"BEFORE\tmyorg/app:latest\tsha256:bbb\tmyorg/app@sha256:d2",
|
||||
"BEFORE\tredis:7\tsha256:ccc\tredis@sha256:d3",
|
||||
"===SU:DOCKER_PULL===",
|
||||
"nginx Pulling ",
|
||||
"nginx Pull complete ",
|
||||
"myorg/app Pulling ",
|
||||
"myorg/app Downloaded newer image ",
|
||||
"redis Pulling ",
|
||||
'redis Error response from daemon: Head "https://registry-1.docker.io/v2/library/redis/manifests/7": unauthorized: incorrect username or password for token=AbCdEf123',
|
||||
"===SU:DOCKER_INSPECT_AFTER===",
|
||||
"AFTER\tnginx:latest\tsha256:aaa\tnginx@sha256:d1\t1.25.3",
|
||||
"AFTER\tmyorg/app:latest\tsha256:zzz\tmyorg/app@sha256:d9\t2.4.0",
|
||||
"AFTER\tredis:7\tsha256:ccc\tredis@sha256:d3\t7.2.0",
|
||||
"===SU:EXIT=18===",
|
||||
].join("\n");
|
||||
|
||||
describe("parseDockerPullCheck", () => {
|
||||
it("lit les sections BEFORE / AFTER et le code de sortie", () => {
|
||||
const p = parseDockerPullCheck(RAW);
|
||||
expect(p.exitCode).toBe(18);
|
||||
expect(p.before["myorg/app:latest"]).toEqual({ id: "sha256:bbb", digest: "myorg/app@sha256:d2" });
|
||||
expect(p.after["myorg/app:latest"]).toEqual({
|
||||
id: "sha256:zzz",
|
||||
digest: "myorg/app@sha256:d9",
|
||||
version: "2.4.0",
|
||||
});
|
||||
});
|
||||
|
||||
it("détecte l'erreur d'authentification registry et la nettoie (pas d'URL ni de token)", () => {
|
||||
const p = parseDockerPullCheck(RAW);
|
||||
expect(p.pullErrors.length).toBeGreaterThan(0);
|
||||
const auth = p.pullErrors.find((e) => e.kind === "registry_auth_failed");
|
||||
expect(auth).toBeTruthy();
|
||||
expect(auth!.message).not.toContain("registry-1.docker.io");
|
||||
expect(auth!.message).not.toContain("AbCdEf123");
|
||||
expect(auth!.message.toLowerCase()).toContain("unauthorized");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDockerPullResult", () => {
|
||||
it("classe up_to_date / updates_available par image et n'émet de change que pour les modifiées", () => {
|
||||
const r = buildDockerPullResult("media", RAW);
|
||||
const byImage = new Map(r.services.map((s) => [s.image, s]));
|
||||
|
||||
expect(byImage.get("nginx:latest")!.status).toBe("up_to_date");
|
||||
const app = byImage.get("myorg/app:latest")!;
|
||||
expect(app.status).toBe("updates_available");
|
||||
expect(app.currentImageId).toBe("sha256:bbb");
|
||||
expect(app.candidateImageId).toBe("sha256:zzz");
|
||||
expect(app.candidateVersion).toBe("2.4.0");
|
||||
|
||||
// Un seul change "pulled" (app). nginx inchangé, redis id inchangé.
|
||||
expect(r.changes).toHaveLength(1);
|
||||
expect(r.changes[0]).toMatchObject({
|
||||
stack: "media",
|
||||
imageRef: "myorg/app:latest",
|
||||
fromImageId: "sha256:bbb",
|
||||
toImageId: "sha256:zzz",
|
||||
operation: "pulled",
|
||||
});
|
||||
});
|
||||
|
||||
it("status global = error quand le pull renvoie un code non nul / une erreur", () => {
|
||||
const r = buildDockerPullResult("media", RAW);
|
||||
expect(r.status).toBe("error");
|
||||
expect(r.errors.some((e) => e.source === "docker")).toBe(true);
|
||||
});
|
||||
|
||||
it("status = updates_available sans erreur quand tout réussit", () => {
|
||||
const ok = [
|
||||
"===SU:DOCKER_INSPECT_BEFORE===",
|
||||
"BEFORE\tapp:latest\tsha256:old\tapp@sha256:o",
|
||||
"===SU:DOCKER_PULL===",
|
||||
"app Pulling ",
|
||||
"app Downloaded newer image ",
|
||||
"===SU:DOCKER_INSPECT_AFTER===",
|
||||
"AFTER\tapp:latest\tsha256:new\tapp@sha256:n\t3.0",
|
||||
"===SU:EXIT=0===",
|
||||
].join("\n");
|
||||
const r = buildDockerPullResult("s", ok);
|
||||
expect(r.status).toBe("updates_available");
|
||||
expect(r.errors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dockerDedupKey", () => {
|
||||
it("utilise les digests en priorité", () => {
|
||||
expect(dockerDedupKey("app:latest", "app@sha256:d2", "app@sha256:d9")).toBe(
|
||||
"app:latest|app@sha256:d2|app@sha256:d9",
|
||||
);
|
||||
});
|
||||
|
||||
it("retombe sur les image IDs quand les digests manquent", () => {
|
||||
expect(dockerDedupKey("app:latest", null, null, "sha256:bbb", "sha256:zzz")).toBe(
|
||||
"app:latest|sha256:bbb|sha256:zzz",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
// server/services/dockerPull.ts
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { db, schema } from "../db/client.js";
|
||||
import { getMachineRow, getCreds } from "./machines.js";
|
||||
import { renderTemplate } from "../templates/render.js";
|
||||
import { runScriptSudo } from "../ssh/client.js";
|
||||
import { outputHub } from "../ws/outputHub.js";
|
||||
import type {
|
||||
DockerImageChange,
|
||||
DockerSnapshotService,
|
||||
SnapshotError,
|
||||
SnapshotStatus,
|
||||
} from "@shared/types.js";
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Fonctions pures (testables) : parsing + comparaison déterministe.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
interface ImageInspect {
|
||||
id: string | null;
|
||||
digest: string | null;
|
||||
version?: string | null;
|
||||
}
|
||||
|
||||
export interface DockerPullParsed {
|
||||
before: Record<string, ImageInspect>;
|
||||
after: Record<string, ImageInspect>;
|
||||
pullErrors: SnapshotError[];
|
||||
exitCode: number | null;
|
||||
}
|
||||
|
||||
export interface DockerPullResult {
|
||||
services: DockerSnapshotService[];
|
||||
changes: DockerImageChange[];
|
||||
errors: SnapshotError[];
|
||||
status: SnapshotStatus;
|
||||
exitCode: number | null;
|
||||
}
|
||||
|
||||
const nz = (s: string | undefined): string | null => (s && s.length ? s : null);
|
||||
|
||||
/** Retire URLs et secrets (token/bearer/password) d'une ligne d'erreur Docker. */
|
||||
export function cleanDockerError(line: string): string {
|
||||
return line
|
||||
.replace(/https?:\/\/\S+/gi, "<url>")
|
||||
.replace(/\b(token|bearer|authorization|auth|password|passwd|secret|key)=\S+/gi, "$1=<redacted>")
|
||||
.replace(/\bBearer\s+\S+/gi, "Bearer <redacted>")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function section(raw: string, start: string, end?: string): string {
|
||||
const i = raw.indexOf(start);
|
||||
if (i < 0) return "";
|
||||
const from = i + start.length;
|
||||
const j = end ? raw.indexOf(end, from) : -1;
|
||||
return raw.slice(from, j < 0 ? undefined : j);
|
||||
}
|
||||
|
||||
export function parseDockerPullCheck(raw: string): DockerPullParsed {
|
||||
const before: Record<string, ImageInspect> = {};
|
||||
const after: Record<string, ImageInspect> = {};
|
||||
|
||||
for (const line of section(raw, "===SU:DOCKER_INSPECT_BEFORE===", "===SU:DOCKER_PULL===").split("\n")) {
|
||||
if (!line.startsWith("BEFORE\t")) continue;
|
||||
const [, img, id, dg] = line.split("\t");
|
||||
if (img) before[img] = { id: nz(id), digest: nz(dg) };
|
||||
}
|
||||
for (const line of section(raw, "===SU:DOCKER_INSPECT_AFTER===", "===SU:EXIT=").split("\n")) {
|
||||
if (!line.startsWith("AFTER\t")) continue;
|
||||
const [, img, id, dg, ver] = line.split("\t");
|
||||
if (img) after[img] = { id: nz(id), digest: nz(dg), version: nz(ver) };
|
||||
}
|
||||
|
||||
const pullSection = section(raw, "===SU:DOCKER_PULL===", "===SU:DOCKER_INSPECT_AFTER===");
|
||||
const seen = new Set<string>();
|
||||
const pullErrors: SnapshotError[] = [];
|
||||
for (const line of pullSection.split("\n")) {
|
||||
if (!/\b(error|unauthorized|denied|forbidden|failed to|no such host|connection refused|timeout)\b/i.test(line)) {
|
||||
continue;
|
||||
}
|
||||
const message = cleanDockerError(line);
|
||||
if (!message || seen.has(message)) continue;
|
||||
seen.add(message);
|
||||
const kind = /\b(unauthorized|authentication required|denied|forbidden|incorrect username)\b/i.test(line)
|
||||
? "registry_auth_failed"
|
||||
: "pull_failed";
|
||||
pullErrors.push({ source: "docker", kind, severity: "error", message });
|
||||
}
|
||||
|
||||
const exitMatch = /===SU:EXIT=(\d+)===/.exec(raw);
|
||||
const exitCode = exitMatch ? Number(exitMatch[1]) : null;
|
||||
return { before, after, pullErrors, exitCode };
|
||||
}
|
||||
|
||||
/** Compare BEFORE/AFTER et construit le résultat canonique (services + changes). */
|
||||
export function buildDockerPullResult(stackName: string, raw: string): DockerPullResult {
|
||||
const { before, after, pullErrors, exitCode } = parseDockerPullCheck(raw);
|
||||
const services: DockerSnapshotService[] = [];
|
||||
const changes: DockerImageChange[] = [];
|
||||
|
||||
const images = Array.from(new Set([...Object.keys(before), ...Object.keys(after)])).sort();
|
||||
for (const img of images) {
|
||||
const b = before[img];
|
||||
const a = after[img];
|
||||
const fromId = b?.id ?? null;
|
||||
const toId = a?.id ?? null;
|
||||
const fromDigest = b?.digest ?? null;
|
||||
const toDigest = a?.digest ?? null;
|
||||
const candidateVersion = a?.version ?? null;
|
||||
|
||||
const changed =
|
||||
(!!toId && !!fromId && toId !== fromId) ||
|
||||
(!!toDigest && !!fromDigest && toDigest !== fromDigest);
|
||||
|
||||
const status: NonNullable<DockerSnapshotService["status"]> = !a
|
||||
? "error"
|
||||
: changed
|
||||
? "updates_available"
|
||||
: "up_to_date";
|
||||
|
||||
services.push({
|
||||
serviceName: img,
|
||||
image: img,
|
||||
currentImageId: fromId,
|
||||
currentDigest: fromDigest,
|
||||
candidateImageId: toId,
|
||||
candidateDigest: toDigest,
|
||||
currentVersion: null,
|
||||
candidateVersion,
|
||||
status,
|
||||
});
|
||||
|
||||
if (changed) {
|
||||
changes.push({
|
||||
stack: stackName,
|
||||
serviceName: img,
|
||||
imageRef: img,
|
||||
fromImageId: fromId,
|
||||
toImageId: toId,
|
||||
fromDigest,
|
||||
toDigest,
|
||||
operation: "pulled",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const hasFailure = pullErrors.length > 0 || (exitCode !== null && exitCode !== 0);
|
||||
const status: SnapshotStatus = hasFailure
|
||||
? "error"
|
||||
: services.some((s) => s.status === "updates_available")
|
||||
? "updates_available"
|
||||
: "ok";
|
||||
|
||||
return { services, changes, errors: pullErrors, status, exitCode };
|
||||
}
|
||||
|
||||
/** Empreinte de déduplication Docker : digests prioritaires, fallback image IDs. */
|
||||
export function dockerDedupKey(
|
||||
image: string,
|
||||
fromDigest: string | null,
|
||||
toDigest: string | null,
|
||||
fromId?: string | null,
|
||||
toId?: string | null,
|
||||
): string {
|
||||
if (fromDigest || toDigest) return `${image}|${fromDigest ?? ""}|${toDigest ?? ""}`;
|
||||
return `${image}|${fromId ?? ""}|${toId ?? ""}`;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Orchestration : pull-check d'un stack (SSH + persistance).
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
export interface PullCheckOutcome {
|
||||
result: DockerPullResult;
|
||||
raw: string;
|
||||
stackName: string;
|
||||
}
|
||||
|
||||
/** Lance `docker compose pull` sur un stack `enabled`, compare et persiste les services. */
|
||||
export async function pullCheckStack(
|
||||
machineId: string,
|
||||
stackId: string,
|
||||
onData?: (chunk: string) => void,
|
||||
): Promise<PullCheckOutcome> {
|
||||
const m = getMachineRow(machineId);
|
||||
if (!m) throw new Error("Machine introuvable");
|
||||
const stack = db
|
||||
.select()
|
||||
.from(schema.dockerComposeStacks)
|
||||
.where(eq(schema.dockerComposeStacks.id, stackId))
|
||||
.get();
|
||||
if (!stack || stack.machineId !== machineId) throw new Error("Stack introuvable");
|
||||
if (stack.status !== "enabled") {
|
||||
throw new Error(`Stack non activé (statut ${stack.status}) : pull-check refusé`);
|
||||
}
|
||||
|
||||
const script = renderTemplate("docker/pull-check.sh.tpl", { stackDir: stack.workingDir });
|
||||
const res = await runScriptSudo(
|
||||
getCreds(m),
|
||||
script,
|
||||
(c) => {
|
||||
onData?.(c);
|
||||
outputHub.publish(machineId, c);
|
||||
},
|
||||
900000,
|
||||
);
|
||||
const raw = res.stdout;
|
||||
const result = buildDockerPullResult(stack.name, raw);
|
||||
|
||||
// Persistance des services (upsert par stackId + serviceName).
|
||||
const now = new Date().toISOString();
|
||||
for (const s of result.services) {
|
||||
const existing = db
|
||||
.select()
|
||||
.from(schema.dockerStackServices)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.dockerStackServices.stackId, stackId),
|
||||
eq(schema.dockerStackServices.serviceName, s.serviceName),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
const fields = {
|
||||
imageRef: s.image,
|
||||
currentImageId: s.currentImageId ?? null,
|
||||
currentDigest: s.currentDigest ?? null,
|
||||
candidateImageId: s.candidateImageId ?? null,
|
||||
candidateDigest: s.candidateDigest ?? null,
|
||||
versionLabel: s.candidateVersion ?? null,
|
||||
status: s.status ?? null,
|
||||
updatedAt: now,
|
||||
};
|
||||
if (existing) {
|
||||
db.update(schema.dockerStackServices).set(fields).where(eq(schema.dockerStackServices.id, existing.id)).run();
|
||||
} else {
|
||||
db.insert(schema.dockerStackServices)
|
||||
.values({ id: randomUUID(), stackId, serviceName: s.serviceName, ...fields })
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
db.update(schema.dockerComposeStacks)
|
||||
.set({ lastUpdateAt: now, updatedAt: now })
|
||||
.where(eq(schema.dockerComposeStacks.id, stackId))
|
||||
.run();
|
||||
db.update(schema.dockerSettings)
|
||||
.set({ lastPullCheckAt: now, updatedAt: now })
|
||||
.where(eq(schema.dockerSettings.machineId, machineId))
|
||||
.run();
|
||||
|
||||
return { result, raw, stackName: stack.name };
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// server/services/dockerScan.test.ts
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseDockerScan } from "./dockerScan.js";
|
||||
|
||||
const raw = [
|
||||
"===SU:DOCKER_SCAN===",
|
||||
"STACK_OK\tdir=/opt/stacks/media\tfile=/opt/stacks/media/compose.yaml",
|
||||
"STACK_INVALID\tdir=/opt/stacks/broken\tfile=/opt/stacks/broken/compose.yml",
|
||||
"===SU:DOCKER_LABELS===",
|
||||
"ACTIVE\tproject=media\tworking_dir=/opt/stacks/media",
|
||||
"===SU:EXIT=0===",
|
||||
].join("\n");
|
||||
|
||||
describe("parseDockerScan", () => {
|
||||
it("extrait stacks valides/invalides et actifs", () => {
|
||||
const r = parseDockerScan(raw);
|
||||
expect(r.stacks).toEqual([
|
||||
{ workingDir: "/opt/stacks/media", composeFile: "/opt/stacks/media/compose.yaml", valid: true },
|
||||
{ workingDir: "/opt/stacks/broken", composeFile: "/opt/stacks/broken/compose.yml", valid: false },
|
||||
]);
|
||||
expect(r.active).toEqual([{ project: "media", workingDir: "/opt/stacks/media" }]);
|
||||
});
|
||||
|
||||
it("retourne des listes vides si rien n'est trouvé", () => {
|
||||
const r = parseDockerScan("===SU:DOCKER_SCAN===\n===SU:DOCKER_LABELS===\n===SU:EXIT=0===");
|
||||
expect(r.stacks).toHaveLength(0);
|
||||
expect(r.active).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
// server/services/dockerScan.ts
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { basename } from "node:path";
|
||||
import { db, schema } from "../db/client.js";
|
||||
import { getMachineRow, getCreds } from "./machines.js";
|
||||
import { renderTemplate } from "../templates/render.js";
|
||||
import { runScriptSudo } from "../ssh/client.js";
|
||||
import { outputHub } from "../ws/outputHub.js";
|
||||
|
||||
export interface DockerScanResult {
|
||||
stacks: { workingDir: string; composeFile: string; valid: boolean }[];
|
||||
active: { project: string; workingDir: string }[];
|
||||
}
|
||||
|
||||
function fields(line: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const part of line.split("\t")) {
|
||||
const i = part.indexOf("=");
|
||||
if (i > 0) out[part.slice(0, i)] = part.slice(i + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseDockerScan(raw: string): DockerScanResult {
|
||||
const stacks: DockerScanResult["stacks"] = [];
|
||||
const active: DockerScanResult["active"] = [];
|
||||
for (const line of raw.split("\n")) {
|
||||
const l = line.trimEnd();
|
||||
if (l.startsWith("STACK_OK\t") || l.startsWith("STACK_INVALID\t")) {
|
||||
const f = fields(l);
|
||||
stacks.push({ workingDir: f.dir ?? "", composeFile: f.file ?? "", valid: l.startsWith("STACK_OK") });
|
||||
} else if (l.startsWith("ACTIVE\t")) {
|
||||
const f = fields(l);
|
||||
active.push({ project: f.project ?? "", workingDir: f.working_dir ?? "" });
|
||||
}
|
||||
}
|
||||
return { stacks, active };
|
||||
}
|
||||
|
||||
/** Racines Compose déclarées (enabled) d'une machine. */
|
||||
export function getComposeRoots(machineId: string): string[] {
|
||||
return db
|
||||
.select()
|
||||
.from(schema.dockerComposeRoots)
|
||||
.where(eq(schema.dockerComposeRoots.machineId, machineId))
|
||||
.all()
|
||||
.filter((r) => r.enabled)
|
||||
.map((r) => r.path);
|
||||
}
|
||||
|
||||
/** Paramètres Docker + racines déclarées d'une machine. */
|
||||
export function getDockerSettings(machineId: string) {
|
||||
const settings = db
|
||||
.select()
|
||||
.from(schema.dockerSettings)
|
||||
.where(eq(schema.dockerSettings.machineId, machineId))
|
||||
.get();
|
||||
const roots = db
|
||||
.select()
|
||||
.from(schema.dockerComposeRoots)
|
||||
.where(eq(schema.dockerComposeRoots.machineId, machineId))
|
||||
.all();
|
||||
return { settings: settings ?? null, roots };
|
||||
}
|
||||
|
||||
/** Liste les stacks d'une machine avec leurs services. */
|
||||
export function listStacks(machineId: string) {
|
||||
const stacks = db
|
||||
.select()
|
||||
.from(schema.dockerComposeStacks)
|
||||
.where(eq(schema.dockerComposeStacks.machineId, machineId))
|
||||
.all();
|
||||
return stacks.map((s) => ({
|
||||
...s,
|
||||
composeFiles: safeParseArray(s.composeFilesJson),
|
||||
services: db
|
||||
.select()
|
||||
.from(schema.dockerStackServices)
|
||||
.where(eq(schema.dockerStackServices.stackId, s.id))
|
||||
.all(),
|
||||
}));
|
||||
}
|
||||
|
||||
const STACK_STATUSES = ["candidate", "enabled", "ignored", "error"] as const;
|
||||
export type StackStatus = (typeof STACK_STATUSES)[number];
|
||||
|
||||
/** Change le cycle de vie d'un stack (candidate → enabled → …). */
|
||||
export function setStackStatus(machineId: string, stackId: string, status: StackStatus) {
|
||||
if (!STACK_STATUSES.includes(status)) throw new Error(`Statut invalide : ${status}`);
|
||||
const stack = db
|
||||
.select()
|
||||
.from(schema.dockerComposeStacks)
|
||||
.where(eq(schema.dockerComposeStacks.id, stackId))
|
||||
.get();
|
||||
if (!stack || stack.machineId !== machineId) throw new Error("Stack introuvable");
|
||||
db.update(schema.dockerComposeStacks)
|
||||
.set({ status, updatedAt: new Date().toISOString() })
|
||||
.where(eq(schema.dockerComposeStacks.id, stackId))
|
||||
.run();
|
||||
return db
|
||||
.select()
|
||||
.from(schema.dockerComposeStacks)
|
||||
.where(eq(schema.dockerComposeStacks.id, stackId))
|
||||
.get();
|
||||
}
|
||||
|
||||
function safeParseArray(json: string): string[] {
|
||||
try {
|
||||
const v = JSON.parse(json);
|
||||
return Array.isArray(v) ? (v as string[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Déclare/active Docker pour une machine + ses racines Compose (idempotent). */
|
||||
export function setDockerRoots(machineId: string, paths: string[], scanDepth = 4): void {
|
||||
const now = new Date().toISOString();
|
||||
db.insert(schema.dockerSettings)
|
||||
.values({ machineId, enabled: 1, scanDepth, pruneMode: "safe", updatedAt: now })
|
||||
.onConflictDoUpdate({ target: schema.dockerSettings.machineId, set: { enabled: 1, scanDepth, updatedAt: now } })
|
||||
.run();
|
||||
db.delete(schema.dockerComposeRoots).where(eq(schema.dockerComposeRoots.machineId, machineId)).run();
|
||||
for (const path of paths) {
|
||||
db.insert(schema.dockerComposeRoots).values({
|
||||
id: randomUUID(),
|
||||
machineId,
|
||||
path,
|
||||
enabled: 1,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).run();
|
||||
}
|
||||
}
|
||||
|
||||
/** Scanne les racines déclarées et upsert les stacks candidats. Renvoie le résultat parsé. */
|
||||
export async function scanDockerStacks(machineId: string): Promise<DockerScanResult> {
|
||||
const m = getMachineRow(machineId);
|
||||
if (!m) throw new Error("Machine introuvable");
|
||||
const roots = getComposeRoots(machineId);
|
||||
const settings = db
|
||||
.select()
|
||||
.from(schema.dockerSettings)
|
||||
.where(eq(schema.dockerSettings.machineId, machineId))
|
||||
.get();
|
||||
const depth = settings?.scanDepth ?? 4;
|
||||
if (roots.length === 0) return { stacks: [], active: [] };
|
||||
|
||||
const script = renderTemplate("docker/scan-compose.sh.tpl", {
|
||||
composeRoots: roots.join(" "),
|
||||
composeScanDepth: depth,
|
||||
});
|
||||
let raw = "";
|
||||
const res = await runScriptSudo(getCreds(m), script, (c) => {
|
||||
raw += c;
|
||||
outputHub.publish(machineId, c);
|
||||
});
|
||||
raw = res.stdout;
|
||||
const parsed = parseDockerScan(raw);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const activeDirs = new Set(parsed.active.map((a) => a.workingDir));
|
||||
for (const s of parsed.stacks) {
|
||||
if (!s.valid) continue;
|
||||
const name = basename(s.workingDir);
|
||||
const existing = db
|
||||
.select()
|
||||
.from(schema.dockerComposeStacks)
|
||||
.where(eq(schema.dockerComposeStacks.workingDir, s.workingDir))
|
||||
.get();
|
||||
const detectedBy = activeDirs.has(s.workingDir) ? "label" : "root_scan";
|
||||
if (existing) {
|
||||
db.update(schema.dockerComposeStacks)
|
||||
.set({ lastScanAt: now, detectedBy, updatedAt: now })
|
||||
.where(eq(schema.dockerComposeStacks.id, existing.id))
|
||||
.run();
|
||||
} else {
|
||||
db.insert(schema.dockerComposeStacks).values({
|
||||
id: randomUUID(),
|
||||
machineId,
|
||||
name,
|
||||
workingDir: s.workingDir,
|
||||
composeFilesJson: JSON.stringify([s.composeFile]),
|
||||
status: "candidate",
|
||||
detectedBy,
|
||||
lastScanAt: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).run();
|
||||
}
|
||||
}
|
||||
db.update(schema.dockerSettings)
|
||||
.set({ lastScanAt: now, updatedAt: now })
|
||||
.where(eq(schema.dockerSettings.machineId, machineId))
|
||||
.run();
|
||||
return parsed;
|
||||
}
|
||||
+396
-13
@@ -6,28 +6,113 @@ import { join } from "node:path";
|
||||
import { db, schema } from "../db/client.js";
|
||||
import { env } from "../env.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 { parseRebootRequired, buildAptExecutionResult } from "./aptParse.js";
|
||||
import { parseBootIdBefore, verifyReboot } from "./rebootVerify.js";
|
||||
import type { RebootResult } from "@shared/types.js";
|
||||
import { extractSection } from "./refresh.js";
|
||||
import { extractSection, refreshMachine } from "./refresh.js";
|
||||
import { buildReportMarkdown } from "./report.js";
|
||||
import { outputHub } from "../ws/outputHub.js";
|
||||
import { upsertMachineState, recordEvent } from "./machineState.js";
|
||||
import type { ActionType, AptExecutionResult, ExecutionResult, ExecutionStatus } from "@shared/types.js";
|
||||
|
||||
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",
|
||||
// Actions APT/système résolues par profil OS (resolveTemplate → proxmox/raspbian si dispo,
|
||||
// sinon fallback apt/). La valeur est le basename d'action (sans dossier ni extension).
|
||||
const APT_ACTION_FILE: Partial<Record<ActionType, string>> = {
|
||||
apt_full_upgrade: "full-upgrade",
|
||||
apt_upgrade: "upgrade",
|
||||
apt_autoremove: "autoremove",
|
||||
apt_clean: "clean",
|
||||
reboot: "reboot",
|
||||
reboot_verified: "reboot",
|
||||
apt_proxy_persistent: "apt-proxy-persistent",
|
||||
};
|
||||
|
||||
export async function runAction(machineId: string, action: ActionType): Promise<ExecutionResult> {
|
||||
export interface RunActionOpts {
|
||||
stackId?: string;
|
||||
aggressive?: boolean; // docker_prune_images
|
||||
profileId?: string; // post_install
|
||||
values?: Record<string, string | number | boolean | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive une exécution terminée (log brut + rapport + lignes DB + état machine +
|
||||
* event) et renvoie l'ExecutionResult. Mutualise le boilerplate des branches Docker.
|
||||
*/
|
||||
function archiveExecution(args: {
|
||||
machineId: string;
|
||||
machineName: string;
|
||||
executionId: string;
|
||||
action: ActionType;
|
||||
startedAt: string;
|
||||
status: ExecutionStatus;
|
||||
raw: string;
|
||||
importantLines: string[];
|
||||
docker?: ExecutionResult["docker"];
|
||||
postInstall?: ExecutionResult["postInstall"];
|
||||
reboot?: ExecutionResult["reboot"];
|
||||
errors?: ExecutionResult["errors"];
|
||||
}): ExecutionResult {
|
||||
const { machineId, machineName, executionId, action, startedAt, status, raw, importantLines } = args;
|
||||
const finishedAt = new Date().toISOString();
|
||||
const dir = join(env.reportsDir, machineId);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const rawLogPath = join(dir, `${executionId}.log`);
|
||||
const reportPath = join(dir, `${executionId}.md`);
|
||||
writeFileSync(rawLogPath, raw || importantLines.join("\n") + "\n", "utf8");
|
||||
const result: ExecutionResult = {
|
||||
executionId, machineId, startedAt, finishedAt, mode: "manual", action, status,
|
||||
rebootRequiredAfterRun: false,
|
||||
importantLogLines: importantLines,
|
||||
rawLogRef: rawLogPath, reportRef: reportPath,
|
||||
...(args.docker ? { docker: args.docker } : {}),
|
||||
...(args.postInstall ? { postInstall: args.postInstall } : {}),
|
||||
...(args.reboot ? { reboot: args.reboot } : {}),
|
||||
...(args.errors && args.errors.length ? { errors: args.errors } : {}),
|
||||
};
|
||||
writeFileSync(reportPath, buildReportMarkdown(result, machineName), "utf8");
|
||||
const reportId = randomUUID();
|
||||
db.update(schema.executions).set({
|
||||
finishedAt, status, schemaVersion: 1,
|
||||
resultJson: JSON.stringify(result), importantJson: JSON.stringify(importantLines),
|
||||
reportPath, rawLogPath, reportId,
|
||||
exitCode: status === "ok" ? 0 : 1,
|
||||
errorKind: status === "error" ? "execution_failed" : null,
|
||||
errorMessage: status === "error" ? (importantLines.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: `${machineName} — ${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" ? (importantLines.at(-1) ?? null) : null,
|
||||
});
|
||||
recordEvent({
|
||||
machineId, eventType: `action_${action}`,
|
||||
severity: status === "error" ? "error" : "info",
|
||||
executionId, message: `Action ${action} : ${status}`,
|
||||
});
|
||||
outputHub.publish(machineId, `\n===SU:DONE status=${status}===\n`);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function runAction(
|
||||
machineId: string,
|
||||
action: ActionType,
|
||||
opts?: RunActionOpts,
|
||||
): Promise<ExecutionResult> {
|
||||
const m = getMachineRow(machineId);
|
||||
if (!m) throw new Error("Machine introuvable");
|
||||
|
||||
@@ -40,10 +125,295 @@ export async function runAction(machineId: string, action: ActionType): Promise<
|
||||
}).run();
|
||||
upsertMachineState(machineId, { status: "running", runningJobId: executionId });
|
||||
|
||||
// --- SJ-4 : docker_scan délégué au service dédié (évite un double rendu sans racines) ---
|
||||
if (action === "docker_scan") {
|
||||
const { scanDockerStacks } = await import("./dockerScan.js");
|
||||
const startedAtDocker = startedAt;
|
||||
let scanStatus: ExecutionStatus = "ok";
|
||||
let scanSummaryLines: string[] = [];
|
||||
try {
|
||||
const parsed = await scanDockerStacks(machineId);
|
||||
scanSummaryLines = [
|
||||
`docker_scan: ${parsed.stacks.length} stacks trouvées (${parsed.stacks.filter((s) => s.valid).length} valides)`,
|
||||
...parsed.stacks.map((s) => ` ${s.valid ? "OK" : "INVALID"} ${s.workingDir}`),
|
||||
...parsed.active.map((a) => ` ACTIVE project=${a.project} dir=${a.workingDir}`),
|
||||
];
|
||||
outputHub.publish(machineId, `\n===SU:DONE status=ok stacks=${parsed.stacks.length}===\n`);
|
||||
} catch (err) {
|
||||
scanStatus = "error";
|
||||
scanSummaryLines = [`[ERREUR] ${(err as Error).message}`];
|
||||
outputHub.publish(machineId, `\n[ERREUR] ${(err as Error).message}\n`);
|
||||
}
|
||||
const finishedAtDocker = new Date().toISOString();
|
||||
const rawDocker = scanSummaryLines.join("\n") + "\n";
|
||||
const dirDocker = join(env.reportsDir, machineId);
|
||||
mkdirSync(dirDocker, { recursive: true });
|
||||
const rawLogPathDocker = join(dirDocker, `${executionId}.log`);
|
||||
const reportPathDocker = join(dirDocker, `${executionId}.md`);
|
||||
writeFileSync(rawLogPathDocker, rawDocker, "utf8");
|
||||
const resultDocker: ExecutionResult = {
|
||||
executionId, machineId, startedAt: startedAtDocker, finishedAt: finishedAtDocker,
|
||||
mode: "manual", action, status: scanStatus,
|
||||
rebootRequiredAfterRun: false,
|
||||
importantLogLines: scanSummaryLines,
|
||||
rawLogRef: rawLogPathDocker, reportRef: reportPathDocker,
|
||||
};
|
||||
writeFileSync(reportPathDocker, buildReportMarkdown(resultDocker, m.name), "utf8");
|
||||
const reportIdDocker = randomUUID();
|
||||
db.update(schema.executions).set({
|
||||
finishedAt: finishedAtDocker, status: scanStatus, schemaVersion: 1,
|
||||
resultJson: JSON.stringify(resultDocker), importantJson: JSON.stringify(scanSummaryLines),
|
||||
reportPath: reportPathDocker, rawLogPath: rawLogPathDocker, reportId: reportIdDocker,
|
||||
exitCode: scanStatus === "ok" ? 0 : 1,
|
||||
errorKind: scanStatus === "error" ? "execution_failed" : null,
|
||||
errorMessage: scanStatus === "error" ? (scanSummaryLines.at(-1) ?? null) : null,
|
||||
}).where(eq(schema.executions.id, executionId)).run();
|
||||
db.update(schema.machines).set({ status: scanStatus === "error" ? "error" : "unknown" })
|
||||
.where(eq(schema.machines.id, machineId)).run();
|
||||
db.insert(schema.reports).values({
|
||||
id: reportIdDocker, machineId, executionId, kind: "machine",
|
||||
title: `${m.name} — docker_scan`, path: reportPathDocker, createdAt: finishedAtDocker,
|
||||
}).run();
|
||||
db.insert(schema.rawArtifacts).values({
|
||||
id: randomUUID(), machineId, kind: "raw_log", path: rawLogPathDocker,
|
||||
bytes: statSync(rawLogPathDocker).size,
|
||||
createdAt: finishedAtDocker,
|
||||
retentionPolicy: scanStatus === "error" ? "failed" : "default",
|
||||
}).run();
|
||||
upsertMachineState(machineId, {
|
||||
status: scanStatus === "error" ? "error" : "unknown",
|
||||
runningJobId: null,
|
||||
lastErrorKind: scanStatus === "error" ? "execution_failed" : null,
|
||||
lastErrorMessage: scanStatus === "error" ? (scanSummaryLines.at(-1) ?? null) : null,
|
||||
});
|
||||
recordEvent({
|
||||
machineId, eventType: "action_docker_scan",
|
||||
severity: scanStatus === "error" ? "error" : "info",
|
||||
executionId, message: `Action docker_scan : ${scanStatus}`,
|
||||
});
|
||||
return resultDocker;
|
||||
}
|
||||
|
||||
// --- SJ-5 : docker_pull_check délégué au service dédié (pull + comparaison + persistance) ---
|
||||
if (action === "docker_pull_check") {
|
||||
if (!opts?.stackId) throw new Error("docker_pull_check requiert un stackId");
|
||||
const { pullCheckStack, dockerDedupKey } = await import("./dockerPull.js");
|
||||
let rawPull = "";
|
||||
let pullStatus: ExecutionStatus = "ok";
|
||||
let importantPull: string[] = [];
|
||||
let dockerExec: ExecutionResult["docker"];
|
||||
try {
|
||||
const outcome = await pullCheckStack(machineId, opts.stackId, (c) => {
|
||||
rawPull += c;
|
||||
});
|
||||
rawPull = outcome.raw;
|
||||
const r = outcome.result;
|
||||
pullStatus = r.status === "error" ? "error" : r.status === "warning" ? "warning" : "ok";
|
||||
const changes = r.changes.map((ch) => ({
|
||||
...ch,
|
||||
dedupKey: dockerDedupKey(ch.imageRef ?? ch.stack, ch.fromDigest ?? null, ch.toDigest ?? null, ch.fromImageId ?? null, ch.toImageId ?? null),
|
||||
}));
|
||||
dockerExec = { pull: { changes, ...(r.errors.length ? { errors: r.errors } : {}) } };
|
||||
importantPull = [
|
||||
`docker_pull_check ${outcome.stackName} : ${r.changes.length} image(s) mise(s) à jour (${r.services.length} service(s))`,
|
||||
...r.services.map((s) => ` ${s.status} ${s.image}`),
|
||||
...r.errors.map((e) => ` [${e.kind}] ${e.message}`),
|
||||
];
|
||||
outputHub.publish(machineId, `\n===SU:DONE status=${pullStatus} changes=${r.changes.length}===\n`);
|
||||
} catch (err) {
|
||||
pullStatus = "error";
|
||||
importantPull = [`[ERREUR] ${(err as Error).message}`];
|
||||
rawPull += `\n[ERREUR] ${(err as Error).message}\n`;
|
||||
outputHub.publish(machineId, `\n[ERREUR] ${(err as Error).message}\n`);
|
||||
}
|
||||
const finishedAtPull = new Date().toISOString();
|
||||
const dirPull = join(env.reportsDir, machineId);
|
||||
mkdirSync(dirPull, { recursive: true });
|
||||
const rawLogPathPull = join(dirPull, `${executionId}.log`);
|
||||
const reportPathPull = join(dirPull, `${executionId}.md`);
|
||||
writeFileSync(rawLogPathPull, rawPull || importantPull.join("\n") + "\n", "utf8");
|
||||
const resultPull: ExecutionResult = {
|
||||
executionId, machineId, startedAt, finishedAt: finishedAtPull,
|
||||
mode: "manual", action, status: pullStatus,
|
||||
rebootRequiredAfterRun: false,
|
||||
importantLogLines: importantPull,
|
||||
rawLogRef: rawLogPathPull, reportRef: reportPathPull,
|
||||
...(dockerExec ? { docker: dockerExec } : {}),
|
||||
};
|
||||
writeFileSync(reportPathPull, buildReportMarkdown(resultPull, m.name), "utf8");
|
||||
const reportIdPull = randomUUID();
|
||||
db.update(schema.executions).set({
|
||||
finishedAt: finishedAtPull, status: pullStatus, schemaVersion: 1,
|
||||
resultJson: JSON.stringify(resultPull), importantJson: JSON.stringify(importantPull),
|
||||
reportPath: reportPathPull, rawLogPath: rawLogPathPull, reportId: reportIdPull,
|
||||
exitCode: pullStatus === "ok" ? 0 : 1,
|
||||
errorKind: pullStatus === "error" ? "execution_failed" : null,
|
||||
errorMessage: pullStatus === "error" ? (importantPull.at(-1) ?? null) : null,
|
||||
}).where(eq(schema.executions.id, executionId)).run();
|
||||
db.update(schema.machines).set({ status: pullStatus === "error" ? "error" : "unknown" })
|
||||
.where(eq(schema.machines.id, machineId)).run();
|
||||
db.insert(schema.reports).values({
|
||||
id: reportIdPull, machineId, executionId, kind: "machine",
|
||||
title: `${m.name} — docker_pull_check`, path: reportPathPull, createdAt: finishedAtPull,
|
||||
}).run();
|
||||
db.insert(schema.rawArtifacts).values({
|
||||
id: randomUUID(), machineId, kind: "raw_log", path: rawLogPathPull,
|
||||
bytes: statSync(rawLogPathPull).size, createdAt: finishedAtPull,
|
||||
retentionPolicy: pullStatus === "error" ? "failed" : "default",
|
||||
}).run();
|
||||
upsertMachineState(machineId, {
|
||||
status: pullStatus === "error" ? "error" : "unknown",
|
||||
runningJobId: null,
|
||||
lastErrorKind: pullStatus === "error" ? "execution_failed" : null,
|
||||
lastErrorMessage: pullStatus === "error" ? (importantPull.at(-1) ?? null) : null,
|
||||
});
|
||||
recordEvent({
|
||||
machineId, eventType: "action_docker_pull_check",
|
||||
severity: pullStatus === "error" ? "error" : "info",
|
||||
executionId, message: `Action docker_pull_check : ${pullStatus}`,
|
||||
});
|
||||
return resultPull;
|
||||
}
|
||||
|
||||
// --- SJ-6 : actions Docker destructives (apply / prune / down) ---
|
||||
if (action === "docker_compose_apply") {
|
||||
if (!opts?.stackId) throw new Error("docker_compose_apply requiert un stackId");
|
||||
const { applyStack } = await import("./dockerApply.js");
|
||||
try {
|
||||
const o = await applyStack(machineId, opts.stackId, executionId, (c) => outputHub.publish(machineId, c));
|
||||
const p = o.parsed;
|
||||
const status: ExecutionStatus = p.errors.length || (p.exitCode !== null && p.exitCode !== 0) ? "error" : "ok";
|
||||
const important = [
|
||||
`docker_compose_apply ${o.stackName} : ${p.recreated.length} recréé(s), ${p.running.length} running, ${p.exited.length} exited`,
|
||||
...p.recreated.map((n) => ` recreated ${n}`),
|
||||
...p.exited.map((n) => ` exited ${n}`),
|
||||
...p.errors.map((e) => ` [${e.kind}] ${e.message}`),
|
||||
];
|
||||
return archiveExecution({
|
||||
machineId, machineName: m.name, executionId, action, startedAt, status, raw: o.raw,
|
||||
importantLines: important, docker: { up: { recreated: p.recreated, running: p.running, exited: p.exited, ...(p.errors.length ? { errors: p.errors } : {}) } },
|
||||
});
|
||||
} catch (err) {
|
||||
return archiveExecution({ machineId, machineName: m.name, executionId, action, startedAt, status: "error", raw: "", importantLines: [`[ERREUR] ${(err as Error).message}`] });
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "docker_prune_images") {
|
||||
const { pruneImages } = await import("./dockerApply.js");
|
||||
try {
|
||||
const o = await pruneImages(machineId, executionId, !!opts?.aggressive, (c) => outputHub.publish(machineId, c));
|
||||
const p = o.parsed;
|
||||
const status: ExecutionStatus = p.errors.length || (p.exitCode !== null && p.exitCode !== 0) ? "error" : "ok";
|
||||
const mb = (p.bytesReclaimed / 1e6).toFixed(1);
|
||||
const important = [
|
||||
`docker_prune_images (${opts?.aggressive ? "agressif" : "safe"}) : ${p.imagesDeleted.length} image(s), ${mb} Mo récupérés`,
|
||||
...p.errors.map((e) => ` [${e.kind}] ${e.message}`),
|
||||
];
|
||||
return archiveExecution({
|
||||
machineId, machineName: m.name, executionId, action, startedAt, status, raw: o.raw,
|
||||
importantLines: important, docker: { prune: { imagesDeleted: p.imagesDeleted, bytesReclaimed: p.bytesReclaimed, ...(p.errors.length ? { errors: p.errors } : {}) } },
|
||||
});
|
||||
} catch (err) {
|
||||
return archiveExecution({ machineId, machineName: m.name, executionId, action, startedAt, status: "error", raw: "", importantLines: [`[ERREUR] ${(err as Error).message}`] });
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "docker_compose_down") {
|
||||
if (!opts?.stackId) throw new Error("docker_compose_down requiert un stackId");
|
||||
const { downStack } = await import("./dockerApply.js");
|
||||
try {
|
||||
const o = await downStack(machineId, opts.stackId, (c) => outputHub.publish(machineId, c));
|
||||
const p = o.parsed;
|
||||
const status: ExecutionStatus = p.errors.length || (p.exitCode !== null && p.exitCode !== 0) ? "error" : "ok";
|
||||
const important = [
|
||||
`docker_compose_down ${o.stackName} : ${p.removed.length} conteneur(s) retiré(s)`,
|
||||
...p.removed.map((n) => ` removed ${n}`),
|
||||
...p.errors.map((e) => ` [${e.kind}] ${e.message}`),
|
||||
];
|
||||
return archiveExecution({
|
||||
machineId, machineName: m.name, executionId, action, startedAt, status, raw: o.raw,
|
||||
importantLines: important, errors: p.errors,
|
||||
});
|
||||
} catch (err) {
|
||||
return archiveExecution({ machineId, machineName: m.name, executionId, action, startedAt, status: "error", raw: "", importantLines: [`[ERREUR] ${(err as Error).message}`] });
|
||||
}
|
||||
}
|
||||
|
||||
// --- SJ-8 : post-install (profil + champs de formulaire) ---
|
||||
if (action === "post_install") {
|
||||
if (!opts?.profileId) throw new Error("post_install requiert un profileId");
|
||||
const { runPostInstall, rebootAndRebind } = await import("./postInstall.js");
|
||||
try {
|
||||
const o = await runPostInstall(machineId, opts.profileId, opts.values ?? {}, () => {});
|
||||
const r = o.result;
|
||||
const important = [
|
||||
`post_install ${opts.profileId} : ${r.packagesInstalled.length} paquet(s), ${r.filesModified.length} fichier(s) modifié(s)${r.rebootsRequested ? " · reboot demandé" : ""}`,
|
||||
...r.packagesInstalled.map((p) => ` + ${p}`),
|
||||
...r.filesModified.map((f) => ` ~ ${f}`),
|
||||
...(r.networkChange ? [` réseau : ${r.networkChange.oldEndpoint ?? "?"} → ${r.networkChange.newEndpoint ?? "?"} (reconnexion ${r.networkChange.reconnectHost ?? "?"})`] : []),
|
||||
...(r.errors?.map((e) => ` [${e.kind}] ${e.message}`) ?? []),
|
||||
];
|
||||
|
||||
// identity_network + reboot coché + succès : reboote, attend la nouvelle IP, corrige la BDD.
|
||||
let rebootResult: ExecutionResult["reboot"];
|
||||
const newHost = r.networkChange?.reconnectHost ?? r.networkChange?.newEndpoint ?? null;
|
||||
if (o.status === "ok" && opts.profileId === "identity_network" && opts.values?.rebootAfterInstall && newHost) {
|
||||
const newName = opts.values?.newHostname != null ? String(opts.values.newHostname) : null;
|
||||
rebootResult = await rebootAndRebind(machineId, newHost, newName, () => {});
|
||||
important.push(
|
||||
rebootResult.status === "ok"
|
||||
? ` reboot vérifié → machine basculée sur ${newHost} (BDD mise à jour)`
|
||||
: ` reboot/reconnexion : ${rebootResult.status} (BDD inchangée)`,
|
||||
);
|
||||
}
|
||||
|
||||
const finalStatus: ExecutionStatus = rebootResult && rebootResult.status !== "ok" ? "error" : o.status;
|
||||
outputHub.publish(machineId, `\n===SU:DONE status=${finalStatus}===\n`);
|
||||
return archiveExecution({
|
||||
machineId, machineName: m.name, executionId, action, startedAt, status: finalStatus, raw: o.raw,
|
||||
importantLines: important, postInstall: r, reboot: rebootResult, errors: r.errors,
|
||||
});
|
||||
} catch (err) {
|
||||
return archiveExecution({ machineId, machineName: m.name, executionId, action, startedAt, status: "error", raw: "", importantLines: [`[ERREUR] ${(err as Error).message}`] });
|
||||
}
|
||||
}
|
||||
|
||||
// --- SJ-7 : sonde machine (lecture seule) déléguée au service dédié ---
|
||||
if (action === "machine_probe") {
|
||||
const { runProbe } = await import("./machineProbe.js");
|
||||
try {
|
||||
const o = await runProbe(machineId, () => {});
|
||||
const important = [
|
||||
`machine_probe : os=${o.probe.osId ?? "?"} ${o.probe.osVersion ?? ""} arch=${o.probe.arch ?? "?"} virt=${o.probe.virt ?? "?"}`,
|
||||
`proposition : os_family=${o.proposal.osFamily} machine_kind=${o.proposal.machineKind} virtualization=${o.proposal.virtualization}`,
|
||||
...(o.changes.length ? ["corrections proposées (non appliquées) :", ...o.changes.map((c) => ` ${c}`)] : ["aucune correction proposée"]),
|
||||
];
|
||||
outputHub.publish(machineId, `\n===SU:DONE status=ok===\n`);
|
||||
return archiveExecution({ machineId, machineName: m.name, executionId, action, startedAt, status: "ok", raw: o.raw, importantLines: important });
|
||||
} catch (err) {
|
||||
return archiveExecution({ machineId, machineName: m.name, executionId, action, startedAt, status: "error", raw: "", importantLines: [`[ERREUR] ${(err as Error).message}`] });
|
||||
}
|
||||
}
|
||||
|
||||
const proxy = m.aptProxyMode === "runtime" ? m.aptProxyUrl : null;
|
||||
const rel = TEMPLATE_FOR[action];
|
||||
if (!rel) throw new Error("Action sans template: " + action);
|
||||
const script = renderTemplate(rel, { aptProxy: proxy });
|
||||
// Résolution du template : Docker inspect = chemin direct ; sinon résolution par profil OS.
|
||||
let rel: string;
|
||||
if (action === "docker_inspect_current") {
|
||||
rel = "docker/inspect-compose.sh.tpl";
|
||||
} else {
|
||||
const file = APT_ACTION_FILE[action];
|
||||
if (!file) throw new Error("Action sans template: " + action);
|
||||
rel = resolveTemplate(file, m.osFamily);
|
||||
}
|
||||
// Docker inspect par-stack : injecter stackDir ; ignoré par les templates APT.
|
||||
let stackDir: string | null = null;
|
||||
if (opts?.stackId) {
|
||||
const st = db.select().from(schema.dockerComposeStacks).where(eq(schema.dockerComposeStacks.id, opts.stackId)).get();
|
||||
stackDir = st?.workingDir ?? null;
|
||||
}
|
||||
// Proxy persistant : l'URL est passée comme variable de template (jamais un secret).
|
||||
const aptProxyUrl = action === "apt_proxy_persistent" ? m.aptProxyUrl : null;
|
||||
const script = renderTemplate(rel, { aptProxy: proxy, stackDir, aptProxyUrl });
|
||||
|
||||
const inactivity = action === "reboot" ? 0 : 600000;
|
||||
|
||||
@@ -171,6 +541,19 @@ export async function runAction(machineId: string, action: ActionType): Promise<
|
||||
});
|
||||
|
||||
outputHub.publish(machineId, `\n===SU:DONE status=${status}===\n`);
|
||||
|
||||
// Après une action APT qui modifie l'état des paquets, régénérer le snapshot
|
||||
// pour que la webUI reflète les mises à jour restantes (retour amelioration.md #3).
|
||||
const REFRESH_AFTER: ActionType[] = ["apt_full_upgrade", "apt_upgrade", "apt_dist_upgrade", "apt_autoremove"];
|
||||
if (status !== "error" && REFRESH_AFTER.includes(action)) {
|
||||
try {
|
||||
await refreshMachine(machineId);
|
||||
} catch (err) {
|
||||
// Refresh best-effort : ne pas faire échouer l'action si la ré-analyse échoue.
|
||||
recordEvent({ machineId, eventType: "post_action_refresh_failed", severity: "warning", executionId,
|
||||
message: `Refresh post-${action} échoué : ${(err as Error).message}` });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { extractImportantMessages } from "./importantMessages.js";
|
||||
|
||||
describe("extractImportantMessages", () => {
|
||||
it("classe les erreurs APT (E:, dpkg) en error", () => {
|
||||
const raw = [
|
||||
"E: Unable to locate package toto",
|
||||
"dpkg: error processing package nginx (--configure):",
|
||||
"Inst libc6 [2.36] (2.37 Debian:13)",
|
||||
].join("\n");
|
||||
const msgs = extractImportantMessages(raw, "apt");
|
||||
expect(msgs.filter((m) => m.severity === "error").length).toBe(2);
|
||||
expect(msgs.every((m) => m.category === "error")).toBe(true);
|
||||
});
|
||||
|
||||
it("classe W: et erreurs GPG en warning", () => {
|
||||
const raw = [
|
||||
"W: GPG error: http://deb.debian.org ... NO_PUBKEY 1234ABCD",
|
||||
"W: Target Packages is configured multiple times",
|
||||
].join("\n");
|
||||
const msgs = extractImportantMessages(raw, "apt");
|
||||
expect(msgs.length).toBe(2);
|
||||
expect(msgs.every((m) => m.severity === "warning")).toBe(true);
|
||||
});
|
||||
|
||||
it("détecte les annonces de dépréciation / changement majeur", () => {
|
||||
const raw = "Note: package foo is deprecated and will be removed in the next release";
|
||||
const msgs = extractImportantMessages(raw, "apt");
|
||||
expect(msgs.some((m) => m.category === "future_major_change")).toBe(true);
|
||||
});
|
||||
|
||||
it("nettoie les secrets éventuels dans les URLs", () => {
|
||||
const raw = "E: Failed to fetch https://user:pass@repo.example/x";
|
||||
const msgs = extractImportantMessages(raw, "apt");
|
||||
expect(msgs[0]!.message).not.toContain("user:pass");
|
||||
});
|
||||
|
||||
it("ignore les lignes normales", () => {
|
||||
const raw = "Reading package lists...\nBuilding dependency tree...";
|
||||
expect(extractImportantMessages(raw, "apt")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
// server/services/importantMessages.ts
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { db, schema } from "../db/client.js";
|
||||
|
||||
export type MessageSource = "apt" | "docker" | "post_install" | "ssh" | "system";
|
||||
export type MessageCategory = "error" | "warning" | "future_major_change" | "security";
|
||||
|
||||
export interface ExtractedMessage {
|
||||
source: MessageSource;
|
||||
category: MessageCategory;
|
||||
severity: "error" | "warning" | "info";
|
||||
message: string;
|
||||
packageName?: string | null;
|
||||
}
|
||||
|
||||
/** Masque les identifiants éventuels dans une ligne (URLs user:pass@, tokens). */
|
||||
function clean(line: string): string {
|
||||
return line
|
||||
.replace(/https?:\/\/[^/@\s]+:[^/@\s]+@/gi, "https://<redacted>@")
|
||||
.replace(/\b(token|bearer|password|secret|key)=\S+/gi, "$1=<redacted>")
|
||||
.trim();
|
||||
}
|
||||
|
||||
const DEPRECATION = /\b(deprecat|will be removed|no longer supported|end of life|end-of-life|\bEOL\b|obsolete)\b/i;
|
||||
const GPG = /\b(GPG error|NO_PUBKEY|KEYEXPIRED|EXPKEYSIG|not signed)\b/i;
|
||||
|
||||
/** Extrait les messages importants (erreurs/warnings/évolutions) d'une sortie brute. */
|
||||
export function extractImportantMessages(raw: string, source: MessageSource): ExtractedMessage[] {
|
||||
const out: ExtractedMessage[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const line of raw.split("\n")) {
|
||||
const t = line.trim();
|
||||
if (!t) continue;
|
||||
|
||||
let category: MessageCategory | null = null;
|
||||
let severity: ExtractedMessage["severity"] = "warning";
|
||||
|
||||
if (/^E:/.test(t) || /dpkg:\s*error/i.test(t) || /unmet dependencies|unable to correct problems/i.test(t)) {
|
||||
category = "error";
|
||||
severity = "error";
|
||||
} else if (DEPRECATION.test(t)) {
|
||||
category = "future_major_change";
|
||||
severity = "warning";
|
||||
} else if (/^W:/.test(t) || GPG.test(t)) {
|
||||
category = "warning";
|
||||
severity = "warning";
|
||||
} else if (source === "docker" && /\b(error|unauthorized|denied|failed)\b/i.test(t)) {
|
||||
category = "error";
|
||||
severity = "error";
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
const message = clean(t);
|
||||
if (!message || seen.has(message)) continue;
|
||||
seen.add(message);
|
||||
out.push({ source, category, severity, message });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Persiste les messages (dédup par machine+source+message non acquitté → maj lastSeen). */
|
||||
export function recordImportantMessages(
|
||||
machineId: string,
|
||||
messages: ExtractedMessage[],
|
||||
refs: { snapshotId?: string | null; executionId?: string | null } = {},
|
||||
): void {
|
||||
const now = new Date().toISOString();
|
||||
for (const m of messages) {
|
||||
const existing = db
|
||||
.select()
|
||||
.from(schema.importantMessages)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.importantMessages.machineId, machineId),
|
||||
eq(schema.importantMessages.source, m.source),
|
||||
eq(schema.importantMessages.message, m.message),
|
||||
eq(schema.importantMessages.acknowledged, 0),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
if (existing) {
|
||||
db.update(schema.importantMessages).set({ lastSeenAt: now }).where(eq(schema.importantMessages.id, existing.id)).run();
|
||||
} else {
|
||||
db.insert(schema.importantMessages).values({
|
||||
id: randomUUID(),
|
||||
machineId,
|
||||
source: m.source,
|
||||
category: m.category,
|
||||
severity: m.severity,
|
||||
packageName: m.packageName ?? null,
|
||||
message: m.message,
|
||||
snapshotId: refs.snapshotId ?? null,
|
||||
executionId: refs.executionId ?? null,
|
||||
firstSeenAt: now,
|
||||
lastSeenAt: now,
|
||||
acknowledged: 0,
|
||||
}).run();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function listImportantMessages(machineId: string, includeAck = false) {
|
||||
const rows = db
|
||||
.select()
|
||||
.from(schema.importantMessages)
|
||||
.where(eq(schema.importantMessages.machineId, machineId))
|
||||
.all();
|
||||
return rows
|
||||
.filter((r) => includeAck || !r.acknowledged)
|
||||
.sort((a, b) => b.lastSeenAt.localeCompare(a.lastSeenAt));
|
||||
}
|
||||
|
||||
export function acknowledgeMessage(id: string, by = "ui") {
|
||||
db.update(schema.importantMessages)
|
||||
.set({ acknowledged: 1, acknowledgedAt: new Date().toISOString(), acknowledgedBy: by })
|
||||
.where(eq(schema.importantMessages.id, id))
|
||||
.run();
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseMetrics } from "./machineMetrics.js";
|
||||
|
||||
const RAW = [
|
||||
"===SU:METRICS_CPU===",
|
||||
"0.08 0.12 0.09 1/234 5678",
|
||||
"4",
|
||||
"===SU:METRICS_MEM===",
|
||||
"MemTotal: 4194304 kB",
|
||||
"MemAvailable: 2097152 kB",
|
||||
"===SU:METRICS_FS===",
|
||||
"FS\t/\text4\t32000000000\t9280000000\t29%",
|
||||
"FS\t/boot\text2\t500000000\t475000000\t95%",
|
||||
"===SU:EXIT=0===",
|
||||
].join("\n");
|
||||
|
||||
describe("parseMetrics", () => {
|
||||
it("lit load average et cores", () => {
|
||||
const m = parseMetrics(RAW);
|
||||
expect(m.cpu.load1).toBe(0.08);
|
||||
expect(m.cpu.load5).toBe(0.12);
|
||||
expect(m.cpu.cores).toBe(4);
|
||||
});
|
||||
|
||||
it("calcule la mémoire en octets (kB→B) et le pourcentage utilisé", () => {
|
||||
const m = parseMetrics(RAW);
|
||||
expect(m.memory.totalBytes).toBe(4194304 * 1024);
|
||||
expect(m.memory.availableBytes).toBe(2097152 * 1024);
|
||||
expect(m.memory.usedBytes).toBe((4194304 - 2097152) * 1024);
|
||||
expect(m.memory.usedPercent).toBe(50);
|
||||
});
|
||||
|
||||
it("liste les systèmes de fichiers", () => {
|
||||
const m = parseMetrics(RAW);
|
||||
expect(m.filesystems).toHaveLength(2);
|
||||
expect(m.filesystems[0]).toEqual({
|
||||
mount: "/",
|
||||
fstype: "ext4",
|
||||
sizeBytes: 32000000000,
|
||||
usedBytes: 9280000000,
|
||||
usedPercent: 29,
|
||||
});
|
||||
});
|
||||
|
||||
it("émet un warning pour un FS quasi plein (>=90%)", () => {
|
||||
const m = parseMetrics(RAW);
|
||||
expect(m.warnings.some((w) => w.includes("/boot"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
// server/services/machineMetrics.ts
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db, schema } from "../db/client.js";
|
||||
import { getMachineRow, getCreds } from "./machines.js";
|
||||
import { renderTemplate } from "../templates/render.js";
|
||||
import { runScriptSudo } from "../ssh/client.js";
|
||||
import type { MachineMetricsSimple } from "@shared/types.js";
|
||||
|
||||
function section(raw: string, start: string, end?: string): string {
|
||||
const i = raw.indexOf(start);
|
||||
if (i < 0) return "";
|
||||
const from = i + start.length;
|
||||
const j = end ? raw.indexOf(end, from) : -1;
|
||||
return raw.slice(from, j < 0 ? undefined : j).trim();
|
||||
}
|
||||
|
||||
const num = (s: string | undefined): number | null => {
|
||||
if (s === undefined) return null;
|
||||
const n = Number(s);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
};
|
||||
|
||||
export function parseMetrics(raw: string): MachineMetricsSimple {
|
||||
const cpuLines = section(raw, "===SU:METRICS_CPU===", "===SU:METRICS_MEM===").split("\n").filter(Boolean);
|
||||
const loadParts = (cpuLines[0] ?? "").trim().split(/\s+/);
|
||||
const cpu = {
|
||||
load1: num(loadParts[0]),
|
||||
load5: num(loadParts[1]),
|
||||
cores: num((cpuLines[1] ?? "").trim()),
|
||||
};
|
||||
|
||||
const memBlock = section(raw, "===SU:METRICS_MEM===", "===SU:METRICS_FS===");
|
||||
const memKb = (key: string): number | null => {
|
||||
const m = new RegExp(`^${key}:\\s+(\\d+)\\s*kB`, "m").exec(memBlock);
|
||||
return m?.[1] ? Number(m[1]) * 1024 : null;
|
||||
};
|
||||
const totalBytes = memKb("MemTotal");
|
||||
const availableBytes = memKb("MemAvailable");
|
||||
const usedBytes = totalBytes !== null && availableBytes !== null ? totalBytes - availableBytes : null;
|
||||
const usedPercent = totalBytes && usedBytes !== null ? Math.round((usedBytes / totalBytes) * 100) : null;
|
||||
const memory = { totalBytes, usedBytes, availableBytes, usedPercent };
|
||||
|
||||
const filesystems: MachineMetricsSimple["filesystems"] = [];
|
||||
for (const line of section(raw, "===SU:METRICS_FS===", "===SU:EXIT=").split("\n")) {
|
||||
if (!line.startsWith("FS\t")) continue;
|
||||
const [, mount, fstype, size, used, pcent] = line.split("\t");
|
||||
filesystems.push({
|
||||
mount: mount ?? "",
|
||||
fstype: fstype ?? "",
|
||||
sizeBytes: Number(size) || 0,
|
||||
usedBytes: Number(used) || 0,
|
||||
usedPercent: Number((pcent ?? "").replace("%", "")) || 0,
|
||||
});
|
||||
}
|
||||
|
||||
const warnings: string[] = [];
|
||||
for (const fs of filesystems) {
|
||||
if (fs.usedPercent >= 90) warnings.push(`Disque ${fs.mount} à ${fs.usedPercent}%`);
|
||||
}
|
||||
if (usedPercent !== null && usedPercent >= 90) warnings.push(`Mémoire à ${usedPercent}%`);
|
||||
|
||||
return { collectedAt: new Date().toISOString(), cpu, memory, filesystems, warnings };
|
||||
}
|
||||
|
||||
/** Collecte les métriques d'une machine via SSH et persiste machine_metrics_latest. */
|
||||
export async function collectMetrics(machineId: string): Promise<MachineMetricsSimple> {
|
||||
const m = getMachineRow(machineId);
|
||||
if (!m) throw new Error("Machine introuvable");
|
||||
const script = renderTemplate("apt/machine-metrics.sh.tpl", {});
|
||||
const res = await runScriptSudo(getCreds(m), script, () => {});
|
||||
const metrics = parseMetrics(res.stdout);
|
||||
|
||||
const root = metrics.filesystems.find((f) => f.mount === "/");
|
||||
db.insert(schema.machineMetricsLatest)
|
||||
.values({
|
||||
machineId,
|
||||
collectedAt: metrics.collectedAt,
|
||||
cpuLoad1: metrics.cpu.load1,
|
||||
cpuLoad5: metrics.cpu.load5,
|
||||
cpuCores: metrics.cpu.cores,
|
||||
memoryTotalBytes: metrics.memory.totalBytes,
|
||||
memoryUsedBytes: metrics.memory.usedBytes,
|
||||
memoryAvailableBytes: metrics.memory.availableBytes,
|
||||
memoryUsedPercent: metrics.memory.usedPercent,
|
||||
filesystemsJson: JSON.stringify(metrics.filesystems),
|
||||
rootUsedPercent: root?.usedPercent ?? null,
|
||||
warningsJson: JSON.stringify(metrics.warnings),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: schema.machineMetricsLatest.machineId,
|
||||
set: {
|
||||
collectedAt: metrics.collectedAt,
|
||||
cpuLoad1: metrics.cpu.load1,
|
||||
cpuLoad5: metrics.cpu.load5,
|
||||
cpuCores: metrics.cpu.cores,
|
||||
memoryTotalBytes: metrics.memory.totalBytes,
|
||||
memoryUsedBytes: metrics.memory.usedBytes,
|
||||
memoryAvailableBytes: metrics.memory.availableBytes,
|
||||
memoryUsedPercent: metrics.memory.usedPercent,
|
||||
filesystemsJson: JSON.stringify(metrics.filesystems),
|
||||
rootUsedPercent: root?.usedPercent ?? null,
|
||||
warningsJson: JSON.stringify(metrics.warnings),
|
||||
},
|
||||
})
|
||||
.run();
|
||||
|
||||
return metrics;
|
||||
}
|
||||
|
||||
/** Dernières métriques stockées (sans SSH), si présentes. */
|
||||
export function getLatestMetrics(machineId: string): MachineMetricsSimple | null {
|
||||
const row = db.select().from(schema.machineMetricsLatest).where(eq(schema.machineMetricsLatest.machineId, machineId)).get();
|
||||
if (!row) return null;
|
||||
return {
|
||||
collectedAt: row.collectedAt,
|
||||
cpu: { load1: row.cpuLoad1, load5: row.cpuLoad5, cores: row.cpuCores },
|
||||
memory: {
|
||||
totalBytes: row.memoryTotalBytes,
|
||||
usedBytes: row.memoryUsedBytes,
|
||||
availableBytes: row.memoryAvailableBytes,
|
||||
usedPercent: row.memoryUsedPercent,
|
||||
},
|
||||
filesystems: row.filesystemsJson ? JSON.parse(row.filesystemsJson) : [],
|
||||
warnings: row.warningsJson ? JSON.parse(row.warningsJson) : [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseProbe, proposeCorrections, buildRecommendations } from "./machineProbe.js";
|
||||
|
||||
const PROXMOX = [
|
||||
"===SU:PROBE_OS===",
|
||||
'PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"',
|
||||
"ID=debian",
|
||||
'VERSION_ID="12"',
|
||||
"VERSION_CODENAME=bookworm",
|
||||
"===SU:PROBE_ARCH===",
|
||||
"x86_64",
|
||||
"amd64",
|
||||
"===SU:PROBE_VIRT===",
|
||||
"none",
|
||||
"===SU:PROBE_PROXMOX===",
|
||||
"PROXMOX=1",
|
||||
"===SU:PROBE_RPI===",
|
||||
"RPI=0",
|
||||
"===SU:PROBE_GPU===",
|
||||
"01:00.0 VGA compatible controller: Matrox MGA G200eW",
|
||||
"===SU:PROBE_NET===",
|
||||
"vmbr0 10.0.3.202/24",
|
||||
"===SU:EXIT=0===",
|
||||
].join("\n");
|
||||
|
||||
const RPI = [
|
||||
"===SU:PROBE_OS===",
|
||||
"ID=debian",
|
||||
"VERSION_CODENAME=bookworm",
|
||||
"===SU:PROBE_ARCH===",
|
||||
"aarch64",
|
||||
"arm64",
|
||||
"===SU:PROBE_VIRT===",
|
||||
"none",
|
||||
"===SU:PROBE_PROXMOX===",
|
||||
"PROXMOX=0",
|
||||
"===SU:PROBE_RPI===",
|
||||
"RPI=1",
|
||||
"===SU:PROBE_GPU===",
|
||||
"no-lspci",
|
||||
"===SU:PROBE_NET===",
|
||||
"eth0 192.168.1.50/24",
|
||||
"===SU:EXIT=0===",
|
||||
].join("\n");
|
||||
|
||||
const KVM_VM = [
|
||||
"===SU:PROBE_OS===",
|
||||
"ID=ubuntu",
|
||||
'VERSION_ID="24.04"',
|
||||
"VERSION_CODENAME=noble",
|
||||
"===SU:PROBE_ARCH===",
|
||||
"x86_64",
|
||||
"amd64",
|
||||
"===SU:PROBE_VIRT===",
|
||||
"kvm",
|
||||
"===SU:PROBE_PROXMOX===",
|
||||
"PROXMOX=0",
|
||||
"===SU:PROBE_RPI===",
|
||||
"RPI=0",
|
||||
"===SU:PROBE_GPU===",
|
||||
"no-lspci",
|
||||
"===SU:PROBE_NET===",
|
||||
"ens18 10.0.3.5/24",
|
||||
"===SU:EXIT=0===",
|
||||
].join("\n");
|
||||
|
||||
describe("parseProbe", () => {
|
||||
it("extrait os-release, arch, virt et drapeaux", () => {
|
||||
const p = parseProbe(PROXMOX);
|
||||
expect(p.osId).toBe("debian");
|
||||
expect(p.osVersion).toBe("12");
|
||||
expect(p.osCodename).toBe("bookworm");
|
||||
expect(p.arch).toBe("x86_64");
|
||||
expect(p.dpkgArch).toBe("amd64");
|
||||
expect(p.virt).toBe("none");
|
||||
expect(p.isProxmox).toBe(true);
|
||||
expect(p.isRpi).toBe(false);
|
||||
expect(p.gpus).toHaveLength(1);
|
||||
expect(p.net).toEqual([{ iface: "vmbr0", addr: "10.0.3.202/24" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("proposeCorrections", () => {
|
||||
it("Proxmox → os_family proxmox + machine_kind proxmox_host", () => {
|
||||
const c = proposeCorrections(parseProbe(PROXMOX));
|
||||
expect(c.osFamily).toBe("proxmox");
|
||||
expect(c.machineKind).toBe("proxmox_host");
|
||||
expect(c.virtualization).toBe("none");
|
||||
});
|
||||
|
||||
it("Raspberry Pi → raspbian + raspberry_pi", () => {
|
||||
const c = proposeCorrections(parseProbe(RPI));
|
||||
expect(c.osFamily).toBe("raspbian");
|
||||
expect(c.machineKind).toBe("raspberry_pi");
|
||||
});
|
||||
|
||||
it("VM KVM Ubuntu → ubuntu + vm + virtualization kvm", () => {
|
||||
const c = proposeCorrections(parseProbe(KVM_VM));
|
||||
expect(c.osFamily).toBe("ubuntu");
|
||||
expect(c.machineKind).toBe("vm");
|
||||
expect(c.virtualization).toBe("kvm");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sonde enrichie (cpu/mem/disk + recommandations)", () => {
|
||||
const ENRICHED = [
|
||||
"===SU:PROBE_OS===",
|
||||
"ID=debian",
|
||||
"===SU:PROBE_ARCH===",
|
||||
"x86_64",
|
||||
"amd64",
|
||||
"===SU:PROBE_VIRT===",
|
||||
"kvm",
|
||||
"===SU:PROBE_PROXMOX===",
|
||||
"PROXMOX=0",
|
||||
"===SU:PROBE_RPI===",
|
||||
"RPI=0",
|
||||
"===SU:PROBE_GPU===",
|
||||
"no-lspci",
|
||||
"===SU:PROBE_NET===",
|
||||
"ens18 10.0.0.8/22",
|
||||
"===SU:PROBE_CPU===",
|
||||
"MODEL=Intel(R) Xeon(R) CPU E5-2670",
|
||||
"4",
|
||||
"===SU:PROBE_MEM===",
|
||||
"MemTotal: 4194304 kB",
|
||||
"===SU:PROBE_DISK===",
|
||||
"DISK\tsda\t34359738368",
|
||||
"DISK\tsdb\t1073741824000",
|
||||
"===SU:EXIT=0===",
|
||||
].join("\n");
|
||||
|
||||
it("extrait cpuModel/cores, mémoire et disques", () => {
|
||||
const p = parseProbe(ENRICHED);
|
||||
expect(p.cpuModel).toBe("Intel(R) Xeon(R) CPU E5-2670");
|
||||
expect(p.cpuCores).toBe(4);
|
||||
expect(p.memoryBytes).toBe(4194304 * 1024);
|
||||
expect(p.disks).toHaveLength(2);
|
||||
expect(p.disks[0]).toEqual({ name: "sda", sizeBytes: 34359738368 });
|
||||
});
|
||||
|
||||
it("recommande vm_guest_tools sur KVM", () => {
|
||||
const recs = buildRecommendations(parseProbe(ENRICHED));
|
||||
expect(recs.some((r) => r.profileId === "vm_guest_tools")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
// server/services/machineProbe.ts
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db, schema } from "../db/client.js";
|
||||
import { getMachineRow, getCreds } from "./machines.js";
|
||||
import { renderTemplate } from "../templates/render.js";
|
||||
import { runScriptSudo } from "../ssh/client.js";
|
||||
import { outputHub } from "../ws/outputHub.js";
|
||||
import type { OsFamily, MachineKind } from "@shared/types.js";
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Fonctions pures (testables).
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
export interface ProbeResult {
|
||||
osId: string | null;
|
||||
osVersion: string | null;
|
||||
osCodename: string | null;
|
||||
arch: string | null;
|
||||
dpkgArch: string | null;
|
||||
virt: string | null;
|
||||
isProxmox: boolean;
|
||||
isRpi: boolean;
|
||||
gpus: string[];
|
||||
net: { iface: string; addr: string }[];
|
||||
cpuModel: string | null;
|
||||
cpuCores: number | null;
|
||||
memoryBytes: number | null;
|
||||
disks: { name: string; sizeBytes: number }[];
|
||||
}
|
||||
|
||||
export interface CorrectionProposal {
|
||||
osFamily: OsFamily;
|
||||
machineKind: MachineKind;
|
||||
virtualization: string;
|
||||
}
|
||||
|
||||
export interface ProfileRecommendation {
|
||||
profileId: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
function section(raw: string, start: string, end?: string): string {
|
||||
const i = raw.indexOf(start);
|
||||
if (i < 0) return "";
|
||||
const from = i + start.length;
|
||||
const j = end ? raw.indexOf(end, from) : -1;
|
||||
return raw.slice(from, j < 0 ? undefined : j).trim();
|
||||
}
|
||||
|
||||
function osReleaseValue(block: string, key: string): string | null {
|
||||
const m = new RegExp(`^${key}=(.*)$`, "m").exec(block);
|
||||
if (!m || m[1] === undefined) return null;
|
||||
return m[1].replace(/^"(.*)"$/, "$1").trim() || null;
|
||||
}
|
||||
|
||||
export function parseProbe(raw: string): ProbeResult {
|
||||
const os = section(raw, "===SU:PROBE_OS===", "===SU:PROBE_ARCH===");
|
||||
const archBlock = section(raw, "===SU:PROBE_ARCH===", "===SU:PROBE_VIRT===").split("\n");
|
||||
const virt = section(raw, "===SU:PROBE_VIRT===", "===SU:PROBE_PROXMOX===").split("\n")[0]?.trim() || null;
|
||||
const prox = section(raw, "===SU:PROBE_PROXMOX===", "===SU:PROBE_RPI===");
|
||||
const rpi = section(raw, "===SU:PROBE_RPI===", "===SU:PROBE_GPU===");
|
||||
const gpuBlock = section(raw, "===SU:PROBE_GPU===", "===SU:PROBE_NET===");
|
||||
const netBlock = section(raw, "===SU:PROBE_NET===", "===SU:PROBE_CPU===");
|
||||
const cpuBlock = section(raw, "===SU:PROBE_CPU===", "===SU:PROBE_MEM===");
|
||||
const memBlock = section(raw, "===SU:PROBE_MEM===", "===SU:PROBE_DISK===");
|
||||
const diskBlock = section(raw, "===SU:PROBE_DISK===", "===SU:EXIT=");
|
||||
|
||||
const gpus = gpuBlock
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l && l !== "no-lspci");
|
||||
|
||||
const net: ProbeResult["net"] = [];
|
||||
for (const line of netBlock.split("\n")) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length >= 2 && parts[0] && parts[1] && parts[0] !== "lo") {
|
||||
net.push({ iface: parts[0], addr: parts[1] });
|
||||
}
|
||||
}
|
||||
|
||||
const cpuModelMatch = /^MODEL=(.+)$/m.exec(cpuBlock);
|
||||
const coresMatch = /^\s*(\d+)\s*$/m.exec(cpuBlock);
|
||||
const memMatch = /^MemTotal:\s+(\d+)\s*kB/m.exec(memBlock);
|
||||
|
||||
const disks: ProbeResult["disks"] = [];
|
||||
for (const line of diskBlock.split("\n")) {
|
||||
if (!line.startsWith("DISK\t")) continue;
|
||||
const [, name, size] = line.split("\t");
|
||||
if (name) disks.push({ name, sizeBytes: Number(size) || 0 });
|
||||
}
|
||||
|
||||
return {
|
||||
osId: osReleaseValue(os, "ID"),
|
||||
osVersion: osReleaseValue(os, "VERSION_ID"),
|
||||
osCodename: osReleaseValue(os, "VERSION_CODENAME"),
|
||||
arch: archBlock[0]?.trim() || null,
|
||||
dpkgArch: archBlock[1]?.trim() || null,
|
||||
virt,
|
||||
isProxmox: /PROXMOX=1/.test(prox),
|
||||
isRpi: /RPI=1/.test(rpi),
|
||||
gpus,
|
||||
net,
|
||||
cpuModel: cpuModelMatch?.[1]?.trim() || null,
|
||||
cpuCores: coresMatch?.[1] ? Number(coresMatch[1]) : null,
|
||||
memoryBytes: memMatch?.[1] ? Number(memMatch[1]) * 1024 : null,
|
||||
disks,
|
||||
};
|
||||
}
|
||||
|
||||
/** Recommandations de profils post-install déduites de la sonde. */
|
||||
export function buildRecommendations(p: ProbeResult): ProfileRecommendation[] {
|
||||
const recs: ProfileRecommendation[] = [];
|
||||
if (p.virt === "kvm" || p.virt === "qemu") {
|
||||
recs.push({ profileId: "vm_guest_tools", reason: "QEMU/KVM détecté → qemu-guest-agent" });
|
||||
} else if (p.virt === "vmware") {
|
||||
recs.push({ profileId: "vm_guest_tools", reason: "VMware détecté → open-vm-tools" });
|
||||
}
|
||||
return recs;
|
||||
}
|
||||
|
||||
const VM_VIRTS = new Set(["kvm", "qemu", "vmware", "oracle", "microsoft", "xen", "bochs", "parallels"]);
|
||||
const LXC_VIRTS = new Set(["lxc", "lxc-libvirt", "openvz", "systemd-nspawn", "docker", "podman"]);
|
||||
|
||||
export function proposeCorrections(p: ProbeResult): CorrectionProposal {
|
||||
const virtualization = p.virt && p.virt !== "none" ? p.virt : "none";
|
||||
|
||||
let osFamily: OsFamily;
|
||||
if (p.isProxmox) osFamily = "proxmox";
|
||||
else if (p.isRpi) osFamily = "raspbian";
|
||||
else if (p.osId === "ubuntu") osFamily = "ubuntu";
|
||||
else if (p.osId === "debian" || p.osId === "raspbian") osFamily = "debian";
|
||||
else osFamily = "unknown";
|
||||
|
||||
let machineKind: MachineKind;
|
||||
if (p.isProxmox) machineKind = "proxmox_host";
|
||||
else if (p.isRpi) machineKind = "raspberry_pi";
|
||||
else if (p.virt && VM_VIRTS.has(p.virt)) machineKind = "vm";
|
||||
else if (p.virt && LXC_VIRTS.has(p.virt)) machineKind = "lxc";
|
||||
else if (p.virt === "none") machineKind = "physical";
|
||||
else machineKind = "unknown";
|
||||
|
||||
return { osFamily, machineKind, virtualization };
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Orchestration (SSH, lecture seule). Persiste les faits matériels ; ne corrige PAS
|
||||
// os_family/machine_kind automatiquement — la proposition est renvoyée pour validation.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
export interface ProbeOutcome {
|
||||
probe: ProbeResult;
|
||||
proposal: CorrectionProposal;
|
||||
recommendations: ProfileRecommendation[];
|
||||
raw: string;
|
||||
changes: string[]; // diff entre l'actuel et la proposition (pour l'UI)
|
||||
}
|
||||
|
||||
export async function runProbe(machineId: string, onData?: (c: string) => void): Promise<ProbeOutcome> {
|
||||
const m = getMachineRow(machineId);
|
||||
if (!m) throw new Error("Machine introuvable");
|
||||
const script = renderTemplate("apt/machine-probe.sh.tpl", {});
|
||||
const res = await runScriptSudo(getCreds(m), script, (c) => {
|
||||
onData?.(c);
|
||||
outputHub.publish(machineId, c);
|
||||
});
|
||||
const raw = res.stdout;
|
||||
const probe = parseProbe(raw);
|
||||
const proposal = proposeCorrections(probe);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const hwFields = {
|
||||
cpuModel: probe.cpuModel,
|
||||
cpuCores: probe.cpuCores,
|
||||
memoryBytes: probe.memoryBytes,
|
||||
disksJson: JSON.stringify(probe.disks),
|
||||
gpusJson: JSON.stringify(probe.gpus),
|
||||
networkJson: JSON.stringify(probe.net),
|
||||
updatedAt: now,
|
||||
};
|
||||
db.insert(schema.machineHardware)
|
||||
.values({ machineId, ...hwFields })
|
||||
.onConflictDoUpdate({ target: schema.machineHardware.machineId, set: hwFields })
|
||||
.run();
|
||||
|
||||
const changes: string[] = [];
|
||||
if (proposal.osFamily !== m.osFamily) changes.push(`os_family: ${m.osFamily} → ${proposal.osFamily}`);
|
||||
if (proposal.machineKind !== (m.machineKind ?? "unknown")) {
|
||||
changes.push(`machine_kind: ${m.machineKind ?? "—"} → ${proposal.machineKind}`);
|
||||
}
|
||||
if (proposal.virtualization !== (m.virtualization ?? "none")) {
|
||||
changes.push(`virtualization: ${m.virtualization ?? "—"} → ${proposal.virtualization}`);
|
||||
}
|
||||
|
||||
return { probe, proposal, recommendations: buildRecommendations(probe), raw, changes };
|
||||
}
|
||||
@@ -1,8 +1,20 @@
|
||||
// server/services/machineState.ts
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { db, schema } from "../db/client.js";
|
||||
import type { UpdateSnapshot } from "@shared/types.js";
|
||||
|
||||
/** Derniers événements d'une machine (timeline), du plus récent au plus ancien. */
|
||||
export function listMachineEvents(machineId: string, limit = 30) {
|
||||
return db
|
||||
.select()
|
||||
.from(schema.machineEvents)
|
||||
.where(eq(schema.machineEvents.machineId, machineId))
|
||||
.orderBy(desc(schema.machineEvents.createdAt))
|
||||
.limit(limit)
|
||||
.all();
|
||||
}
|
||||
|
||||
export interface AptDerivedState {
|
||||
status: string;
|
||||
aptStatus: string;
|
||||
|
||||
@@ -5,7 +5,7 @@ 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";
|
||||
import type { AptProxyMode, MachineKind, MachineView, OsFamily } from "@shared/types.js";
|
||||
import { writeCredentials, readCredentials, resolveCreds } from "./credentials.js";
|
||||
|
||||
export interface CreateMachineInput {
|
||||
@@ -15,8 +15,10 @@ export interface CreateMachineInput {
|
||||
username: string;
|
||||
password: string;
|
||||
sudoPassword?: string | null;
|
||||
aptProxyMode?: "direct" | "runtime";
|
||||
aptProxyMode?: AptProxyMode;
|
||||
aptProxyUrl?: string | null;
|
||||
osFamily?: OsFamily; // choix manuel ; sinon auto-détecté via os-release
|
||||
machineKind?: MachineKind;
|
||||
}
|
||||
|
||||
type MachineRow = typeof schema.machines.$inferSelect;
|
||||
@@ -29,13 +31,43 @@ function toView(m: MachineRow): MachineView {
|
||||
port: m.port,
|
||||
osFamily: m.osFamily as OsFamily,
|
||||
username: m.username,
|
||||
aptProxyMode: m.aptProxyMode as "direct" | "runtime",
|
||||
aptProxyMode: m.aptProxyMode as AptProxyMode,
|
||||
aptProxyUrl: m.aptProxyUrl,
|
||||
status: m.status as MachineView["status"],
|
||||
lastCheckedAt: m.lastCheckedAt,
|
||||
machineKind: (m.machineKind as MachineKind | null) ?? "unknown",
|
||||
virtualization: m.virtualization,
|
||||
};
|
||||
}
|
||||
|
||||
export interface UpdateMachineInput {
|
||||
name?: string;
|
||||
hostname?: string;
|
||||
port?: number;
|
||||
osFamily?: OsFamily;
|
||||
machineKind?: MachineKind;
|
||||
virtualization?: string | null;
|
||||
aptProxyMode?: AptProxyMode;
|
||||
aptProxyUrl?: string | null;
|
||||
}
|
||||
|
||||
/** Met à jour les champs de profil/proxy/identité d'une machine (jamais les secrets). */
|
||||
export function updateMachine(id: string, input: UpdateMachineInput): MachineView {
|
||||
const row = getMachineRow(id);
|
||||
if (!row) throw new Error("Machine introuvable");
|
||||
const patch: Partial<MachineRow> = { updatedAt: new Date().toISOString() };
|
||||
if (input.name !== undefined) patch.name = input.name;
|
||||
if (input.hostname !== undefined) patch.hostname = input.hostname;
|
||||
if (input.port !== undefined) patch.port = input.port;
|
||||
if (input.osFamily !== undefined) patch.osFamily = input.osFamily;
|
||||
if (input.machineKind !== undefined) patch.machineKind = input.machineKind;
|
||||
if (input.virtualization !== undefined) patch.virtualization = input.virtualization;
|
||||
if (input.aptProxyMode !== undefined) patch.aptProxyMode = input.aptProxyMode;
|
||||
if (input.aptProxyUrl !== undefined) patch.aptProxyUrl = input.aptProxyUrl;
|
||||
db.update(schema.machines).set(patch).where(eq(schema.machines.id, id)).run();
|
||||
return toView(getMachineRow(id)!);
|
||||
}
|
||||
|
||||
export function getCreds(m: MachineRow): SshCreds {
|
||||
const key = env.requireMasterKey();
|
||||
const { encPassword, encSudoPassword } = resolveCreds(
|
||||
@@ -94,11 +126,11 @@ export async function createMachine(input: CreateMachineInput): Promise<MachineV
|
||||
name: input.name,
|
||||
hostname: input.hostname,
|
||||
port: input.port,
|
||||
osFamily: os.family,
|
||||
osFamily: input.osFamily && input.osFamily !== "unknown" ? input.osFamily : os.family, // manuel prioritaire, "unknown" => auto
|
||||
osVersion: os.version || null,
|
||||
osCodename: null,
|
||||
arch: null,
|
||||
machineKind: null,
|
||||
machineKind: input.machineKind ?? null,
|
||||
virtualization: null,
|
||||
hardwareProfile: null,
|
||||
username: input.username,
|
||||
@@ -121,3 +153,32 @@ export async function createMachine(input: CreateMachineInput): Promise<MachineV
|
||||
export function deleteMachine(id: string): void {
|
||||
db.delete(schema.machines).where(eq(schema.machines.id, id)).run();
|
||||
}
|
||||
|
||||
/** Faits matériels d'une machine (machine_hardware rempli par machine_probe + colonnes machines). */
|
||||
export function getMachineHardware(id: string) {
|
||||
const m = getMachineRow(id);
|
||||
if (!m) throw new Error("Machine introuvable");
|
||||
const hw = db.select().from(schema.machineHardware).where(eq(schema.machineHardware.machineId, id)).get();
|
||||
const parse = <T>(j: string | null | undefined): T[] => {
|
||||
try { return j ? (JSON.parse(j) as T[]) : []; } catch { return []; }
|
||||
};
|
||||
return {
|
||||
osFamily: m.osFamily,
|
||||
osVersion: m.osVersion,
|
||||
arch: m.arch,
|
||||
machineKind: m.machineKind,
|
||||
virtualization: m.virtualization,
|
||||
gpus: parse<string>(hw?.gpusJson),
|
||||
network: parse<{ iface: string; addr: string }>(hw?.networkJson),
|
||||
probed: !!hw,
|
||||
};
|
||||
}
|
||||
|
||||
/** Applique un proxy APT à toutes les machines. Renvoie le nombre de machines modifiées. */
|
||||
export function applyProxyToAllMachines(mode: AptProxyMode, url: string | null): number {
|
||||
const res = db
|
||||
.update(schema.machines)
|
||||
.set({ aptProxyMode: mode, aptProxyUrl: url, updatedAt: new Date().toISOString() })
|
||||
.run();
|
||||
return res.changes;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
PROFILES,
|
||||
validateProfileValues,
|
||||
maskSecretValues,
|
||||
buildPostInstallResult,
|
||||
previewProfile,
|
||||
type ProfileManifest,
|
||||
} from "./postInstall.js";
|
||||
|
||||
describe("validateProfileValues", () => {
|
||||
const identity = PROFILES.identity_network!;
|
||||
|
||||
it("échoue si un champ requis manque", () => {
|
||||
const r = validateProfileValues(identity, { newHostname: "srv1" });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.errors.some((e) => e.field === "interfaceName")).toBe(true);
|
||||
});
|
||||
|
||||
it("échoue sur une IP/CIDR invalide", () => {
|
||||
const r = validateProfileValues(identity, {
|
||||
newHostname: "srv1",
|
||||
domain: "home",
|
||||
interfaceName: "eth0",
|
||||
staticAddress: "999.1.1.1/24",
|
||||
gateway: "10.0.0.1",
|
||||
dnsNameservers: "10.0.0.1",
|
||||
reconnectHost: "10.0.0.50",
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.errors.some((e) => e.field === "staticAddress")).toBe(true);
|
||||
});
|
||||
|
||||
it("passe avec des valeurs valides", () => {
|
||||
const r = validateProfileValues(identity, {
|
||||
newHostname: "srv1",
|
||||
domain: "home",
|
||||
interfaceName: "eth0",
|
||||
staticAddress: "10.0.0.50/22",
|
||||
gateway: "10.0.0.1",
|
||||
dnsNameservers: "10.0.0.1 10.0.0.10",
|
||||
reconnectHost: "10.0.0.50",
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.errors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("maskSecretValues", () => {
|
||||
const manifest: ProfileManifest = {
|
||||
id: "x",
|
||||
label: "x",
|
||||
description: "",
|
||||
risk: "low",
|
||||
requiresConfirmation: false,
|
||||
template: "custom/bootstrap-root.sh.tpl",
|
||||
fields: [
|
||||
{ name: "user", type: "string", required: true },
|
||||
{ name: "token", type: "secret", required: true },
|
||||
],
|
||||
};
|
||||
|
||||
it("masque les champs secret et conserve les autres", () => {
|
||||
const masked = maskSecretValues(manifest, { user: "gilles", token: "s3cr3t-ABC" });
|
||||
expect(masked.user).toBe("gilles");
|
||||
expect(masked.token).toBe("********");
|
||||
expect(JSON.stringify(masked)).not.toContain("s3cr3t");
|
||||
});
|
||||
});
|
||||
|
||||
describe("profils SJ-9 (presetVars + sections)", () => {
|
||||
it("base_tools injecte la liste de paquets fixe", () => {
|
||||
expect(PROFILES.base_tools).toBeTruthy();
|
||||
const script = previewProfile("base_tools", {});
|
||||
expect(script).toContain("nano");
|
||||
expect(script).toContain("htop");
|
||||
});
|
||||
|
||||
it("sharing ne rend que les paquets cochés", () => {
|
||||
const script = previewProfile("sharing", { installSamba: true, installNfs: false, installMdns: true });
|
||||
expect(script).toContain("samba");
|
||||
expect(script).toContain("avahi-daemon");
|
||||
expect(script).not.toContain("nfs-kernel-server");
|
||||
});
|
||||
|
||||
it("docker_official exige une confirmation", () => {
|
||||
expect(PROFILES.docker_official!.requiresConfirmation).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPostInstallResult", () => {
|
||||
const raw = [
|
||||
"===SU:CUSTOM_IDENTITY===",
|
||||
"FILE_MODIFIED=/etc/hosts",
|
||||
"FILE_MODIFIED=/etc/network/interfaces",
|
||||
"OLD_ENDPOINT=10.0.0.99",
|
||||
"HOSTNAME_SET=srv1",
|
||||
"ERR=interface_not_found",
|
||||
"NEW_ENDPOINT=10.0.0.50",
|
||||
"RECONNECT_REQUIRED=1",
|
||||
"REBOOT_REQUESTED=1",
|
||||
"===SU:EXIT=0===",
|
||||
].join("\n");
|
||||
|
||||
it("extrait fichiers modifiés, reboot, changement réseau et erreurs", () => {
|
||||
const r = buildPostInstallResult(raw, ["identity_network"], { newHostname: "srv1" });
|
||||
expect(r.profilesRun).toEqual(["identity_network"]);
|
||||
expect(r.filesModified).toContain("/etc/hosts");
|
||||
expect(r.filesModified).toContain("/etc/network/interfaces");
|
||||
expect(r.rebootsRequested).toBe(true);
|
||||
expect(r.networkChange).toEqual({ oldEndpoint: "10.0.0.99", newEndpoint: "10.0.0.50", reconnectHost: "10.0.0.50" });
|
||||
expect(r.errors?.some((e) => e.kind === "interface_not_found")).toBe(true);
|
||||
expect(r.variablesUsed.newHostname).toBe("srv1");
|
||||
});
|
||||
|
||||
it("parse les paquets installés du bootstrap", () => {
|
||||
const boot = [
|
||||
"===SU:CUSTOM_BOOTSTRAP===",
|
||||
"PKG_INSTALLED=sudo",
|
||||
"PKG_INSTALLED=curl",
|
||||
"GROUP_ADDED=sudo:gilles",
|
||||
"SUDO_OK=1",
|
||||
"===SU:EXIT=0===",
|
||||
].join("\n");
|
||||
const r = buildPostInstallResult(boot, ["bootstrap_root"], { operatorUser: "gilles" });
|
||||
expect(r.packagesInstalled).toEqual(["sudo", "curl"]);
|
||||
expect(r.rebootsRequested).toBe(false);
|
||||
expect(r.errors ?? []).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,368 @@
|
||||
// server/services/postInstall.ts
|
||||
import { getMachineRow, getCreds, updateMachine } from "./machines.js";
|
||||
import { renderTemplate, type TemplateVars } from "../templates/render.js";
|
||||
import { runScriptSudo } from "../ssh/client.js";
|
||||
import { outputHub } from "../ws/outputHub.js";
|
||||
import { parseBootIdBefore, verifyReboot } from "./rebootVerify.js";
|
||||
import type { PostInstallResult, RebootResult, SnapshotError } from "@shared/types.js";
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Manifestes de profils (registre versionné en code ; templates versionnés sur disque).
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
export type FieldType =
|
||||
| "string" | "hostname" | "ipv4" | "ipv4_cidr" | "ipv4_list"
|
||||
| "select" | "bool" | "int" | "path" | "secret";
|
||||
|
||||
export interface ProfileField {
|
||||
name: string;
|
||||
type: FieldType;
|
||||
required: boolean;
|
||||
label?: string;
|
||||
default?: string | number | boolean;
|
||||
defaultFrom?: string; // valeur détectée par machine_probe (ex. detected.primaryInterface)
|
||||
options?: string[]; // pour select
|
||||
}
|
||||
|
||||
export interface ProfileManifest {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
risk: "low" | "medium" | "network_change";
|
||||
requiresConfirmation: boolean;
|
||||
template: string; // chemin relatif sous templates/
|
||||
fields: ProfileField[];
|
||||
presetVars?: Record<string, string | number | boolean>; // variables fixes (ex. liste de paquets)
|
||||
}
|
||||
|
||||
export const PROFILES: Record<string, ProfileManifest> = {
|
||||
bootstrap_root: {
|
||||
id: "bootstrap_root",
|
||||
label: "Bootstrap (sudo + base)",
|
||||
description: "Installe sudo, ca-certificates, curl et ajoute l'opérateur au groupe sudo.",
|
||||
risk: "low",
|
||||
requiresConfirmation: false,
|
||||
template: "custom/bootstrap-root.sh.tpl",
|
||||
fields: [
|
||||
{ name: "operatorUser", type: "string", required: true, label: "Utilisateur opérateur", defaultFrom: "sshUser" },
|
||||
],
|
||||
},
|
||||
identity_network: {
|
||||
id: "identity_network",
|
||||
label: "Hostname + IP statique (Debian/VM)",
|
||||
description: "Définit hostname, domaine et IP statique. Cible Debian + ifupdown (VM netinstall) ; refuse proprement les autres cas (Ubuntu/netplan…). Réseau appliqué au reboot.",
|
||||
risk: "network_change",
|
||||
requiresConfirmation: true,
|
||||
template: "custom/identity-network.sh.tpl",
|
||||
fields: [
|
||||
{ name: "newHostname", type: "hostname", required: true, label: "Nouveau hostname" },
|
||||
{ name: "domain", type: "string", required: true, label: "Domaine", default: "home" },
|
||||
{ name: "interfaceName", type: "select", required: true, label: "Interface", defaultFrom: "detected.primaryInterface" },
|
||||
{ name: "staticAddress", type: "ipv4_cidr", required: true, label: "Adresse statique (CIDR)" },
|
||||
{ name: "gateway", type: "ipv4", required: true, label: "Passerelle", default: "10.0.0.1" },
|
||||
{ name: "dnsNameservers", type: "ipv4_list", required: true, label: "DNS", default: "10.0.0.1 10.0.0.10" },
|
||||
{ name: "reconnectHost", type: "ipv4", required: true, label: "IP de reconnexion", defaultFrom: "staticAddress.ip" },
|
||||
{ name: "rebootAfterInstall", type: "bool", required: false, label: "Reboot après application" },
|
||||
],
|
||||
},
|
||||
base_tools: {
|
||||
id: "base_tools",
|
||||
label: "Outils de base",
|
||||
description: "nano, less, bash-completion, tmux, screen, htop, iotop, ncdu, tree, rsync, unzip, zip, tar.",
|
||||
risk: "low",
|
||||
requiresConfirmation: false,
|
||||
template: "custom/install-package-groups.sh.tpl",
|
||||
fields: [],
|
||||
presetVars: { packages: "nano less bash-completion tmux screen htop iotop ncdu tree rsync unzip zip tar" },
|
||||
},
|
||||
network_tools: {
|
||||
id: "network_tools",
|
||||
label: "Outils réseau",
|
||||
description: "iproute2, iputils-ping, dnsutils, traceroute, tcpdump, nmap, mtr-tiny, lsof, netcat-openbsd.",
|
||||
risk: "low",
|
||||
requiresConfirmation: false,
|
||||
template: "custom/install-package-groups.sh.tpl",
|
||||
fields: [],
|
||||
presetVars: { packages: "iproute2 iputils-ping dnsutils traceroute tcpdump nmap mtr-tiny lsof netcat-openbsd" },
|
||||
},
|
||||
dev_git: {
|
||||
id: "dev_git",
|
||||
label: "Dev / Git",
|
||||
description: "git, curl, wget, jq, gnupg, lsb-release.",
|
||||
risk: "low",
|
||||
requiresConfirmation: false,
|
||||
template: "custom/install-package-groups.sh.tpl",
|
||||
fields: [],
|
||||
presetVars: { packages: "git curl wget jq gnupg lsb-release" },
|
||||
},
|
||||
docker_official: {
|
||||
id: "docker_official",
|
||||
label: "Docker (dépôt officiel)",
|
||||
description: "Docker Engine depuis le dépôt officiel Debian + plugin compose ; ajoute l'utilisateur au groupe docker.",
|
||||
risk: "medium",
|
||||
requiresConfirmation: true,
|
||||
template: "custom/docker-official-debian.sh.tpl",
|
||||
fields: [
|
||||
{ name: "dockerUser", type: "string", required: true, label: "Utilisateur docker", defaultFrom: "sshUser" },
|
||||
{ name: "composeRoot", type: "path", required: true, label: "Dossier Compose", default: "/home/gilles/docker" },
|
||||
{ name: "rebootAfterInstall", type: "bool", required: false, label: "Reboot après installation" },
|
||||
],
|
||||
},
|
||||
sharing: {
|
||||
id: "sharing",
|
||||
label: "Partage réseau",
|
||||
description: "Installe Samba / NFS / mDNS selon les cases cochées (configuration détaillée renvoyée à la tâche 4).",
|
||||
risk: "medium",
|
||||
requiresConfirmation: true,
|
||||
template: "custom/sharing.sh.tpl",
|
||||
fields: [
|
||||
{ name: "installSamba", type: "bool", required: false, label: "Samba" },
|
||||
{ name: "installNfs", type: "bool", required: false, label: "NFS" },
|
||||
{ name: "installMdns", type: "bool", required: false, label: "mDNS (avahi)" },
|
||||
],
|
||||
},
|
||||
vm_guest_tools: {
|
||||
id: "vm_guest_tools",
|
||||
label: "Outils invité VM",
|
||||
description: "Agent invité selon l'hyperviseur (qemu-guest-agent ou open-vm-tools).",
|
||||
risk: "low",
|
||||
requiresConfirmation: false,
|
||||
template: "custom/vm-guest-tools.sh.tpl",
|
||||
fields: [
|
||||
{ name: "guestAgent", type: "select", required: true, label: "Agent invité", default: "qemu-guest-agent", options: ["qemu-guest-agent", "open-vm-tools"] },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Validation (pure, testable).
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
export type ProfileValues = Record<string, string | number | boolean | undefined>;
|
||||
export interface ValidationResult {
|
||||
ok: boolean;
|
||||
errors: { field: string; message: string }[];
|
||||
}
|
||||
|
||||
const IPV4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
|
||||
const HOSTNAME = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/;
|
||||
|
||||
function isIpv4(v: string): boolean {
|
||||
const m = IPV4.exec(v.trim());
|
||||
return !!m && m.slice(1).every((o) => Number(o) >= 0 && Number(o) <= 255);
|
||||
}
|
||||
function isIpv4Cidr(v: string): boolean {
|
||||
const [ip, mask] = v.trim().split("/");
|
||||
return !!ip && !!mask && isIpv4(ip) && Number(mask) >= 0 && Number(mask) <= 32;
|
||||
}
|
||||
|
||||
function validateField(field: ProfileField, raw: string | number | boolean): string | null {
|
||||
const v = String(raw).trim();
|
||||
switch (field.type) {
|
||||
case "hostname":
|
||||
return HOSTNAME.test(v) ? null : "hostname invalide";
|
||||
case "ipv4":
|
||||
return isIpv4(v) ? null : "adresse IPv4 invalide";
|
||||
case "ipv4_cidr":
|
||||
return isIpv4Cidr(v) ? null : "adresse CIDR invalide (ex. 10.0.0.50/22)";
|
||||
case "ipv4_list":
|
||||
return v.split(/[\s,]+/).filter(Boolean).every(isIpv4) ? null : "liste d'IPv4 invalide";
|
||||
case "int":
|
||||
return /^-?\d+$/.test(v) ? null : "entier attendu";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function validateProfileValues(manifest: ProfileManifest, values: ProfileValues): ValidationResult {
|
||||
const errors: { field: string; message: string }[] = [];
|
||||
for (const field of manifest.fields) {
|
||||
const v = values[field.name];
|
||||
const empty = v === undefined || v === null || String(v).trim() === "";
|
||||
if (empty) {
|
||||
if (field.required) errors.push({ field: field.name, message: "champ requis" });
|
||||
continue;
|
||||
}
|
||||
const err = validateField(field, v);
|
||||
if (err) errors.push({ field: field.name, message: err });
|
||||
}
|
||||
return { ok: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Masquage des secrets (jamais en clair vers UI/MCP/preview).
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
export function maskSecretValues(manifest: ProfileManifest, values: ProfileValues): ProfileValues {
|
||||
const secrets = new Set(manifest.fields.filter((f) => f.type === "secret").map((f) => f.name));
|
||||
const out: ProfileValues = {};
|
||||
for (const [k, v] of Object.entries(values)) {
|
||||
out[k] = secrets.has(k) && v !== undefined && v !== "" ? "********" : v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Valeurs non sensibles uniquement (pour variablesUsed / persistance / Hermes). */
|
||||
function nonSecretValues(manifest: ProfileManifest, values: ProfileValues): Record<string, string | number | boolean> {
|
||||
const secrets = new Set(manifest.fields.filter((f) => f.type === "secret").map((f) => f.name));
|
||||
const out: Record<string, string | number | boolean> = {};
|
||||
for (const [k, v] of Object.entries(values)) {
|
||||
if (!secrets.has(k) && v !== undefined) out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Rendu + preview.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
function toTemplateVars(values: ProfileValues): TemplateVars {
|
||||
const vars: TemplateVars = {};
|
||||
for (const [k, v] of Object.entries(values)) vars[k] = v as never;
|
||||
return vars;
|
||||
}
|
||||
|
||||
export function renderProfile(profileId: string, values: ProfileValues): string {
|
||||
const manifest = PROFILES[profileId];
|
||||
if (!manifest) throw new Error(`Profil inconnu : ${profileId}`);
|
||||
return renderTemplate(manifest.template, toTemplateVars({ ...manifest.presetVars, ...values }));
|
||||
}
|
||||
|
||||
/** Preview du script rendu avec masquage des secrets. */
|
||||
export function previewProfile(profileId: string, values: ProfileValues): string {
|
||||
const manifest = PROFILES[profileId];
|
||||
if (!manifest) throw new Error(`Profil inconnu : ${profileId}`);
|
||||
return renderTemplate(manifest.template, toTemplateVars({ ...manifest.presetVars, ...maskSecretValues(manifest, values) }));
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Parsing du résultat (pure, testable).
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
function collectPrefixed(raw: string, prefix: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const line of raw.split("\n")) {
|
||||
const t = line.trim();
|
||||
if (t.startsWith(prefix)) out.push(t.slice(prefix.length));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function firstPrefixed(raw: string, prefix: string): string | null {
|
||||
for (const line of raw.split("\n")) {
|
||||
const t = line.trim();
|
||||
if (t.startsWith(prefix)) return t.slice(prefix.length);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildPostInstallResult(
|
||||
raw: string,
|
||||
profilesRun: string[],
|
||||
variablesUsed: Record<string, string | number | boolean>,
|
||||
): PostInstallResult {
|
||||
const errors: SnapshotError[] = collectPrefixed(raw, "ERR=").map((kind) => ({
|
||||
source: "post_install",
|
||||
kind,
|
||||
severity: "error",
|
||||
message: `Échec post-install : ${kind}`,
|
||||
}));
|
||||
|
||||
const oldEndpoint = firstPrefixed(raw, "OLD_ENDPOINT=");
|
||||
const newEndpoint = firstPrefixed(raw, "NEW_ENDPOINT=");
|
||||
const networkChange = oldEndpoint !== null || newEndpoint !== null
|
||||
? { oldEndpoint: oldEndpoint || null, newEndpoint: newEndpoint || null, reconnectHost: newEndpoint || null }
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
profilesRun,
|
||||
variablesUsed,
|
||||
filesModified: collectPrefixed(raw, "FILE_MODIFIED="),
|
||||
packagesInstalled: collectPrefixed(raw, "PKG_INSTALLED="),
|
||||
servicesEnabled: collectPrefixed(raw, "SERVICE_ENABLED="),
|
||||
rebootsRequested: /REBOOT_REQUESTED=1/.test(raw),
|
||||
...(networkChange ? { networkChange } : {}),
|
||||
...(errors.length ? { errors } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Orchestration (SSH).
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
export interface PostInstallOutcome {
|
||||
result: PostInstallResult;
|
||||
raw: string;
|
||||
status: "ok" | "error";
|
||||
}
|
||||
|
||||
export async function runPostInstall(
|
||||
machineId: string,
|
||||
profileId: string,
|
||||
values: ProfileValues,
|
||||
onData?: (c: string) => void,
|
||||
): Promise<PostInstallOutcome> {
|
||||
const m = getMachineRow(machineId);
|
||||
if (!m) throw new Error("Machine introuvable");
|
||||
const manifest = PROFILES[profileId];
|
||||
if (!manifest) throw new Error(`Profil inconnu : ${profileId}`);
|
||||
const validation = validateProfileValues(manifest, values);
|
||||
if (!validation.ok) {
|
||||
throw new Error("Champs invalides : " + validation.errors.map((e) => `${e.field} (${e.message})`).join(", "));
|
||||
}
|
||||
|
||||
const script = renderProfile(profileId, values);
|
||||
const res = await runScriptSudo(getCreds(m), script, (c) => {
|
||||
onData?.(c);
|
||||
outputHub.publish(machineId, c);
|
||||
});
|
||||
const raw = res.stdout;
|
||||
const result = buildPostInstallResult(raw, [profileId], nonSecretValues(manifest, values));
|
||||
const failed = (result.errors?.length ?? 0) > 0 || (/===SU:EXIT=\d+===/.test(raw) && !/===SU:EXIT=0===/.test(raw));
|
||||
return { result, raw, status: failed ? "error" : "ok" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reboote la machine puis attend son retour sur la NOUVELLE IP, et corrige la BDD
|
||||
* (hostname SSH + nom) si la reconnexion réussit. Sécurité : si la machine ne revient
|
||||
* pas sur la nouvelle IP, la BDD n'est PAS modifiée (récupération via console + backups).
|
||||
*/
|
||||
export async function rebootAndRebind(
|
||||
machineId: string,
|
||||
newHost: string,
|
||||
newName: string | null,
|
||||
onData?: (c: string) => void,
|
||||
): Promise<RebootResult> {
|
||||
const m = getMachineRow(machineId);
|
||||
if (!m) throw new Error("Machine introuvable");
|
||||
const creds = getCreds(m);
|
||||
|
||||
// 1) Reboot sur l'ancienne connexion (capture boot_id avant).
|
||||
let raw = "";
|
||||
try {
|
||||
const res = await runScriptSudo(creds, renderTemplate("apt/reboot.sh.tpl", {}), (c) => {
|
||||
raw += c;
|
||||
onData?.(c);
|
||||
outputHub.publish(machineId, c);
|
||||
}, 0);
|
||||
raw = res.stdout;
|
||||
} catch {
|
||||
/* la connexion tombe pendant le reboot : normal */
|
||||
}
|
||||
const beforeBootId = parseBootIdBefore(raw);
|
||||
outputHub.publish(machineId, `\n[reboot] attente du retour sur ${newHost}...\n`);
|
||||
|
||||
// 2) Reconnexion sur la NOUVELLE IP.
|
||||
const reboot = await verifyReboot({ ...creds, hostname: newHost }, {
|
||||
beforeBootId,
|
||||
requestedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// 3) Bascule BDD uniquement si la machine est bien revenue sur la nouvelle IP.
|
||||
if (reboot.status === "ok") {
|
||||
updateMachine(machineId, { hostname: newHost, ...(newName ? { name: newName } : {}) });
|
||||
outputHub.publish(machineId, `\n[reboot] machine basculée sur ${newHost}, BDD mise à jour.\n`);
|
||||
} else {
|
||||
outputHub.publish(machineId, `\n[reboot] reconnexion ${newHost} échouée (${reboot.status}) — BDD inchangée.\n`);
|
||||
}
|
||||
return reboot;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { runScriptSudo } from "../ssh/client.js";
|
||||
import { buildAptSnapshotDetail } from "./aptParse.js";
|
||||
import { outputHub } from "../ws/outputHub.js";
|
||||
import { deriveAptState, upsertMachineState, recordEvent } from "./machineState.js";
|
||||
import { extractImportantMessages, recordImportantMessages } from "./importantMessages.js";
|
||||
import type { UpdateSnapshot, MachineStatus, AptSnapshotDetail } from "@shared/types.js";
|
||||
|
||||
/** Extrait la section entre deux marqueurs ===SU:X=== d'une sortie de script. */
|
||||
@@ -82,6 +83,7 @@ export async function refreshMachine(machineId: string): Promise<UpdateSnapshot>
|
||||
db.update(schema.machines).set({ status, lastCheckedAt: checkedAt }).where(eq(schema.machines.id, machineId)).run();
|
||||
|
||||
upsertMachineState(machineId, deriveAptState(snapshot));
|
||||
recordImportantMessages(machineId, extractImportantMessages(raw, "apt"), { snapshotId });
|
||||
recordEvent({
|
||||
machineId,
|
||||
eventType: "apt_refresh",
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
// server/services/scheduler.ts
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { Cron } from "croner";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db, schema } from "../db/client.js";
|
||||
import { listMachines } from "./machines.js";
|
||||
import { refreshMachine } from "./refresh.js";
|
||||
import { collectMetrics } from "./machineMetrics.js";
|
||||
import { recordEvent } from "./machineState.js";
|
||||
|
||||
export type ScheduleAction = "apt_update_analyze" | "machine_metrics_simple" | "docker_scan";
|
||||
|
||||
export interface ScheduleScope {
|
||||
machineIds: "all" | string[];
|
||||
}
|
||||
|
||||
export interface ScheduleView {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
cron: string;
|
||||
timezone: string | null;
|
||||
scope: ScheduleScope;
|
||||
actions: ScheduleAction[];
|
||||
concurrency: number;
|
||||
lastRunAt: string | null;
|
||||
lastStatus: string | null;
|
||||
}
|
||||
|
||||
type ScheduleRow = typeof schema.schedules.$inferSelect;
|
||||
|
||||
function toView(r: ScheduleRow): ScheduleView {
|
||||
return {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
enabled: !!r.enabled,
|
||||
cron: r.cron,
|
||||
timezone: r.timezone,
|
||||
scope: JSON.parse(r.scopeJson) as ScheduleScope,
|
||||
actions: JSON.parse(r.actionsJson) as ScheduleAction[],
|
||||
concurrency: r.concurrency,
|
||||
lastRunAt: r.lastRunAt,
|
||||
lastStatus: r.lastStatus,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function listSchedules(): ScheduleView[] {
|
||||
return db.select().from(schema.schedules).all().map(toView);
|
||||
}
|
||||
|
||||
export function getSchedule(id: string): ScheduleView | null {
|
||||
const r = db.select().from(schema.schedules).where(eq(schema.schedules.id, id)).get();
|
||||
return r ? toView(r) : null;
|
||||
}
|
||||
|
||||
export interface ScheduleInput {
|
||||
name: string;
|
||||
cron: string;
|
||||
timezone?: string | null;
|
||||
enabled?: boolean;
|
||||
scope?: ScheduleScope;
|
||||
actions: ScheduleAction[];
|
||||
concurrency?: number;
|
||||
}
|
||||
|
||||
export function createSchedule(input: ScheduleInput): ScheduleView {
|
||||
// Valide l'expression cron (lève si invalide), sans planifier.
|
||||
new Cron(input.cron).stop();
|
||||
const id = randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
db.insert(schema.schedules).values({
|
||||
id,
|
||||
name: input.name,
|
||||
enabled: input.enabled === false ? 0 : 1,
|
||||
cron: input.cron,
|
||||
timezone: input.timezone ?? "Europe/Paris",
|
||||
scopeJson: JSON.stringify(input.scope ?? { machineIds: "all" }),
|
||||
actionsJson: JSON.stringify(input.actions),
|
||||
concurrency: input.concurrency ?? 2,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).run();
|
||||
reloadSchedules();
|
||||
return getSchedule(id)!;
|
||||
}
|
||||
|
||||
export function updateSchedule(id: string, input: Partial<ScheduleInput>): ScheduleView {
|
||||
const cur = db.select().from(schema.schedules).where(eq(schema.schedules.id, id)).get();
|
||||
if (!cur) throw new Error("Schedule introuvable");
|
||||
if (input.cron) new Cron(input.cron).stop(); // valide sans planifier
|
||||
db.update(schema.schedules).set({
|
||||
...(input.name !== undefined ? { name: input.name } : {}),
|
||||
...(input.enabled !== undefined ? { enabled: input.enabled ? 1 : 0 } : {}),
|
||||
...(input.cron !== undefined ? { cron: input.cron } : {}),
|
||||
...(input.timezone !== undefined ? { timezone: input.timezone } : {}),
|
||||
...(input.scope !== undefined ? { scopeJson: JSON.stringify(input.scope) } : {}),
|
||||
...(input.actions !== undefined ? { actionsJson: JSON.stringify(input.actions) } : {}),
|
||||
...(input.concurrency !== undefined ? { concurrency: input.concurrency } : {}),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}).where(eq(schema.schedules.id, id)).run();
|
||||
reloadSchedules();
|
||||
return getSchedule(id)!;
|
||||
}
|
||||
|
||||
export function deleteSchedule(id: string): void {
|
||||
db.delete(schema.schedules).where(eq(schema.schedules.id, id)).run();
|
||||
reloadSchedules();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exécution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const locked = new Set<string>();
|
||||
|
||||
function resolveMachineIds(scope: ScheduleScope): string[] {
|
||||
const all = listMachines().map((m) => m.id);
|
||||
return scope.machineIds === "all" ? all : scope.machineIds.filter((id) => all.includes(id));
|
||||
}
|
||||
|
||||
async function runActionOnMachine(machineId: string, action: ScheduleAction): Promise<void> {
|
||||
if (action === "apt_update_analyze") {
|
||||
await refreshMachine(machineId);
|
||||
} else if (action === "machine_metrics_simple") {
|
||||
await collectMetrics(machineId);
|
||||
} else if (action === "docker_scan") {
|
||||
const { scanDockerStacks } = await import("./dockerScan.js");
|
||||
await scanDockerStacks(machineId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Exécute un schedule : actions sur le périmètre, avec verrou par machine et concurrence. */
|
||||
export async function runSchedule(id: string): Promise<{ ran: number; errors: number }> {
|
||||
const sched = getSchedule(id);
|
||||
if (!sched) throw new Error("Schedule introuvable");
|
||||
const machineIds = resolveMachineIds(sched.scope);
|
||||
let ran = 0;
|
||||
let errors = 0;
|
||||
|
||||
const queue = [...machineIds];
|
||||
const worker = async () => {
|
||||
for (;;) {
|
||||
const machineId = queue.shift();
|
||||
if (!machineId) break;
|
||||
if (locked.has(machineId)) continue; // une action tourne déjà sur cette machine
|
||||
locked.add(machineId);
|
||||
try {
|
||||
for (const action of sched.actions) {
|
||||
await runActionOnMachine(machineId, action);
|
||||
}
|
||||
ran++;
|
||||
} catch (err) {
|
||||
errors++;
|
||||
recordEvent({
|
||||
machineId,
|
||||
eventType: "schedule_action_failed",
|
||||
severity: "warning",
|
||||
message: `Schedule « ${sched.name} » : ${(err as Error).message}`,
|
||||
});
|
||||
} finally {
|
||||
locked.delete(machineId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const pool = Math.max(1, Math.min(sched.concurrency, machineIds.length || 1));
|
||||
await Promise.all(Array.from({ length: pool }, () => worker()));
|
||||
|
||||
db.update(schema.schedules)
|
||||
.set({ lastRunAt: new Date().toISOString(), lastStatus: errors ? `partial (${errors} err)` : "ok" })
|
||||
.where(eq(schema.schedules.id, id))
|
||||
.run();
|
||||
return { ran, errors };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Enregistrement croner
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let jobs: Cron[] = [];
|
||||
|
||||
export function reloadSchedules(): void {
|
||||
for (const j of jobs) j.stop();
|
||||
jobs = [];
|
||||
for (const s of listSchedules()) {
|
||||
if (!s.enabled) continue;
|
||||
try {
|
||||
jobs.push(
|
||||
new Cron(s.cron, { timezone: s.timezone ?? undefined, name: s.id }, () => {
|
||||
runSchedule(s.id).catch((err) => console.error(`[scheduler] ${s.name}:`, (err as Error).message));
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(`[scheduler] cron invalide pour ${s.name}:`, (err as Error).message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function stopSchedules(): void {
|
||||
for (const j of jobs) j.stop();
|
||||
jobs = [];
|
||||
}
|
||||
@@ -20,4 +20,17 @@ describe("renderTemplate", () => {
|
||||
expect(out).toContain("===SU:APT_SIM_DISTUPGRADE===");
|
||||
expect(out).toContain("apt-mark showhold");
|
||||
});
|
||||
|
||||
it("rend les variables Docker en <% %> sans toucher aux Go-templates {{...}}", () => {
|
||||
const out = renderTemplate("docker/scan-compose.sh.tpl", { composeRoots: "/opt/stacks", composeScanDepth: 3 });
|
||||
expect(out).toContain("/opt/stacks");
|
||||
expect(out).toContain("{{.ID}}"); // Go-template Docker resté littéral
|
||||
expect(out).not.toContain("<%composeRoots%>");
|
||||
});
|
||||
|
||||
it("rétro-compat : les templates APT ({{ }}) restent fonctionnels", () => {
|
||||
const out = renderTemplate("apt/check.sh.tpl", { aptProxy: "http://proxy:3142" });
|
||||
expect(out).toContain("http://proxy:3142");
|
||||
expect(out).not.toContain("{{");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,12 +7,39 @@ const TEMPLATES_ROOT = resolve(process.cwd(), "templates");
|
||||
|
||||
export interface TemplateVars {
|
||||
aptProxy?: string | null;
|
||||
aptProxyUrl?: string | null; // proxy persistant (apt_proxy_persistent)
|
||||
// Docker template vars
|
||||
composeRoots?: string | number | null;
|
||||
composeScanDepth?: string | number | null;
|
||||
stackDir?: string | null;
|
||||
// Post-install (SJ-8) — toutes optionnelles, jamais de secret.
|
||||
operatorUser?: string | null;
|
||||
packages?: string | null; // liste shell-safe rendue par le backend
|
||||
newHostname?: string | null;
|
||||
domain?: string | null;
|
||||
interfaceName?: string | null;
|
||||
staticAddress?: string | null;
|
||||
gateway?: string | null;
|
||||
dnsNameservers?: string | null;
|
||||
reconnectHost?: string | null;
|
||||
dhcpEndpoint?: string | null;
|
||||
dockerUser?: string | null;
|
||||
composeRoot?: string | null;
|
||||
rebootAfterInstall?: boolean;
|
||||
[key: string]: unknown; // champs de profil dynamiques (typés au cas par cas)
|
||||
}
|
||||
|
||||
export function renderTemplate(relPath: string, vars: TemplateVars): string {
|
||||
export function renderTemplate(
|
||||
relPath: string,
|
||||
vars: TemplateVars,
|
||||
opts?: { tags?: [string, string] },
|
||||
): string {
|
||||
const tpl = readFileSync(resolve(TEMPLATES_ROOT, relPath), "utf8");
|
||||
// Mustache échappe le HTML par défaut; on désactive (ce sont des scripts shell).
|
||||
return Mustache.render(tpl, vars, {}, { escape: (s) => s });
|
||||
// Les templates Docker contiennent des Go-templates {{...}} : on bascule les
|
||||
// délimiteurs Mustache sur <% %> pour ne pas les interpréter.
|
||||
const tags = opts?.tags ?? (relPath.startsWith("docker/") ? (["<%", "%>"] as [string, string]) : undefined);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return Mustache.render(tpl, vars, {}, { escape: (s: any) => s, ...(tags ? { tags } : {}) } as any);
|
||||
}
|
||||
|
||||
/** Existence par défaut d'un template relatif à templates/. */
|
||||
|
||||
@@ -8,6 +8,7 @@ export type ActionType =
|
||||
| "apt_autoremove" | "apt_clean" | "reboot_verified"
|
||||
| "docker_scan" | "docker_inspect_current" | "docker_pull_check"
|
||||
| "docker_compose_apply" | "docker_prune_images" | "docker_compose_down"
|
||||
| "apt_proxy_persistent"
|
||||
| "machine_probe" | "post_install";
|
||||
export type ExecutionStatus = "ok" | "warning" | "error";
|
||||
export type ApiClientScope = "read" | "operate" | "admin" | "debug";
|
||||
@@ -183,6 +184,7 @@ export interface DockerImageChange {
|
||||
fromDigest?: string | null;
|
||||
toDigest?: string | null;
|
||||
operation: "pulled" | "recreated" | "pruned";
|
||||
dedupKey?: string; // empreinte fonctionnelle (mutualisation Hermes)
|
||||
}
|
||||
|
||||
export interface DockerExecutionResult {
|
||||
@@ -206,6 +208,23 @@ export interface RebootResult {
|
||||
errors?: SnapshotError[];
|
||||
}
|
||||
|
||||
export interface AptRepositoriesAnalysis {
|
||||
osFamily: OsFamily;
|
||||
components: string[];
|
||||
repos: { uri: string; suite: string; components: string[] }[];
|
||||
proxmox?: { enterprise: boolean; noSubscription: boolean };
|
||||
warnings: { kind: string; message: string }[];
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
export interface MachineMetricsSimple {
|
||||
collectedAt: string;
|
||||
cpu: { load1: number | null; load5: number | null; cores: number | null };
|
||||
memory: { totalBytes: number | null; usedBytes: number | null; availableBytes: number | null; usedPercent: number | null };
|
||||
filesystems: { mount: string; fstype: string; sizeBytes: number; usedBytes: number; usedPercent: number }[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface PostInstallResult {
|
||||
profilesRun: string[];
|
||||
variablesUsed: Record<string, string | number | boolean>;
|
||||
@@ -249,6 +268,9 @@ export interface MachineView {
|
||||
aptProxyUrl: string | null;
|
||||
status: MachineStatus;
|
||||
lastCheckedAt: string | null;
|
||||
// Ajouts SJ-7 (optionnels, rétro-compatibles) :
|
||||
machineKind?: MachineKind;
|
||||
virtualization?: string | null;
|
||||
}
|
||||
|
||||
/** Client API local/Hermes — ne contient jamais le token brut. */
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/bin/sh
|
||||
# Proxy APT persistant : écrit /etc/apt/apt.conf.d/01proxy (idempotent, sauvegarde l'existant).
|
||||
# Action explicite (écriture disque). aptProxyUrl est fourni par le backend (jamais un secret).
|
||||
export LC_ALL=C
|
||||
CONF=/etc/apt/apt.conf.d/01proxy
|
||||
echo "===SU:PROXY_BEFORE==="
|
||||
[ -f "$CONF" ] && cat "$CONF" || echo "ABSENT"
|
||||
echo "===SU:PROXY_WRITE==="
|
||||
{{#aptProxyUrl}}
|
||||
# Sauvegarde horodatée si le fichier existe déjà.
|
||||
[ -f "$CONF" ] && cp -a "$CONF" "${CONF}.bak.$(date +%Y%m%d%H%M%S)" && echo "BACKUP=1"
|
||||
printf 'Acquire::http::Proxy "%s";\nAcquire::https::Proxy "%s";\n' "{{aptProxyUrl}}" "{{aptProxyUrl}}" > "$CONF"
|
||||
CODE=$?
|
||||
echo "WROTE=$CONF"
|
||||
{{/aptProxyUrl}}
|
||||
{{^aptProxyUrl}}
|
||||
echo "NO_PROXY_URL"
|
||||
CODE=2
|
||||
{{/aptProxyUrl}}
|
||||
echo "===SU:PROXY_AFTER==="
|
||||
cat "$CONF" 2>/dev/null || echo "ABSENT"
|
||||
echo "===SU:EXIT=${CODE}==="
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/bin/sh
|
||||
# Métriques légères CPU/RAM/disque. Non destructif, rapide, sans installation.
|
||||
export LC_ALL=C
|
||||
echo "===SU:METRICS_CPU==="
|
||||
cat /proc/loadavg 2>/dev/null
|
||||
nproc 2>/dev/null
|
||||
echo "===SU:METRICS_MEM==="
|
||||
grep -E '^(MemTotal|MemAvailable):' /proc/meminfo 2>/dev/null
|
||||
echo "===SU:METRICS_FS==="
|
||||
df -B1 -T -x tmpfs -x devtmpfs -x overlay -x squashfs 2>/dev/null \
|
||||
| awk 'NR>1 {print "FS\t"$7"\t"$2"\t"$3"\t"$4"\t"$6}'
|
||||
echo "===SU:EXIT=0==="
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/bin/sh
|
||||
# Sonde lecture seule : OS, arch, virtualisation, Proxmox/RPi, GPU, réseau.
|
||||
# Aucune écriture. Le backend propose des corrections (jamais appliquées sans validation).
|
||||
export LC_ALL=C
|
||||
echo "===SU:PROBE_OS==="
|
||||
cat /etc/os-release 2>/dev/null
|
||||
echo "===SU:PROBE_ARCH==="
|
||||
uname -m
|
||||
dpkg --print-architecture 2>/dev/null
|
||||
echo "===SU:PROBE_VIRT==="
|
||||
systemd-detect-virt 2>/dev/null || echo "none"
|
||||
echo "===SU:PROBE_PROXMOX==="
|
||||
[ -d /etc/pve ] && echo "PROXMOX=1" || echo "PROXMOX=0"
|
||||
echo "===SU:PROBE_RPI==="
|
||||
grep -qi raspberry /proc/cpuinfo 2>/dev/null && echo "RPI=1" || echo "RPI=0"
|
||||
echo "===SU:PROBE_GPU==="
|
||||
command -v lspci >/dev/null 2>&1 && lspci 2>/dev/null | grep -Ei 'vga|3d|display' || echo "no-lspci"
|
||||
echo "===SU:PROBE_NET==="
|
||||
ip -o -4 addr show 2>/dev/null | awk '{print $2, $4}'
|
||||
echo "===SU:PROBE_CPU==="
|
||||
LANG=C lscpu 2>/dev/null | grep -E '^Model name:' | sed 's/^Model name:[[:space:]]*/MODEL=/' || true
|
||||
nproc 2>/dev/null
|
||||
echo "===SU:PROBE_MEM==="
|
||||
grep -E '^MemTotal:' /proc/meminfo 2>/dev/null
|
||||
echo "===SU:PROBE_DISK==="
|
||||
lsblk -b -d -n -o NAME,TYPE,SIZE 2>/dev/null | awk '$2=="disk"{print "DISK\t"$1"\t"$3}'
|
||||
echo "===SU:EXIT=0==="
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/bin/sh
|
||||
# Analyse des dépôts APT (lecture seule). Ne modifie rien.
|
||||
export LC_ALL=C
|
||||
echo "===SU:REPO_DEB==="
|
||||
grep -rhE '^[[:space:]]*deb[[:space:]]' /etc/apt/sources.list /etc/apt/sources.list.d/ 2>/dev/null | grep -vE '^[[:space:]]*#'
|
||||
echo "===SU:REPO_DEB822==="
|
||||
grep -rhE '^(URIs|Suites|Components|Enabled):' /etc/apt/sources.list.d/ 2>/dev/null
|
||||
echo "===SU:EXIT=0==="
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/bin/sh
|
||||
# Bootstrap première prépa (après DHCP / su -) : sudo + outils de base, ajout au groupe sudo.
|
||||
# Non interactif. Échec contrôlé. Aucun secret (operatorUser = champ de formulaire).
|
||||
export LC_ALL=C
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
echo "===SU:CUSTOM_BOOTSTRAP==="
|
||||
apt-get update -qq 2>&1
|
||||
if apt-get install -y sudo resolvconf ca-certificates curl 2>&1; then
|
||||
for p in sudo resolvconf ca-certificates curl; do echo "PKG_INSTALLED=$p"; done
|
||||
CODE=0
|
||||
else
|
||||
echo "ERR=package_install_failed"
|
||||
CODE=1
|
||||
fi
|
||||
if usermod -aG sudo "{{operatorUser}}" 2>&1; then
|
||||
echo "GROUP_ADDED=sudo:{{operatorUser}}"
|
||||
else
|
||||
echo "ERR=sudo_setup_failed"
|
||||
fi
|
||||
if su - "{{operatorUser}}" -c 'sudo -n true' 2>/dev/null; then
|
||||
echo "SUDO_OK=1"
|
||||
else
|
||||
echo "SUDO_CHECK_PENDING=1"
|
||||
fi
|
||||
echo "===SU:EXIT=${CODE}==="
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/bin/sh
|
||||
# Docker Engine depuis le dépôt officiel Debian (docs.docker.com/engine/install/debian).
|
||||
export LC_ALL=C
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
echo "===SU:CUSTOM_DOCKER==="
|
||||
apt-get update -qq 2>&1
|
||||
apt-get install -y ca-certificates curl 2>&1
|
||||
install -m 0755 -d /etc/apt/keyrings
|
||||
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc 2>&1
|
||||
chmod a+r /etc/apt/keyrings/docker.asc
|
||||
. /etc/os-release
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian ${VERSION_CODENAME} stable" > /etc/apt/sources.list.d/docker.list && echo "FILE_MODIFIED=/etc/apt/sources.list.d/docker.list"
|
||||
apt-get update -qq 2>&1
|
||||
if apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin 2>&1; then
|
||||
for p in docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin; do echo "PKG_INSTALLED=$p"; done
|
||||
echo "SERVICE_ENABLED=docker"
|
||||
CODE=0
|
||||
else
|
||||
echo "ERR=docker_install_failed"
|
||||
CODE=1
|
||||
fi
|
||||
usermod -aG docker "{{dockerUser}}" 2>&1 && echo "GROUP_ADDED=docker:{{dockerUser}}" || echo "ERR=docker_group_failed"
|
||||
mkdir -p "{{composeRoot}}" 2>&1 && echo "FILE_MODIFIED={{composeRoot}}"
|
||||
echo "DOCKER_GROUP_RELOGIN_REQUIRED=1"
|
||||
{{#rebootAfterInstall}}echo "REBOOT_REQUESTED=1"{{/rebootAfterInstall}}
|
||||
echo "===SU:EXIT=${CODE}==="
|
||||
@@ -0,0 +1,97 @@
|
||||
#!/bin/sh
|
||||
# Identité + réseau : hostname, /etc/hosts, IP statique (ifupdown drop-in).
|
||||
# Le changement d'IP s'applique AU REBOOT (on ne coupe jamais SSH en live).
|
||||
# Sauvegardes horodatées avant toute écriture. Échec contrôlé.
|
||||
export LC_ALL=C
|
||||
HOST="{{newHostname}}"
|
||||
DOMAIN="{{domain}}"
|
||||
IFACE="{{interfaceName}}"
|
||||
ADDR="{{staticAddress}}"
|
||||
GW="{{gateway}}"
|
||||
DNS="{{dnsNameservers}}"
|
||||
echo "===SU:CUSTOM_IDENTITY==="
|
||||
|
||||
# --- Précheck : Debian + ifupdown uniquement (MVP, cible VM netinstall) ---
|
||||
. /etc/os-release 2>/dev/null
|
||||
if [ "$ID" != "debian" ]; then
|
||||
echo "ERR=os_not_supported"
|
||||
echo "DETAIL=identity_network ne gère que Debian (ID=$ID)"
|
||||
echo "===SU:EXIT=2==="
|
||||
exit 2
|
||||
fi
|
||||
if ls /etc/netplan/*.yaml >/dev/null 2>&1; then
|
||||
echo "ERR=unsupported_network_manager"
|
||||
echo "DETAIL=netplan détecté ; ce profil cible ifupdown"
|
||||
echo "===SU:EXIT=2==="
|
||||
exit 2
|
||||
fi
|
||||
if [ ! -f /etc/network/interfaces ]; then
|
||||
echo "ERR=ifupdown_not_found"
|
||||
echo "DETAIL=/etc/network/interfaces absent"
|
||||
echo "===SU:EXIT=2==="
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# --- Sauvegardes ---
|
||||
TS=$(date +%s)
|
||||
cp -a /etc/hosts "/etc/hosts.su.bak.${TS}" 2>/dev/null
|
||||
[ -f /etc/network/interfaces ] && cp -a /etc/network/interfaces "/etc/network/interfaces.su.bak.${TS}" 2>/dev/null
|
||||
[ -f /etc/hostname ] && cp -a /etc/hostname "/etc/hostname.su.bak.${TS}" 2>/dev/null
|
||||
|
||||
echo "OLD_ENDPOINT={{dhcpEndpoint}}"
|
||||
|
||||
# --- Hostname (immédiat, ne coupe pas SSH) ---
|
||||
if hostnamectl set-hostname "$HOST" 2>/dev/null || { printf '%s\n' "$HOST" > /etc/hostname; }; then
|
||||
printf '%s\n' "$HOST" > /etc/hostname
|
||||
echo "HOSTNAME_SET=$HOST"
|
||||
echo "FILE_MODIFIED=/etc/hostname"
|
||||
else
|
||||
echo "ERR=hostname_failed"
|
||||
fi
|
||||
|
||||
# --- /etc/hosts : ligne 127.0.1.1 <fqdn> <host> ---
|
||||
FQDN="$HOST"
|
||||
[ -n "$DOMAIN" ] && FQDN="$HOST.$DOMAIN"
|
||||
if grep -qE '^127\.0\.1\.1' /etc/hosts 2>/dev/null; then
|
||||
sed -i -E "s|^127\.0\.1\.1.*|127.0.1.1\t${FQDN} ${HOST}|" /etc/hosts && echo "FILE_MODIFIED=/etc/hosts"
|
||||
else
|
||||
printf '127.0.1.1\t%s %s\n' "$FQDN" "$HOST" >> /etc/hosts && echo "FILE_MODIFIED=/etc/hosts"
|
||||
fi
|
||||
|
||||
# --- IP statique (ifupdown drop-in, appliqué au reboot) ---
|
||||
if ip link show "$IFACE" >/dev/null 2>&1; then
|
||||
echo "IFACE_OK=$IFACE"
|
||||
mkdir -p /etc/network/interfaces.d
|
||||
# S'assure que le fichier principal source le répertoire interfaces.d.
|
||||
if [ -f /etc/network/interfaces ] && ! grep -qE '^[[:space:]]*source(-directory)?[[:space:]]+/etc/network/interfaces\.d' /etc/network/interfaces; then
|
||||
printf '\nsource /etc/network/interfaces.d/*\n' >> /etc/network/interfaces
|
||||
fi
|
||||
# Neutralise (commente) toute strophe existante de l'interface dans le fichier principal.
|
||||
if [ -f /etc/network/interfaces ]; then
|
||||
awk -v IFACE="$IFACE" '
|
||||
$0 ~ "^[[:space:]]*(auto|allow-hotplug)[[:space:]]+" IFACE "([[:space:]]|$)" { print "#SU# " $0; next }
|
||||
$0 ~ "^[[:space:]]*iface[[:space:]]+" IFACE "([[:space:]]|$)" { inblk=1; print "#SU# " $0; next }
|
||||
inblk==1 && $0 ~ /^[[:space:]]+[^[:space:]]/ { print "#SU# " $0; next }
|
||||
inblk==1 { inblk=0 }
|
||||
{ print }
|
||||
' /etc/network/interfaces > /etc/network/interfaces.su.tmp && cat /etc/network/interfaces.su.tmp > /etc/network/interfaces && rm -f /etc/network/interfaces.su.tmp
|
||||
fi
|
||||
# Écrit la configuration statique en drop-in.
|
||||
{
|
||||
echo "auto $IFACE"
|
||||
echo "iface $IFACE inet static"
|
||||
echo " address $ADDR"
|
||||
echo " gateway $GW"
|
||||
[ -n "$DNS" ] && echo " dns-nameservers $DNS"
|
||||
} > "/etc/network/interfaces.d/${IFACE}.cfg"
|
||||
echo "FILE_MODIFIED=/etc/network/interfaces.d/${IFACE}.cfg"
|
||||
echo "STATIC_TARGET=$ADDR gw $GW dns $DNS"
|
||||
else
|
||||
echo "ERR=interface_not_found"
|
||||
fi
|
||||
|
||||
echo "NEW_ENDPOINT={{reconnectHost}}"
|
||||
echo "RECONNECT_REQUIRED=1"
|
||||
echo "NETWORK_APPLIES_ON=reboot"
|
||||
{{#rebootAfterInstall}}echo "REBOOT_REQUESTED=1"{{/rebootAfterInstall}}
|
||||
echo "===SU:EXIT=0==="
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/bin/sh
|
||||
# Installe un groupe de paquets. {{packages}} = liste shell-safe fournie par le backend.
|
||||
export LC_ALL=C
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
echo "===SU:CUSTOM_PKGGROUPS==="
|
||||
apt-get update -qq 2>&1
|
||||
if apt-get install -y {{packages}} 2>&1; then
|
||||
for p in {{packages}}; do echo "PKG_INSTALLED=$p"; done
|
||||
CODE=0
|
||||
else
|
||||
echo "ERR=package_install_failed"
|
||||
CODE=1
|
||||
fi
|
||||
echo "===SU:EXIT=${CODE}==="
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/bin/sh
|
||||
# Partage réseau : installe les paquets cochés (Samba/NFS/mDNS). Config détaillée = tâche 4.
|
||||
export LC_ALL=C
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
echo "===SU:CUSTOM_SHARING==="
|
||||
PKGS=""
|
||||
{{#installSamba}}PKGS="$PKGS samba"{{/installSamba}}
|
||||
{{#installNfs}}PKGS="$PKGS nfs-kernel-server"{{/installNfs}}
|
||||
{{#installMdns}}PKGS="$PKGS avahi-daemon libnss-mdns"{{/installMdns}}
|
||||
if [ -z "$PKGS" ]; then
|
||||
echo "ERR=no_sharing_selected"
|
||||
echo "===SU:EXIT=2==="
|
||||
exit 2
|
||||
fi
|
||||
apt-get update -qq 2>&1
|
||||
if apt-get install -y $PKGS 2>&1; then
|
||||
for p in $PKGS; do echo "PKG_INSTALLED=$p"; done
|
||||
{{#installSamba}}echo "SERVICE_ENABLED=smbd"{{/installSamba}}
|
||||
{{#installNfs}}echo "SERVICE_ENABLED=nfs-kernel-server"{{/installNfs}}
|
||||
{{#installMdns}}echo "SERVICE_ENABLED=avahi-daemon"{{/installMdns}}
|
||||
CODE=0
|
||||
else
|
||||
echo "ERR=sharing_install_failed"
|
||||
CODE=1
|
||||
fi
|
||||
echo "===SU:EXIT=${CODE}==="
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/bin/sh
|
||||
# Agent invité VM : {{guestAgent}} = qemu-guest-agent ou open-vm-tools.
|
||||
export LC_ALL=C
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
echo "===SU:CUSTOM_VMTOOLS==="
|
||||
apt-get update -qq 2>&1
|
||||
if apt-get install -y {{guestAgent}} 2>&1; then
|
||||
echo "PKG_INSTALLED={{guestAgent}}"
|
||||
echo "SERVICE_ENABLED={{guestAgent}}"
|
||||
CODE=0
|
||||
else
|
||||
echo "ERR=vmtools_install_failed"
|
||||
CODE=1
|
||||
fi
|
||||
echo "===SU:EXIT=${CODE}==="
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
export LC_ALL=C
|
||||
cd "<%stackDir%>" || { echo "===SU:DOCKER_ERR==="; echo "compose_not_found"; echo "===SU:EXIT=2==="; exit 2; }
|
||||
echo "===SU:DOCKER_APPLY==="
|
||||
docker compose up -d --remove-orphans 2>&1
|
||||
CODE=$?
|
||||
echo "===SU:DOCKER_PS_AFTER==="
|
||||
docker compose ps --format json 2>&1
|
||||
echo "===SU:DOCKER_INSPECT_AFTER==="
|
||||
docker compose config --images 2>/dev/null | while IFS= read -r img; do
|
||||
docker image inspect "$img" --format 'IMG\t{{.Id}}\t{{join .RepoDigests ","}}' 2>/dev/null || echo "IMG_MISSING\t$img"
|
||||
done
|
||||
echo "===SU:EXIT=${CODE}==="
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/bin/sh
|
||||
export LC_ALL=C
|
||||
cd "<%stackDir%>" || { echo "===SU:DOCKER_ERR==="; echo "compose_not_found"; echo "===SU:EXIT=2==="; exit 2; }
|
||||
echo "===SU:DOCKER_DOWN==="
|
||||
# --volumes et --rmi INTERDITS au MVP : down simple uniquement (préserve les volumes).
|
||||
docker compose down 2>&1
|
||||
CODE=$?
|
||||
echo "===SU:EXIT=${CODE}==="
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/bin/sh
|
||||
export LC_ALL=C
|
||||
cd "<%stackDir%>" || { echo "===SU:DOCKER_ERR==="; echo "compose_not_found"; echo "===SU:EXIT=2==="; exit 2; }
|
||||
echo "===SU:DOCKER_CONFIG_IMAGES==="
|
||||
docker compose config --images 2>&1
|
||||
echo "===SU:DOCKER_PS==="
|
||||
docker compose ps --format json 2>&1
|
||||
echo "===SU:DOCKER_IMAGES==="
|
||||
docker compose images --format json 2>&1
|
||||
echo "===SU:DOCKER_INSPECT==="
|
||||
docker compose config --images 2>/dev/null | while IFS= read -r img; do
|
||||
docker image inspect "$img" \
|
||||
--format 'IMG\t{{.Id}}\t{{join .RepoDigests ","}}\t{{index .Config.Labels "org.opencontainers.image.version"}}\t{{index .Config.Labels "org.opencontainers.image.source"}}' 2>/dev/null \
|
||||
|| echo "IMG_MISSING\t$img"
|
||||
done
|
||||
echo "===SU:EXIT=0==="
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
export LC_ALL=C
|
||||
echo "===SU:DOCKER_PRUNE==="
|
||||
<%#aggressive%>
|
||||
# Mode agressif : supprime TOUTES les images non référencées (>168h). Validation UI distincte.
|
||||
docker image prune -a -f --filter "until=168h" 2>&1
|
||||
<%/aggressive%>
|
||||
<%^aggressive%>
|
||||
# Mode sûr par défaut : images dangling uniquement.
|
||||
docker image prune -f 2>&1
|
||||
<%/aggressive%>
|
||||
CODE=$?
|
||||
echo "===SU:EXIT=${CODE}==="
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/bin/sh
|
||||
export LC_ALL=C
|
||||
cd "<%stackDir%>" || { echo "===SU:DOCKER_ERR==="; echo "compose_not_found"; echo "===SU:EXIT=2==="; exit 2; }
|
||||
echo "===SU:DOCKER_INSPECT_BEFORE==="
|
||||
docker compose config --images 2>/dev/null | while IFS= read -r img; do
|
||||
id=$(docker image inspect "$img" --format '{{.Id}}' 2>/dev/null || echo "")
|
||||
dg=$(docker image inspect "$img" --format '{{join .RepoDigests ","}}' 2>/dev/null || echo "")
|
||||
echo "BEFORE $img $id $dg"
|
||||
done
|
||||
echo "===SU:DOCKER_PULL==="
|
||||
# Télécharge les images candidates SANS démarrer de conteneurs.
|
||||
docker compose pull --policy always --ignore-buildable 2>&1
|
||||
CODE=$?
|
||||
echo "===SU:DOCKER_INSPECT_AFTER==="
|
||||
docker compose config --images 2>/dev/null | while IFS= read -r img; do
|
||||
id=$(docker image inspect "$img" --format '{{.Id}}' 2>/dev/null || echo "")
|
||||
dg=$(docker image inspect "$img" --format '{{join .RepoDigests ","}}' 2>/dev/null || echo "")
|
||||
ver=$(docker image inspect "$img" --format '{{index .Config.Labels "org.opencontainers.image.version"}}' 2>/dev/null || echo "")
|
||||
echo "AFTER $img $id $dg $ver"
|
||||
done
|
||||
echo "===SU:EXIT=${CODE}==="
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/bin/sh
|
||||
export LC_ALL=C
|
||||
echo "===SU:DOCKER_SCAN==="
|
||||
ROOTS="<%composeRoots%>"
|
||||
DEPTH="<%composeScanDepth%>"
|
||||
for root in $ROOTS; do
|
||||
[ -d "$root" ] || continue
|
||||
find "$root" -maxdepth "$DEPTH" -type f \
|
||||
\( -name 'compose.yaml' -o -name 'compose.yml' \
|
||||
-o -name 'docker-compose.yaml' -o -name 'docker-compose.yml' \) \
|
||||
-not -path '*/.git/*' -not -path '*/node_modules/*' \
|
||||
-not -path '*/backup/*' -not -path '*/old/*' -not -path '*/archive/*' \
|
||||
2>/dev/null | while IFS= read -r f; do
|
||||
dir=$(dirname "$f")
|
||||
if docker compose -f "$f" config --quiet >/dev/null 2>&1; then
|
||||
echo "STACK_OK\tdir=$dir\tfile=$f"
|
||||
else
|
||||
echo "STACK_INVALID\tdir=$dir\tfile=$f"
|
||||
fi
|
||||
done
|
||||
done
|
||||
echo "===SU:DOCKER_LABELS==="
|
||||
docker ps --format '{{.ID}}' 2>/dev/null | while read -r id; do
|
||||
proj=$(docker inspect --format '{{index .Config.Labels "com.docker.compose.project"}}' "$id" 2>/dev/null)
|
||||
wd=$(docker inspect --format '{{index .Config.Labels "com.docker.compose.project.working_dir"}}' "$id" 2>/dev/null)
|
||||
[ -n "$proj" ] && echo "ACTIVE\tproject=$proj\tworking_dir=$wd"
|
||||
done
|
||||
echo "===SU:EXIT=0==="
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/bin/sh
|
||||
# Proxmox VE : dist-upgrade (kernel PVE, proxmox-ve, Ceph). Capture diff dpkg.
|
||||
export LC_ALL=C
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
{{#aptProxy}}export http_proxy="{{aptProxy}}"; export https_proxy="{{aptProxy}}"
|
||||
{{/aptProxy}}
|
||||
echo "===SU:DPKG_BEFORE==="
|
||||
dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n' 2>/dev/null
|
||||
echo "===SU:APT_FULLUPGRADE==="
|
||||
apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold dist-upgrade 2>&1
|
||||
CODE=$?
|
||||
echo "===SU:DPKG_AFTER==="
|
||||
dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n' 2>/dev/null
|
||||
echo "===SU:REBOOT==="
|
||||
if [ -f /run/reboot-required ] || [ -f /var/run/reboot-required ]; then echo "REBOOT_REQUIRED=1"; else echo "REBOOT_REQUIRED=0"; fi
|
||||
echo "===SU:EXIT=${CODE}==="
|
||||
@@ -0,0 +1,37 @@
|
||||
#!/bin/sh
|
||||
# Proxmox VE : refresh index + simulations + held + reboot-check + état des dépôts PVE.
|
||||
# Non destructif. Exécuté entier sous sudo par la couche SSH.
|
||||
export LC_ALL=C
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
{{#aptProxy}}export http_proxy="{{aptProxy}}"; export https_proxy="{{aptProxy}}"
|
||||
{{/aptProxy}}
|
||||
|
||||
echo "===SU:PVE_REPOS==="
|
||||
# Détecte le dépôt entreprise actif sans abonnement (cause classique d'échec apt update).
|
||||
grep -RhsE '^[^#]*deb .*enterprise\.proxmox\.com' /etc/apt/sources.list /etc/apt/sources.list.d/ 2>/dev/null \
|
||||
| sed 's/^/ENTERPRISE_REPO=/' || true
|
||||
grep -RhsE '^[^#]*deb .*download\.proxmox\.com.*pve-no-subscription' /etc/apt/sources.list /etc/apt/sources.list.d/ 2>/dev/null \
|
||||
| sed 's/^/NOSUB_REPO=/' || true
|
||||
|
||||
echo "===SU:APT_UPDATE==="
|
||||
apt-get update -qq 2>&1
|
||||
UPD=$?
|
||||
|
||||
echo "===SU:APT_SIM_UPGRADE==="
|
||||
apt-get -s -y upgrade 2>&1
|
||||
|
||||
echo "===SU:APT_SIM_DISTUPGRADE==="
|
||||
apt-get -s -y dist-upgrade 2>&1
|
||||
|
||||
echo "===SU:APT_HELD==="
|
||||
apt-mark showhold 2>/dev/null
|
||||
|
||||
echo "===SU:REBOOT==="
|
||||
if [ -f /run/reboot-required ] || [ -f /var/run/reboot-required ]; then
|
||||
echo "REBOOT_REQUIRED=1"
|
||||
[ -f /var/run/reboot-required.pkgs ] && sed 's/^/PKG=/' /var/run/reboot-required.pkgs
|
||||
else
|
||||
echo "REBOOT_REQUIRED=0"
|
||||
fi
|
||||
|
||||
echo "===SU:EXIT=${UPD}==="
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/bin/sh
|
||||
# Raspberry Pi OS : full-upgrade (apt) après contrôle d'espace disque. Capture diff dpkg.
|
||||
export LC_ALL=C
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
{{#aptProxy}}export http_proxy="{{aptProxy}}"; export https_proxy="{{aptProxy}}"
|
||||
{{/aptProxy}}
|
||||
echo "===SU:DISK==="
|
||||
df -Pk / 2>/dev/null | awk 'NR==2{print "ROOT_AVAIL_KB="$4"\nROOT_USE_PCT="$5}'
|
||||
echo "===SU:DPKG_BEFORE==="
|
||||
dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n' 2>/dev/null
|
||||
echo "===SU:APT_FULLUPGRADE==="
|
||||
apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold full-upgrade 2>&1
|
||||
CODE=$?
|
||||
echo "===SU:DPKG_AFTER==="
|
||||
dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n' 2>/dev/null
|
||||
echo "===SU:REBOOT==="
|
||||
if [ -f /run/reboot-required ] || [ -f /var/run/reboot-required ]; then echo "REBOOT_REQUIRED=1"; else echo "REBOOT_REQUIRED=0"; fi
|
||||
echo "===SU:EXIT=${CODE}==="
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/bin/sh
|
||||
# Raspberry Pi OS : refresh + simulations + held + reboot-check + espace disque (carte SD).
|
||||
# Non destructif. rpi-update volontairement NON utilisé (risqué).
|
||||
export LC_ALL=C
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
{{#aptProxy}}export http_proxy="{{aptProxy}}"; export https_proxy="{{aptProxy}}"
|
||||
{{/aptProxy}}
|
||||
|
||||
echo "===SU:DISK==="
|
||||
# Espace libre sur / en Ko (carte SD souvent petite) → le backend peut avertir avant upgrade.
|
||||
df -Pk / 2>/dev/null | awk 'NR==2{print "ROOT_AVAIL_KB="$4"\nROOT_USE_PCT="$5}'
|
||||
|
||||
echo "===SU:APT_UPDATE==="
|
||||
apt-get update -qq 2>&1
|
||||
UPD=$?
|
||||
|
||||
echo "===SU:APT_SIM_UPGRADE==="
|
||||
apt-get -s -y upgrade 2>&1
|
||||
|
||||
echo "===SU:APT_SIM_DISTUPGRADE==="
|
||||
apt-get -s -y dist-upgrade 2>&1
|
||||
|
||||
echo "===SU:APT_HELD==="
|
||||
apt-mark showhold 2>/dev/null
|
||||
|
||||
echo "===SU:REBOOT==="
|
||||
if [ -f /run/reboot-required ] || [ -f /var/run/reboot-required ]; then
|
||||
echo "REBOOT_REQUIRED=1"
|
||||
[ -f /var/run/reboot-required.pkgs ] && sed 's/^/PKG=/' /var/run/reboot-required.pkgs
|
||||
else
|
||||
echo "REBOOT_REQUIRED=0"
|
||||
fi
|
||||
|
||||
echo "===SU:EXIT=${UPD}==="
|
||||
Reference in New Issue
Block a user