Compare commits

...

26 Commits

Author SHA1 Message Date
gilles fa73ab07b0 feat(events): timeline d'événements machine (tâche 5 backlog)
- listMachineEvents (machine_events, 30 derniers, desc) + route GET /machines/:id/events
- api machineEvents ; section repliable « Timeline » dans le panneau détail
  (badge sévérité + horodatage), exploite les events déjà enregistrés par recordEvent

tsc 0 · 118 tests · build OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 19:36:06 +02:00
gilles a93a43e1c8 feat(messages): extraction des messages importants APT (tâche 5 backlog)
- extractImportantMessages (TDD) : E:/dpkg error → error, W:/GPG → warning,
  déprecations/EOL → future_major_change ; nettoyage des secrets dans les URLs
- recordImportantMessages : dédup par (machine, source, message) non acquitté →
  maj lastSeenAt, sinon insert (firstSeen/lastSeen) dans important_messages
- branché dans refreshMachine (sortie APT) avec snapshotId
- routes GET /machines/:id/messages + POST .../:msgId/ack
- UI : carte « Messages importants » (badge sévérité + ack) dans le panneau détail

tsc 0 · 118 tests · build OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 19:30:07 +02:00
gilles ff9cfaa9e1 feat(scheduler): automatisations planifiées (cron) — tâche 5
- table schedules (migration 0007) + service scheduler (croner) : CRUD,
  runSchedule avec scope (all/liste), pool de concurrence et verrou par machine,
  mapping actions → refresh/metrics/docker_scan ; reloadSchedules au boot
- worker = reloadSchedules (remplace le refresh 30 min en dur)
- routes /api/schedules (CRUD + :id/run) ; cron invalide rejeté (validation croner)
- UI Paramètres : onglet « Automatisations » (liste, activer/lancer/supprimer, création)

tsc 0 · 113 tests · build OK · boot OK (migration 0007, CRUD vérifié).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 19:25:44 +02:00
gilles bdbe7af55c docs(amelioration): backlog — update-all, listing double-scroll, su fallback, scripts configurables, partages Samba/NFS
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 19:20:24 +02:00
gilles 1530409d3b feat(post-install): identity_network reboote et rebascule l'IP en BDD (tâche 4)
- updateMachine accepte name/hostname/port (correction d'identité réseau)
- rebootAndRebind : reboot sur l'ancienne connexion → attente du retour sur la
  NOUVELLE IP (verifyReboot avec host cible) → maj BDD (hostname + nom) si OK.
  Sécurité : BDD inchangée si la machine ne revient pas (récupération console/backups)
- execute post_install : si identity_network + reboot coché + succès, déclenche
  rebootAndRebind et joint le RebootResult ; statut error si reconnexion échoue

tsc 0 · 113 tests · build OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 19:12:39 +02:00
gilles 3ea2e66359 fix(post-install): identity_network cadré Debian/ifupdown (VM) avec précheck
- précheck en tête : refuse proprement si OS != debian (os_not_supported),
  si netplan présent (unsupported_network_manager) ou si /etc/network/interfaces
  absent (ifupdown_not_found) — au lieu d'écrire une conf inopérante
- manifeste : label « (Debian/VM) » + description précisant la cible ifupdown
  et l'application au reboot

Validé en réel sur Debian VM (ens18) : strophe DHCP commentée + drop-in statique.
sh -n OK · tsc 0 · 113 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 19:03:28 +02:00
gilles aaf1b4988d fix(post-install): identity_network écrit réellement hostname + /etc/hosts + IP statique
Le template SJ-8 était un stub (echo STATIC_TARGET sans écriture). Désormais :
- /etc/hostname + hostnamectl, ligne 127.0.1.1 <fqdn> <host> dans /etc/hosts
- IP statique via drop-in /etc/network/interfaces.d/<iface>.cfg (ifupdown),
  neutralisation awk de la strophe DHCP existante + source interfaces.d
- sauvegardes horodatées avant écriture ; réseau appliqué AU REBOOT (ne coupe
  jamais SSH en live) ; FILE_MODIFIED émis après écriture réelle

Cible Debian/ifupdown (netinstall). syntaxe sh -n validée sur rendu.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 18:47:44 +02:00
gilles d1b0290e3b feat(apt): analyse des dépôts APT (lecture seule) (tâche 4)
- template repositories (deb lines + deb822), non destructif
- analyzeRepositories (TDD) : composants, repos, détection Proxmox
  enterprise/no-subscription, warnings (pve_enterprise_without_subscription,
  pve_repo_missing) + notes Debian/Ubuntu composants manquants
- route POST /machines/:id/apt-repositories ; api analyzeRepositories
- popup config : bloc « Dépôts APT » (composants + warnings + notes)

Analyse uniquement (modification = action validée séparée, future). tsc 0 · 113 tests · build OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 18:41:11 +02:00
gilles e3e824185f feat(probe): sonde enrichie CPU/RAM/disques + recommandations de profils (tâche 4)
- template machine-probe : lscpu Model name + nproc, MemTotal, lsblk disques
- parseProbe étendu (cpuModel/cpuCores/memoryBytes/disks) + buildRecommendations
  (KVM/QEMU → vm_guest_tools) ; tests TDD
- runProbe persiste cpu/mem/disks dans machine_hardware ; /probe renvoie recommendations
- popup Sonde affiche cpu/ram/disks + profils conseillés

tsc 0 · 110 tests · build OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 18:36:49 +02:00
gilles c390addadb feat(metrics): machine_metrics_simple — CPU/RAM/disque live par machine (tâche 4)
- template machine-metrics (loadavg/nproc, /proc/meminfo, df -B1) non destructif
- parseMetrics (TDD) → cpu load/cores, mémoire kB→B + %, filesystems, warnings >=90%
- collectMetrics (SSH léger) persiste machine_metrics_latest ; getLatestMetrics (sans SSH)
- routes GET /machines/:id/metrics + POST /metrics/collect ; api latestMetrics/collectMetrics
- section Hardware : bloc métriques live (CPU/RAM/disques + alertes) + bouton Collecter
  → comble le gap « Health » de la tâche 3

tsc 0 · 108 tests · build OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 17:01:45 +02:00
gilles 58abebf687 feat(ui): ajout machine OS/type, section Hardware, identité app (tâche 3)
- AddMachineModal : sélecteurs OS + Type machine ; createMachine accepte
  osFamily/machineKind (manuel prioritaire, "Autre/auto" → détection os-release)
- section Hardware sur la tuile + panneau détail : os/type/virt/arch/gpu/réseau
  depuis machine_hardware (sonde) via GET /machines/:id/hardware
- identité : favicon.svg (serveur + LED Gruvbox), favicon.ico, apple-touch-icon,
  PWA 192/512, site.webmanifest ; liens + theme-color dans index.html

tsc 0 · 104 tests · build OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 14:07:46 +02:00
gilles 3b16fdd52a feat(post-install): catalogue de profils — paquets, Docker officiel, partages, VM tools (tâche 2 SJ-9)
- mécanisme presetVars (variables fixes injectées au rendu, surchargées par le formulaire)
- 6 profils : base_tools / network_tools / dev_git (listes de paquets, low),
  docker_official (dépôt officiel Debian, confirmation), sharing (Samba/NFS/mDNS, confirmation),
  vm_guest_tools (qemu/vmware)
- 4 templates custom (install-package-groups, docker-official-debian, sharing, vm-guest-tools)
  émettant PKG_INSTALLED/SERVICE_ENABLED/ERR → réutilise buildPostInstallResult
- l'UI post-install générique les expose automatiquement (manifeste → formulaire → run)

tsc 0 · 104 tests · build OK · boot OK (8 profils servis). Clôt le volet moteur tâche 2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:40:20 +02:00
gilles 4eb0335900 feat(ui): section post-install interactive (profils + preview) (tâche 3)
Branche le frontend sur le moteur post-install (SJ-8) :
- liste des profils (badge de risque), dépliage → champs de formulaire typés
  (text/select/bool/secret), pré-remplis depuis defaults + utilisateur SSH
- Preview (script rendu, secrets masqués) en Popup
- Exécuter : profils sûrs en direct, profils à risque (identity_network) via
  confirmation Popup → action_request approuvé ; auto-sélection machine →
  flux visible dans le terminal
- api client : getProfiles / previewProfile / runProfile + types

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 10:57:44 +02:00
gilles e6f4ae470b feat(post-install): moteur de profils + bootstrap + identité/réseau (tâche 2 SJ-8)
- templates custom/bootstrap-root + identity-network (sortie structurée parsable,
  sauvegardes, échec contrôlé, jamais de coupure réseau sans reconnexion)
- postInstall: registre de manifestes (champs typés + defaults/defaultFrom),
  validateProfileValues + maskSecretValues + buildPostInstallResult (TDD),
  renderProfile/previewProfile (masquage secrets), runPostInstall (SSH)
- execute: RunActionOpts.profileId/values + branche post_install (bloc postInstall)
- action_requests: post_install accepté, payload profileId/values transmis à approve
- routes: GET /profiles, POST .../preview (script masqué + validation),
  POST .../run (action_request si requiresConfirmation, sinon direct)

Champs = formulaire (pas de question SSH interactive) ; secrets jamais sérialisés ;
identity_network exige confirmation. tsc 0 · 101 tests · build OK · boot OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 08:02:32 +02:00
gilles faa654c95a feat(ui): config machine (sonde+proxy), mode Listing, défaut apt-cacher-ng
- popup Profil sur la tuile : sonde machine → propositions os_family/
  machine_kind/virtualization avec Appliquer ; proxy APT (mode + url) +
  appliquer persistant
- mode d'affichage Tuiles/Liste : toggle + bouton Ajouter déplacés dans le
  header de page ; vue Liste = liste compacte + panneau détail « Machine view »
  (sections Docker/Post-install dépliées ; pliées en mode tuile)
- Popup rendu via portail document.body (position fixed, z-index 1000) :
  passe au premier plan, échappe au backdrop-filter des tuiles
- Paramètres : onglet Proxy APT (défaut apt-cacher-ng + appliquer à toutes
  les machines) ; AddMachineModal pré-remplit le proxy par défaut
- api client : settings, updateMachine, probe ; icônes network/grid/list

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 07:53:57 +02:00
gilles 2b684da9cd feat(api): profil machine éditable, sonde, et réglages globaux apt-cacher-ng
- machines : updateMachine (PATCH /machines/:id) + POST /machines/:id/probe
  (sonde synchrone → faits + proposition de correction) ; MachineView expose
  machineKind/virtualization ; CreateMachineInput accepte aptProxyMode persistent
- app_settings (clé/valeur, migration 0006) + service appSettings :
  défaut apt-cacher-ng (mode + url) ; applyProxyToAllMachines
- routes /settings : GET, PUT /apt-proxy, POST /apt-proxy/apply-all

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 07:53:47 +02:00
gilles 0ab6b1d392 3 2026-06-06 07:24:30 +02:00
gilles bafb085995 feat(os): profils Proxmox/RPi + machine_probe + proxy persistent (tâche 2 SJ-7)
- templates proxmox/ (update-analyze: dépôts PVE ; full-upgrade) et raspbian/
  (update-analyze: espace disque ; full-upgrade)
- execute résout les actions APT par profil OS (resolveTemplate) → proxmox/
  raspbian si dispo, sinon fallback apt/ (non-régression debian/ubuntu vérifiée)
- machine_probe (lecture seule) : template + parseProbe/proposeCorrections (TDD)
  → propose os_family/machine_kind/virtualization, persiste machine_hardware,
  n'applique jamais auto ; branche execute + allowlist route
- apt_proxy_persistent : ActionType + template idempotent (/etc/apt/apt.conf.d/
  01proxy, backup) + TemplateVars.aptProxyUrl + allowlist route

tsc 0 · 95 tests · build OK · résolution OS vérifiée.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 07:14:43 +02:00
gilles b5ec14dcd8 chore: charge .env via --env-file dans dev:server et start (Node 22+)
L'app lit process.env sans dotenv ; les scripts npm ne fournissaient pas
SU_MASTER_KEY. Ajoute --env-file=.env pour que pnpm dev / pnpm start
fonctionnent directement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 07:09:07 +02:00
gilles c79c3e5ccb feat(ui): section Docker interactive sur la tuile machine (tâche 3)
Branche le frontend sur le backend Docker (SJ-4/5/6) :
- scan, configuration des racines Compose, liste stacks + services avec
  badges de statut (candidat/activé/maj dispo/à jour)
- activer/ignorer/désactiver un stack ; pull-check (non destructif)
- apply/down/prune via action_request + confirmation Popup (design system)
- toute action streamée auto-sélectionne la machine → flux visible dans le
  terminal de droite (outputHub rejoue le buffer)
- api client : docker settings/roots/scan/stacks/status + action-requests
- icônes trash/check, styles docker-* (variables CSS uniquement)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 07:09:07 +02:00
gilles 2c15b8c06b feat(docker): routes de gestion des stacks (settings/roots/scan/list/enable)
Rend le flux Docker déclenchable via l'API (prérequis SJ-5/SJ-6) :
- GET  /machines/:id/docker/settings — settings + racines Compose
- POST /machines/:id/docker/roots    — déclare/active les racines à scanner
- POST /machines/:id/docker/scan     — scan passif (background, WS)
- GET  /machines/:id/docker/stacks   — liste stacks + services
- PATCH /machines/:id/docker/stacks/:stackId — cycle candidate→enabled→ignored

dockerScan: getDockerSettings, listStacks, setStackStatus. Les actions
pull-check/apply/down restent réservées aux stacks enabled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 06:24:43 +02:00
gilles 47fe952240 feat(settings): backup/restore de la base de données (amelioration #4)
- service dbBackup : createBackup (VACUUM INTO → archive .db cohérente),
  validateSqlite (header + integrity_check + schéma), prepareRestore
  (sauvegarde de sécurité auto + dépôt <db>.incoming)
- swap hors-ligne au démarrage (db/client.ts) : aucune corruption d'une base
  ouverte ; restauration appliquée au redémarrage
- routes GET /system/db/info|backup, POST /system/db/restore
- lib api : dbInfo / dbBackup (download navigateur) / dbRestore (upload)
- SettingsModal : onglet « Base de données » (taille, télécharger, restaurer
  avec confirmation Popup), icônes database/upload, styles DS variables only

Testé end-to-end : backup 184 Ko valide, restore + safety .bak + swap au boot,
fichier invalide rejeté. tsc 0 erreur · 91 tests · build OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 06:13:03 +02:00
gilles edb22a59c7 feat(docker): apply/prune/down + socle action_requests (tâche 2 SJ-6)
- migration 0005 : tables docker_image_events + action_requests
- templates apply-compose (up -d --remove-orphans), prune-images (safe/agressif),
  down-compose (sans volumes/rmi)
- dockerApply: parsers TDD (apply recreated/running/exited, prune images+bytes,
  down removed, parseHumanBytes) + orchestration applyStack/pruneImages/downStack
  réservée aux stacks enabled, insère docker_image_events
- actionRequests: create/approve/reject/list — actions destructives validées
  explicitement (Hermes propose, opérateur approuve, run en arrière-plan) ;
  hors API directe (POST /:id/actions reste passif uniquement)
- routes /machines/:id/action-requests + /action-requests/:id[/approve|/reject]
- execute: RunActionOpts.aggressive, branches apply/prune/down, helper
  archiveExecution mutualisant le boilerplate d'archivage

tsc 0 erreur · 91 tests · build OK · boot OK (migrations 0000→0005).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 06:05:59 +02:00
gilles b1c81ba518 feat(docker): pull-check + comparaison déterministe par stack (tâche 2 SJ-5)
- template docker/pull-check.sh.tpl (pull sans up, inspect before/after)
- dockerPull: parseDockerPullCheck + buildDockerPullResult (TDD) — compare
  image id/digest/label OCI → services up_to_date|updates_available|error,
  changes operation=pulled ; erreurs registry nettoyées (URL/token/password)
- dockerDedupKey (digests prioritaires, fallback image ids) + DockerImageChange.dedupKey
- pullCheckStack: SSH + upsert docker_stack_services, refuse stack non enabled,
  refresh Docker séparé (hors refreshMachine, pas de pull auto)
- execute: runAction(opts.stackId), branche docker_pull_check, injection stackDir
  (corrige docker_inspect_current) ; route: allowlist Docker passifs + pull_check,
  destructives toujours hors API jusqu'à action_requests (SJ-6)

Pas de migration (schéma SJ-4 suffisant). tsc 0 erreur · 85 tests · build OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 21:02:38 +02:00
gilles 2af8e74079 feat(docker): scan/inspect passifs des stacks Compose (tâche 2 SJ-4)
- 4 tables Docker (settings/compose_roots/compose_stacks/stack_services)
  + migration 0004 (timestamps journal monotones)
- templates docker/scan-compose + inspect-compose ; renderTemplate bascule
  sur délimiteurs <% %> pour les templates docker/ afin de préserver les
  Go-templates {{.ID}} intacts
- dockerScan: parseDockerScan (TDD) + scanDockerStacks (persiste stacks
  candidats, complète la détection par labels)
- action docker_scan branchée dans execute (route dédiée, archivage report/log)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 20:54:52 +02:00
gilles 434a149f1f fix(execute): refresh snapshot après apt upgrade/full-upgrade (amelioration #3)
Après une action APT appliquée avec succès, relance refreshMachine pour
que la webui reflète l'état réel des paquets. Échec de refresh = event
warning non bloquant (post_action_refresh_failed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 20:54:38 +02:00
94 changed files with 15659 additions and 102 deletions
+1 -1
View File
@@ -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
View File
@@ -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
+6
View File
@@ -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

+9
View File
@@ -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

+14
View File
@@ -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
View File
@@ -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">
+10 -3
View File
@@ -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
View File
@@ -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;
}
+45 -15
View File
@@ -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>
);
}
+318 -3
View File
@@ -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&nbsp;
<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">
+164
View File
@@ -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
View File
@@ -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
View File
@@ -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
);
+34
View File
@@ -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
+28
View File
@@ -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
}
]
}
+36
View File
@@ -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"]),
);
});
});
+110
View File
@@ -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
View File
@@ -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();
}
+63
View File
@@ -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);
}
});
+23 -3
View File
@@ -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);
+37
View File
@@ -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);
}
});
+45
View File
@@ -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);
}
});
+12
View File
@@ -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);
+66 -2
View File
@@ -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 });
+46
View File
@@ -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);
});
+49
View File
@@ -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);
});
+24
View File
@@ -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 });
});
+131
View File
@@ -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);
}
+43
View File
@@ -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();
}
+40
View File
@@ -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);
});
});
+80
View File
@@ -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);
}
+81
View File
@@ -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`),
};
}
+99
View File
@@ -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);
});
});
+289
View File
@@ -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 };
}
+105
View File
@@ -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",
);
});
});
+253
View File
@@ -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 };
}
+29
View File
@@ -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);
});
});
+198
View File
@@ -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
View File
@@ -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;
}
+42
View File
@@ -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);
});
});
+120
View File
@@ -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();
}
+49
View File
@@ -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);
});
});
+126
View File
@@ -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) : [],
};
}
+146
View File
@@ -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);
});
});
+195
View File
@@ -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 };
}
+12
View File
@@ -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;
+66 -5
View File
@@ -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;
}
+130
View File
@@ -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);
});
});
+368
View File
@@ -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;
}
+2
View File
@@ -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",
+206
View File
@@ -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 = [];
}
+13
View File
@@ -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("{{");
});
});
+30 -3
View File
@@ -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/. */
+22
View File
@@ -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. */
+22
View File
@@ -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}==="
+12
View File
@@ -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==="
+27
View File
@@ -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==="
+8
View File
@@ -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==="
+25
View File
@@ -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}==="
+97
View File
@@ -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}==="
+26
View File
@@ -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}==="
+15
View File
@@ -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}==="
+13
View File
@@ -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}==="
+8
View File
@@ -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}==="
+16
View File
@@ -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==="
+13
View File
@@ -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}==="
+21
View File
@@ -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}==="
+28
View File
@@ -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==="
+16
View File
@@ -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}==="
+37
View File
@@ -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}==="
+18
View File
@@ -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}==="
+34
View File
@@ -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}==="