Compare commits

...

10 Commits

Author SHA1 Message Date
gilles 08919752e3 feat: socle BDD (tâche 1.9 Phase 1-2) + moteur APT (tâche 2 SJ-0→3) + WIP capabilities/auth/Rust
Checkpoint multi-chantiers (arbre vert : tsc 0 erreur, 70 tests, build OK).
- tâche 1.9 Phase 1 : schéma socle (machine_state/events/reports/raw_artifacts/
  hardware/metrics + colonnes étendues) + wiring refresh/execute. Migration 0002.
- tâche 1.9 Phase 2 : machine_credentials + machine_host_keys (non destructif,
  dual-read + backfill). Migration 0003. Fix séquence journal de migration.
- tâche 2 : SJ-0 (types étendus rétro-compatibles, réducteur Docker, resolveTemplate),
  SJ-1 (update-analyze enrichi), SJ-2 (apply + diff dpkg + timeout inactivité SSH),
  SJ-3 (reboot vérifié boot_id).
- WIP parallèle inclus : /api/capabilities, auth/apiTokens/apiClients, system metrics,
  scaffold app_rust, ajustements frontend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 19:50:25 +02:00
gilles 0fbca06d3d docs: roadmap tâches 1.9-8 (briefs, gates de validation, designs tâche 2) + plans d'implémentation
Cartographie complète (liste_taches/coherence_taches), briefs tacheN + gates
validation_tacheN, design tâche 2 (docs/design/tache2/), specs/plans jalon 1-2
et tâche 1.9/2 (Phase 1, Phase 2, SJ-0→3). Validations consignées (1.9 , 2-8 🟡).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 19:50:25 +02:00
gilles f9ce991ec5 feat(ui): classes layout header/statusbar/inputs/terminal
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 05:27:04 +02:00
gilles cebe991601 feat(ui): helper sumUpdates (TDD)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 05:26:31 +02:00
gilles b9699bfb8f feat(ui): helper de thème dark/light persisté (TDD)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 05:26:00 +02:00
gilles d3bf4a9fd2 feat(ui): brancher le design system (exports ESM, Font Awesome, polices offline)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 05:25:21 +02:00
gilles f8a8478749 docs: consignes tâche 2 (design moteur templates) + gate de validation
tache2.md: mission design/investigation, périmètre strict, clôture obligatoire.
validation_tache2.md: grille de validation, gate avant toute phase de dev.
amelioration.md: retour d'usage (séparation terminal entre machines).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 05:23:35 +02:00
gilles 1310bc1637 docs: plan d'implémentation jalon 2 (polish design system)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 05:09:14 +02:00
gilles 8d105b63ec docs: spec jalon 2 - séparation terminal par machine + remontée d'état
Suite au test live: retour d'usage (amelioration.md) sur la séparation
des sorties entre machines distinctes dans le terminal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 05:05:27 +02:00
gilles 50df83fda1 docs: spec jalon 2 (polish design system)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 04:52:50 +02:00
118 changed files with 21088 additions and 102 deletions
+3
View File
@@ -8,3 +8,6 @@ reports/*
# Dépôts de référence (git imbriqués) — inspiration uniquement, gérés séparément
linux-update-dashboard/
nas-ops/
# Clé de session dev (jamais commitée)
.dev-session-key.txt
+2
View File
@@ -0,0 +1,2 @@
- 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 ?
+1
View File
@@ -0,0 +1 @@
target/
+728
View File
@@ -0,0 +1,728 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "bitflags"
version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a"
[[package]]
name = "cairo-rs"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cc8d9aa793480744cd9a0524fef1a2e197d9eaa0f739cde19d16aba530dcb95"
dependencies = [
"bitflags",
"cairo-sys-rs",
"glib",
"libc",
]
[[package]]
name = "cairo-sys-rs"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8b4985713047f5faee02b8db6a6ef32bbb50269ff53c1aee716d1d195b76d54"
dependencies = [
"glib-sys",
"libc",
"system-deps",
]
[[package]]
name = "cfg-expr"
version = "0.20.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb693542bcafa528e198be0ebd9d3632ca5b7c93dbe7237460e199910835997c"
dependencies = [
"smallvec",
"target-lexicon",
]
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "field-offset"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f"
dependencies = [
"memoffset",
"rustc_version",
]
[[package]]
name = "futures-channel"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [
"futures-core",
]
[[package]]
name = "futures-core"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-executor"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
dependencies = [
"futures-core",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-io"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
[[package]]
name = "futures-macro"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "futures-task"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-util"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-core",
"futures-macro",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "gdk-pixbuf"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25f420376dbee041b2db374ce4573892a36222bb3f6c0c43e24f0d67eae9b646"
dependencies = [
"gdk-pixbuf-sys",
"gio",
"glib",
"libc",
]
[[package]]
name = "gdk-pixbuf-sys"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48f31b37b1fc4b48b54f6b91b7ef04c18e00b4585d98359dd7b998774bbd91fb"
dependencies = [
"gio-sys",
"glib-sys",
"gobject-sys",
"libc",
"system-deps",
]
[[package]]
name = "gdk4"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd42fdbbf48612c6e8f47c65fb92d2e8f39c25aecd6af047e83897c1a22d2a4e"
dependencies = [
"cairo-rs",
"gdk-pixbuf",
"gdk4-sys",
"gio",
"glib",
"libc",
"pango",
]
[[package]]
name = "gdk4-sys"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d974ac4f15e67472c3a9728daf612590b4a5762a4b33f0edd298df0b80d043c"
dependencies = [
"cairo-sys-rs",
"gdk-pixbuf-sys",
"gio-sys",
"glib-sys",
"gobject-sys",
"libc",
"pango-sys",
"pkg-config",
"system-deps",
]
[[package]]
name = "gio"
version = "0.22.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3848bcba3a35cc0a71df8ba8ecfd799d6bfb862342a53a4a915fb62213aa4e6"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-util",
"gio-sys",
"glib",
"libc",
"pin-project-lite",
"smallvec",
]
[[package]]
name = "gio-sys"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64729ba2772c080448f9f966dba8f4456beeb100d8c28a865ef8a0f2ef4987e1"
dependencies = [
"glib-sys",
"gobject-sys",
"libc",
"system-deps",
"windows-sys",
]
[[package]]
name = "glib"
version = "0.22.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c207e04e51605dcf7b2924c41591b3a10e1438eaac5bcf448fb91f325381104a"
dependencies = [
"bitflags",
"futures-channel",
"futures-core",
"futures-executor",
"futures-task",
"futures-util",
"gio-sys",
"glib-macros",
"glib-sys",
"gobject-sys",
"libc",
"memchr",
"smallvec",
]
[[package]]
name = "glib-macros"
version = "0.22.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "506d23499707c7142898429757e8d9a3871d965239a2cb66dfa05052be6d6f19"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "glib-sys"
version = "0.22.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f7fbac234ed5bc2a28359b7bde8e1b9cdf1441cc2d7f068e4824672d7db9445"
dependencies = [
"libc",
"system-deps",
]
[[package]]
name = "gobject-sys"
version = "0.22.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22a861859b887a79cf461359c192c97a57d8fb0229dd291232e57aa11f6fa72c"
dependencies = [
"glib-sys",
"libc",
"system-deps",
]
[[package]]
name = "graphene-rs"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7d1b7881f96869f49808b6adfe906a93a57a34204952253444d68c3208d71f1"
dependencies = [
"glib",
"graphene-sys",
"libc",
]
[[package]]
name = "graphene-sys"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "517f062f3fd6b7fd3e57a3f038a74b3c23ca32f51199ff028aa704609943f79c"
dependencies = [
"glib-sys",
"libc",
"pkg-config",
"system-deps",
]
[[package]]
name = "gsk4"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53c912dfcbd28acace5fc99c40bb9f25e1dcb73efb1f2608327f66a99acdcb62"
dependencies = [
"cairo-rs",
"gdk4",
"glib",
"graphene-rs",
"gsk4-sys",
"libc",
"pango",
]
[[package]]
name = "gsk4-sys"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7d54bbc7a9d8b6ffe4f0c95eede15ccfb365c8bf521275abe6bcfb57b18fb8a"
dependencies = [
"cairo-sys-rs",
"gdk4-sys",
"glib-sys",
"gobject-sys",
"graphene-sys",
"libc",
"pango-sys",
"system-deps",
]
[[package]]
name = "gtk4"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7181b837f04cbe93f79441475f7a00560a92cba7a72e38cc1a68b6f8b78eaae2"
dependencies = [
"cairo-rs",
"field-offset",
"futures-channel",
"gdk-pixbuf",
"gdk4",
"gio",
"glib",
"graphene-rs",
"gsk4",
"gtk4-macros",
"gtk4-sys",
"libc",
"pango",
]
[[package]]
name = "gtk4-macros"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3581b242ba62fdff122ebb626ea641582ec326031622bd19d60f85029c804a87"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "gtk4-sys"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20ba8e695e2640455561274e65e45f0a151619e450746007667f4b23ceae4e1b"
dependencies = [
"cairo-sys-rs",
"gdk-pixbuf-sys",
"gdk4-sys",
"gio-sys",
"glib-sys",
"gobject-sys",
"graphene-sys",
"gsk4-sys",
"libc",
"pango-sys",
"system-deps",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "indexmap"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "libadwaita"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc0da4e27b20d3e71f830e5b0f0188d22c257986bf421c02cfde777fe07932a4"
dependencies = [
"gdk4",
"gio",
"glib",
"gtk4",
"libadwaita-sys",
"libc",
"pango",
]
[[package]]
name = "libadwaita-sys"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aaee067051c5d3c058d050d167688b80b67de1950cfca77730549aa761fc5d7d"
dependencies = [
"gdk4-sys",
"gio-sys",
"glib-sys",
"gobject-sys",
"gtk4-sys",
"libc",
"pango-sys",
"system-deps",
]
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "memchr"
version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
[[package]]
name = "memoffset"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
dependencies = [
"autocfg",
]
[[package]]
name = "pango"
version = "0.22.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "251bdc6e6487b811be0e406a21e301e07e45c0aa8fa39e00c0c8e12a91752438"
dependencies = [
"gio",
"glib",
"libc",
"pango-sys",
]
[[package]]
name = "pango-sys"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd111a20ca90fedf03e09c59783c679c00900f1d8491cca5399f5e33609d5d6"
dependencies = [
"glib-sys",
"gobject-sys",
"libc",
"system-deps",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pkg-config"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "proc-macro-crate"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
"toml_edit",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "serde_spanned"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
dependencies = [
"serde_core",
]
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "system-deps"
version = "7.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7"
dependencies = [
"cfg-expr",
"heck",
"pkg-config",
"toml",
"version-compare",
]
[[package]]
name = "system-update-gnome"
version = "0.1.0"
dependencies = [
"gtk4",
"libadwaita",
"serde",
"serde_json",
]
[[package]]
name = "target-lexicon"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
[[package]]
name = "toml"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned",
"toml_datetime",
"toml_parser",
"toml_writer",
"winnow",
]
[[package]]
name = "toml_datetime"
version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_edit"
version = "0.25.12+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7"
dependencies = [
"indexmap",
"toml_datetime",
"toml_parser",
"winnow",
]
[[package]]
name = "toml_parser"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
dependencies = [
"winnow",
]
[[package]]
name = "toml_writer"
version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "version-compare"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "winnow"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1"
dependencies = [
"memchr",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "system-update-gnome"
version = "0.1.0"
edition = "2024"
description = "Client local Rust/GNOME pour le backend system_update"
license = "UNLICENSED"
[dependencies]
adw = { package = "libadwaita", version = "0.9", features = ["v1_6"], optional = true }
gtk = { package = "gtk4", version = "0.11", features = ["v4_6"], optional = true }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[features]
default = []
gui = ["dep:adw", "dep:gtk"]
+56
View File
@@ -0,0 +1,56 @@
# system-update-gnome
Scaffold de l'application locale Rust/GNOME pour `system_update`.
Ce sous-dossier est volontairement dédié au développement de l'application Rust :
- backend/webapp : racine du projet, `server/`, `client/`, `shared/` ;
- application Rust/GNOME : `app_rust/system-update-gnome/` ;
- artefacts Cargo : `app_rust/system-update-gnome/target/`, ignoré par Git.
État actuel :
- client CLI minimal ;
- aucune dépendance externe pour garder le premier build vérifiable sans accès réseau ;
- test de connexion HTTP vers `GET /api/capabilities` ;
- stratégie token séparée dans `src/token_store.rs` ;
- première UI GTK/libadwaita derrière la feature Cargo `gui`.
Exemples :
```bash
cargo run -- --server http://127.0.0.1:8787 capabilities
cargo run -- --server http://127.0.0.1:8787 status
cargo run -- --server http://127.0.0.1:8787 metrics
cargo run -- --server http://127.0.0.1:8787 machines
SYSTEM_UPDATE_SERVER=http://127.0.0.1:8787 cargo run -- capabilities
```
Interface graphique :
```bash
cargo run --features gui -- gui
```
Avec un serveur précis :
```bash
cargo run --features gui -- --server http://10.0.1.137:8787 gui
```
Pré-requis pour le futur incrément GTK/libadwaita :
```bash
sudo apt install libgtk-4-dev libadwaita-1-dev
```
Règles :
- l'application ne fait pas de SSH direct ;
- les credentials machines restent côté backend ;
- les actions passent par l'API du serveur ;
- le token local sera stocké via trousseau système dans un incrément suivant.
Voir aussi :
- `docs/token-storage.md`.
@@ -0,0 +1,33 @@
# Stockage du token API
Objectif : l'application locale ne doit pas stocker le token API en clair dans un fichier de configuration.
État actuel du scaffold :
- `--token` : accepté pour test manuel ponctuel ;
- `SYSTEM_UPDATE_TOKEN` : accepté pour développement ;
- trousseau système : prévu, pas encore activé dans `Cargo.toml`.
Identité prévue dans le trousseau :
- service : `system-update` ;
- compte : `api-token`.
Choix technique à finaliser :
- la documentation actuelle de `keyring` indique que Linux dispose de plusieurs magasins possibles, dont keyutils et Secret Service ;
- pour une app GNOME, Secret Service est le choix naturel ;
- l'écosystème `keyring` 4.x sépare davantage les composants de librairie, donc l'ajout Cargo doit être fait après choix précis entre `keyring-core` + store Secret Service ou une version `keyring` 3.x encore centrée librairie.
Références :
- https://docs.rs/keyring/latest/keyring/
- https://docs.rs/crate/keyring/latest
- https://github.com/open-source-cooperative/keyring-rs/wiki/Keyring-Core
Règle de sécurité :
- token jamais loggé ;
- token jamais écrit dans les rapports ;
- token affiché uniquement sous forme de préfixe côté backend ;
- révocation possible côté backend via `api_clients.revoked_at`.
+157
View File
@@ -0,0 +1,157 @@
use std::io::{Read, Write};
use std::net::TcpStream;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HttpUrl {
pub host: String,
pub port: u16,
pub base_path: String,
}
impl HttpUrl {
pub fn parse(server_url: &str) -> Result<Self, String> {
let without_scheme = server_url
.strip_prefix("http://")
.ok_or_else(|| "ce premier client supporte seulement http://".to_string())?;
let (host_port, base_path) = match without_scheme.split_once('/') {
Some((host_port, path)) => (host_port, format!("/{path}")),
None => (without_scheme, String::new()),
};
let (host, port) = match host_port.rsplit_once(':') {
Some((host, port)) => {
let parsed_port = port
.parse::<u16>()
.map_err(|_| "port serveur invalide".to_string())?;
(host.to_string(), parsed_port)
}
None => (host_port.to_string(), 80),
};
if host.is_empty() {
return Err("hôte serveur manquant".to_string());
}
Ok(Self {
host,
port,
base_path,
})
}
pub fn path(&self, endpoint: &str) -> String {
let base = self.base_path.trim_end_matches('/');
let endpoint = endpoint.trim_start_matches('/');
if base.is_empty() {
format!("/{endpoint}")
} else {
format!("{base}/{endpoint}")
}
}
}
pub struct ApiClient {
server: HttpUrl,
token: Option<String>,
}
impl ApiClient {
pub fn new(server_url: &str, token: Option<String>) -> Result<Self, String> {
Ok(Self {
server: HttpUrl::parse(server_url)?,
token,
})
}
pub fn get_capabilities(&self) -> Result<String, String> {
self.get("/api/capabilities")
}
pub fn get_system_status(&self) -> Result<String, String> {
self.get("/api/system/status")
}
pub fn get_system_metrics(&self) -> Result<String, String> {
self.get("/api/system/metrics")
}
pub fn get_machines(&self) -> Result<String, String> {
self.get("/api/machines")
}
fn get(&self, endpoint: &str) -> Result<String, String> {
let path = self.server.path(endpoint);
let mut request = format!(
"GET {path} HTTP/1.1\r\nHost: {}\r\nUser-Agent: system-update-gnome/0.1\r\nAccept: application/json\r\nConnection: close\r\n",
self.server.host
);
if let Some(token) = &self.token {
request.push_str(&format!("Authorization: Bearer {token}\r\n"));
}
request.push_str("\r\n");
let mut stream = TcpStream::connect((&*self.server.host, self.server.port))
.map_err(|err| format!("connexion serveur échouée: {err}"))?;
stream
.write_all(request.as_bytes())
.map_err(|err| format!("envoi requête échoué: {err}"))?;
let mut response = String::new();
stream
.read_to_string(&mut response)
.map_err(|err| format!("lecture réponse échouée: {err}"))?;
split_http_response(&response)
}
}
fn split_http_response(response: &str) -> Result<String, String> {
let (headers, body) = response
.split_once("\r\n\r\n")
.ok_or_else(|| "réponse HTTP invalide".to_string())?;
let status_line = headers.lines().next().unwrap_or_default();
if !status_line.contains(" 200 ") {
return Err(format!("réponse serveur inattendue: {status_line}"));
}
Ok(body.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_default_http_port() {
let url = HttpUrl::parse("http://localhost").expect("url");
assert_eq!(url.host, "localhost");
assert_eq!(url.port, 80);
assert_eq!(url.path("/api/capabilities"), "/api/capabilities");
}
#[test]
fn parses_explicit_http_port_and_base_path() {
let url = HttpUrl::parse("http://10.0.0.80:8787/system-update").expect("url");
assert_eq!(url.host, "10.0.0.80");
assert_eq!(url.port, 8787);
assert_eq!(
url.path("/api/capabilities"),
"/system-update/api/capabilities"
);
}
#[test]
fn rejects_https_until_tls_client_is_added() {
assert!(HttpUrl::parse("https://10.0.0.80:8787").is_err());
}
#[test]
fn extracts_success_body() {
let body = split_http_response(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n{\"ok\":true}",
)
.expect("body");
assert_eq!(body, "{\"ok\":true}");
}
}
+122
View File
@@ -0,0 +1,122 @@
use crate::token_store::TokenSource;
use std::env;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AppConfig {
pub server_url: String,
pub token: Option<String>,
}
impl AppConfig {
pub fn from_args(args: &[String]) -> Result<(Self, Command), String> {
let mut server_url = env::var("SYSTEM_UPDATE_SERVER")
.unwrap_or_else(|_| "http://127.0.0.1:8787".to_string());
let mut token = TokenSource::from_env().load();
let mut command = None;
let mut i = 1;
while i < args.len() {
match args[i].as_str() {
"--server" => {
i += 1;
server_url = args
.get(i)
.ok_or_else(|| "--server attend une URL".to_string())?
.clone();
}
"--token" => {
i += 1;
let raw_token = args
.get(i)
.ok_or_else(|| "--token attend une valeur".to_string())?
.clone();
token = TokenSource::CliArgument(raw_token).load();
}
"capabilities" => command = Some(Command::Capabilities),
"status" => command = Some(Command::Status),
"metrics" => command = Some(Command::Metrics),
"machines" => command = Some(Command::Machines),
"gui" => command = Some(Command::Gui),
"help" | "--help" | "-h" => command = Some(Command::Help),
other => return Err(format!("argument inconnu: {other}")),
}
i += 1;
}
validate_server_url(&server_url)?;
Ok((Self { server_url, token }, command.unwrap_or(Command::Help)))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Command {
Capabilities,
Status,
Metrics,
Machines,
Gui,
Help,
}
pub fn validate_server_url(url: &str) -> Result<(), String> {
if url.starts_with("http://") || url.starts_with("https://") {
Ok(())
} else {
Err("l'URL serveur doit commencer par http:// ou https://".to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_http_server_url() {
assert!(validate_server_url("http://127.0.0.1:8787").is_ok());
}
#[test]
fn rejects_missing_scheme() {
assert!(validate_server_url("127.0.0.1:8787").is_err());
}
#[test]
fn parses_capabilities_command() {
let args = vec![
"system-update-gnome".to_string(),
"--server".to_string(),
"http://10.0.0.80:8787".to_string(),
"capabilities".to_string(),
];
let (config, command) = AppConfig::from_args(&args).expect("config");
assert_eq!(config.server_url, "http://10.0.0.80:8787");
assert_eq!(command, Command::Capabilities);
}
#[test]
fn parses_status_command() {
let args = vec!["system-update-gnome".to_string(), "status".to_string()];
let (_, command) = AppConfig::from_args(&args).expect("config");
assert_eq!(command, Command::Status);
}
#[test]
fn parses_gui_command() {
let args = vec!["system-update-gnome".to_string(), "gui".to_string()];
let (_, command) = AppConfig::from_args(&args).expect("config");
assert_eq!(command, Command::Gui);
}
#[test]
fn parses_machines_command() {
let args = vec!["system-update-gnome".to_string(), "machines".to_string()];
let (_, command) = AppConfig::from_args(&args).expect("config");
assert_eq!(command, Command::Machines);
}
}
+517
View File
@@ -0,0 +1,517 @@
use crate::api::ApiClient;
use crate::config::AppConfig;
use adw::prelude::*;
use serde::Deserialize;
const APP_CSS: &str = r#"
window { background: #28201b; color: #ead9b8; }
.su-header { background: #241b17; border-bottom: 1px solid #4a3a2f; }
.su-root { background: #28201b; }
.su-sidebar {
background: #30261f;
border-right: 1px solid #5a4738;
padding: 12px;
}
.su-terminal-pane {
background: #181b1d;
border-left: 1px solid #4a3a2f;
}
.su-terminal-head {
background: #241b17;
color: #bdae93;
border-bottom: 1px solid #3a2c24;
padding: 8px 10px;
font-family: monospace;
font-size: 11px;
}
.su-terminal-output {
background: #181b1d;
color: #f6e3b4;
padding: 8px;
font-family: monospace;
font-size: 11px;
}
.su-center { padding: 16px; }
.su-title { font-size: 20px; font-weight: 700; color: #f1d8aa; }
.su-muted { color: #bdae93; font-size: 12px; }
.su-label {
color: #bdae93;
font-family: monospace;
font-size: 10px;
letter-spacing: 2px;
text-transform: uppercase;
}
.su-card {
background: #30261f;
border: 1px solid #6a5544;
border-radius: 8px;
padding: 14px;
box-shadow: 0 6px 14px rgba(0,0,0,.22);
}
.su-dot-ok { background: #6ad13f; border-radius: 999px; min-width: 10px; min-height: 10px; }
.su-dot-unknown { background: #928374; border-radius: 999px; min-width: 10px; min-height: 10px; }
.su-machine-name { font-weight: 700; font-size: 15px; color: #f1e0bc; }
.su-mono { font-family: monospace; color: #bdae93; font-size: 12px; }
.su-taskbar {
background: #241b17;
border-top: 1px solid #5a4738;
min-height: 28px;
}
.su-task-cell {
padding: 5px 12px;
border-right: 1px solid #4a3a2f;
color: #bdae93;
font-family: monospace;
font-size: 11px;
}
.su-task-mode { background: #d79921; color: #28201b; font-weight: 700; }
"#;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Machine {
name: String,
hostname: String,
port: u16,
os_family: String,
status: String,
}
pub fn run(config: AppConfig) {
let app = adw::Application::builder()
.application_id("local.system-update.gnome")
.build();
app.connect_activate(move |app| {
build_window(app, config.clone());
});
app.run_with_args::<&str>(&[]);
}
fn build_window(app: &adw::Application, config: AppConfig) {
install_css();
let server_entry = gtk::Entry::builder()
.text(&config.server_url)
.hexpand(true)
.placeholder_text("http://10.0.1.137:8787")
.build();
let terminal = gtk::TextView::builder()
.editable(false)
.monospace(true)
.vexpand(true)
.hexpand(true)
.css_classes(["su-terminal-output"])
.build();
terminal.buffer().set_text(
"Terminal API prêt.\nLes retours capabilities/status/metrics/machines apparaissent ici.",
);
let machines_flow = gtk::FlowBox::builder()
.selection_mode(gtk::SelectionMode::None)
.column_spacing(12)
.row_spacing(12)
.max_children_per_line(3)
.min_children_per_line(1)
.homogeneous(false)
.build();
let task_status = gtk::Label::new(Some("server 10.0.1.137:8787"));
task_status.add_css_class("su-task-cell");
let task_metrics = gtk::Label::new(Some("metrics --"));
task_metrics.add_css_class("su-task-cell");
let add_button = gtk::Button::with_label("+ Ajouter");
let refresh_button = gtk::Button::with_label("Refresh");
let capabilities = gtk::Button::with_label("Capabilities");
let status = gtk::Button::with_label("Status");
let metrics = gtk::Button::with_label("Metrics");
let header = adw::HeaderBar::builder()
.title_widget(&gtk::Label::new(Some("System Update")))
.css_classes(["su-header"])
.build();
let left = build_hermes_panel();
let center = build_center_panel(&machines_flow, &refresh_button, &add_button);
let right = build_terminal_panel(&terminal, &server_entry, &capabilities, &status, &metrics);
let taskbar = build_taskbar(&task_status, &task_metrics);
let body = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.hexpand(true)
.vexpand(true)
.build();
body.append(&left);
body.append(&center);
body.append(&right);
let root = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.css_classes(["su-root"])
.build();
root.append(&header);
root.append(&body);
root.append(&taskbar);
let window = adw::ApplicationWindow::builder()
.application(app)
.title("System Update")
.default_width(1480)
.default_height(760)
.content(&root)
.build();
wire_machine_refresh(
&refresh_button,
&server_entry,
config.token.clone(),
&terminal,
&machines_flow,
&task_status,
);
connect_action(
&capabilities,
&server_entry,
config.token.clone(),
&terminal,
&task_status,
Action::Capabilities,
);
connect_action(
&status,
&server_entry,
config.token.clone(),
&terminal,
&task_status,
Action::Status,
);
connect_action(
&metrics,
&server_entry,
config.token,
&terminal,
&task_status,
Action::Metrics,
);
load_machines(&server_entry, None, &terminal, &machines_flow, &task_status);
window.present();
}
fn install_css() {
let provider = gtk::CssProvider::new();
provider.load_from_data(APP_CSS);
if let Some(display) = gtk::gdk::Display::default() {
gtk::style_context_add_provider_for_display(
&display,
&provider,
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
);
}
}
fn build_hermes_panel() -> gtk::Box {
let panel = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.width_request(225)
.css_classes(["su-sidebar"])
.spacing(12)
.build();
let title = gtk::Label::new(Some("HERMES"));
title.set_xalign(0.0);
title.add_css_class("su-label");
let text = gtk::Label::new(Some(
"Copilote d'exploitation -- à venir.\nAnalyse des mises à jour, plans et rapports seront disponibles ici.",
));
text.set_wrap(true);
text.set_xalign(0.0);
text.add_css_class("su-muted");
panel.append(&title);
panel.append(&text);
panel
}
fn build_center_panel(
machines_flow: &gtk::FlowBox,
refresh_button: &gtk::Button,
add_button: &gtk::Button,
) -> gtk::Box {
let center = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.hexpand(true)
.vexpand(true)
.spacing(14)
.css_classes(["su-center"])
.build();
let title = gtk::Label::new(Some("Machines"));
title.set_xalign(0.0);
title.add_css_class("su-title");
let tools = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(8)
.build();
tools.append(&title);
tools.append(&gtk::Box::builder().hexpand(true).build());
tools.append(refresh_button);
tools.append(add_button);
let scroll = gtk::ScrolledWindow::builder()
.hexpand(true)
.vexpand(true)
.child(machines_flow)
.build();
center.append(&tools);
center.append(&scroll);
center
}
fn build_terminal_panel(
terminal: &gtk::TextView,
server_entry: &gtk::Entry,
capabilities: &gtk::Button,
status: &gtk::Button,
metrics: &gtk::Button,
) -> gtk::Box {
let right = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.width_request(365)
.css_classes(["su-terminal-pane"])
.build();
let head = gtk::Label::new(Some("TERMINAL API"));
head.set_xalign(0.0);
head.add_css_class("su-terminal-head");
let controls = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(8)
.margin_top(8)
.margin_bottom(8)
.margin_start(8)
.margin_end(8)
.build();
controls.append(server_entry);
let buttons = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(6)
.build();
buttons.append(capabilities);
buttons.append(status);
buttons.append(metrics);
controls.append(&buttons);
let scroll = gtk::ScrolledWindow::builder()
.hexpand(true)
.vexpand(true)
.child(terminal)
.build();
right.append(&head);
right.append(&controls);
right.append(&scroll);
right
}
fn build_taskbar(task_status: &gtk::Label, task_metrics: &gtk::Label) -> gtk::Box {
let taskbar = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.css_classes(["su-taskbar"])
.build();
let mode = gtk::Label::new(Some(" SYSTEM UPDATE "));
mode.add_css_class("su-task-cell");
mode.add_css_class("su-task-mode");
let scope = gtk::Label::new(Some("machines"));
scope.add_css_class("su-task-cell");
let spacer = gtk::Box::builder().hexpand(true).build();
taskbar.append(&mode);
taskbar.append(&scope);
taskbar.append(task_status);
taskbar.append(&spacer);
taskbar.append(task_metrics);
taskbar
}
fn wire_machine_refresh(
button: &gtk::Button,
server_entry: &gtk::Entry,
token: Option<String>,
terminal: &gtk::TextView,
machines_flow: &gtk::FlowBox,
task_status: &gtk::Label,
) {
let server_entry = server_entry.clone();
let terminal = terminal.clone();
let machines_flow = machines_flow.clone();
let task_status = task_status.clone();
button.connect_clicked(move |_| {
load_machines(
&server_entry,
token.clone(),
&terminal,
&machines_flow,
&task_status,
);
});
}
fn load_machines(
server_entry: &gtk::Entry,
token: Option<String>,
terminal: &gtk::TextView,
machines_flow: &gtk::FlowBox,
task_status: &gtk::Label,
) {
let server_url = server_entry.text().to_string();
let raw = ApiClient::new(&server_url, token).and_then(|client| client.get_machines());
while let Some(child) = machines_flow.first_child() {
machines_flow.remove(&child);
}
match raw {
Ok(json) => {
terminal.buffer().set_text(&json);
match serde_json::from_str::<Vec<Machine>>(&json) {
Ok(machines) => {
for machine in &machines {
machines_flow.insert(&machine_card(machine), -1);
}
task_status.set_text(&format!("{server_url} · {} machines", machines.len()));
}
Err(err) => {
task_status.set_text("machines: JSON invalide");
machines_flow
.insert(&empty_card(&format!("JSON machines invalide: {err}")), -1);
}
}
}
Err(err) => {
terminal.buffer().set_text(&format!("Erreur: {err}"));
task_status.set_text("server erreur");
machines_flow.insert(&empty_card(&err), -1);
}
}
}
fn machine_card(machine: &Machine) -> gtk::Box {
let card = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(8)
.width_request(230)
.css_classes(["su-card"])
.build();
let row = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(8)
.build();
let dot = gtk::Box::builder()
.width_request(10)
.height_request(10)
.css_classes([if machine.status == "unknown" {
"su-dot-unknown"
} else {
"su-dot-ok"
}])
.build();
let name = gtk::Label::new(Some(&machine.name));
name.set_xalign(0.0);
name.add_css_class("su-machine-name");
row.append(&dot);
row.append(&name);
let host = gtk::Label::new(Some(&format!(
"{}:{} · {}",
machine.hostname, machine.port, machine.os_family
)));
host.set_xalign(0.0);
host.add_css_class("su-mono");
let updates = gtk::Label::new(Some("UPDATES 0"));
updates.set_xalign(0.0);
updates.add_css_class("su-label");
let buttons = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(6)
.build();
buttons.append(&gtk::Button::with_label("Refresh"));
buttons.append(&gtk::Button::with_label("Upgrade"));
buttons.append(&gtk::Button::with_label("Reboot"));
card.append(&row);
card.append(&host);
card.append(&updates);
card.append(&buttons);
card
}
fn empty_card(message: &str) -> gtk::Box {
let card = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(8)
.width_request(300)
.css_classes(["su-card"])
.build();
let title = gtk::Label::new(Some("Aucune machine"));
title.set_xalign(0.0);
title.add_css_class("su-machine-name");
let text = gtk::Label::new(Some(message));
text.set_wrap(true);
text.set_xalign(0.0);
text.add_css_class("su-muted");
card.append(&title);
card.append(&text);
card
}
#[derive(Clone, Copy)]
enum Action {
Capabilities,
Status,
Metrics,
}
fn connect_action(
button: &gtk::Button,
server_entry: &gtk::Entry,
token: Option<String>,
terminal: &gtk::TextView,
task_status: &gtk::Label,
action: Action,
) {
let server_entry = server_entry.clone();
let terminal = terminal.clone();
let task_status = task_status.clone();
button.connect_clicked(move |_| {
let server_url = server_entry.text().to_string();
let body = ApiClient::new(&server_url, token.clone()).and_then(|client| match action {
Action::Capabilities => client.get_capabilities(),
Action::Status => client.get_system_status(),
Action::Metrics => client.get_system_metrics(),
});
match body {
Ok(json) => {
terminal.buffer().set_text(&json);
task_status.set_text(&format!("{server_url} · ok"));
}
Err(err) => {
terminal.buffer().set_text(&format!("Erreur: {err}"));
task_status.set_text("server erreur");
}
}
});
}
+89
View File
@@ -0,0 +1,89 @@
mod api;
mod config;
#[cfg(feature = "gui")]
mod gui;
mod token_store;
use api::ApiClient;
use config::{AppConfig, Command};
use std::env;
use std::process::ExitCode;
use token_store::keyring_identity;
fn main() -> ExitCode {
let args: Vec<String> = env::args().collect();
match run(&args) {
Ok(()) => ExitCode::SUCCESS,
Err(err) => {
eprintln!("erreur: {err}");
ExitCode::FAILURE
}
}
}
fn run(args: &[String]) -> Result<(), String> {
let (config, command) = AppConfig::from_args(args)?;
match command {
Command::Capabilities => {
let client = ApiClient::new(&config.server_url, config.token)?;
let body = client.get_capabilities()?;
println!("{body}");
}
Command::Status => {
let client = ApiClient::new(&config.server_url, config.token)?;
let body = client.get_system_status()?;
println!("{body}");
}
Command::Metrics => {
let client = ApiClient::new(&config.server_url, config.token)?;
let body = client.get_system_metrics()?;
println!("{body}");
}
Command::Machines => {
let client = ApiClient::new(&config.server_url, config.token)?;
let body = client.get_machines()?;
println!("{body}");
}
Command::Gui => run_gui(config)?,
Command::Help => print_help(),
}
Ok(())
}
#[cfg(feature = "gui")]
fn run_gui(config: AppConfig) -> Result<(), String> {
gui::run(config);
Ok(())
}
#[cfg(not(feature = "gui"))]
fn run_gui(_config: AppConfig) -> Result<(), String> {
Err(
"l'interface graphique n'est pas compilée. Lance: cargo run --features gui -- gui"
.to_string(),
)
}
fn print_help() {
let (keyring_service, keyring_account) = keyring_identity();
println!(
"system-update-gnome\n\
\n\
Usage:\n\
system-update-gnome --server http://127.0.0.1:8787 capabilities\n\
system-update-gnome --server http://127.0.0.1:8787 status\n\
system-update-gnome --server http://127.0.0.1:8787 metrics\n\
system-update-gnome --server http://127.0.0.1:8787 machines\n\
system-update-gnome --server http://127.0.0.1:8787 gui\n\
\n\
Variables:\n\
SYSTEM_UPDATE_SERVER URL du backend, défaut http://127.0.0.1:8787\n\
SYSTEM_UPDATE_TOKEN Token API optionnel\n\
\n\
Futur trousseau:\n\
service: {keyring_service}\n\
compte: {keyring_account}\n"
);
}
@@ -0,0 +1,59 @@
use std::env;
pub const KEYRING_SERVICE: &str = "system-update";
pub const KEYRING_ACCOUNT: &str = "api-token";
pub fn keyring_identity() -> (&'static str, &'static str) {
(KEYRING_SERVICE, KEYRING_ACCOUNT)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TokenSource {
CliArgument(String),
Environment(Option<String>),
}
impl TokenSource {
pub fn from_env() -> Self {
Self::Environment(env::var("SYSTEM_UPDATE_TOKEN").ok())
}
pub fn load(self) -> Option<String> {
match self {
Self::CliArgument(token) => clean_token(token),
Self::Environment(token) => token.and_then(clean_token),
}
}
}
fn clean_token(token: String) -> Option<String> {
let trimmed = token.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn trims_cli_token() {
assert_eq!(
TokenSource::CliArgument(" su_token ".to_string()).load(),
Some("su_token".to_string())
);
}
#[test]
fn ignores_empty_token() {
assert_eq!(TokenSource::CliArgument(" ".to_string()).load(), None);
}
#[test]
fn documents_future_keyring_identity() {
assert_eq!(keyring_identity(), ("system-update", "api-token"));
}
}
+83 -5
View File
@@ -1,16 +1,94 @@
// client/src/App.tsx
import { useState } from "react";
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 { HermesPanel } from "./panels/HermesPanel.js";
import { Dashboard } from "./panels/Dashboard.js";
import { TerminalPanel } from "./panels/TerminalPanel.js";
import { SettingsModal } from "./panels/SettingsModal.js";
import { applyTheme, getInitialTheme, nextTheme, type Theme } from "./lib/theme.js";
const EMPTY_SUMMARY: DashboardSummary = { machines: 0, updates: 0, errors: 0, running: 0 };
export function App() {
const [selected, setSelected] = useState<string | null>(null);
const [summary, setSummary] = useState<DashboardSummary>(EMPTY_SUMMARY);
const [metrics, setMetrics] = useState<SystemMetrics | null>(null);
const [theme, setTheme] = useState<Theme>(() => getInitialTheme());
const [settingsOpen, setSettingsOpen] = useState(false);
useEffect(() => {
applyTheme(theme);
}, [theme]);
useEffect(() => {
let cancelled = false;
async function loadMetrics() {
try {
const next = await api.systemMetrics();
if (!cancelled) setMetrics(next);
} catch {
if (!cancelled) setMetrics(null);
}
}
void loadMetrics();
const timer = window.setInterval(loadMetrics, 10_000);
return () => {
cancelled = true;
window.clearInterval(timer);
};
}, []);
return (
<div className="su-layout">
<HermesPanel />
<Dashboard onSelect={setSelected} />
<TerminalPanel machineId={selected} />
<div className="su-app">
<header className="su-header">
<div className="su-brand">
<span className="su-brand-mark">SU</span>
<div>
<h1>System Update</h1>
<span className="mono">dashboard SSH agentless</span>
</div>
</div>
<div className="su-header-summary">
<span>{summary.machines} machines</span>
<span>{summary.updates} updates</span>
<span>{summary.running} jobs</span>
<span>{summary.errors} erreurs</span>
</div>
<div className="su-spacer" />
<button className="interactive su-header-button" onClick={() => setTheme(nextTheme(theme))}>
{theme === "dark" ? "Light" : "Dark"}
</button>
<button className="interactive su-header-button" onClick={() => setSettingsOpen(true)}>
Paramètres
</button>
</header>
<div className="su-row">
<HermesPanel />
<Dashboard onSelect={setSelected} onSummaryChange={setSummary} />
<TerminalPanel machineId={selected} />
</div>
<footer className="su-statusbar">
<span className="cell mode">SYSTEM UPDATE</span>
<span className="cell">machines {summary.machines}</span>
<span className="cell">apt {summary.updates}</span>
<span className="cell">jobs {summary.running}</span>
<span className="cell">ram {formatMb(metrics?.process.rssMb)}</span>
<span className="cell">heap {formatMb(metrics?.process.heapUsedMb)}</span>
<span className="cell">load {formatLoad(metrics?.host.loadAverage1m)}</span>
<span className="cell">terminal {selected ?? "none"}</span>
<span className="cell clock">{new Date().toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })}</span>
</footer>
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
</div>
);
}
function formatMb(value: number | undefined): string {
return typeof value === "number" ? `${Math.round(value)}M` : "--";
}
function formatLoad(value: number | undefined): string {
return typeof value === "number" ? value.toFixed(2) : "--";
}
+17
View File
@@ -46,6 +46,17 @@ const ICON_MAP = {
filter: 'filter',
download: 'download',
folder: 'folder',
docker: 'boxes-stacked',
package: 'box-open',
script: 'file-code',
shield: 'shield-halved',
key: 'key',
locked: 'lock',
logs: 'file-lines',
report: 'clipboard-list',
copy: 'copy',
collapse: 'down-left-and-up-right-to-center',
upgrade: 'cloud-arrow-down',
node: 'circle-nodes',
user: 'user',
};
@@ -656,3 +667,9 @@ Object.assign(window, {
`;
document.head.appendChild(s);
})();
export {
Icon, Tooltip, IconButton, Toggle, StatusLed,
BatteryGauge, RadialGauge, BigRadialGauge,
Popup, Button, TreeNav, Sparkline, LineChart,
};
@@ -1,5 +1,6 @@
// client/src/features/machines/AddMachineModal.tsx
import { useState } from "react";
import { api } from "../../lib/api.js";
interface Props { onClose: () => void; onCreated: () => void; }
@@ -12,11 +13,7 @@ export function AddMachineModal({ onClose, onCreated }: Props) {
async function submit() {
setBusy(true); setError(null);
try {
const res = await fetch("/api/machines", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ ...form, port: Number(form.port), sudoPassword: form.sudoPassword || null }),
});
if (!res.ok) throw new Error((await res.json()).error ?? "Échec");
await api.createMachine({ ...form, port: Number(form.port), sudoPassword: form.sudoPassword || null });
onCreated(); onClose();
} catch (e) { setError((e as Error).message); } finally { setBusy(false); }
}
+185 -18
View File
@@ -1,5 +1,7 @@
// client/src/features/machines/MachineTile.tsx
import type { MachineView } from "@shared/types.js";
import { useState } from "react";
import type { MachineStatus, MachineView } from "@shared/types.js";
import { Button, Icon, IconButton, StatusLed } from "../../components/ui-kit.js";
interface Props {
machine: MachineView;
@@ -10,30 +12,195 @@ interface Props {
onReboot: (id: string) => void;
}
const STATUS_COLOR: Record<string, string> = {
ok: "var(--ok)", updates_available: "var(--warn)", error: "var(--err)",
running: "var(--info)", unknown: "var(--ink-4)",
const STATUS_LED: Record<MachineStatus, "ok" | "warn" | "err" | "info" | "off"> = {
ok: "ok",
updates_available: "warn",
error: "err",
running: "info",
unknown: "off",
};
export function MachineTile({ machine, packageCount, onSelect, onRefresh, onUpgrade, onReboot }: Props) {
const STATUS_TEXT: Record<MachineStatus, string> = {
ok: "OK",
updates_available: "Updates",
error: "Erreur",
running: "Action en cours",
unknown: "Inconnu",
};
export function MachineTile({
machine,
packageCount,
onSelect,
onRefresh,
onUpgrade,
onReboot,
}: Props) {
const [dockerOpen, setDockerOpen] = useState(false);
const [postOpen, setPostOpen] = useState(false);
const expanded = dockerOpen || postOpen;
const isError = machine.status === "error" || machine.status === "unknown";
return (
<div className="glass" style={{ padding: 16, borderRadius: 10 }} onClick={() => onSelect(machine.id)}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ width: 10, height: 10, borderRadius: 999, background: STATUS_COLOR[machine.status] }} />
<strong>{machine.name}</strong>
<article
className={`machine-tile glass ${expanded ? "machine-tile-expanded" : ""}`}
onClick={() => onSelect(machine.id)}
>
<header className="machine-tile-head">
<div className="machine-title-row">
<StatusLed status={STATUS_LED[machine.status]} size={10} pulse={machine.status === "running"} />
<div className="machine-title-text">
<strong>{machine.name}</strong>
<span className="mono">{machine.hostname}:{machine.port} · {machine.osFamily}</span>
</div>
</div>
<span className={`machine-status-pill ${isError ? "machine-status-danger" : ""}`}>
{STATUS_TEXT[machine.status]}
</span>
</header>
<div className="machine-summary">
<Metric label="Updates" value={packageCount.toString()} tone={packageCount > 0 ? "warn" : "ok"} />
<Metric label="Reboot" value="-" />
<Metric label="Dernier check" value={formatDate(machine.lastCheckedAt)} />
</div>
<div className="mono" style={{ color: "var(--ink-3)", fontSize: 12 }}>
{machine.hostname}:{machine.port} · {machine.osFamily}
{isError && (
<div className="machine-alert">
<Icon name="alert" size={14} style={undefined} />
<span>État machine à vérifier avant toute action sensible.</span>
</div>
)}
<div className="machine-actions" onClick={(event) => event.stopPropagation()}>
<IconButton
icon="refresh"
label="Update + analyse"
active={false}
danger={false}
primary={false}
onClick={() => onRefresh(machine.id)}
/>
<IconButton
icon="upgrade"
label="Upgrade système"
active={false}
danger={false}
primary={packageCount > 0}
onClick={() => onUpgrade(machine.id)}
/>
<IconButton
icon="power"
label="Reboot"
active={false}
danger
primary={false}
onClick={() => onReboot(machine.id)}
/>
<IconButton
icon="terminal"
label="Ouvrir les logs machine"
active={false}
danger={false}
primary={false}
onClick={() => onSelect(machine.id)}
/>
</div>
<div style={{ margin: "10px 0", fontSize: 13 }}>
<span className="label">UPDATES</span>{" "}
<span className="mono">{packageCount}</span>
<div className="machine-sections" onClick={(event) => event.stopPropagation()}>
<SectionToggle
icon="docker"
title="Docker"
open={dockerOpen}
onToggle={() => setDockerOpen((value) => !value)}
/>
{dockerOpen && <DockerSection />}
<SectionToggle
icon="script"
title="Post-install"
open={postOpen}
onToggle={() => setPostOpen((value) => !value)}
/>
{postOpen && <PostInstallSection />}
</div>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }} onClick={(e) => e.stopPropagation()}>
<button className="interactive" onClick={() => onRefresh(machine.id)}>Refresh</button>
<button className="interactive" onClick={() => onUpgrade(machine.id)}>Upgrade</button>
<button className="interactive" onClick={() => onReboot(machine.id)}>Reboot</button>
</article>
);
}
function Metric({ label, value, tone }: { label: string; value: string; tone?: "ok" | "warn" }) {
return (
<div className="machine-metric">
<span className="label">{label}</span>
<span className={`mono ${tone === "warn" ? "machine-metric-warn" : tone === "ok" ? "machine-metric-ok" : ""}`}>
{value}
</span>
</div>
);
}
function SectionToggle({
icon,
title,
open,
onToggle,
}: {
icon: string;
title: string;
open: boolean;
onToggle: () => void;
}) {
return (
<button className="machine-section-toggle interactive" onClick={onToggle}>
<span className="machine-section-title">
<Icon name={icon} size={14} style={undefined} />
<span>{title}</span>
</span>
<Icon name={open ? "chevD" : "chevR"} size={12} style={undefined} />
</button>
);
}
function DockerSection() {
return (
<div className="machine-section-body">
<div className="machine-section-row">
<span className="mono">Docker non scanné</span>
<Button icon="cog" size="sm" onClick={() => undefined}>Paramètres</Button>
</div>
<div className="machine-placeholder">
Roots compose, stacks, upgrades image et prune seront affichés ici dès que le backend Docker sera disponible.
</div>
</div>
);
}
function PostInstallSection() {
return (
<div className="machine-section-body">
<label className="machine-check-row">
<input type="checkbox" />
<span>Profil network tools</span>
</label>
<label className="machine-check-row">
<input type="checkbox" />
<span>Profil partage Samba/NFS</span>
</label>
<div className="machine-placeholder">
Les champs dynamiques seront dépliés ici selon les profils sélectionnés.
</div>
</div>
);
}
function formatDate(value: string | null): string {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "-";
return date.toLocaleString("fr-FR", {
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
+23 -3
View File
@@ -1,16 +1,36 @@
// client/src/lib/api.ts
import type { MachineView, UpdateSnapshot, ActionType } from "@shared/types.js";
import type { ActionType, MachineView, SystemMetrics, UpdateSnapshot } from "@shared/types.js";
async function readJsonBody(res: Response): Promise<unknown> {
const text = await res.text();
if (!text.trim()) return null;
try {
return JSON.parse(text);
} catch {
return { error: text };
}
}
async function req<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`/api${path}`, {
headers: { "content-type": "application/json" },
...init,
});
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? res.statusText);
return res.json() as Promise<T>;
const body = await readJsonBody(res);
if (!res.ok) {
const apiUnavailable = res.status >= 500 && body === null;
const error = apiUnavailable
? "API indisponible: le serveur backend ne répond pas."
: body && typeof body === "object" && "error" in body
? String(body.error)
: res.statusText;
throw new Error(error || "Erreur API");
}
return body as T;
}
export const api = {
systemMetrics: () => req<SystemMetrics>("/system/metrics"),
listMachines: () => req<MachineView[]>("/machines"),
createMachine: (body: unknown) => req<MachineView>("/machines", { method: "POST", body: JSON.stringify(body) }),
refresh: (id: string) => req<UpdateSnapshot>(`/machines/${id}/refresh`, { method: "POST" }),
+11
View File
@@ -0,0 +1,11 @@
import { describe, it, expect } from "vitest";
import { sumUpdates } from "./stats.js";
describe("sumUpdates", () => {
it("somme les compteurs", () => {
expect(sumUpdates({ a: 2, b: 3, c: 0 })).toBe(5);
});
it("retourne 0 pour un objet vide", () => {
expect(sumUpdates({})).toBe(0);
});
});
+4
View File
@@ -0,0 +1,4 @@
// client/src/lib/stats.ts
export function sumUpdates(counts: Record<string, number>): number {
return Object.values(counts).reduce((acc, n) => acc + n, 0);
}
+25
View File
@@ -0,0 +1,25 @@
import { describe, it, expect, beforeEach } from "vitest";
import { nextTheme, getInitialTheme } from "./theme.js";
describe("nextTheme", () => {
it("bascule dark <-> light", () => {
expect(nextTheme("dark")).toBe("light");
expect(nextTheme("light")).toBe("dark");
});
});
describe("getInitialTheme", () => {
beforeEach(() => {
// @ts-expect-error - environnement node sans localStorage
delete globalThis.localStorage;
});
it("retombe sur dark sans localStorage", () => {
expect(getInitialTheme()).toBe("dark");
});
it("lit la valeur persistée si présente", () => {
const store: Record<string, string> = { "su-theme": "light" };
// @ts-expect-error - stub minimal
globalThis.localStorage = { getItem: (k: string) => store[k] ?? null };
expect(getInitialTheme()).toBe("light");
});
});
+25
View File
@@ -0,0 +1,25 @@
// client/src/lib/theme.ts
export type Theme = "dark" | "light";
const KEY = "su-theme";
export function nextTheme(t: Theme): Theme {
return t === "dark" ? "light" : "dark";
}
export function getInitialTheme(): Theme {
try {
const v = globalThis.localStorage?.getItem(KEY);
return v === "light" ? "light" : "dark";
} catch {
return "dark";
}
}
export function applyTheme(t: Theme): void {
try {
document.documentElement.dataset.theme = t;
globalThis.localStorage?.setItem(KEY, t);
} catch {
/* localStorage indisponible (mode privé) : on ignore la persistance */
}
}
+4
View File
@@ -1,5 +1,9 @@
import React from "react";
import { createRoot } from "react-dom/client";
import "@fortawesome/fontawesome-free/css/all.min.css";
import "@fontsource/inter";
import "@fontsource/jetbrains-mono";
import "@fontsource/share-tech-mono";
import "./styles/app.css";
import { App } from "./App.js";
+52 -14
View File
@@ -1,35 +1,73 @@
// client/src/panels/Dashboard.tsx
import { useEffect, useState } from "react";
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 { AddMachineModal } from "../features/machines/AddMachineModal.js";
import { sumUpdates } from "../lib/stats.js";
interface Props { onSelect: (id: string) => void; }
export interface DashboardSummary {
machines: number;
updates: number;
errors: number;
running: number;
}
export function Dashboard({ onSelect }: Props) {
interface Props {
onSelect: (id: string) => void;
onSummaryChange?: (summary: DashboardSummary) => void;
}
export function Dashboard({ onSelect, onSummaryChange }: 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);
async function load() {
const ms = await api.listMachines();
setMachines(ms);
const entries = await Promise.all(ms.map(async (m) => {
try { return [m.id, (await api.snapshot(m.id)).apt.count] as const; }
catch { return [m.id, 0] as const; }
}));
setCounts(Object.fromEntries(entries));
setError(null);
try {
const ms = await api.listMachines();
setMachines(ms);
const entries = await Promise.all(ms.map(async (m) => {
try { return [m.id, (await api.snapshot(m.id)).apt.count] as const; }
catch { return [m.id, 0] as const; }
}));
setCounts(Object.fromEntries(entries));
} catch (err) {
setMachines([]);
setCounts({});
setError((err as Error).message);
} finally {
setLoading(false);
}
}
useEffect(() => { void load(); }, []);
const summary = useMemo<DashboardSummary>(() => ({
machines: machines.length,
updates: sumUpdates(counts),
errors: machines.filter((m) => m.status === "error").length,
running: machines.filter((m) => m.status === "running").length,
}), [machines, counts]);
useEffect(() => {
onSummaryChange?.(summary);
}, [onSummaryChange, summary]);
return (
<main className="su-center">
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 16 }}>
<h2 style={{ margin: 0 }}>Machines</h2>
<button className="interactive" onClick={() => setAdding(true)}>+ Ajouter</button>
<div className="su-dashboard-head">
<div>
<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>
{machines.length === 0 && <p style={{ color: "var(--ink-3)" }}>Aucune machine. Clique sur « + Ajouter ».</p>}
{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
+259
View File
@@ -0,0 +1,259 @@
// client/src/panels/SettingsModal.tsx
import { useState } from "react";
import { Icon } from "../components/ui-kit.js";
interface Props {
open: boolean;
onClose: () => void;
}
type SettingsTab =
| "appearance"
| "tiles"
| "layout"
| "docker"
| "scripts"
| "hermes"
| "terminal"
| "retention";
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: "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" },
];
export function SettingsModal({ open, onClose }: Props) {
const [active, setActive] = useState<SettingsTab>("appearance");
if (!open) return null;
return (
<div className="settings-backdrop" onClick={onClose}>
<section className="settings-modal glass-strong" onClick={(event) => event.stopPropagation()}>
<header className="settings-head">
<div>
<span className="label">PARAMÈTRES</span>
<h2>System Update</h2>
</div>
<button className="interactive settings-close" onClick={onClose} aria-label="Fermer">
<Icon name="close" size={14} style={undefined} />
</button>
</header>
<div className="settings-body">
<nav className="settings-nav">
{TABS.map((tab) => (
<button
key={tab.id}
className={`interactive settings-nav-item ${active === tab.id ? "active" : ""}`}
onClick={() => setActive(tab.id)}
>
<Icon name={tab.icon} size={14} style={undefined} />
<span>{tab.label}</span>
</button>
))}
</nav>
<div className="settings-content">
{active === "appearance" && <AppearanceSettings />}
{active === "tiles" && <TileSettings />}
{active === "layout" && <LayoutSettings />}
{active === "docker" && <DockerSettings />}
{active === "scripts" && <ScriptsSettings />}
{active === "hermes" && <HermesSettings />}
{active === "terminal" && <TerminalSettings />}
{active === "retention" && <RetentionSettings />}
</div>
</div>
<footer className="settings-footer">
<span className="mono">settings backend pending</span>
<button className="interactive settings-secondary" onClick={onClose}>Fermer</button>
<button className="interactive settings-primary" onClick={onClose}>Sauvegarder</button>
</footer>
</section>
</div>
);
}
function AppearanceSettings() {
return (
<SettingsSection title="Apparence">
<Field label="Thème">
<select className="su-field" defaultValue="system">
<option value="system">Système</option>
<option value="dark">Dark</option>
<option value="light">Light</option>
</select>
</Field>
<Field label="Zoom UI">
<input className="su-field" type="number" min="80" max="130" defaultValue="100" />
</Field>
<Field label="Densité">
<select className="su-field" defaultValue="compact">
<option value="compact">Compact</option>
<option value="comfortable">Confort</option>
</select>
</Field>
</SettingsSection>
);
}
function TileSettings() {
return (
<SettingsSection title="Tuiles machine">
<Field label="Largeur minimale">
<input className="su-field" type="number" min="240" max="420" defaultValue="280" />
</Field>
<Field label="Sections ouvertes par défaut">
<div className="settings-checks">
<Check label="Docker" />
<Check label="Post-install" />
<Check label="Hardware" />
</div>
</Field>
<Field label="Mode erreur">
<select className="su-field" defaultValue="expanded">
<option value="badge">Badge</option>
<option value="expanded">Alerte visible</option>
</select>
</Field>
</SettingsSection>
);
}
function LayoutSettings() {
return (
<SettingsSection title="Volets">
<Field label="Hermes largeur">
<input className="su-field" type="number" min="200" max="300" defaultValue="240" />
</Field>
<Field label="Terminal largeur">
<input className="su-field" type="number" min="320" max="460" defaultValue="380" />
</Field>
<Field label="Mobile">
<select className="su-field" defaultValue="tabs">
<option value="tabs">Onglets</option>
<option value="bottom">Barre basse</option>
</select>
</Field>
</SettingsSection>
);
}
function DockerSettings() {
return (
<SettingsSection title="Docker">
<Field label="Roots Compose">
<textarea className="su-field settings-textarea" defaultValue={"/home/gilles/docker\n/opt/docker"} />
</Field>
<Field label="Prune">
<select className="su-field" defaultValue="safe">
<option value="safe">Images inutilisées</option>
<option value="aggressive">Agressif avec validation</option>
</select>
</Field>
</SettingsSection>
);
}
function ScriptsSettings() {
return (
<SettingsSection title="Scripts">
<Field label="Catalogue">
<select className="su-field" defaultValue="local">
<option value="local">Scripts locaux</option>
<option value="shared">Scripts partagés</option>
</select>
</Field>
<Field label="Profils visibles">
<div className="settings-checks">
<Check label="Network" checked />
<Check label="Dev tools" checked />
<Check label="Domotique" />
</div>
</Field>
</SettingsSection>
);
}
function HermesSettings() {
return (
<SettingsSection title="Hermes">
<Field label="Endpoint">
<input className="su-field" defaultValue="http://10.0.0.80:8000" />
</Field>
<Field label="Contexte max">
<input className="su-field" type="number" min="1000" max="64000" defaultValue="12000" />
</Field>
</SettingsSection>
);
}
function TerminalSettings() {
return (
<SettingsSection title="Terminal">
<Field label="Mode">
<select className="su-field" defaultValue="logs">
<option value="logs">Logs actions</option>
<option value="ssh">SSH interactif</option>
</select>
</Field>
<Field label="Police">
<input className="su-field" type="number" min="10" max="18" defaultValue="12" />
</Field>
</SettingsSection>
);
}
function RetentionSettings() {
return (
<SettingsSection title="Nettoyage">
<Field label="Logs bruts">
<input className="su-field" type="number" min="7" max="365" defaultValue="90" />
</Field>
<Field label="Rapports">
<input className="su-field" type="number" min="30" max="730" defaultValue="365" />
</Field>
<Field label="Messages importants">
<select className="su-field" defaultValue="keep">
<option value="keep">Conserver non acquittés</option>
<option value="archive">Archiver</option>
</select>
</Field>
</SettingsSection>
);
}
function SettingsSection({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="settings-section">
<h3>{title}</h3>
<div className="settings-fields">{children}</div>
</div>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="settings-field">
<span className="label">{label}</span>
{children}
</label>
);
}
function Check({ label, checked }: { label: string; checked?: boolean }) {
return (
<label className="settings-check">
<input type="checkbox" defaultChecked={checked} />
<span>{label}</span>
</label>
);
}
+23 -3
View File
@@ -18,11 +18,31 @@ export function TerminalPanel({ machineId }: { machineId: string | null }) {
const fit = new FitAddon();
term.loadAddon(fit);
term.open(ref.current);
fit.fit();
const fitTerminal = () => {
try { fit.fit(); } catch { /* xterm peut être entre deux cycles de layout. */ }
};
const frame = window.requestAnimationFrame(fitTerminal);
const resizeObserver = new ResizeObserver(fitTerminal);
resizeObserver.observe(ref.current);
term.writeln(machineId ? `# flux ${machineId}` : "# sélectionne une machine");
const disconnect = machineId ? connectOutput(machineId, (c) => term.write(c)) : () => {};
return () => { disconnect(); term.dispose(); };
return () => {
window.cancelAnimationFrame(frame);
resizeObserver.disconnect();
disconnect();
term.dispose();
};
}, [machineId]);
return <div className="su-terminal" ref={ref} style={{ padding: 6 }} />;
return (
<aside className="su-terminal-wrap">
<div className="su-terminal-head">
<span className="label">TERMINAL</span>
<span className="mono" style={{ color: "var(--ink-3)", fontSize: 12 }}>
{machineId ?? "aucune machine"}
</span>
</div>
<div className="su-terminal" ref={ref} />
</aside>
);
}
+337 -4
View File
@@ -7,8 +7,341 @@ body {
background: var(--bg-1);
color: var(--ink-1);
}
.su-layout { display: flex; height: 100vh; }
.su-hermes { width: 280px; min-width: 200px; background: var(--bg-2); border-right: 1px solid var(--border-1); padding: 14px; }
.su-center { flex: 1; overflow: auto; padding: 18px; }
.su-terminal { width: 360px; min-width: 320px; background: var(--bg-0); border-left: 1px solid var(--border-1); }
/* Ossature : rangée 3 volets */
.su-app { display: flex; flex-direction: column; width: 100%; height: 100vh; overflow: hidden; }
.su-row { flex: 1; display: flex; width: 100%; min-width: 0; min-height: 0; overflow: hidden; }
.su-header {
height: 52px; flex: 0 0 52px;
display: flex; align-items: center; gap: 12px;
padding: 0 16px;
background: var(--bg-2);
border-bottom: 1px solid var(--border-1);
}
.su-header h1 { font-size: 15px; margin: 0; font-weight: 600; }
.su-spacer { flex: 1; }
.su-brand { display: flex; align-items: center; gap: 10px; min-width: 210px; }
.su-brand-mark {
width: 30px; height: 30px; border-radius: 8px;
display: inline-flex; align-items: center; justify-content: center;
background: var(--accent); color: var(--bg-1);
font-weight: 800; font-family: var(--font-mono); font-size: 12px;
}
.su-brand .mono { display: block; color: var(--ink-3); font-size: 11px; margin-top: 2px; }
.su-header-summary { display: flex; gap: 8px; flex-wrap: wrap; color: var(--ink-3); font-size: 12px; }
.su-header-summary span,
.su-header-button {
border: 1px solid var(--border-1);
background: var(--bg-3);
color: var(--ink-2);
border-radius: 8px;
padding: 6px 9px;
}
.su-header-button { font-family: var(--font-ui); }
.su-hermes { flex: 0 0 clamp(220px, 15vw, 280px); min-width: 0; background: var(--bg-2); border-right: 1px solid var(--border-1); padding: 14px; overflow: auto; }
.su-center { flex: 1 1 auto; min-width: 0; overflow: auto; padding: 18px; }
.su-terminal-wrap { flex: 0 0 clamp(320px, 28vw, 440px); min-width: 0; display: flex; flex-direction: column; background: var(--bg-0); border-left: 1px solid var(--border-1); overflow: hidden; }
.su-terminal-head { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--border-1); }
.su-terminal { flex: 1; min-width: 0; min-height: 0; padding: 6px; overflow: hidden; }
.su-terminal .xterm { height: 100%; }
.su-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 14px; }
.su-dashboard-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
margin-bottom: 16px;
}
.su-dashboard-head h2 { margin: 0; font-size: 22px; }
.su-dashboard-head p { margin: 4px 0 0; color: var(--ink-3); font-size: 12px; }
.su-add-button {
background: var(--bg-3);
color: var(--ink-1);
border: 1px solid var(--border-2);
border-radius: 8px;
padding: 8px 12px;
font-family: var(--font-ui);
}
.machine-tile {
min-width: 0;
padding: 14px;
border-radius: 10px;
display: flex;
flex-direction: column;
gap: 12px;
}
.machine-tile-expanded { grid-column: span 2; }
.machine-tile-head,
.machine-title-row,
.machine-actions,
.machine-section-toggle,
.machine-section-row,
.machine-check-row {
display: flex;
align-items: center;
}
.machine-tile-head { justify-content: space-between; gap: 12px; min-width: 0; }
.machine-title-row { gap: 9px; min-width: 0; }
.machine-title-text { display: flex; flex-direction: column; min-width: 0; }
.machine-title-text strong { color: var(--ink-1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.machine-title-text .mono { color: var(--ink-3); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.machine-status-pill {
flex: 0 0 auto;
padding: 4px 8px;
border-radius: 999px;
background: var(--bg-3);
border: 1px solid var(--border-2);
color: var(--ink-3);
font-size: 11px;
font-family: var(--font-mono);
}
.machine-status-danger { color: var(--err); border-color: var(--err); background: rgba(251, 73, 52, 0.08); }
.machine-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.machine-metric {
min-width: 0;
padding: 8px;
border-radius: 8px;
background: var(--bg-2);
border: 1px solid var(--border-1);
}
.machine-metric .mono { display: block; margin-top: 3px; font-size: 13px; color: var(--ink-1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.machine-metric-warn { color: var(--warn) !important; }
.machine-metric-ok { color: var(--ok) !important; }
.machine-alert {
display: flex;
gap: 8px;
align-items: center;
color: var(--err);
font-size: 12px;
padding: 8px;
border: 1px solid var(--err);
border-radius: 8px;
background: rgba(251, 73, 52, 0.08);
}
.machine-actions { gap: 7px; flex-wrap: wrap; }
.machine-sections {
display: flex;
flex-direction: column;
gap: 8px;
border-top: 1px solid var(--border-1);
padding-top: 10px;
}
.machine-section-toggle {
width: 100%;
justify-content: space-between;
gap: 8px;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid var(--border-1);
background: var(--bg-2);
color: var(--ink-2);
font-family: var(--font-ui);
}
.machine-section-title { display: inline-flex; align-items: center; gap: 8px; }
.machine-section-body {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px;
border-radius: 8px;
background: var(--bg-1);
border: 1px solid var(--border-1);
}
.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; }
.machine-check-row input { accent-color: var(--accent); }
@media (max-width: 1180px) {
.machine-tile-expanded { grid-column: 1 / -1; }
}
@media (max-width: 920px) {
.su-hermes { flex-basis: 220px; }
.su-terminal-wrap { flex-basis: 320px; }
.su-header-summary { display: none; }
}
/* Status bar style tmux */
.su-statusbar {
height: 26px; flex: 0 0 26px;
display: flex; align-items: stretch;
background: var(--bg-2);
border-top: 1px solid var(--border-1);
font-family: var(--font-terminal);
font-size: 11px;
}
.su-statusbar .cell { display: flex; align-items: center; padding: 0 12px; border-right: 1px solid var(--border-1); color: var(--ink-2); }
.su-statusbar .cell.mode { background: var(--accent); color: var(--bg-1); font-weight: 700; letter-spacing: 0.08em; }
.su-statusbar .clock { margin-left: auto; border-right: none; border-left: 1px solid var(--border-1); }
/* Champs de formulaire tokenisés */
.su-field {
padding: 9px 12px; font-size: 13px; font-family: var(--font-ui);
background: var(--bg-1); color: var(--ink-1);
border: 1px solid var(--border-2); border-radius: 8px;
outline: none;
}
.su-field:focus { border-color: var(--accent-soft); }
.settings-backdrop {
position: fixed;
inset: 0;
z-index: 50;
display: grid;
place-items: center;
padding: 24px;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(4px);
}
.settings-modal {
width: min(920px, 96vw);
max-height: min(720px, 92vh);
display: flex;
flex-direction: column;
border-radius: 12px;
overflow: hidden;
}
.settings-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid var(--border-1);
background: var(--bg-3);
}
.settings-head h2 { margin: 2px 0 0; font-size: 18px; }
.settings-close {
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
border: 1px solid var(--border-2);
background: var(--bg-2);
color: var(--ink-2);
}
.settings-body {
flex: 1;
min-height: 0;
display: flex;
}
.settings-nav {
flex: 0 0 210px;
padding: 12px;
border-right: 1px solid var(--border-1);
background: var(--bg-2);
overflow: auto;
}
.settings-nav-item {
width: 100%;
display: flex;
align-items: center;
gap: 9px;
padding: 9px 10px;
border-radius: 8px;
border: 1px solid transparent;
background: transparent;
color: var(--ink-2);
font-family: var(--font-ui);
text-align: left;
}
.settings-nav-item.active {
background: var(--accent-tint);
border-color: var(--accent-soft);
color: var(--ink-1);
}
.settings-content {
flex: 1;
min-width: 0;
padding: 18px;
overflow: auto;
}
.settings-section h3 { margin: 0 0 14px; font-size: 18px; }
.settings-fields {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 14px;
}
.settings-field {
display: flex;
flex-direction: column;
gap: 7px;
}
.settings-field .su-field {
width: 100%;
}
.settings-textarea {
min-height: 96px;
resize: vertical;
}
.settings-checks {
display: flex;
flex-direction: column;
gap: 8px;
padding: 9px 12px;
border-radius: 8px;
border: 1px solid var(--border-2);
background: var(--bg-1);
}
.settings-check {
display: flex;
align-items: center;
gap: 8px;
color: var(--ink-2);
font-size: 13px;
}
.settings-check input { accent-color: var(--accent); }
.settings-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid var(--border-1);
background: var(--bg-2);
}
.settings-footer .mono {
margin-right: auto;
color: var(--ink-3);
font-size: 11px;
}
.settings-primary,
.settings-secondary {
border-radius: 8px;
padding: 8px 12px;
font-family: var(--font-ui);
border: 1px solid var(--border-2);
}
.settings-primary {
background: var(--accent);
color: var(--bg-1);
border-color: var(--accent-soft);
}
.settings-secondary {
background: var(--bg-3);
color: var(--ink-1);
}
@media (max-width: 720px) {
.settings-body { flex-direction: column; }
.settings-nav {
flex: 0 0 auto;
display: flex;
gap: 6px;
overflow-x: auto;
border-right: 0;
border-bottom: 1px solid var(--border-1);
}
.settings-nav-item { flex: 0 0 auto; width: auto; }
}
+122
View File
@@ -0,0 +1,122 @@
# Revue de cohérence — tâches system_update
> **But** : vérifier que les tâches 1.9 à 8 s'enchaînent proprement vers l'objectif final : une webapp serveur `system_update` exploitable via Docker Compose, avec API extensible et clients futurs.
---
## 1. Verdict global
Les tâches sont globalement cohérentes.
Il n'y a pas de contradiction bloquante détectée entre :
- moteur templates/scripts ;
- frontend web ;
- backend/API ;
- BDD ;
- Hermes/MCP ;
- optimisation/nettoyage ;
- app locale future.
Les recouvrements repérés sont normaux : certains sujets sont transverses et doivent apparaître dans plusieurs tâches, mais avec responsabilités différentes.
---
## 2. Flux de développement recommandé
Ordre logique :
```text
1. Validation tâche 1.9 → schéma BDD cible
2. Validation tâche 2 → templates, APT, Docker, contrats JSON
3. Validation tâche 4 → scripts post-install/hardware/profils
4. Validation tâche 5 → backend/API/jobs/storage
5. Validation tâche 3 → frontend web/tuiles/paramètres/layout
6. Validation tâche 6 → Hermes/MCP/skills
7. Validation tâche 7 → optimisation/nettoyage/sécurité/découverte
8. Validation tâche 8 → app Rust/GNOME future
```
Pourquoi cet ordre :
- la BDD et les contrats JSON structurent tout ;
- les scripts/templates produisent les données ;
- le backend stocke, orchestre et expose ;
- le frontend consomme ces contrats ;
- Hermes/MCP et optimisations s'appuient sur le backend ;
- l'app Rust/GNOME reste une évolution future via API commune.
---
## 3. Recouvrements acceptés
### Métriques simples
- `tache4.md` : définit le script SSH `machine_metrics_simple`.
- `tache5.md` : définit stockage/API/snapshot.
- `tache7.md` : définit affichage footer, optimisation et usage observabilité.
- `tache1.9.md` : définit tables `machine_metrics_latest`.
Ce n'est pas un doublon : chaque tâche couvre une couche différente.
### Logs, rapports et messages importants
- `tache5.md` : stockage backend, API, rétention.
- `tache6.md` : accès Hermes/MCP.
- `tache7.md` : nettoyage/rétention.
- `tache1.9.md` : tables.
Ce recouvrement est nécessaire.
### Paramètres frontend
- `tache3.md` : UX paramètres.
- `tache1.9.md` : stockage `app_settings`, `user_preferences`, `machine_ui_state`.
- `tache5.md` : API `/api/settings`.
Ce découpage est cohérent.
### App locale Rust/GNOME
- `tache8.md` : client natif futur.
- `tache5.md` : API commune/capabilities.
- `tache1.9.md` : table `api_clients`.
Ce n'est pas un chantier immédiat ; il doit rester futur.
---
## 4. Points corrigés pendant la revue
- `tache2.md` et `validation_tache2.md` parlaient encore de 7 questions d'investigation alors que la tâche en contient 8 après ajout des profils machine. Corrigé.
---
## 5. Objectif final Docker Compose
Objectif final confirmé :
```text
Une webapp serveur system_update déployable via Docker Compose :
- backend API ;
- frontend web servi par le backend ou reverse proxy ;
- SQLite persisté en volume ;
- reports/logs persistés en volume ;
- configuration via variables d'environnement ;
- secrets master key hors image ;
- accès réseau vers machines SSH ;
- option future reverse proxy/TLS.
```
Cet objectif doit rester un critère transversal dans les validations, surtout tâches 5, 7 et le plan d'implémentation final.
---
## 6. Risques à surveiller
- Ne pas implémenter toutes les tâches en un seul jalon : trop grand.
- Garder les actions dangereuses validées UI, même si Hermes ou l'app Rust les demande.
- Ne pas exposer les credentials SSH/sudo/API dans logs, UI, Hermes, MCP ou clients locaux.
- Garder SQLite au MVP, mais écrire le schéma pour migrer vers PostgreSQL.
- Garder les scripts critiques versionnés sur disque, pas uniquement en BDD.
- Ne pas confondre terminal live d'exécution, vrai terminal SSH interactif et conversation Hermes.
+295
View File
@@ -0,0 +1,295 @@
# Consigne icônes — system_update
> **Type** : brief de création d'icônes SVG et assets applicatifs.
> **Langue** : français.
> **But** : transmettre à un agent spécialisé les contraintes de création d'icônes pour la webapp `system_update`.
---
## 1. Contexte
`system_update` est une application d'administration système agentless SSH :
- suivi de machines Debian, Ubuntu, Proxmox, Raspberry Pi OS ;
- update/analyse APT ;
- Docker Compose ;
- scripts post-install ;
- métriques simples ;
- logs/rapports ;
- discussion Hermes ;
- terminal SSH.
Le design system est **Gruvbox seventies** :
- rétro-industriel ;
- console de monitoring ;
- SCADA / terminal années 70 ;
- fond brun/gris usé ;
- accent orange brûlé ;
- UI technique, dense, lisible.
Lire aussi :
- `design_system/consigne_design_system.md`
- `design_system/tokens/tokens.css`
- `design_system/tokens/tokens.gnome.css`
- `design_system/tokens/tokens.json`
- `tache3.md`
---
## 2. Règles absolues
- Ne pas utiliser d'emoji.
- on peut utiliser des logos officiels de distributions, Docker, Proxmox, Raspberry Pi, NVIDIA, etc.
- on peut utiliser des mascotte.
- Ne pas créer d'illustration complexe.
- Icônes lisibles en `16px`, `20px`, `24px`, `32px`.
- SVG monochrome ou bichrome maximum.
- Les couleurs doivent être pilotables par CSS : `currentColor`, variables CSS ou classes.
- Pas de hex en dur dans les SVG finaux, sauf si un export bitmap final est explicitement demandé.
- Stroke régulier, formes simples, angles légèrement industriels.
- Éviter les arrondis excessifs.
- Les icônes UI courantes doivent d'abord utiliser Font Awesome via `Icon`; créer un SVG custom seulement pour les concepts spécifiques.
---
## 3. Assets applicatifs à créer
### Favicon principal
Fichier cible recommandé :
```text
client/public/favicon.svg
```
Concept :
- petit terminal ou serveur ;
- LED de statut ;
- flèche d'update ;
- grille machine ou stack discret.
Contraintes :
- lisible à `16x16` ;
- pas de texte ;
- silhouette reconnaissable ;
- version dark/light compatible.
### Fallback navigateur
```text
client/public/favicon.ico
```
Exporter depuis le SVG en tailles :
- `16x16`
- `32x32`
- `48x48`
### Icônes smartphone / PWA
```text
client/public/apple-touch-icon.png
client/public/icon-192.png
client/public/icon-512.png
client/public/maskable-512.png
```
Contraintes :
- fond plein compatible thème Gruvbox ;
- symbole centré ;
- marge de sécurité pour icône maskable ;
- lisible sur fond clair et sombre ;
- pas de détails fins.
---
## 4. Direction visuelle
Formes recommandées :
- serveur rack compact ;
- terminal carré ;
- grille 2x2 de machines ;
- LED ronde ;
- flèche circulaire d'update ;
- ligne de terminal ;
- stack de conteneurs ;
- puce CPU ;
- disque ;
- bouclier sécurité.
Palette conceptuelle :
- fond : utiliser les tokens `--bg-2`, `--bg-3` ;
- trait principal : `--ink-1` ou `currentColor` ;
- accent : `--accent` ;
- statut ok : `--ok` ;
- warning : `--warn` ;
- erreur : `--err`.
Le SVG doit rester utilisable en `currentColor`. Les variantes colorées ne doivent être utilisées que pour favicon/app icon, pas pour toutes les icônes UI.
---
## 5. Liste d'icônes nécessaires
### Navigation et layout
| Alias | Usage | Source recommandée |
|---|---|---|
| `app-logo` | logo app, favicon | SVG custom |
| `machines` | onglet machines | Font Awesome ou SVG custom |
| `hermes` | volet discussion agent | SVG custom si identité locale nécessaire |
| `settings` | paramètres | Font Awesome |
| `terminal` | volet terminal | Font Awesome |
| `logs` | logs bruts | Font Awesome |
| `report` | rapport Markdown | Font Awesome |
| `copy` | copier message/commande | Font Awesome |
| `fullscreen` | terminal plein écran | Font Awesome |
| `collapse` | réduire volet | Font Awesome |
### Actions système
| Alias | Usage | Source recommandée |
|---|---|---|
| `refresh` | analyse/refresh | Font Awesome |
| `analyze` | update + analyse | Font Awesome |
| `upgrade` | upgrade | Font Awesome |
| `full-upgrade` | full/dist-upgrade | SVG custom optionnel |
| `reboot` | reboot | Font Awesome |
| `reboot-verified` | reboot vérifié | SVG custom optionnel |
| `stop` | arrêter action | Font Awesome |
| `dry-run` | simulation | Font Awesome |
| `approve` | validation action | Font Awesome |
| `reject` | refus action | Font Awesome |
### Type de machine
| Alias | Usage | Source recommandée |
|---|---|---|
| `server` | machine générique | Font Awesome |
| `vm` | machine virtuelle | SVG custom optionnel |
| `physical-host` | machine physique | SVG custom optionnel |
| `proxmox-host` | hôte hyperviseur générique | SVG custom, sans logo Proxmox |
| `container` | LXC/container | Font Awesome ou SVG custom |
| `raspberry-pi` | Raspberry Pi générique | SVG custom sans logo officiel |
| `workstation` | poste/serveur GPU | Font Awesome |
### Hardware et métriques
| Alias | Usage | Source recommandée |
|---|---|---|
| `cpu` | CPU/load | Font Awesome existant |
| `memory` | RAM | Font Awesome existant |
| `disk` | disque/df | Font Awesome existant |
| `network` | réseau | Font Awesome existant |
| `gpu` | GPU | SVG custom optionnel |
| `temperature` | température | Font Awesome |
| `smart-disk` | SMART disk | SVG custom optionnel |
| `benchmark` | benchmark | Font Awesome |
| `machine-probe` | détection hardware | SVG custom optionnel |
### APT, Docker, scripts
| Alias | Usage | Source recommandée |
|---|---|---|
| `package` | paquet APT | Font Awesome |
| `repository` | dépôt APT | Font Awesome |
| `firmware` | firmware | SVG custom optionnel |
| `driver` | driver | SVG custom optionnel |
| `docker` | Docker installé/absent | SVG custom ou Font Awesome si disponible |
| `compose-stack` | stack Compose | SVG custom recommandé |
| `container-image` | image Docker | SVG custom optionnel |
| `prune` | nettoyage images | SVG custom optionnel |
| `script` | script install | Font Awesome |
| `profile` | profil post-install | Font Awesome |
### Sécurité et états
| Alias | Usage | Source recommandée |
|---|---|---|
| `ok` | succès | Font Awesome |
| `warning` | warning | Font Awesome |
| `error` | erreur | Font Awesome |
| `locked` | action verrouillée | Font Awesome |
| `secret` | secret masqué | Font Awesome |
| `key` | clé SSH/API | Font Awesome |
| `shield` | sécurité | Font Awesome |
| `disconnected` | machine/Hermes déconnecté | Font Awesome |
| `running` | action en cours | Font Awesome |
---
## 6. Icônes SVG custom prioritaires
Priorité haute :
1. `app-logo`
2. `compose-stack`
3. `machine-probe`
4. `reboot-verified`
Priorité moyenne :
1. `proxmox-host`
2. `physical-host`
3. `vm`
4. `gpu`
5. `firmware`
6. `driver`
Priorité basse :
1. `smart-disk`
2. `prune`
3. `container-image`
4. `raspberry-pi`
---
## 7. Format attendu des SVG
Recommandation :
```xml
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
<path d="..." stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
```
Contraintes :
- `viewBox="0 0 24 24"` pour icônes UI ;
- `viewBox="0 0 512 512"` possible pour logo/app icon source ;
- `stroke-width` entre `1.6` et `2`;
- pas de filtre SVG, pas de blur ;
- pas de texte vectorisé ;
- pas de dépendance externe ;
- fichiers nommés en kebab-case.
---
## 8. Validation visuelle
Chaque icône doit être vérifiée :
- en dark theme ;
- en light theme ;
- en `16px`, `20px`, `24px`, `32px` ;
- sur fond `--bg-2` et `--bg-3` ;
- en état normal, warning, error si applicable ;
- avec le composant `IconButton` et tooltip.
Critères d'acceptation :
- silhouette compréhensible sans label à `20px` ;
- pas de confusion entre `refresh`, `upgrade`, `reboot` ;
- pas de confusion entre `vm`, `physical-host`, `proxmox-host`, `container` ;
- pas de dépendance à une marque externe ;
- rendu cohérent avec le design system Gruvbox seventies.
+91
View File
@@ -0,0 +1,91 @@
# Tâche 2 — Moteur de templates de mise à jour : synthèse de design
> **Type** : document de design / spec. Aucune implémentation. Langue : français.
> **Périmètre** : design du moteur de templates (APT + Docker + scripts custom) et des contrats de données associés, prêt à passer en `writing-plans`.
> **Statut** : design figé proposé, à valider contre `validation_tache2.md` (gate obligatoire avant tout code).
---
## 1. Objet de la mission
Concevoir — sans coder — le **moteur de templates de mise à jour complet** et les **contrats JSON** associés, couvrant cinq axes :
- **Axe A** — Templates APT complets et OS-aware (update/analyse, upgrade, full/dist-upgrade, clean, autoremove, reboot-check, reboot vérifié), profils OS + type machine, proxy apt-cacher-ng.
- **Axe B** — Capture des mises à jour *prévues* (snapshot) et *appliquées* (diff réel avant/après), consommables par Hermes via déduplication + réduction déterministe.
- **Axe C** — Taxonomie des erreurs APT/dpkg/Docker + stratégie de remédiation.
- **Axe D** — Docker Compose : scan, inspect, pull-check, apply, prune, down, par SSH, avec racines déclarées + détection labels.
- **Axe E** — Scripts personnalisés (post-install, installation de paquets) avec garde-fous, manifestes et champs dynamiques.
La logique métier vit dans des **templates shell versionnés sur disque** (esprit `nas-ops`), rendus en Mustache et poussés en SSH (`server/ssh/client.ts`). Le backend orchestre, parse en **JSON canonique**, archive logs + rapports. Hermes analyse les JSON réduits, n'exécute jamais de SSH, ne reçoit jamais de secret.
---
## 2. Cartographie des livrables
| Fichier | Contenu | Axe / Livrable §4 |
|---|---|---|
| `00-synthese.md` (ce fichier) | Vue d'ensemble, décisions clés, couverture du gate | tous |
| `10-templates-apt.md` | Inventaire + pseudo-shell des templates APT, sémantique, marqueurs | A, §4.1, §4.2 |
| `20-docker.md` | Inventaire + pseudo-shell Docker Compose, flux, sécurité prune/down | D, §4.1, §4.2 |
| `30-scripts-custom.md` | Modèle des profils post-install, manifestes, champs dynamiques, garde-fous | E, §4.1, §4.7 |
| `40-contrats-json.md` | Schémas JSON canoniques étendus + types TS rétro-compatibles + déduplication/réduction | B, §4.3 |
| `50-erreurs.md` | Taxonomie des erreurs APT/dpkg/Docker/réseau + codes + remédiation | C, §4.4 |
| `60-profils-os-machine.md` | Modèle profils OS + type machine + overrides + proxy APT + détection | A, §4.5, §4.6 |
| `70-securite.md` | Frontière Hermes/MCP, actions destructives, validations, surface MCP | §4.8 |
| `80-sous-jalons.md` | Découpage en sous-jalons priorisé, prêt pour `writing-plans` | §4.9 |
| `90-questions-investigation.md` | Les 8 questions §3 tranchées (MVP / alternatives / risques) | §3 |
| `99-couverture-gate.md` | Auto-évaluation case par case de `validation_tache2.md` | gate |
---
## 3. Décisions structurantes (résumé)
Détail et justifications dans `90-questions-investigation.md`. Synthèse :
1. **Parsing : hybride, parsing-TS dominant (MVP).** On conserve l'approche actuelle (marqueurs `===SU:XXX===` + parsing TS dans `server/services/`), enrichie par des **données structurées en TSV/clé=valeur produites côté shell** (ex. `dpkg-query -W -f=...`, `docker ... --format json`) là où le format est déjà stable et documenté. Pas de génération de gros JSON imbriqué dans le shell. Rétro-compatible avec le jalon 1.
2. **Profils OS = fichiers de templates par profil + héritage par convention de dossier.** Arborescence `templates/<famille>/<commande>.sh.tpl` avec un profil `base` et des overrides par OS résolus par ordre de priorité. Le moteur de rendu choisit le template le plus spécifique disponible (fallback vers `base`).
3. **Type machine = choix manuel à l'ajout + action `machine_probe` de correction.** L'opérateur choisit `os_family` et `machine_kind` au formulaire ; une sonde non destructive propose des corrections (jamais appliquées automatiquement sans validation).
4. **Diff avant/après = snapshot dpkg autour de chaque action APT réelle.** `dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n'` avant et après ; le backend calcule le diff. L'exit code APT ne suffit jamais à déclarer un succès.
5. **Extensions de `shared/types.ts` : tout en champs optionnels.** Élargissement des unions (`OsFamily`, `ActionType`, `AptProxyMode`), ajout de blocs optionnels `apt` (détaillé), `docker`, `custom`/`postInstall`, `reboot`, `errors` sur `UpdateSnapshot` / `ExecutionResult`. Un snapshot/exécution du jalon 1 reste strictement valide.
6. **Opérations longues : `nohup` + fichier exit-code généralisé pour les actions applicatives longues, mais pas pour le refresh.** Reboot vérifié = mécanisme dédié (boot_id avant/après, reconnexion, délai adaptatif). Le refresh/analyse reste synchrone et court.
7. **Sécurité prune/down/scripts : barrière de validation côté webapp + `action_requests`.** Hermes propose, ne déclenche jamais. Secrets jamais lus ni renvoyés (registry creds, sudo, tokens). Erreurs nettoyées avant UI/MCP.
8. **Surface MCP minimale, en lecture + déclenchement d'actions déjà autorisées.** Réutilise les outils v1 du rapport (`list_machines`, `get_machine_snapshot`, `get_machine_execution`, `list_templates`, `preview_template`, `run_refresh`, `run_action`, `search_reports`) ; aucune nouvelle primitive d'exécution SSH exposée.
---
## 4. Principes invariants respectés
- **Convention templates** : tous les templates émettent des marqueurs `===SU:XXX===` ; `LC_ALL=C` ; `DEBIAN_FRONTEND=noninteractive` pour APT ; exécution sous `sudo -S` via `runScriptSudo` (base64) ; marqueur de sortie `===SU:EXIT=N===`.
- **Réduction déterministe avant LLM** : le réducteur (`aptReduce.ts`, à étendre en `reduceLines.ts`) ne garde que les lignes utiles APT et Docker ; le log brut complet est archivé séparément (`raw_artifacts` / `rawLogPath`).
- **Déduplication** : APT par `os_family + package + from + to + origin` ; Docker par `image + fromDigest + toDigest` (fallback `image + fromImageId + toImageId`).
- **Templates versionnés sur disque** : éditables depuis le front mais sauvegardés comme ressources de projet (revues Git), versionnés via `install_recipe_versions` pour les scripts custom.
- **Backend orchestre, shell porte la logique métier, JSON canonique = langage commun** frontend / MCP / Hermes.
- **Réutilisation de l'existant** : pas de nouveau mécanisme d'exécution SSH ; on s'appuie sur `runScriptSudo` / `runPlain` et sur la table `executions` + WebSocket terminal.
---
## 5. Alignement avec `tache1.9.md` (schéma BDD cible)
Les contrats JSON et tables dérivées de ce design se rangent dans les tables prévues :
- Snapshot APT/Docker → `snapshots(kind, payload_json, important_json)` + tables dérivées `apt_planned_packages`, `docker_compose_stacks`, `docker_stack_services`.
- Résultats d'exécution → `executions(result_json, important_json)` + `apt_applied_packages`, `docker_image_events`, `apt_errors`.
- Scripts custom → `install_profiles`, `install_recipes`, `install_recipe_versions`, `machine_profile_state`, `script_variables_presets`.
- Messages importants extraits → `important_messages`.
- Config Docker par machine → `docker_settings`, `docker_compose_roots`.
- Profils OS / type machine → colonnes `machines.os_family / machine_kind / virtualization / hardware_profile` + `machine_hardware` (sonde).
Aucune table nouvelle n'est requise par la tâche 2 ; le design réutilise la cible `tache1.9.md`.
---
## 6. Ce qui reste hors périmètre (suggestions, pas exécuté)
- Catalogue détaillé des scripts post-install → renvoyé à **tâche 4** (ce design pose le mécanisme moteur + les manifestes attendus).
- API/jobs/route d'action, file de jobs persistante → **tâche 5**.
- Affichage UI fin des snapshots/actions → **tâche 3**.
- Skill Hermes et analyse → **tâche 6**.
- Politique de rétention/purge des logs → **tâche 7**.
- Découverte réseau de machines → hors tâche 2.
Ces points sont mentionnés comme contexte d'emboîtement mais ne sont pas conçus en détail ici.
+202
View File
@@ -0,0 +1,202 @@
# 10 — Templates APT : inventaire, sémantique et pseudo-shell
> Axe A + livrables §4.1 et §4.2. Cohérent avec `templates/apt/check.sh.tpl`, `full-upgrade.sh.tpl`, `reboot.sh.tpl` existants, la convention `===SU:XXX===`, `LC_ALL=C`, `DEBIAN_FRONTEND=noninteractive`, exécution sous `sudo -S` (`server/ssh/client.ts`).
---
## 1. Sémantique APT clarifiée (manpage `apt-get`)
| Commande | Effet | Peut supprimer ? | Peut installer du nouveau ? |
|---|---|---|---|
| `apt-get update` | Resynchronise les index de paquets. Ne modifie aucun paquet installé. | non | non |
| `apt-get -s upgrade` | **Simulation** : installe les nouvelles versions des paquets installés **sans jamais supprimer** ni installer de nouveaux paquets. Les paquets dont l'upgrade exigerait une suppression/installation restent *held back*. | non | non |
| `apt-get -s dist-upgrade` / `full-upgrade` | **Simulation** : gère intelligemment les changements de dépendances, peut **installer de nouveaux paquets et en supprimer** pour satisfaire les dépendances. | oui | oui |
| `apt-get autoremove` | Retire les dépendances automatiquement installées et devenues inutiles. | oui | non |
| `apt-get clean` | Vide le cache local `/var/cache/apt/archives`. N'affecte pas l'état des paquets. | non | non |
> `apt full-upgrade` (commande `apt`) ≡ `apt-get dist-upgrade` (commande `apt-get`). **On utilise toujours `apt-get` en script** (non interactif, stable), jamais `apt` (UI humaine). L'UI parle d'« full-upgrade » comme alias convivial ; la commande système est `apt-get dist-upgrade`.
Lignes documentées parsées : `Inst <pkg> [<cur>] (<target> <origin> [<arch>])`, `Conf <pkg>`, `Remv <pkg>`. Le log brut complet reste archivé ; seules ces lignes + `E:`/`W:`/`dpkg:`/`reboot-required` alimentent Hermes.
Sources : `apt-get` https://manpages.debian.org/apt-get · `dpkg` https://manpages.debian.org/dpkg · `dpkg-query` https://manpages.debian.org/dpkg-query · `apt-listchanges` https://manpages.debian.org/bookworm/apt-listchanges/apt-listchanges.1.en.html · `needrestart` https://manpages.debian.org/bookworm/needrestart/needrestart.1.en.html
---
## 2. Inventaire des templates APT
| Template | Action (`ActionType`) | Rôle | Type | OS ciblés | Destructif ? | Marqueurs |
|---|---|---|---|---|---|---|
| `apt/update-analyze.sh.tpl` | `apt_update_analyze` | Refresh index + simulation `upgrade` et `dist-upgrade` + reboot-check. **Tâche de fond, non destructif.** Remplace/étend l'actuel `check.sh.tpl`. | snapshot | tous | non | `===SU:APT_UPDATE===`, `===SU:APT_SIM_UPGRADE===`, `===SU:APT_SIM_DISTUPGRADE===`, `===SU:APT_HELD===`, `===SU:REBOOT===`, `===SU:EXIT=N===` |
| `apt/upgrade.sh.tpl` | `apt_upgrade` | Applique l'upgrade simple (sans suppression volontaire). Snapshot dpkg avant/après. | action | tous | oui (modif paquets) | `===SU:DPKG_BEFORE===`, `===SU:APT_UPGRADE===`, `===SU:DPKG_AFTER===`, `===SU:REBOOT===`, `===SU:EXIT=N===` |
| `apt/full-upgrade.sh.tpl` | `apt_full_upgrade` | Applique `apt-get dist-upgrade`. **Conserve l'existant (jalon 1) en l'enrichissant du diff dpkg.** | action | tous | oui (peut supprimer) | idem upgrade + `===SU:APT_FULLUPGRADE===` |
| `apt/autoremove.sh.tpl` | `apt_autoremove` | Retire les dépendances inutiles, avec simulation préalable affichée. | action | tous | oui (supprime) | `===SU:APT_SIM_AUTOREMOVE===`, `===SU:DPKG_BEFORE===`, `===SU:APT_AUTOREMOVE===`, `===SU:DPKG_AFTER===`, `===SU:EXIT=N===` |
| `apt/clean.sh.tpl` | `apt_clean` | Vide le cache des paquets téléchargés. Action séparée, peu risquée. | action | tous | non (cache only) | `===SU:APT_CLEAN===`, `===SU:EXIT=N===` |
| `apt/reboot-check.sh.tpl` | (intégré au refresh) | Vérifie `/run/reboot-required`, `/var/run/reboot-required`, liste `reboot-required.pkgs`, état `needrestart -b`. | snapshot | tous | non | `===SU:REBOOT===` |
| `apt/reboot.sh.tpl` | `reboot_verified` | Lit `boot_id` avant, planifie le reboot, émet un marqueur parsable avant coupure SSH. Conserve l'existant en ajoutant `boot_id`. | action | tous | oui (reboot) | `===SU:BOOT_ID_BEFORE===`, `===SU:REBOOT_NOW===` |
> **Compatibilité jalon 1** : `apt_full_upgrade` et `reboot` restent des actions valides. `check.sh.tpl` n'est pas supprimé ; `update-analyze.sh.tpl` est son successeur enrichi (le refresh peut basculer dessus sans casser le parsing existant — voir §6 ci-dessous).
---
## 3. Variables de rendu (Mustache)
Étend `TemplateVars` (extension proposée, voir `40-contrats-json.md`). Variables utilisées par les templates APT :
```text
aptProxy string|null proxy apt-cacher-ng injecté à l'exécution (mode runtime)
osProfile string debian | ubuntu | proxmox | raspbian
machineKind string physical | vm | proxmox_host | lxc | raspberry_pi | workstation
confValues bool true => --force-confdef --force-confold (défaut)
inactivityTimeout int secondes; 0 = désactivé (défaut 600)
```
Les sections Mustache `{{#aptProxy}}…{{/aptProxy}}` restent identiques à l'existant.
---
## 4. Pseudo-shell des templates clés
### 4.1 `apt/update-analyze.sh.tpl` (refresh + analyse, non destructif)
```sh
#!/bin/sh
# Refresh index + simulations upgrade/dist-upgrade + reboot-check.
# Exécuté entier sous sudo par la couche SSH. Non destructif.
export LC_ALL=C
export DEBIAN_FRONTEND=noninteractive
{{#aptProxy}}export http_proxy="{{aptProxy}}"; export https_proxy="{{aptProxy}}"
{{/aptProxy}}
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==="
# Paquets retenus (held back) : présents en dist-upgrade mais pas en upgrade.
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
# needrestart en mode batch/list si présent (jamais interactif).
command -v needrestart >/dev/null 2>&1 && needrestart -b 2>/dev/null | grep -E '^NEEDRESTART-(KSTA|SVC)' || true
echo "===SU:EXIT=${UPD}==="
```
Le backend parse :
- section `APT_SIM_UPGRADE` → liste `upgrade` (paquets `Inst`),
- section `APT_SIM_DISTUPGRADE` → liste `dist-upgrade` (inclut `Inst` nouveaux + `Remv` suppressions),
- `held` = `APT_HELD` (et/ou paquets présents en dist-upgrade absents d'upgrade),
- `REBOOT_REQUIRED` + `PKG=` → reboot requis + paquets concernés.
Statut snapshot APT : `ok` si rien ; `updates_available` si `Inst` non vides ; `warning` si dist-upgrade implique des `Remv` ou des `held` ; `error` si `APT_UPDATE` échoue (dépôt injoignable, clé GPG…).
### 4.2 `apt/full-upgrade.sh.tpl` (application, avec diff dpkg)
```sh
#!/bin/sh
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}==="
```
Le backend compare `DPKG_BEFORE` et `DPKG_AFTER` (clé = `package+arch`) → `installed` / `upgraded` / `removed` / `unchanged`, versions finales réelles. **L'exit code seul ne suffit pas** : un paquet annoncé en simulation mais resté inchangé est signalé comme anomalie.
> Politique non interactive justifiée : `--force-confdef`+`--force-confold` conservent les fichiers de config locaux quand dpkg ne peut pas trancher, pour ne pas écraser une configuration distante. Les prompts (conffile, debconf, apt-listchanges, needrestart) sont traités comme **risques de blocage à détecter** (timeout d'inactivité), pas comme dialogues à exposer. Voir `50-erreurs.md` (`human_interaction_required`).
### 4.3 `apt/autoremove.sh.tpl`
```sh
#!/bin/sh
export LC_ALL=C
export DEBIAN_FRONTEND=noninteractive
echo "===SU:APT_SIM_AUTOREMOVE==="
apt-get -s -y autoremove 2>&1 # prévisualisation des Remv
echo "===SU:DPKG_BEFORE==="
dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n' 2>/dev/null
echo "===SU:APT_AUTOREMOVE==="
apt-get -y autoremove 2>&1
CODE=$?
echo "===SU:DPKG_AFTER==="
dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n' 2>/dev/null
echo "===SU:EXIT=${CODE}==="
```
Confirmation UI explicite requise (action qui supprime des paquets).
### 4.4 `apt/clean.sh.tpl`
```sh
#!/bin/sh
export LC_ALL=C
echo "===SU:APT_CLEAN==="
BEFORE=$(du -sb /var/cache/apt/archives 2>/dev/null | awk '{print $1}')
apt-get clean 2>&1
AFTER=$(du -sb /var/cache/apt/archives 2>/dev/null | awk '{print $1}')
echo "FREED_BYTES=$((BEFORE - AFTER))"
echo "===SU:EXIT=0==="
```
### 4.5 `apt/reboot.sh.tpl` (reboot vérifié — capture boot_id)
```sh
#!/bin/sh
export LC_ALL=C
echo "===SU:BOOT_ID_BEFORE==="
cat /proc/sys/kernel/random/boot_id 2>/dev/null
echo "===SU:REBOOT_NOW==="
# Reboot différé pour laisser le canal SSH se fermer proprement.
nohup sh -c 'sleep 2; reboot' >/dev/null 2>&1 &
echo "reboot planifié"
```
Le **backend** orchestre la vérification (hors template) : il a lu `boot_id` *avant*, attend la coupure SSH, retente la connexion (délai adaptatif), relit `boot_id` via `runPlain` (`cat /proc/sys/kernel/random/boot_id`). Reboot `ok` seulement si la machine revient ET `boot_id` a changé. Statuts d'échec : `reboot_command_failed`, `ssh_never_went_down`, `machine_did_not_return`, `boot_id_unchanged`, `timeout`. Champs résultat : `beforeBootId`, `afterBootId`, `requestedAt`, `sshWentDownAt`, `sshCameBackAt`, `waitedSeconds`, `status`, `errors`. Délai adaptatif par machine : `lastRebootDurationSeconds``nextRecommendedWaitSeconds` (avec marge). Voir `40-contrats-json.md` (`RebootResult`).
---
## 5. Spécificités par profil OS (détail dans `60-profils-os-machine.md`)
- **Debian** : avant de proposer firmware/drivers propriétaires, vérifier la présence des composants `contrib`, `non-free`, `non-free-firmware` (template `apt/check-components.sh.tpl`, lecture seule). Pas d'activation automatique.
- **Ubuntu** : `ubuntu-drivers devices` (lecture) pour proposer des drivers (NVIDIA/GPU) — proposition uniquement, jamais installé par défaut.
- **Proxmox** : profil dédié. Vérifier dépôts PVE (`pve-no-subscription` vs `enterprise`), meta-package `proxmox-ve`, kernel PVE, Ceph éventuel, puis `apt-get dist-upgrade`. Ne **jamais** traiter comme une Debian générique.
- **Raspberry Pi OS** : profil dédié. Attention firmware/kernel, **vérifier l'espace disque avant upgrade**, utiliser `full-upgrade`.
Le template le plus spécifique disponible sous `templates/<profil>/<commande>.sh.tpl` est choisi, sinon fallback `templates/apt/<commande>.sh.tpl` (profil `base`).
---
## 6. Compatibilité et migration (non-régression jalon 1)
- L'actuel `check.sh.tpl` produit `===SU:UPDATE===` / `===SU:SIMULATE===` / `===SU:REBOOT===` / `===SU:END===`. Le nouveau `update-analyze.sh.tpl` ajoute des sections **sans supprimer** la sémantique : le parsing actuel (`extractSection(raw, "===SU:SIMULATE===", "===SU:REBOOT===")`) peut être conservé en parallèle pendant la migration.
- **Recommandation MVP** : introduire `update-analyze.sh.tpl` comme nouveau template et faire pointer le refresh dessus dans un sous-jalon dédié, en gardant `check.sh.tpl` jusqu'à bascule validée. Aucune rupture du flux prouvé en prod.
- `full-upgrade.sh.tpl` et `reboot.sh.tpl` existants restent fonctionnels ; on **ajoute** les sections `DPKG_BEFORE/AFTER` et `BOOT_ID_BEFORE` (extension, pas remplacement de marqueurs).
+211
View File
@@ -0,0 +1,211 @@
# 20 — Docker Compose : inventaire, flux et pseudo-shell
> Axe D + livrables §4.1/§4.2. Conçu pour cocher chaque case de `validation_tache2.md §6` (« Focus Docker Compose »). Gestion **par SSH sur la machine cible** via `server/ssh/client.ts` et templates versionnés ; pas de moteur parallèle.
---
## 1. Méthode retenue (MVP)
- **Gestion par SSH** sur la machine cible, réutilisant `runScriptSudo` / `runPlain` et la table `executions`, le WebSocket terminal, `rawLogPath`, `reportPath`, statut `ok|warning|error`. **Pas de second système de jobs Docker.**
- Variante `docker context` over SSH : **citée comme alternative opérateur**, pas le moteur MVP.
- **Découverte des stacks** depuis des **racines déclarées par machine** (`composeRoots`, ex. `/opt/stacks`, `/srv/docker`), scan limité en profondeur (`composeScanDepth`, défaut 4), puis validation UI.
- **Détection par labels Compose** (`com.docker.compose.project`, `com.docker.compose.service`, et si présent `com.docker.compose.project.working_dir`) = **complément** pour retrouver les stacks actifs, pas l'unique source de vérité.
- Cycle de vie d'un stack : `candidate` (juste détecté) → `enabled` (validé par l'utilisateur) → actions autorisées. `pull`, `up`, `down`, `prune` **uniquement sur un stack `enabled`/validé**.
Sources citées : `docker compose pull` https://docs.docker.com/reference/cli/docker/compose/pull/ · `up` https://docs.docker.com/reference/cli/docker/compose/up/ · `config` https://docs.docker.com/reference/cli/docker/compose/config/ · `ps` https://docs.docker.com/reference/cli/docker/compose/ps/ · `images` https://docs.docker.com/reference/cli/docker/compose/images/ · `down` https://docs.docker.com/reference/cli/docker/compose/down/ · `image inspect` https://docs.docker.com/reference/cli/docker/image/inspect/ · `image prune` https://docs.docker.com/reference/cli/docker/image/prune/
---
## 2. Inventaire des templates Docker
| Template | Action (`ActionType`) | Rôle | Effet disque | Destructif ? | Validation UI |
|---|---|---|---|---|---|
| `docker/scan-compose.sh.tpl` | `docker_scan` | Scanne les racines déclarées, trouve les fichiers compose, valide chaque candidat. **Passif.** | non | non | non |
| `docker/inspect-compose.sh.tpl` | `docker_inspect_current` | État actuel sans changement : config images, ps, images, inspect. **Passif.** | non | non | non |
| `docker/pull-check.sh.tpl` | `docker_pull_check` | `docker compose pull` (télécharge sans démarrer), compare ID/digest/labels avant-après. **Écrit sur le disque Docker** (pas un scan pur). | oui (cache images) | non (pas applicatif) | non (mais non passif) |
| `docker/apply-compose.sh.tpl` | `docker_compose_apply` | `docker compose up -d --remove-orphans` après validation. Recapture ps/images/inspect. | oui | oui (recrée conteneurs) | **oui, explicite** |
| `docker/prune-images.sh.tpl` | `docker_prune_images` | `docker image prune -f` (safe) ; mode agressif `-a -f --filter "until=168h"`. | oui | oui (agressif) | **oui pour agressif** |
| `docker/down-compose.sh.tpl` | `docker_compose_down` | `docker compose down`. **Action séparée et destructive**, hors chemin de mise à jour normal. | oui | oui | **oui, forte** |
> `--volumes` et `--rmi` sur `down` : **interdits au MVP** (ou protégés par une validation forte distincte). Le chemin de mise à jour normal n'utilise jamais `down` : `up -d` recrée les conteneurs quand l'image ou la config change, en préservant les volumes montés.
Tous les templates : `LC_ALL=C`, marqueurs `===SU:DOCKER_*===`, sortie parsable, log brut archivé.
---
## 3. Flux de mise à jour Docker (formalisé)
1. `docker_scan` — découverte des stacks candidats (racines déclarées + labels actifs).
2. `docker_inspect_current` — état actuel des services, conteneurs, images.
3. `docker_pull_check` — téléchargement des images candidates **sans démarrage de conteneurs**.
4. Comparaison déterministe — image ref, image ID, repo digest, labels OCI (`org.opencontainers.image.version`, `revision`, `source`, `created`) si présents.
5. Proposition UI/Hermes — liste des stacks/services avec update dispo, erreurs de pull, inconnues.
6. `docker_compose_apply` après validation utilisateur — `docker compose up -d --remove-orphans`.
7. Vérification après application — conteneurs recréés, état `running/exited`, health si dispo, erreurs.
8. `docker_prune_images` après succès ou action séparée — images supprimées, espace récupéré, erreurs.
**Points clés validés** :
- `docker compose pull` télécharge mais **ne démarre pas** les conteneurs → bon pré-check applicatif, pas un scan sans effet.
- `docker compose up -d` recrée les conteneurs quand l'image/config a changé, **en préservant les volumes**`down` inutile pour une MAJ normale.
- `docker image prune -f` = images *dangling* (sûr) ; `docker image prune -a` = **toutes** les images non référencées par un conteneur (destructif).
---
## 4. Pseudo-shell des templates
### 4.1 `docker/scan-compose.sh.tpl`
```sh
#!/bin/sh
export LC_ALL=C
echo "===SU:DOCKER_SCAN==="
# {{composeRoots}} rendu en liste shell-safe par le backend (une racine par ligne).
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")
# Valide le candidat ; n'applique rien.
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==="
# Complément : stacks actifs détectés par 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==="
```
> Note de rendu : les `{{ }}` Docker Go-template sont échappés ici pour ne pas être interprétés par Mustache (le moteur de rendu réel utilisera des délimiteurs Mustache personnalisés ou un échappement, à fixer en implémentation). Seules `composeRoots`/`composeScanDepth` sont des variables Mustache.
### 4.2 `docker/inspect-compose.sh.tpl`
```sh
#!/bin/sh
export LC_ALL=C
cd "{{stackDir}}" || { echo "===SU:DOCKER_ERR===\ncompose_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==="
# Pour chaque image utilisée : Id, RepoDigests, labels OCI.
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==="
```
### 4.3 `docker/pull-check.sh.tpl`
```sh
#!/bin/sh
export LC_ALL=C
cd "{{stackDir}}" || { 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\t$img\t$id\t$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\t$img\t$id\t$dg\t$ver"
done
echo "===SU:EXIT=${CODE}==="
```
Backend : compare `BEFORE`/`AFTER` par `image ref`. Si `id`/`digest` change → `updates_available`. Erreurs `pull_failed`/`registry_auth_failed` nettoyées (jamais d'URL/token sensible vers UI/MCP).
### 4.4 `docker/apply-compose.sh.tpl`
```sh
#!/bin/sh
export LC_ALL=C
cd "{{stackDir}}" || { 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}==="
```
### 4.5 `docker/prune-images.sh.tpl`
```sh
#!/bin/sh
export LC_ALL=C
echo "===SU:DOCKER_PRUNE==="
{{#aggressive}}
# Mode agressif : nécessite validation UI explicite distincte.
docker image prune -a -f --filter "until=168h" 2>&1
{{/aggressive}}
{{^aggressive}}
# Mode sûr par défaut : dangling images uniquement.
docker image prune -f 2>&1
{{/aggressive}}
CODE=$?
echo "===SU:EXIT=${CODE}==="
```
Le backend parse `Total reclaimed space` et `deleted` pour `bytesReclaimed` et la liste d'images supprimées.
### 4.6 `docker/down-compose.sh.tpl`
```sh
#!/bin/sh
export LC_ALL=C
cd "{{stackDir}}" || { echo "compose_not_found"; echo "===SU:EXIT=2==="; exit 2; }
echo "===SU:DOCKER_DOWN==="
# --volumes et --rmi INTERDITS au MVP. down simple uniquement.
docker compose down 2>&1
CODE=$?
echo "===SU:EXIT=${CODE}==="
```
---
## 5. Réduction Hermes (Docker)
Seules ces lignes (+ le JSON canonique) sont transmises : `Pulling`, `Digest`, `Status`, `Downloaded newer image`, `Recreating`, `Started`, `Error`, `deleted`, `Total reclaimed space`. Le log brut complet reste archivé (`raw_artifacts` / `rawLogPath`).
---
## 6. Insertion dans la webapp existante
- **Config machine** : nouveaux champs `dockerEnabled`, `composeRoots[]`, `composeScanDepth`, `composeStacks[]`**optionnels** ou dans un endpoint dédié ; `MachineView` n'est pas cassé (les champs sont ajoutés en option). Stockés dans `docker_settings` / `docker_compose_roots` / `docker_compose_stacks` (`tache1.9.md`).
- **Refresh/snapshot** : le refresh machine peut produire un snapshot combiné `apt` + `docker`, **ou** un refresh Docker séparé pour éviter de lancer `docker_pull_check` automatiquement (recommandé : pull-check séparé car non-passif).
- **Actions** : extension progressive de `ActionType` (`docker_scan`, `docker_pull_check`, `docker_compose_apply`, `docker_prune_images`, `docker_compose_down`) avec filtrage d'autorisation conservé sur `POST /api/machines/:id/actions`.
- **Executions/rapports** : réutilisation de la table `executions`, du WebSocket terminal, de `rawLogPath`/`reportPath`/statut. Pas de second système.
- **UI machine** : compteur Docker séparé du compteur APT (ex. stacks avec updates) ; vue détail par stack/service ; boutons d'action validés (`Pull/check`, `Appliquer`, `Prune`, `Down`).
- **Validation utilisateur** : `docker_compose_apply`, `docker_prune_images` agressif et `docker_compose_down` passent par une confirmation UI explicite (via `action_requests`). Hermes propose, ne déclenche jamais.
- **Secrets** : credentials registry (`~/.docker/config.json`, helpers, tokens) **jamais lus ni renvoyés** ; erreurs nettoyées si elles exposent une URL sensible (voir `70-securite.md`).
+171
View File
@@ -0,0 +1,171 @@
# 30 — Scripts personnalisés / post-install : modèle moteur
> Axe E + livrables §4.1/§4.7. Conçu pour cocher `validation_tache2.md §8` (« scripts post-install et profils personnalisés »). **Le catalogue détaillé est renvoyé à la tâche 4** ; ce document pose le **mécanisme moteur**, les **manifestes**, les **champs dynamiques** et les **garde-fous**.
---
## 1. Principe UX et absence d'interactivité SSH
- **Interdiction stricte des questions interactives au milieu d'un script SSH.** Toute question nécessaire devient un **champ de formulaire** dans la webapp avant exécution.
- Les profils post-install sont **cochables** ; cocher un profil déplie ses champs obligatoires.
- Chaque profil fournit un **manifeste** : `id`, `label`, `description`, `fields`, valeurs par défaut, validations, prévisualisation, `risk`, `requiresConfirmation`.
- Le bouton d'exécution reste **désactivé** tant que les champs requis des profils cochés ne sont pas valides.
- La webapp propose une **preview du template rendu** avant exécution (`preview_template`), avec **masquage des secrets** et signalement des changements réseau/reboot.
- Les scripts s'exécutent en **mode non interactif** ; s'ils détectent une décision non fournie, ils **échouent avec une erreur structurée** au lieu de bloquer.
Stockage : `install_profiles` (catalogue + `manifest_json`), `install_recipes`/`install_recipe_versions` (scripts versionnés + sha256), `machine_profile_state` (état par machine, variables **non sensibles**), `script_variables_presets` (préréglages réutilisables). Cf. `tache1.9.md §9`.
---
## 2. Profils post-install attendus (composables)
| Profil | Rôle | Risque | Confirmation |
|---|---|---|---|
| `bootstrap_root` | Première prépa après DHCP/`su -` : installe `sudo`, `resolvconf`, `ca-certificates`, `curl` ; ajoute l'opérateur au groupe `sudo` ; vérifie `sudo`. | low | non |
| `identity_network` | Hostname, domaine/search `.home`, `/etc/hosts`, IP statique dans `/etc/network/interfaces`. | network_change | **oui** |
| `base_tools` | Outils de base **sans vim** : `nano`, `less`, `bash-completion`, `tmux`, `screen`, `htop`, `iotop`, `ncdu`, `tree`, `rsync`, `unzip`, `zip`, `tar`. | low | non |
| `network_tools` | `iproute2`, `iputils-ping`, `dnsutils`, `traceroute`, `net-tools`(opt), `tcpdump`, `nmap`, `mtr-tiny`, `lsof`, `netcat-openbsd`. | low | non |
| `dev_git` | `git`, `curl`, `wget`, `jq`, `yq`, `gnupg`, `lsb-release` ; `build-essential` optionnel. | low | non |
| `sharing` | `samba`, `nfs-kernel-server`, `avahi-daemon`, `libnss-mdns`, configurables séparément. | medium | oui |
| `docker_official` | Docker Engine depuis le dépôt officiel Debian : `docker-ce`, `docker-ce-cli`, `containerd.io`, `docker-buildx-plugin`, `docker-compose-plugin` ; ajout user au groupe `docker` ; dossier Compose dans le home ; reboot/reconnexion si nécessaire. | medium | oui |
| `vm_guest_tools` | `qemu-guest-agent` ou `open-vm-tools` selon hyperviseur choisi. | low | non |
| Optionnels (non installés par défaut) | `security_basic`, `backup_tools`, `monitoring`, `mail_notify`, `time_sync`, `storage_tools`. | variable | selon profil |
> Les scripts hardware/drivers/benchmark ne sont **jamais installés par défaut** et exigent validation (voir `60-profils-os-machine.md`).
---
## 3. Champs dynamiques générés
- **`identity_network`** : `newHostname`, `domain`/`search`, `interfaceName`, `staticAddress` (CIDR, ex. `10.0.x.y/22`), `gateway` (défaut `10.0.0.1`), `dnsNameservers` (défaut `10.0.0.1`, `10.0.0.10`), `reconnectHost`.
- **`docker_official`** : `dockerUser` (ex. `gilles`), `dockerHomeDir`/`composeRoot` (ex. `/home/gilles/docker`), `installComposePlugin`, `rebootAfterInstall`.
- **`sharing`** : choix séparé Samba/NFS/mDNS, noms de partages, chemins autorisés, utilisateurs/groupes si nécessaire.
- **`vm_guest_tools`** : type d'hyperviseur / paquet cible.
Les champs peuvent être **préremplis** depuis la machine (`machine.name`, IP DHCP, interface primaire détectée par `machine_probe`, utilisateur SSH), mais restent **modifiables** avant validation.
### Exemple de manifeste (attendu dans la spec)
```json
{
"id": "identity_network",
"label": "Hostname + IP statique",
"requiresConfirmation": true,
"risk": "network_change",
"fields": [
{ "name": "newHostname", "type": "hostname", "required": true },
{ "name": "domain", "type": "string", "required": true, "default": "home" },
{ "name": "interfaceName", "type": "select", "required": true, "defaultFrom": "detected.primaryInterface" },
{ "name": "staticAddress", "type": "ipv4_cidr", "required": true },
{ "name": "gateway", "type": "ipv4", "required": true, "default": "10.0.0.1" },
{ "name": "dnsNameservers", "type": "ipv4_list", "required": true, "default": ["10.0.0.1", "10.0.0.10"] },
{ "name": "reconnectHost", "type": "ipv4", "required": true, "defaultFrom": "staticAddress.ip" }
]
}
```
Types de champ proposés : `string`, `hostname`, `ipv4`, `ipv4_cidr`, `ipv4_list`, `select`, `bool`, `int`, `path`, `secret` (jamais sérialisé en clair, jamais envoyé à Hermes/MCP). `defaultFrom` référence une valeur détectée par `machine_probe`.
---
## 4. Templates custom attendus (pseudo-shell)
Tous : `LC_ALL=C`, `DEBIAN_FRONTEND=noninteractive`, marqueurs `===SU:CUSTOM_*===`, `===SU:EXIT=N===`, sortie parsable, log brut archivé. Échec contrôlé si décision manquante.
### 4.1 `custom/bootstrap-root.sh.tpl`
```sh
#!/bin/sh
export LC_ALL=C
export DEBIAN_FRONTEND=noninteractive
echo "===SU:CUSTOM_BOOTSTRAP==="
apt-get update -qq 2>&1
apt-get install -y sudo resolvconf ca-certificates curl 2>&1
CODE=$?
# Ajoute l'opérateur au groupe sudo (variable de formulaire, non secret).
usermod -aG sudo "{{operatorUser}}" 2>&1 || echo "WARN usermod"
# Vérifie sudo
su - "{{operatorUser}}" -c 'sudo -n true' 2>&1 && echo "SUDO_OK" || echo "SUDO_CHECK_PENDING"
echo "===SU:EXIT=${CODE}==="
```
### 4.2 `custom/identity-network.sh.tpl`
```sh
#!/bin/sh
export LC_ALL=C
echo "===SU:CUSTOM_IDENTITY==="
# Sauvegardes avant modification.
cp -a /etc/hosts "/etc/hosts.su.bak.$(date +%s)" 2>/dev/null
cp -a /etc/network/interfaces "/etc/network/interfaces.su.bak.$(date +%s)" 2>/dev/null
OLD_IP="{{dhcpEndpoint}}"
echo "OLD_ENDPOINT=${OLD_IP}"
hostnamectl set-hostname "{{newHostname}}" 2>&1 || echo "hostname_failed"
# Réécrit /etc/network/interfaces pour {{interfaceName}} en statique {{staticAddress}}.
# (rendu détaillé en tâche 4 ; échoue proprement si interface absente)
echo "NEW_ENDPOINT={{reconnectHost}}"
echo "RECONNECT_REQUIRED=1"
echo "===SU:EXIT=0==="
```
Le script **ne coupe jamais la connexion sans stratégie de reconnexion planifiée par la webapp**. Si reboot requis → mécanisme `reboot_verified`. Après application, la webapp vérifie la reconnexion sur la nouvelle IP/hostname et met à jour la machine si le retour est confirmé. Erreurs distinguées : `network_config_invalid`, `interface_not_found`, `dns_config_failed`, `reconnect_failed`, `hostname_failed`, `sudo_setup_failed`.
### 4.3 `custom/install-package-groups.sh.tpl`
```sh
#!/bin/sh
export LC_ALL=C
export DEBIAN_FRONTEND=noninteractive
echo "===SU:CUSTOM_PKGGROUPS==="
# {{packages}} rendu comme liste shell-safe par le backend (jamais de vim par défaut).
apt-get update -qq 2>&1
apt-get install -y {{packages}} 2>&1
CODE=$?
echo "===SU:EXIT=${CODE}==="
```
### 4.4 `custom/docker-official-debian.sh.tpl`
Suit la doc officielle Docker Debian (https://docs.docker.com/engine/install/debian/, https://docs.docker.com/compose/install/linux/) : clé GPG dans `/etc/apt/keyrings`, `docker.sources`, puis paquets.
```sh
#!/bin/sh
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
# docker.sources écrit selon codename détecté (non secret).
apt-get update -qq 2>&1
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin 2>&1
CODE=$?
usermod -aG docker "{{dockerUser}}" 2>&1 || echo "WARN docker group"
mkdir -p "{{composeRoot}}" 2>&1
echo "DOCKER_GROUP_RELOGIN_REQUIRED=1"
{{#rebootAfterInstall}}echo "REBOOT_REQUESTED=1"{{/rebootAfterInstall}}
echo "===SU:EXIT=${CODE}==="
```
### 4.5 `custom/sharing.sh.tpl` et `4.6 custom/vm-guest-tools.sh.tpl`
Installent Samba/NFS/mDNS selon les choix (sans config dangereuse par défaut) / l'agent invité choisi. Mêmes conventions de marqueurs et d'échec contrôlé. Détail des configs renvoyé à la tâche 4.
Sources citées : Docker Debian https://docs.docker.com/engine/install/debian/ · Compose plugin https://docs.docker.com/compose/install/linux/ · Debian network https://wiki.debian.org/NetworkConfiguration · Debian Handbook https://www.debian.org/doc/manuals/debian-handbook/sect.network-config · resolvconf https://packages.debian.org/stable/net/resolvconf
---
## 5. JSON canonique post-install
`ExecutionResult` reçoit un bloc optionnel `postInstall` (voir `40-contrats-json.md`) listant : profils exécutés, variables **non sensibles** utilisées, fichiers modifiés, paquets installés, services activés/démarrés, reboots demandés, erreurs. Secrets/tokens **jamais** inclus (variables sérialisées, logs UI, rapports, MCP). Les changements réseau/Docker sont marqués dans le rapport Markdown avec les prochaines actions attendues (reconnexion, logout/login groupe Docker, reboot).
---
## 6. Insertion dans la webapp existante
- Même mécanique que les autres actions : templates versionnés, preview, exécution SSH (`runScriptSudo`), WebSocket terminal, `executions`, rapport Markdown, log brut.
- Valeurs réutilisables conservées dans `script_variables_presets` (scope `global`/`machine`/`profile`) ; état par machine dans `machine_profile_state`. Le provisioning peut être un assistant ponctuel ou stocké par machine.
- Hermes peut proposer des profils ou expliquer un échec, mais ne reçoit que le JSON réduit et **ne déclenche jamais** les actions à risque sans validation webapp.
- Découpage en sous-jalons indépendants : bootstrap/sudo, identité+réseau, paquets de base, Docker officiel, partage réseau, outils VM/monitoring (voir `80-sous-jalons.md`).
+311
View File
@@ -0,0 +1,311 @@
# 40 — Contrats JSON canoniques étendus + types TypeScript
> Axe B + livrable §4.3. Tranche la question §3.5 (extensions de `shared/types.ts`). **Tous les ajouts sont rétro-compatibles** : champs optionnels, unions élargies. Un `UpdateSnapshot`/`ExecutionResult` du jalon 1 reste strictement valide.
---
## 1. Principe de rétro-compatibilité
État actuel (`shared/types.ts`) :
```ts
export type OsFamily = "debian" | "ubuntu" | "unknown";
export type AptProxyMode = "direct" | "runtime";
export type ActionType = "apt_full_upgrade" | "reboot";
// UpdateSnapshot.apt: { enabled, count, rebootRequired, packages: AptPackage[] }
// ExecutionResult: { ... action: ActionType, status, rebootRequiredAfterRun, importantLogLines, rawLogRef, reportRef }
```
Règles d'extension :
1. **Élargir les unions** (`OsFamily`, `AptProxyMode`, `ActionType`) — additif, aucun retrait.
2. **Ajouter des blocs optionnels** (`docker?`, `errors?`, `apt` détaillé optionnel, `reboot?`, `postInstall?`) — un payload sans ces blocs reste valide.
3. **Ne jamais retirer ni renommer** un champ existant. Le jalon 1 émet `apt: { enabled, count, rebootRequired, packages }` ; on **ajoute** des champs optionnels à côté.
4. Versionner via `schemaVersion?: number` (aligné `snapshots.schema_version` / `executions.schema_version` de `tache1.9.md`). Absence ⇒ version 1.
---
## 2. Extensions des unions
```ts
// Élargissement additif. Le jalon 1 ("debian"|"ubuntu"|"unknown") reste valide.
export type OsFamily = "debian" | "ubuntu" | "proxmox" | "raspbian" | "unknown";
export type MachineKind =
| "physical" | "vm" | "proxmox_host" | "lxc"
| "raspberry_pi" | "workstation" | "unknown";
// "persistent" ajouté (écriture dans /etc/apt/apt.conf.d/).
export type AptProxyMode = "direct" | "runtime" | "persistent";
export type ActionType =
// jalon 1 (conservés tels quels)
| "apt_full_upgrade" | "reboot"
// APT
| "apt_update_analyze" | "apt_upgrade" | "apt_dist_upgrade"
| "apt_autoremove" | "apt_clean" | "reboot_verified"
// Docker
| "docker_scan" | "docker_inspect_current" | "docker_pull_check"
| "docker_compose_apply" | "docker_prune_images" | "docker_compose_down"
// probe + custom
| "machine_probe" | "post_install";
export type SnapshotStatus = "ok" | "updates_available" | "warning" | "error";
export type ExecutionStatus = "ok" | "warning" | "error"; // inchangé
```
> `reboot` (jalon 1) et `reboot_verified` coexistent : `reboot_verified` ajoute la vérification boot_id ; le code jalon 1 continue d'émettre `reboot`.
---
## 3. Snapshot canonique étendu (`UpdateSnapshot`)
```ts
export interface AptPackage {
name: string;
currentVersion: string | null;
targetVersion: string;
origin: string | null;
// Ajouts optionnels (rétro-compatibles) :
arch?: string;
operation?: "upgrade" | "install" | "remove" | "hold";
severityHint?: "normal" | "security";
}
export interface AptSnapshotDetail {
enabled: boolean;
count: number;
rebootRequired: boolean;
packages: AptPackage[];
// Ajouts optionnels :
status?: SnapshotStatus; // ok | updates_available | warning | error
upgradeCount?: number; // simulation `upgrade`
distUpgradeCount?: number; // simulation `dist-upgrade`
installed?: AptPackage[]; // nouveaux paquets (dist-upgrade)
removed?: AptPackage[]; // suppressions prévues (=> status warning)
held?: string[]; // paquets retenus (=> status warning)
rebootPkgs?: string[]; // depuis reboot-required.pkgs
}
export interface DockerSnapshotService {
serviceName: string;
image: string; // image ref (ex. jellyfin/jellyfin:latest)
currentImageId?: string | null;
currentDigest?: string | null;
candidateImageId?: string | null; // après pull-check
candidateDigest?: string | null;
currentVersion?: string | null; // label OCI org.opencontainers.image.version
candidateVersion?: string | null;
sourceUrl?: string | null; // label OCI source
status?: "up_to_date" | "updates_available" | "warning" | "error";
}
export interface DockerSnapshotStack {
name: string;
workingDir: string;
composeFiles: string[];
projectName?: string | null;
status: "candidate" | "enabled" | "ignored" | "error";
detectedBy?: "root_scan" | "label" | "manual";
services: DockerSnapshotService[];
}
export interface DockerSnapshot {
enabled: boolean;
installed: boolean;
count: number; // services avec update dispo
declaredRoots?: string[];
stacks: DockerSnapshotStack[];
status?: SnapshotStatus;
}
export interface SnapshotError {
source: "apt" | "docker" | "post_install" | "ssh" | "system";
kind: string; // voir 50-erreurs.md (taxonomie)
severity: "info" | "warning" | "error";
message: string; // nettoyé, jamais de secret
remediation?: string;
importantLines?: string[];
}
export interface UpdateSnapshot {
machineId: string;
hostname: string;
os: { family: OsFamily; version: string };
checkedAt: string; // ISO 8601
status: MachineStatus;
apt: AptSnapshotDetail; // bloc jalon 1 conservé, champs additifs optionnels
// Ajouts optionnels (rétro-compatibles) :
schemaVersion?: number;
kind?: "apt_update_analyze" | "docker_scan" | "reboot_check" | "combined";
machineKind?: MachineKind;
docker?: DockerSnapshot;
errors?: SnapshotError[];
rawHints?: { logImportantLines: string[] };
}
```
> Le bloc `apt` reste **requis** (présent au jalon 1) ; seuls ses champs *additifs* sont optionnels. `docker`, `errors`, `machineKind`, `kind`, `schemaVersion` sont **optionnels** → un snapshot jalon 1 (sans eux) reste valide.
### Bloc Docker minimal exigé par la validation (couverture §6)
Le bloc snapshot Docker contient au minimum : stacks déclarés (`declaredRoots`), stacks candidats (`stacks[].status="candidate"`), services (`stacks[].services[]`), image ref actuelle (`image`), image ID actuelle (`currentImageId`), digest actuel si dispo (`currentDigest`), labels de version si dispo (`currentVersion`), image ID/digest candidat après pull (`candidateImageId`/`candidateDigest`), statut `up_to_date|updates_available|warning|error`. ✔
---
## 4. Résultat d'exécution étendu (`ExecutionResult`)
```ts
export interface AptChange {
name: string;
arch?: string;
fromVersion: string | null;
toVersion: string | null;
operation: "upgraded" | "installed" | "removed" | "unchanged";
origin?: string | null;
}
export interface AptExecutionResult {
planned: AptPackage[]; // ce qui était prévu (simulation pré-action)
applied: AptChange[]; // diff dpkg réel before/after
installed: AptChange[];
removed: AptChange[];
held: string[];
errors?: SnapshotError[];
rebootRequiredAfterRun: boolean;
}
export interface DockerImageChange {
stack: string;
serviceName?: string;
imageRef?: string;
fromImageId?: string | null;
toImageId?: string | null;
fromDigest?: string | null;
toDigest?: string | null;
operation: "pulled" | "recreated" | "pruned";
}
export interface DockerExecutionResult {
pull?: { changes: DockerImageChange[]; errors?: SnapshotError[] };
up?: { recreated: string[]; running: string[]; exited: string[]; errors?: SnapshotError[] };
prune?: { imagesDeleted: string[]; bytesReclaimed: number; errors?: SnapshotError[] };
errors?: SnapshotError[];
}
export interface RebootResult {
beforeBootId: string | null;
afterBootId: string | null;
requestedAt: string;
sshWentDownAt: string | null;
sshCameBackAt: string | null;
waitedSeconds: number;
status: "ok" | "reboot_command_failed" | "ssh_never_went_down"
| "machine_did_not_return" | "boot_id_unchanged" | "timeout";
lastRebootDurationSeconds?: number;
nextRecommendedWaitSeconds?: number;
errors?: SnapshotError[];
}
export interface PostInstallResult {
profilesRun: string[];
variablesUsed: Record<string, string | number | boolean>; // NON sensible uniquement
filesModified: string[];
packagesInstalled: string[];
servicesEnabled: string[];
rebootsRequested: boolean;
networkChange?: {
oldEndpoint: string | null;
newEndpoint: string | null;
reconnectHost: string | null;
};
errors?: SnapshotError[];
}
export interface ExecutionResult {
executionId: string;
machineId: string;
startedAt: string;
finishedAt: string;
mode: "manual" | "scheduled" | "hermes_requested"; // élargi (jalon 1 = "manual")
action: ActionType;
status: ExecutionStatus;
rebootRequiredAfterRun: boolean;
importantLogLines: string[];
rawLogRef: string;
reportRef: string;
// Ajouts optionnels (rétro-compatibles) :
schemaVersion?: number;
apt?: AptExecutionResult;
docker?: DockerExecutionResult;
reboot?: RebootResult;
postInstall?: PostInstallResult;
errors?: SnapshotError[];
}
```
> `mode` était `"manual"` (littéral) au jalon 1. L'élargir en union `"manual" | "scheduled" | "hermes_requested"` reste compatible (le jalon 1 émet toujours `"manual"`). Tous les nouveaux blocs (`apt`, `docker`, `reboot`, `postInstall`, `errors`) sont optionnels → une exécution jalon 1 reste valide.
---
## 5. Extension de `TemplateVars` (rendu Mustache)
```ts
export interface TemplateVars {
aptProxy?: string | null; // existant
// Ajouts (tous optionnels) :
osProfile?: OsFamily;
machineKind?: MachineKind;
confValues?: boolean;
inactivityTimeout?: number;
// Docker :
composeRoots?: string; // liste rendue shell-safe par le backend
composeScanDepth?: number;
stackDir?: string;
aggressive?: boolean; // prune agressif
// Custom :
operatorUser?: string;
packages?: string; // liste shell-safe
newHostname?: string;
interfaceName?: string;
staticAddress?: string;
reconnectHost?: string;
dockerUser?: string;
composeRoot?: string;
rebootAfterInstall?: boolean;
// ... champs de profil custom (typés au cas par cas en tâche 4)
}
```
---
## 6. Déduplication (empreinte fonctionnelle)
- **APT** : `dedupKey = os_family + "|" + package + "|" + from + "|" + to + "|" + origin`. Permet à Hermes de mutualiser une même mise à jour vue sur plusieurs machines (une seule recherche web, un seul résumé). Stocké dans `apt_planned_packages.dedup_key` / `apt_applied_packages.dedup_key`.
- **Docker** : `dedupKey = image + "|" + fromDigest + "|" + toDigest` ; fallback `image + "|" + fromImageId + "|" + toImageId` quand le digest manque.
Le calcul de `dedupKey` se fait **côté backend TS** (déterministe), pas dans le shell.
---
## 7. Réduction déterministe avant Hermes/MCP
Le réducteur actuel (`server/templates/aptReduce.ts`) garde les lignes : `Inst `, `Conf `, `Remv `, `Err `, `E:`, `W:`, `dpkg:`, `reboot-required`/`REBOOT_REQUIRED`. **Extension proposée** (renommage suggéré `reduceLines.ts`, additif, sans casser `reduceAptLines`) ajoutant les préfixes Docker : `Pulling`, `Digest`, `Status`, `Downloaded newer image`, `Recreating`, `Started`, `Error`, `deleted`, `Total reclaimed space`.
Ce que Hermes reçoit : **JSON canonique réduit** (`important_json`) + lignes importantes (`importantLogLines`). **Jamais** le log brut complet (archivé dans `raw_artifacts`/`rawLogPath`), jamais de secret.
---
## 8. Mapping vers `tache1.9.md` (tables dérivées)
| Bloc JSON | Table dérivée | Colonnes clés |
|---|---|---|
| `apt.packages` / `installed` / `removed` / `held` (simulation) | `apt_planned_packages` | `mode`, `operation`, `current_version`, `target_version`, `origin`, `dedup_key` |
| `apt.applied` (diff dpkg) | `apt_applied_packages` | `from_version`, `to_version`, `operation`, `dedup_key` |
| `errors[]` source apt | `apt_errors` | `kind`, `severity`, `message`, `important_lines_json`, `remediation` |
| `docker.stacks[]` | `docker_compose_stacks` + `docker_stack_services` | `status`, `detected_by`, `current_image_id`, `candidate_digest`, … |
| `docker.pull/up/prune changes` | `docker_image_events` | `from_image_id`, `to_image_id`, `operation`, `bytes_reclaimed` |
| lignes importantes/notices | `important_messages` | `source`, `category`, `package_name`, `message` |
| payload complet snapshot | `snapshots.payload_json` + `important_json` | `kind`, `schema_version`, `status` |
| payload complet exécution | `executions.result_json` + `important_json` | `error_kind`, `error_message`, `exit_code` |
Le JSON complet reste la vérité canonique (archivé) ; les tables dérivées servent recherche/filtres/dédup/badges (conforme à la règle structurante `tache1.9.md §2`).
+77
View File
@@ -0,0 +1,77 @@
# 50 — Taxonomie des erreurs + stratégie de remédiation
> Axe C + livrable §4.4. Codes d'erreur normalisés alignés sur `SnapshotError.kind` (`40-contrats-json.md`) et `apt_errors.kind` (`tache1.9.md`).
---
## 1. Principes
- **L'exit code ne suffit jamais** à déclarer un succès (APT comme Docker). On corrèle exit code + sections parsées + diff réel + lignes importantes.
- **Statut normalisé** : `ok` | `warning` | `error`. `warning` = succès partiel ou effet à surveiller (suppressions de paquets, held back, orphans removed, conteneur unhealthy).
- **Capture des lignes pertinentes** : seules les lignes d'erreur utiles (`E:`, `W:`, `dpkg:`, `Error`, etc.) sont remontées ; le log brut complet reste archivé.
- **Pas d'auto-réparation dangereuse non validée** : on **propose** une remédiation, on ne l'exécute pas automatiquement (`dpkg --configure -a`, `rm /var/lib/dpkg/lock`, etc. exigent validation explicite).
- **Nettoyage des secrets** : toute ligne d'erreur exposant une URL d'auth, un token ou un chemin de credential est nettoyée avant UI/MCP (voir `70-securite.md`).
---
## 2. Taxonomie APT / dpkg
| `kind` | Détection (lignes/exit) | Sévérité | Remédiation proposée (non auto) |
|---|---|---|---|
| `apt_lock_busy` | `Could not get lock /var/lib/dpkg/lock`, `E: Unable to acquire the dpkg frontend lock` | error | Attendre la fin d'un apt/unattended-upgrades concurrent ; relancer. |
| `dpkg_interrupted` | `dpkg was interrupted`, `dpkg --configure -a` | error | Proposer `dpkg --configure -a` **avec validation explicite**. |
| `repo_unreachable` | `Failed to fetch`, `Could not resolve`, `Connection timed out`, `E: Some index files failed to download` | error | Vérifier réseau/proxy apt-cacher-ng/dépôt ; retenter. |
| `gpg_key_error` | `NO_PUBKEY`, `EXPKEYSIG`, `signatures couldn't be verified` | error | Vérifier/mettre à jour la clé du dépôt ; ne pas désactiver la vérif. |
| `package_conflict` | `Depends:`, `but it is not going to be installed`, `held broken packages` | warning/error | Examiner le conflit ; éventuel `dist-upgrade` ; validation. |
| `disk_space_low` | `E: You don't have enough free space`, `No space left on device` | error | Libérer de l'espace (`apt-get clean`), vérifier `/`, `/var`, `/boot`. |
| `packages_held` | `apt-mark showhold` non vide / présents en dist-upgrade absents d'upgrade | warning | Information : paquets retenus ; nécessite dist-upgrade ou hold explicite. |
| `packages_removed` | `Remv ` en simulation/diff | warning | Suppression de paquets ⇒ confirmation UI obligatoire. |
| `human_interaction_required` | timeout d'inactivité atteint, prompt conffile/debconf/needrestart/apt-listchanges détecté | error | Reprise manuelle ; ne pas forcer ; lignes importantes fournies. |
| `kernel_partial_config` | `needrestart` signale services à redémarrer, `reboot-required` | warning | Planifier `reboot_verified`. |
| `apt_unknown_error` | exit ≠ 0 sans motif identifié | error | Consulter le log brut archivé. |
### Gestion des interactions humaines (couverture §7)
- Upgrades réels : `DEBIAN_FRONTEND=noninteractive`, `apt-get -y`, `-o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold`.
- Justification politique par défaut : **conserver les fichiers de config locaux** quand dpkg ne peut pas trancher (ne pas écraser une config distante).
- Prompts potentiels (conffile, debconf, apt-listchanges, needrestart, service restart, maintainer script) = **risques de blocage à détecter**, jamais des dialogues exposés dans le terminal.
- **Timeout d'inactivité** (`inactivityTimeout`, défaut 600 s) et/ou **timeout global** → classer l'exécution en erreur contrôlée `human_interaction_required` si une action reste bloquée. Le backend (couche SSH) détecte l'absence de nouvelles données et coupe proprement, en marquant l'exécution.
---
## 3. Taxonomie Docker (alignée §6 de la validation)
| `kind` | Détection | Sévérité | Remédiation |
|---|---|---|---|
| `docker_not_installed` | `docker: command not found`, exit 127 | error | Proposer profil `docker_official`. |
| `compose_not_found` | dossier/fichier compose absent, `no configuration file provided` | error | Vérifier `composeRoots`/chemin du stack. |
| `compose_config_invalid` | `docker compose config --quiet` exit ≠ 0 | error | Corriger le fichier compose ; stack reste `candidate`. |
| `registry_auth_failed` | `unauthorized`, `denied`, `pull access denied` | error | Vérifier l'auth registry **sur la machine** (jamais lire les creds) ; message nettoyé. |
| `pull_failed` | `Error response from daemon`, `manifest unknown`, timeout | warning/error | Vérifier réseau/tag/registry ; retenter. |
| `image_inspect_failed` | `No such image`, exit ≠ 0 sur inspect | warning | Image absente localement ; relancer pull-check. |
| `up_failed` | `docker compose up` exit ≠ 0 | error | Examiner logs du service ; conserver l'ancien conteneur. |
| `container_unhealthy` | health `unhealthy` après up | warning | Surveiller ; proposer rollback manuel. |
| `prune_failed` | `docker image prune` exit ≠ 0 | warning | Vérifier l'état du daemon. |
| `disk_space_low` | `no space left on device` pendant pull/up | error | Prune sûr, libérer de l'espace. |
---
## 4. Taxonomie réseau / post-install (couverture §8)
| `kind` | Sévérité | Remédiation |
|---|---|---|
| `network_config_invalid` | error | Restaurer la sauvegarde `/etc/network/interfaces.su.bak.*`. |
| `interface_not_found` | error | Vérifier `interfaceName` (sonde `machine_probe`). |
| `dns_config_failed` | warning | Vérifier `resolvconf`/`dnsNameservers`. |
| `reconnect_failed` | error | La webapp retente sur `reconnectHost` ; rollback si échec. |
| `hostname_failed` | error | Vérifier droits / `hostnamectl`. |
| `sudo_setup_failed` | error | Reprendre `bootstrap_root` depuis un contexte root. |
---
## 5. Robustesse, idempotence, reprise
- **Idempotence** : les templates de détection (`update-analyze`, `docker_scan`, `inspect`, `pull-check`) sont rejouables sans effet de bord applicatif (pull-check écrit dans le cache images mais ne démarre rien — rejouable).
- **Opérations longues** : voir `90-questions-investigation.md` Q6. MVP : `nohup` + fichier exit-code pour les actions applicatives longues (full-upgrade, docker apply), reboot vérifié via boot_id ; refresh reste synchrone court.
- **Reprise** : une exécution coupée laisse un état lisible (sections déjà émises + exit-code sur disque côté machine). Le backend peut relire l'état au lieu de tout relancer.
- **Verrous** : `machine_locks` (`tache1.9.md`) empêche deux actions concurrentes destructives sur une même machine (`apt`, `docker`, `reboot`, `exclusive`).
+150
View File
@@ -0,0 +1,150 @@
# 60 — Profils OS, type machine, overrides et proxy APT
> Axe A + livrables §4.5/§4.6. Tranche §3.2 (structure profils OS) et §3.3 (profils machine). Couvre la grille §7 (« Profils OS et type de machine »).
---
## 1. Deux dimensions distinctes
- **`os_family`** : `debian` | `ubuntu` | `proxmox` | `raspbian` | `unknown` (quelle distro / quel jeu de dépôts et de commandes).
- **`machine_kind`** : `physical` | `vm` | `proxmox_host` | `lxc` | `raspberry_pi` | `workstation` | `unknown` (quel matériel / quels scripts pertinents : firmware, drivers, guest tools…).
Les deux sont **orthogonaux** : une Debian peut être physique, VM ou LXC ; un Raspberry Pi OS implique presque toujours `raspberry_pi`. Choisis **manuellement à l'ajout**, corrigeables par `machine_probe`. Stockés dans `machines.os_family` / `machine_kind` / `virtualization` / `hardware_profile` (`tache1.9.md §5`).
---
## 2. Arborescence des templates et héritage (décision §3.2)
**Convention de dossier + fallback `base`.** Le moteur de rendu choisit le template **le plus spécifique disponible**, sinon retombe sur le profil générique.
```text
templates/
├── apt/ # profil "base" (Debian/Ubuntu générique — jalon 1)
│ ├── update-analyze.sh.tpl
│ ├── upgrade.sh.tpl
│ ├── full-upgrade.sh.tpl
│ ├── autoremove.sh.tpl
│ ├── clean.sh.tpl
│ ├── reboot-check.sh.tpl
│ └── reboot.sh.tpl
├── proxmox/ # overrides Proxmox (dépôts PVE, kernel, Ceph)
│ ├── update-analyze.sh.tpl
│ └── full-upgrade.sh.tpl
├── raspbian/ # overrides Raspberry Pi OS (firmware, espace disque)
│ ├── update-analyze.sh.tpl
│ └── full-upgrade.sh.tpl
├── docker/
│ └── *.sh.tpl
└── custom/
└── *.sh.tpl
```
Résolution (pseudo-code backend) :
```text
resolveTemplate(action, osFamily):
candidate = templates/<osFamily>/<action>.sh.tpl
if exists(candidate): return candidate
return templates/apt/<action>.sh.tpl # fallback base
```
**Pourquoi pas un héritage par fragments/includes Mustache ?** Plus simple à auditer en Git (un fichier = un script complet, lisible et testable). Inconvénient : duplication partielle entre profils — accepté au MVP (peu de profils). Alternative notée en §3.2 de `90-questions-investigation.md`.
> **Non-régression jalon 1** : Debian/Ubuntu n'ont pas de dossier dédié ⇒ ils tombent sur `templates/apt/*` (comportement actuel inchangé). Le mécanisme de résolution est **additif**.
---
## 3. Overrides par machine
Au-delà du profil OS, chaque machine peut surcharger :
- `aptProxyMode` / `aptProxyUrl` (déjà présent au jalon 1) ;
- des variables de contexte (`composeRoots`, `inactivityTimeout`, etc.) ;
- l'activation de templates (`templates activables` côté formulaire) ;
- des presets de variables custom (`script_variables_presets` scope `machine`).
Priorité de résolution des variables : **override machine** > **défaut profil OS** > **défaut global**. Aucun override ne peut introduire un secret dans un template (les secrets restent côté `machine_credentials`, injectés uniquement via `runScriptSudo` stdin).
---
## 4. Spécificités par profil OS
### Debian
- `apt-get update` + `dist-upgrade` standard.
- Avant firmware/drivers propriétaires : vérifier `contrib`, `non-free`, `non-free-firmware` dans les sources (lecture seule, template `check-components`). Proposition uniquement.
- Source : https://www.debian.org/releases/bookworm/amd64/ch02s02.en.html
### Ubuntu
- Idem Debian + `ubuntu-drivers devices` (lecture) pour proposer des drivers (NVIDIA/GPU), surtout sur `machine_kind` physique/workstation/gpu. Jamais installé par défaut.
### Proxmox (profil dédié, jamais Debian générique)
- Contrôler les dépôts PVE : `pve-no-subscription` vs `enterprise` (sinon `apt update` échoue sur le dépôt entreprise sans abonnement).
- Meta-package `proxmox-ve`, kernel PVE, Ceph éventuel.
- `apt-get update` puis `apt-get dist-upgrade`.
- Source : https://pve.proxmox.com/wiki/System_Software_Updates
### Raspberry Pi OS (profil dédié)
- Attention firmware/kernel (`rpi-update` **non** utilisé par défaut — risqué).
- **Vérifier l'espace disque avant upgrade** (carte SD souvent petite).
- Utiliser `full-upgrade`.
- Source : https://www.raspberrypi.com/documentation/usage/terminal/
---
## 5. Influence du type machine sur les scripts proposés
| `machine_kind` | Scripts pertinents | À éviter |
|---|---|---|
| `physical` | détection hardware, firmware (`fwupd`), SMART/disques, sensors, drivers GPU, benchmark | guest tools |
| `vm` | guest tools (`qemu-guest-agent` ou `open-vm-tools`) | drivers GPU/firmware (sauf passthrough) |
| `proxmox_host` | profil Proxmox dédié (dépôts PVE, kernel, Ceph) | traitement Debian générique |
| `lxc` | minimal (pas de kernel/firmware propre au conteneur) | firmware, drivers, reboot kernel |
| `raspberry_pi` | profil RPi (firmware/kernel prudent, espace disque) | drivers GPU desktop |
| `workstation` / GPU server | drivers GPU (`ubuntu-drivers`/`nvidia`), benchmark | — |
> Les scripts hardware/drivers/benchmark ne sont **jamais installés par défaut** et **exigent validation explicite** (couverture §7).
---
## 6. Détection / correction : `machine_probe` (décision §3.3)
Action non destructive (lecture seule), proposée à l'ajout et relançable. Sources lues :
```sh
#!/bin/sh
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:EXIT=0==="
```
Le backend propose une **correction** de `os_family`/`machine_kind`/`virtualization`/interface primaire, **jamais appliquée automatiquement** sans validation utilisateur (règle de correction : l'opérateur garde le dernier mot). Résultats persistés dans `machine_hardware` + colonnes `machines`.
**MVP retenu** : choix manuel à l'ajout (Debian/Ubuntu/Proxmox VE/Raspberry Pi OS/autre Linux ; VM/physique/Proxmox/LXC/RPi/GPU-workstation) + `machine_probe` pour proposer des corrections. Alternative (détection 100 % automatique) jugée trop fragile (cas limites : conteneurs imbriqués, distros dérivées) — voir `90-questions-investigation.md` Q3.
---
## 7. Proxy APT (apt-cacher-ng) — trois modes
`AptProxyMode = "direct" | "runtime" | "persistent"` (le `persistent` est l'ajout tâche 2).
| Mode | Mécanisme | Quand |
|---|---|---|
| `direct` | aucun proxy | défaut |
| `runtime` | `export http_proxy/https_proxy` dans le script (sections Mustache `{{#aptProxy}}`) — **comportement jalon 1, conservé** | proxy temporaire pour une exécution |
| `persistent` | écrire `Acquire::http::Proxy "<url>";` dans `/etc/apt/apt.conf.d/01proxy` (action dédiée, idempotente, sauvegarde de l'existant) | proxy permanent de la machine |
Le mode `persistent` est une **action explicite** (écriture sur disque) avec preview ; il n'est pas appliqué silencieusement. `runtime` reste injecté au rendu comme aujourd'hui.
+70
View File
@@ -0,0 +1,70 @@
# 70 — Note de sécurité : frontière Hermes/MCP et actions destructives
> Livrable §4.8. Tranche §3.7 (sécurité prune/scripts) et §3.8 (surface MCP). Aligné `CLAUDE.md` (sécurité non négociable) et `validation_tache2.md §3`/§6/§7/§8.
---
## 1. Ce qui ne doit JAMAIS atteindre Hermes / MCP / un prompt LLM
- Mots de passe SSH, **sudo password**, passphrases de clés, clés privées.
- Tokens / credentials de registry Docker (`~/.docker/config.json`, credential helpers, tokens d'auth).
- Toute variable de champ de type `secret` d'un profil post-install.
- URLs contenant des identifiants (`https://user:pass@…`), en-têtes d'auth, chaînes de connexion.
- Le **log brut complet** (archivé séparément, jamais inliné dans un prompt).
Mécanismes :
- Les secrets vivent uniquement dans `machine_credentials` (chiffrés au repos), injectés **uniquement** via `runScriptSudo` sur **stdin** (`sudo -S -p ''`) — jamais dans le corps du script, jamais en argument, jamais loggés.
- Le JSON canonique métier ne contient **aucun** champ secret (cf. `PostInstallResult.variablesUsed` = non sensible uniquement).
- **Nettoyage des erreurs** avant UI/MCP : un filtre déterministe masque les motifs sensibles (`https?://[^/@\s]+:[^/@\s]+@`, `Authorization:`, chemins `*/config.json`, `token=…`) dans les lignes d'erreur Docker/APT avant affichage et avant réduction Hermes.
---
## 2. Actions destructives → validation explicite côté webapp
Toute action modifiant l'état de la machine de façon non triviale passe par une **confirmation UI explicite** et est tracée comme `action_request` (`tache1.9.md §10`).
| Action | Risque | Validation |
|---|---|---|
| `apt_dist_upgrade` / `apt_full_upgrade` | peut supprimer des paquets | confirmation explicite si `removed`/`held` |
| `apt_autoremove` | supprime des paquets | confirmation explicite |
| `reboot` / `reboot_verified` | redémarrage | confirmation explicite |
| `docker_compose_apply` | recrée les conteneurs | confirmation explicite |
| `docker_prune_images` (agressif `-a`) | supprime des images non dangling | confirmation explicite distincte |
| `docker_compose_down` | arrête le stack | confirmation forte ; `--volumes`/`--rmi` interdits au MVP |
| `post_install:identity_network` | change le réseau / coupe la connexion | preview obligatoire + sauvegarde fichiers + stratégie de reconnexion planifiée |
| `apt_proxy persistent` | écrit dans `/etc/apt/apt.conf.d` | preview |
Règle d'or : **Hermes peut recommander/proposer, mais ne déclenche jamais directement** une action à risque. Le déclenchement passe par l'opérateur via l'UI (ou un `action_request` approuvé).
Actions sûres sans validation : `apt_update_analyze`, `docker_scan`, `docker_inspect_current`, `machine_probe`, `apt_clean`. `docker_pull_check` est **non passif** (écrit dans le cache images) mais non applicatif : pas de validation destructive, mais isolé du chemin de scan pur.
---
## 3. Surface MCP minimale (décision §3.8)
On **réutilise la surface v1** du rapport / `CLAUDE.md`, sans nouvelle primitive d'exécution SSH :
| Outil MCP | Rôle | Renvoie un secret ? |
|---|---|---|
| `list_machines` | liste les machines (vue publique, sans secret) | non |
| `get_machine_snapshot` | dernier snapshot réduit (APT + Docker) | non |
| `get_machine_execution` | résultat d'exécution réduit + réfs logs/rapport | non |
| `list_templates` | liste des templates disponibles | non |
| `preview_template` | rendu d'un template avec **masquage des secrets** | non |
| `run_refresh` | déclenche un refresh/analyse (action sûre) | non |
| `run_action` | déclenche une action **déjà autorisée** ; les actions destructives exigent une validation webapp préalable | non |
| `search_reports` | recherche dans les rapports archivés | non |
Principes :
- Le **MCP est une façade** de l'API métier : aucune logique SSH dedans, aucun secret.
- Aucun nouvel outil pour Docker/post-install : les nouvelles capacités passent par `run_action(actionType, params)` avec le **filtrage d'autorisation** de la route `POST /api/machines/:id/actions`. Surface stable et petite = agents fiables.
- `run_action` sur une action destructive non encore validée → renvoie un `action_request` en attente, **pas** une exécution.
- Audit : tout appel MCP est journalisé (`mcp_audit_log`, `tache1.9.md §11`) avec `request_json` réduit (sans secret).
---
## 4. Traçabilité
- Chaque exécution : log brut archivé (`raw_artifacts`/`rawLogPath`, `redacted=1`), rapport Markdown (`reports`), `important_json` réduit.
- Les changements réseau/Docker sont explicitement marqués dans le rapport avec les prochaines actions attendues (reconnexion, relogin groupe docker, reboot).
- Les secrets ne figurent ni dans les rapports, ni dans les logs UI, ni dans le MCP.
+89
View File
@@ -0,0 +1,89 @@
# 80 — Découpage en sous-jalons implémentables
> Livrable §4.9. Chaque sous-jalon = un cycle spec → plan → implémentation, indépendamment testable, sans casser le jalon 1. Priorisé. Prêt pour `writing-plans`.
---
## Ordre recommandé et dépendances
```text
SJ-0 (socle types/réduction) ──► SJ-1 (APT update/analyse) ──► SJ-2 (APT upgrade + diff)
SJ-3 (reboot vérifié)
SJ-4 (Docker scan/inspect) ──► SJ-5 (Docker pull-check) ──► SJ-6 (Docker apply/prune/down)
SJ-7 (profils OS Proxmox/RPi) [transversal, après SJ-1]
SJ-8 (post-install bootstrap/identité) [après SJ-0]
SJ-9 (post-install Docker officiel / partages / VM tools) [après SJ-8]
```
---
## SJ-0 — Socle : types étendus + réduction + résolution de profil
- **Contenu** : étendre `shared/types.ts` (unions + blocs optionnels, cf. `40-contrats-json.md`), étendre le réducteur (`reduceLines.ts` ajoutant les préfixes Docker), ajouter le mécanisme `resolveTemplate(action, osFamily)` avec fallback `base`, ajouter `schemaVersion`.
- **Testable** : tests unitaires de réduction (APT + Docker), tests de résolution de template, validation qu'un snapshot/exécution jalon 1 reste typé valide.
- **Risque** : faible (additif). **Priorité : 1 (prérequis de tout le reste).**
## SJ-1 — APT update/analyse (snapshot enrichi)
- **Contenu** : `apt/update-analyze.sh.tpl` (update + simulations upgrade/dist-upgrade + held + reboot-check), parsing des sections, `AptSnapshotDetail` enrichi, statut `ok|updates_available|warning|error`. Bascule du refresh dessus (en gardant `check.sh.tpl` jusqu'à validation).
- **Testable** : fixtures de sortie APT → snapshot ; non-régression du refresh jalon 1.
- **Risque** : faible-moyen (toucher le refresh). **Priorité : 2.**
## SJ-2 — APT upgrade / full-upgrade / autoremove / clean + diff dpkg réel
- **Contenu** : templates `upgrade`, `full-upgrade` (enrichi diff), `autoremove`, `clean` ; capture `DPKG_BEFORE/AFTER` ; calcul du diff réel (`AptExecutionResult`) ; timeout d'inactivité + `human_interaction_required` ; confirmations UI pour suppressions.
- **Testable** : fixtures dpkg before/after → diff ; détection des suppressions/held.
- **Risque** : moyen (actions destructives). **Priorité : 3.**
## SJ-3 — Reboot vérifié (boot_id + délai adaptatif)
- **Contenu** : `apt/reboot.sh.tpl` (capture boot_id) + orchestration backend (attente coupure, reconnexion, relecture boot_id), `RebootResult`, délai adaptatif par machine. Conserve l'action `reboot` jalon 1.
- **Testable** : simulation des états (`boot_id_unchanged`, `machine_did_not_return`, `timeout`).
- **Risque** : moyen. **Priorité : 4.**
## SJ-4 — Docker scan + inspect (passifs)
- **Contenu** : `docker/scan-compose.sh.tpl`, `docker/inspect-compose.sh.tpl` ; config machine `dockerEnabled`/`composeRoots`/`composeScanDepth` ; cycle `candidate`/`enabled` ; tables `docker_*`. Détection labels en complément.
- **Testable** : fixtures de scan → liste de stacks ; validation `compose config --quiet`.
- **Risque** : faible (passif). **Priorité : 5.**
## SJ-5 — Docker pull-check + comparaison
- **Contenu** : `docker/pull-check.sh.tpl` ; comparaison déterministe ID/digest/labels OCI ; `DockerSnapshot`/services ; dédup Docker ; refresh Docker séparé (non auto).
- **Testable** : fixtures before/after pull → updates détectées ; nettoyage secrets registry.
- **Risque** : faible-moyen. **Priorité : 6.**
## SJ-6 — Docker apply + prune + down
- **Contenu** : `apply-compose`, `prune-images` (safe/agressif), `down-compose` ; `DockerExecutionResult` ; validations UI explicites ; `docker_image_events`.
- **Testable** : fixtures up/prune → conteneurs recréés / bytes reclaimed.
- **Risque** : moyen-élevé (destructif). **Priorité : 7.**
## SJ-7 — Profils OS Proxmox + Raspberry Pi (+ proxy persistent)
- **Contenu** : dossiers `templates/proxmox/`, `templates/raspbian/` (update-analyze, full-upgrade) ; mode `AptProxyMode="persistent"` ; `machine_probe`.
- **Testable** : résolution de template par OS ; sonde → propositions de correction.
- **Risque** : faible (additif, fallback base préservé). **Priorité : 8 (transversal).**
## SJ-8 — Post-install : bootstrap + identité/réseau
- **Contenu** : moteur de profils (manifestes, champs dynamiques, preview, validations), `custom/bootstrap-root.sh.tpl`, `custom/identity-network.sh.tpl` ; `install_profiles`/`install_recipes` ; stratégie reconnexion ; `PostInstallResult`.
- **Testable** : rendu de manifeste → formulaire ; preview masquant les secrets ; échec contrôlé si champ manquant.
- **Risque** : moyen (réseau). **Priorité : 9.**
## SJ-9 — Post-install : paquets de base + Docker officiel + partages + VM tools
- **Contenu** : `install-package-groups`, `docker-official-debian`, `sharing`, `vm-guest-tools` ; presets de variables ; renvoi du catalogue détaillé à la tâche 4.
- **Testable** : installation de groupes ; idempotence.
- **Risque** : faible-moyen. **Priorité : 10.**
---
## Notes de séquencement
- **SJ-0 est bloquant** pour tous les autres (types + réduction + résolution).
- APT (SJ-1→3) et Docker (SJ-4→6) sont **indépendants** : peuvent être menés en parallèle après SJ-0.
- Chaque sous-jalon livre un logiciel testable et ne casse pas les flux jalon 1 (`refresh`, `apt_full_upgrade`, `reboot`) grâce aux extensions additives.
- Les actions destructives n'arrivent qu'après le socle de validation UI (`action_requests`), conformément à `70-securite.md`.
@@ -0,0 +1,72 @@
# 90 — Les 8 questions d'investigation (§3) tranchées
> Chaque question : **MVP recommandé / alternatives / risques**. Décisions autonomes argumentées, cohérentes avec l'existant et `tache1.9.md`.
---
## Q1 — JSON-in-shell vs parsing-in-TS
**MVP recommandé : hybride à dominante parsing-TS.** On conserve la convention actuelle (marqueurs `===SU:XXX===` + parsing dans `server/services/`). On enrichit avec des **données semi-structurées produites par le shell uniquement quand le format est déjà stable et documenté** : `dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n'` (TSV), `docker compose ps/images --format json`, `docker image inspect --format '...'`. Pas de construction de gros JSON imbriqué à la main dans le shell.
- **Pourquoi** : (a) cohérence avec le jalon 1 (déjà en prod, parsing TS testé) ; (b) testabilité — les fixtures de sortie shell + tests TS sont faciles à maintenir ; (c) robustesse multi-OS — éviter le JSON bricolé en Bash (échappement fragile, comme on le voit dans `nas-ops` avec la concaténation manuelle de chaînes JSON) ; (d) on tire parti des formats JSON **natifs et documentés** de Docker sans les réinventer.
- **Alternatives** : (1) tout-JSON-in-shell façon `nas-ops` — rejeté (échappement fragile, dur à tester, risque de casser sur des noms/versions exotiques) ; (2) tout-parsing-TS sur sortie brute uniquement — rejeté pour Docker où `--format json` est plus sûr que parser du texte libre.
- **Risques** : double convention (TSV/clé=valeur + sections) à documenter clairement ; mitigé par un parseur central par section. Format `docker ... --format json` varie selon version de Compose — pin de la commande + fallback texte.
## Q2 — Structure des profils OS
**MVP recommandé : un fichier de template complet par profil, dans un dossier par OS, avec fallback `base`** (`templates/<osFamily>/<action>.sh.tpl` → sinon `templates/apt/<action>.sh.tpl`). Résolution par convention de chemin (cf. `60-profils-os-machine.md §2`).
- **Pourquoi** : lisibilité et audit Git (un script = un fichier complet, testable isolément) ; n'invalide pas Debian/Ubuntu (pas de dossier dédié ⇒ fallback `apt/`, comportement jalon 1 intact).
- **Alternatives** : (1) héritage par fragments/partials Mustache (DRY) — plus complexe à auditer, reporté ; (2) une matrice de variables dans un seul template géant avec `{{#proxmox}}…` — rejeté (templates illisibles, logique métier noyée dans le rendu).
- **Risques** : duplication partielle entre profils. Accepté au MVP (peu de profils) ; refactor en partials possible plus tard si la duplication devient coûteuse.
## Q3 — Structure des profils machine
**MVP recommandé : choix manuel à l'ajout (`os_family` + `machine_kind`) + action `machine_probe` non destructive proposant des corrections** (jamais appliquées sans validation). Sources : `/etc/os-release`, `uname -m`/`dpkg --print-architecture`, `systemd-detect-virt`, présence `/etc/pve`, `/proc/cpuinfo` (RPi), `lspci` (GPU), `ip addr` (interface).
- **Pourquoi** : la détection auto seule est fragile (conteneurs imbriqués, distros dérivées, VM mal taguées). Le couple « défaut manuel + sonde de correction » est robuste et garde l'opérateur maître.
- **Alternatives** : (1) détection 100 % auto — rejetée (cas limites) ; (2) manuel sans sonde — perte de confort et risque d'erreur de profil.
- **Risques** : utilisateur choisit mal le profil ⇒ `machine_probe` le signale ; correction nécessite validation. Persisté dans `machines` + `machine_hardware`.
## Q4 — Capture avant/après (diff réel)
**MVP recommandé : snapshot dpkg complet avant ET après chaque action APT réelle** via `dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n'`, diff calculé côté backend (clé `package+arch`). L'exit code APT ne suffit jamais.
- **Pourquoi** : `dpkg-query` reflète l'état **réel** installé (pas l'intention APT). Détecte les écarts entre simulation et réalité (paquet annoncé non installé, held back effectif).
- **Alternatives** : (1) parser uniquement la sortie `apt-get` (`Setting up …`) — moins fiable, dépend du locale et du verbeux ; (2) historique `/var/log/dpkg.log` — parsing daté fragile. Les deux gardés comme signaux secondaires, pas comme source de vérité.
- **Risques** : sur de très gros parcs de paquets, deux `dpkg-query` ajoutent quelques secondes — négligeable. Diff côté TS = testable par fixtures.
## Q5 — Contrats JSON (extensions exactes)
**MVP recommandé : extensions additives détaillées dans `40-contrats-json.md`** — unions élargies (`OsFamily`, `AptProxyMode`, `ActionType`, `MachineKind`), blocs optionnels `docker`/`errors`/`reboot`/`postInstall` + champs additifs sur `apt`, `schemaVersion?`. Types TS fournis. Un payload jalon 1 reste valide.
- **Pourquoi** : rétro-compatibilité stricte exigée par le gate (§3 de la validation). Champs optionnels + unions additives = zéro rupture.
- **Alternatives** : versionner par type séparé (`UpdateSnapshotV2`) — rejeté (duplication, migration lourde) au profit de `schemaVersion` sur un type unique.
- **Risques** : un type unique grossit ; mitigé par découpe en sous-interfaces (`AptSnapshotDetail`, `DockerSnapshot`, etc.).
## Q6 — Idempotence & opérations longues
**MVP recommandé : différencier selon la durée et le risque.**
- **Refresh/analyse, scan, inspect** : synchrones et courts (comme le jalon 1).
- **Actions applicatives longues** (`full-upgrade`, `docker apply`) : généraliser l'exécution détachée `nohup` + **fichier exit-code sur la machine** (survit à une coupure SSH), comme prévu au jalon 1 et inspiré de `linux-update-dashboard`. Le backend peut relire l'état/exit-code à la reconnexion plutôt que tout relancer.
- **Reboot** : mécanisme dédié `reboot_verified` (boot_id avant/après + reconnexion + délai adaptatif).
- **Idempotence** : les détections sont rejouables sans effet de bord applicatif ; `pull-check` écrit dans le cache images mais ne démarre rien (rejouable). `machine_locks` évite la concurrence destructive.
- **Alternatives** : (1) tout synchrone — rejeté (un upgrade long meurt avec la session SSH) ; (2) file de jobs persistante dès le MVP — utile mais relève de la **tâche 5** ; ici on pose le mécanisme `nohup`+exit-code et on renvoie l'orchestration job à la tâche 5.
- **Risques** : suivi de progression d'une opération détachée = tailing du fichier de sortie distant + WebSocket ; à câbler proprement en implémentation (réutilise `outputHub`).
## Q7 — Sécurité Docker `prune` / scripts custom
**MVP recommandé : barrière de validation côté webapp (`action_requests`) pour toute action destructive + nettoyage déterministe des secrets dans les erreurs.** `docker_prune_images -a`, `docker_compose_down`, `docker_compose_apply`, suppressions APT, `reboot`, `identity_network` ⇒ confirmation explicite. Hermes propose, ne déclenche jamais. Credentials registry/sudo/tokens jamais lus ni renvoyés (cf. `70-securite.md`).
- **Pourquoi** : respecte `CLAUDE.md` (actions destructives validées, aucun secret vers LLM). La barrière unique (`action_requests`) centralise l'autorisation côté API.
- **Alternatives** : validation par template (flag `requiresConfirmation` dans le manifeste) — complémentaire, pas suffisant seul ; la décision d'autorisation reste côté API/route.
- **Risques** : un script custom pourrait logger un secret ⇒ règle « pas de secret dans le corps du script » + filtre de nettoyage des lignes avant UI/MCP + revue Git des templates.
## Q8 — Surface MCP
**MVP recommandé : conserver la surface v1 (8 outils), sans nouvelle primitive d'exécution SSH.** Les nouvelles capacités (Docker, post-install, APT détaillé) passent par `run_action(actionType, params)` filtré côté route, pas par de nouveaux outils. `preview_template` masque les secrets. `run_action` sur action destructive non validée renvoie un `action_request` en attente. Audit via `mcp_audit_log`.
- **Pourquoi** : surface petite = agents fiables et auditables ; le MCP reste une **façade** sans logique SSH ni secret.
- **Alternatives** : exposer un outil par action (`docker_apply`, `apt_upgrade`…) — rejeté (explosion de surface, duplication d'autorisation).
- **Risques** : `run_action` générique doit valider strictement `actionType`/`params` côté API (liste blanche) ; sinon risque d'action non prévue. Mitigé par la table d'autorisation et `action_requests`.
+182
View File
@@ -0,0 +1,182 @@
# 99 — Auto-évaluation de couverture du gate `validation_tache2.md`
> Relecture case par case. ✅ = couvert ; ⚠️ = couvert avec réserve / hors périmètre design (à confirmer en implémentation). Légende des renvois : fichiers de `docs/design/tache2/`.
---
## §1 Discipline & périmètre
| Case | État | Renvoi |
|---|---|---|
| Aucun code de production modifié (server/client/shared/templates/configs) | ✅ | Seuls `docs/design/tache2/**` + section clôture `tache2.md` créés. À vérifier par `git status`. |
| Jalon 1 et jalon 2 intacts | ✅ | Aucun fichier de jalon touché. |
| Aucun autre chantier hors périmètre | ✅ | Hors-scope listés comme suggestions (`00-synthese.md §6`). |
| Dépôts de référence non copiés | ✅ | `nas-ops`/`linux-update-dashboard` cités en inspiration, pseudo-shell réécrit. |
## §2 Complétude — Axes
| Axe | État | Renvoi |
|---|---|---|
| A — Templates APT + sémantique + profils OS + proxy | ✅ | `10-templates-apt.md`, `60-profils-os-machine.md` |
| B — Capture prévu/appliqué consommable Hermes | ✅ | `40-contrats-json.md` (snapshot/diff/dédup/réduction) |
| C — Taxonomie erreurs + remédiation | ✅ | `50-erreurs.md` |
| D — Docker scan/pull/up/down/prune + détection + JSON | ✅ | `20-docker.md` |
| E — Scripts personnalisés + overrides + garde-fous | ✅ | `30-scripts-custom.md` |
## §2 Complétude — Livrables §4
| Livrable | État | Renvoi |
|---|---|---|
| Inventaire des templates | ✅ | `10` §2, `20` §2, `30` §2/§4 |
| Contenu proposé (pseudo-shell, `===SU:XXX===`) | ✅ | `10` §4, `20` §4, `30` §4 |
| Schémas JSON canoniques étendus | ✅ | `40` |
| Taxonomie des erreurs | ✅ | `50` |
| Modèle profils OS + overrides | ✅ | `60` |
| Modèle profils machine | ✅ | `60` §5 |
| Modèle scripts personnalisés | ✅ | `30` |
| Note de sécurité | ✅ | `70` |
| Découpage en sous-jalons priorisé | ✅ | `80` |
## §2 — 8 questions d'investigation
| | État | Renvoi |
|---|---|---|
| Q1Q8 tranchées (MVP/alternatives/risques) | ✅ | `90-questions-investigation.md` |
## §3 Cohérence & intégration
| Case | État | Renvoi |
|---|---|---|
| Types JSON compatibles `shared/types.ts`, rétro-compatibles | ✅ | `40` §1–§4 (champs optionnels, payload jalon 1 valide) |
| Convention templates (`===SU:`, `LC_ALL=C`, `sudo -S`, parsable) | ✅ | `10`/`20`/`30` |
| Parsing (JSON-in-shell vs TS) explicite et justifié | ✅ | `90` Q1, `40` §7 |
| Couche SSH réutilisée (`server/ssh/client.ts`) | ✅ | `00` §4, `90` Q6 |
| Frontière Hermes/MCP, réduction déterministe | ✅ | `70`, `40` §7 |
| Sécurité actions destructives + pas de secret | ✅ | `70` §1/§2 |
| Profils OS n'invalident pas Debian/Ubuntu prod | ✅ | `60` §2 (fallback `base`) |
| Sous-jalons indépendamment implémentables | ✅ | `80` |
## §4 Non-régression
| Case | État | Note |
|---|---|---|
| `pnpm check`/`test`/`build` verts | ⚠️ | Hors périmètre design (aucun code touché) ; à exécuter par l'orchestrateur. Aucune modification de code n'a été faite. |
| Flux jalon 1 inchangés | ✅ | Extensions additives uniquement ; templates jalon 1 non modifiés. |
## §6 Focus Docker Compose
| Case | État | Renvoi |
|---|---|---|
| Gestion par SSH, réutilise couche existante, `docker context` = alternative | ✅ | `20` §1 |
| Stacks depuis racines déclarées `composeRoots`, scan limité, validation UI | ✅ | `20` §1/§4.1 |
| Détection labels en complément | ✅ | `20` §1/§4.1 |
| Stack détecté = `candidate`, actions sur `enabled` seulement | ✅ | `20` §1 |
| `scan-compose.sh.tpl` (fichiers compose, ignore .git/node_modules/backup/old/archive, `config --quiet`) | ✅ | `20` §4.1 |
| `inspect-compose.sh.tpl` (`config --images`, `ps --format json`, `images --format json`, `image inspect`) | ✅ | `20` §4.2 |
| `pull-check.sh.tpl` (`pull --policy always --ignore-buildable`, compare ID/digest/labels, non passif) | ✅ | `20` §4.3, §1 tableau |
| `apply-compose.sh.tpl` (`up -d --remove-orphans`, recapture) | ✅ | `20` §4.4 |
| `prune-images.sh.tpl` (safe `-f`, agressif `-a -f --filter until=168h` validé) | ✅ | `20` §4.5 |
| `down-compose.sh.tpl` (séparé/destructif, `--volumes`/`--rmi` interdits) | ✅ | `20` §4.6 |
| Flux 1→8 formalisé | ✅ | `20` §3 |
| `pull` télécharge sans démarrer | ✅ | `20` §3 |
| `up -d` recrée si changement, préserve volumes, `down` inutile | ✅ | `20` §3 |
| `prune -f` vs `-a` (destructif) | ✅ | `20` §2/§3 |
| Sources Docker citées | ✅ | `20` §1 |
| Snapshot Docker rétrocompatible (bloc optionnel) | ✅ | `40` §3 |
| Bloc snapshot Docker (stacks/services/ID/digest/labels/candidat/statut) | ✅ | `40` §3 (« bloc Docker minimal ») |
| `ExecutionResult.docker` (pull/up/prune/erreurs/recréés/supprimés/octets) | ✅ | `40` §4 |
| Erreurs Docker structurées (10 codes) | ✅ | `50` §3 |
| Réduction Hermes (lignes Docker) + log brut archivé | ✅ | `20` §5, `40` §7 |
| Config machine (`dockerEnabled`/`composeRoots`/`composeScanDepth`/`composeStacks[]`) sans casser `MachineView` | ✅ | `20` §6, `40` §5 |
| Refresh combiné apt+docker ou Docker séparé | ✅ | `20` §6 |
| `ActionType` étendu (docker_*) + filtrage autorisation | ✅ | `40` §2, `20` §6 |
| Réutilise `executions`/WS/`rawLogPath`/`reportPath`/statut | ✅ | `20` §6 |
| UI compteur Docker séparé + détail + boutons validés | ✅ | `20` §6 |
| Validation UI apply/prune agressif/down ; Hermes ne déclenche pas | ✅ | `20` §6, `70` §2 |
| Secrets registry jamais lus ; erreurs nettoyées | ✅ | `20` §6, `70` §1 |
## §7 Focus APT/reboot
| Case | État | Renvoi |
|---|---|---|
| `apt_update_analyze` distinct des upgrades destructifs | ✅ | `10` §2, `40` §2 |
| update + `-s upgrade` + `-s dist-upgrade` | ✅ | `10` §4.1 |
| Snapshot liste paquets prévus (nom/cur/cible/origine/arch) | ✅ | `10` §4.1, `40` §3 |
| Distingue upgrade vs full/dist (maj/install/remove/held) | ✅ | `10` §4.1, `40` §3 |
| Simulations parsées via `Inst`/`Conf`/`Remv`, log brut archivé | ✅ | `10` §1/§4.1 |
| Statut `ok/updates_available/warning/error`, warning si remove/held | ✅ | `10` §4.1, `40` §3 |
| Sources APT citées | ✅ | `10` §1 |
| Distingue `os_family` et `machine_kind` à l'ajout | ✅ | `60` §1/§6 |
| Choix manuel OS (Debian/Ubuntu/Proxmox/RPi/autre) | ✅ | `60` §6 |
| Choix manuel type (VM/physique/Proxmox/LXC/RPi/GPU-workstation) | ✅ | `60` §6 |
| `machine_probe` détecte/corrige | ✅ | `60` §6 |
| Scripts dépendent du couple OS/type | ✅ | `60` §5 |
| Debian firmware vérifie contrib/non-free/non-free-firmware | ✅ | `60` §4 |
| Proxmox = profil dédié | ✅ | `60` §2/§4 |
| Scripts hardware/drivers/benchmark jamais par défaut, validation | ✅ | `60` §5 |
| Templates APT attendus (update-analyze/upgrade/full-upgrade/autoremove/clean/reboot-check/reboot) | ✅ | `10` §2/§4 |
| Politique non interactive (`noninteractive`, `-y`, confdef/confold) | ✅ | `10` §4.2, `50` §2 |
| Justification confdef/confold | ✅ | `10` §4.2, `50` §2 |
| Prompts traités comme risques de blocage | ✅ | `50` §2 |
| Timeout inactivité/global → erreur contrôlée | ✅ | `50` §2 |
| `human_interaction_required` prévu | ✅ | `50` §2 |
| Pas seulement exit code | ✅ | `50` §1, `10` §4.2 |
| dpkg-query before/after | ✅ | `10` §4.2 |
| Diff backend (maj/install/remove/inchangé/versions/anomalies) | ✅ | `40` §4, `90` Q4 |
| `ExecutionResult.apt` (planned/applied/installed/removed/held/errors/reboot) | ✅ | `40` §4 |
| Rapport MD résume diff + réf log | ✅ | `70` §4, `40` §8 |
| Reboot vérifié (boot_id avant/après, attente, reconnexion) | ✅ | `10` §4.5, `40` §4 |
| Reboot ok si revient ET boot_id changé | ✅ | `10` §4.5 |
| `RebootResult` (beforeBootId…status/errors) | ✅ | `40` §4 |
| Délai adaptatif par machine | ✅ | `10` §4.5, `40` §4 |
| Statuts d'échec reboot distingués | ✅ | `40` §4 (`RebootResult.status`) |
| Reboot = action validée ; Hermes ne déclenche pas | ✅ | `70` §2 |
| `apt_update_analyze` alimente snapshot + tuile | ✅ | `10` §6, `20` §6 |
| Actions via même route + table `executions` | ✅ | `20` §6, `10` §6 |
| UI avant exécution (paquets/suppressions/held/reboot/risque) | ✅ | `70` §2, `40` §3 (renvoi tâche 3 pour le rendu) |
| UI après exécution (réussite/diff/reboot/rapport/log) | ✅ | `70` §4 (renvoi tâche 3) |
| Confirmation UI pour dist/full/autoremove/reboot | ✅ | `70` §2 |
| Nouveaux champs/actions rétrocompatibles | ✅ | `40` §1/§2 |
## §8 Focus post-install
| Case | État | Renvoi |
|---|---|---|
| Interdit questions interactives SSH → champs formulaire | ✅ | `30` §1 |
| Profils cochables dépliant leurs champs | ✅ | `30` §1/§3 |
| Manifeste (`id`/`label`/`description`/`fields`/défauts/validations/preview/risk/confirmations) | ✅ | `30` §1/§3 |
| Bouton désactivé si champs invalides | ✅ | `30` §1 |
| Preview avec masquage secrets + signalement réseau/reboot | ✅ | `30` §1, `70` §1 |
| Échec structuré si décision manquante | ✅ | `30` §1/§4 |
| Profils attendus (bootstrap_root/identity_network/base_tools/network_tools/dev_git/sharing/docker_official/vm_guest_tools + optionnels) | ✅ | `30` §2 |
| Champs `identity_network` | ✅ | `30` §3 |
| Champs `docker_official` | ✅ | `30` §3 |
| Champs `sharing` | ✅ | `30` §3 |
| Champs `vm_guest_tools` | ✅ | `30` §3 |
| Champs préremplis modifiables | ✅ | `30` §3 |
| Exemple de manifeste | ✅ | `30` §3 |
| Templates custom attendus (bootstrap/identity/install-package-groups/docker-official/sharing/vm-guest-tools) | ✅ | `30` §4 |
| Sources citées | ✅ | `30` §4 |
| identity_network à risque (confirmation/preview/sauvegardes) | ✅ | `30` §4.2, `70` §2 |
| Résultat JSON ancien/nouveau endpoint + reconnectHost | ✅ | `40` §4 (`PostInstallResult.networkChange`), `30` §4.2 |
| Pas de coupure sans stratégie reconnexion ; reboot via reboot_verified | ✅ | `30` §4.2 |
| Webapp vérifie reconnexion + met à jour machine | ✅ | `30` §4.2 |
| Erreurs réseau distinguées (6 codes) | ✅ | `50` §4 |
| `ExecutionResult.postInstall` rétrocompatible | ✅ | `40` §4 |
| Résultat liste profils/variables non sensibles/fichiers/paquets/services/reboots/erreurs | ✅ | `40` §4 |
| Secrets jamais inclus | ✅ | `30` §5, `70` §1 |
| Changements réseau/Docker marqués dans rapport MD | ✅ | `30` §5, `70` §4 |
| Même mécanique (templates/preview/SSH/WS/executions/rapport/log) | ✅ | `30` §6 |
| Valeurs réutilisables stockées (où) | ✅ | `30` §6 (`script_variables_presets`/`machine_profile_state`) |
| Hermes propose/explique, JSON réduit, pas de déclenchement risqué | ✅ | `30` §6, `70` §2/§3 |
| Profils découpés en sous-jalons indépendants | ✅ | `80` SJ-8/SJ-9 |
---
## Réserves résiduelles (⚠️)
1. **Non-régression build/tests (§4)** : non exécutée dans cette mission de design (aucun code touché, par consigne). L'orchestrateur doit lancer `pnpm check/test/build` pour confirmer 0 régression — attendu vert puisque aucune modification de code.
2. **Rendu UI fin (§7/§8 « UI avant/après »)** : le design pose les données et les exigences ; le rendu visuel exact relève de la **tâche 3**. Couvert au niveau contrat/exigence, pas au niveau JSX.
3. **Détails Mustache vs Go-templates Docker** : les `{{ }}` de `docker inspect --format` entrent en conflit avec Mustache ; le pseudo-shell le signale (échappement) — choix de délimiteurs à figer en implémentation (SJ-4).
Aucune réserve bloquante identifiée. Verdict visé : **✅ Accepté**.
@@ -0,0 +1,896 @@
# Jalon 2 — Polish design system — Implementation Plan
> **⚠️ STATUT (2026-06-05) : ABSORBÉ PAR LA TÂCHE 3.** La roadmap `liste_taches.md` / `coherence_taches.md` regroupe tout le frontend (layout, tuiles, volet Hermes, terminal, paramètres, thème, status bar, icônes) dans la **tâche 3 (design frontend)**, gate `validation_tache3.md`. Ce plan jalon-2 reste valide comme **matériau d'implémentation du polish** : le wiring DS (exports ESM + Font Awesome + polices, Tasks 1-4) est **déjà commité** et acquis ; les Tasks 5-12 (Header, StatusBar, refonte MachineTile/AddMachineModal/TerminalPanel/Dashboard/App) seront **implémentées plus tard dans le cadre de la tâche 3**, après validation de son design. Ne pas exécuter ce plan isolément.
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Refondre l'UI existante avec les composants du design system Gruvbox (Button, IconButton, StatusLed, Popup), brancher Font Awesome + les polices en offline, ajouter un header (titre + ajout + bascule thème) et une status bar tmux, et rendre le terminal non ambigu entre machines.
**Architecture:** Frontend React/Vite. Le `ui-kit.tsx` (design system) passe en exports ESM. L'état (machines, compteurs, machine sélectionnée, thème) remonte dans `App`, qui distribue en props au Header, au Dashboard (présentationnel), à la StatusBar et au TerminalPanel. Helpers purs (`theme`, `stats`) testés ; le reste est vérifié visuellement.
**Tech Stack:** React 19, Vite 6, @xterm/xterm, @fortawesome/fontawesome-free, @fontsource/{inter,jetbrains-mono,share-tech-mono}, vitest.
---
## File Structure
```
client/src/
├─ main.tsx # MODIF: imports CSS FA + polices
├─ App.tsx # MODIF: remontée d'état, header+statusbar+thème
├─ components/ui-kit.tsx # MODIF: ajout exports ESM (1 ligne)
├─ styles/app.css # MODIF: classes header/statusbar/input/term-header
├─ lib/
│ ├─ theme.ts # NOUVEAU: thème (getInitial/apply/next)
│ ├─ theme.test.ts # NOUVEAU
│ ├─ stats.ts # NOUVEAU: sumUpdates
│ └─ stats.test.ts # NOUVEAU
├─ panels/
│ ├─ Header.tsx # NOUVEAU
│ ├─ StatusBar.tsx # NOUVEAU
│ ├─ HermesPanel.tsx # MODIF: label + Icon
│ ├─ Dashboard.tsx # MODIF: présentationnel (props)
│ └─ TerminalPanel.tsx # MODIF: machine + en-tête + bannière
└─ features/machines/
├─ MachineTile.tsx # MODIF: StatusLed + IconButton
└─ AddMachineModal.tsx # MODIF: Popup + Button
vitest.config.ts # MODIF: inclure client/**/*.test.ts
package.json # MODIF: deps FA + fontsource
```
Le composant `ui-kit.tsx` ne doit JAMAIS être importé dans un test (il touche `window`/`document` au chargement → KO en environnement node). Les tests ne portent que sur `lib/theme.ts` et `lib/stats.ts` (purs, node-safe).
---
## Task 1: Brancher le design system (deps + exports + CSS)
**Files:**
- Modify: `package.json`
- Modify: `client/src/components/ui-kit.tsx` (fin de fichier)
- Modify: `client/src/main.tsx`
- [ ] **Step 1: Installer les dépendances**
Run:
```bash
rtk pnpm add @fortawesome/fontawesome-free @fontsource/inter @fontsource/jetbrains-mono @fontsource/share-tech-mono
```
Expected: 4 paquets ajoutés dans `dependencies`, `pnpm-lock.yaml` mis à jour, install OK.
- [ ] **Step 2: Exporter les composants du design system**
Ajouter à la toute fin de `client/src/components/ui-kit.tsx` (après le dernier `})();`) :
```ts
export {
Icon, Tooltip, IconButton, Toggle, StatusLed,
BatteryGauge, RadialGauge, BigRadialGauge,
Popup, Button, TreeNav, Sparkline, LineChart,
};
```
Ne rien supprimer (garder `// @ts-nocheck`, l'import React, le `Object.assign(window, …)`).
- [ ] **Step 3: Importer FA + polices dans main.tsx**
Remplacer le contenu de `client/src/main.tsx` par :
```tsx
import React from "react";
import { createRoot } from "react-dom/client";
import "@fortawesome/fontawesome-free/css/all.min.css";
import "@fontsource/inter";
import "@fontsource/jetbrains-mono";
import "@fontsource/share-tech-mono";
import "./styles/app.css";
import { App } from "./App.js";
createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
```
- [ ] **Step 4: Vérifier le build**
Run: `rtk pnpm check && rtk pnpm vite build`
Expected: `pnpm check` 0 erreur, build OK (`dist/client` produit, le CSS FA/polices intégré).
- [ ] **Step 5: Commit**
```bash
rtk git add package.json pnpm-lock.yaml client/src/components/ui-kit.tsx client/src/main.tsx
rtk git commit -m "feat(ui): brancher le design system (exports ESM, Font Awesome, polices offline)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 2: Helper thème (TDD)
**Files:**
- Modify: `vitest.config.ts`
- Create: `client/src/lib/theme.ts`, `client/src/lib/theme.test.ts`
- [ ] **Step 1: Inclure les tests client dans vitest**
Dans `vitest.config.ts`, remplacer la ligne `include` par :
```ts
include: ["server/**/*.test.ts", "shared/**/*.test.ts", "client/**/*.test.ts"],
```
- [ ] **Step 2: Écrire le test (échec attendu)**
`client/src/lib/theme.test.ts` :
```ts
import { describe, it, expect, beforeEach } from "vitest";
import { nextTheme, getInitialTheme } from "./theme.js";
describe("nextTheme", () => {
it("bascule dark <-> light", () => {
expect(nextTheme("dark")).toBe("light");
expect(nextTheme("light")).toBe("dark");
});
});
describe("getInitialTheme", () => {
beforeEach(() => {
// @ts-expect-error - environnement node sans localStorage
delete globalThis.localStorage;
});
it("retombe sur dark sans localStorage", () => {
expect(getInitialTheme()).toBe("dark");
});
it("lit la valeur persistée si présente", () => {
const store: Record<string, string> = { "su-theme": "light" };
// @ts-expect-error - stub minimal
globalThis.localStorage = { getItem: (k: string) => store[k] ?? null };
expect(getInitialTheme()).toBe("light");
});
});
```
- [ ] **Step 3: Lancer le test (échec)**
Run: `rtk pnpm vitest run client/src/lib/theme.test.ts`
Expected: FAIL — module `./theme.js` introuvable.
- [ ] **Step 4: Implémenter `client/src/lib/theme.ts`**
```ts
// client/src/lib/theme.ts
export type Theme = "dark" | "light";
const KEY = "su-theme";
export function nextTheme(t: Theme): Theme {
return t === "dark" ? "light" : "dark";
}
export function getInitialTheme(): Theme {
try {
const v = globalThis.localStorage?.getItem(KEY);
return v === "light" ? "light" : "dark";
} catch {
return "dark";
}
}
export function applyTheme(t: Theme): void {
try {
document.documentElement.dataset.theme = t;
globalThis.localStorage?.setItem(KEY, t);
} catch {
/* localStorage indisponible (mode privé) : on ignore la persistance */
}
}
```
- [ ] **Step 5: Lancer le test (succès)**
Run: `rtk pnpm vitest run client/src/lib/theme.test.ts`
Expected: PASS (3 tests).
- [ ] **Step 6: Commit**
```bash
rtk git add vitest.config.ts client/src/lib/theme.ts client/src/lib/theme.test.ts
rtk git commit -m "feat(ui): helper de thème dark/light persisté (TDD)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 3: Helper stats (TDD)
**Files:**
- Create: `client/src/lib/stats.ts`, `client/src/lib/stats.test.ts`
- [ ] **Step 1: Écrire le test (échec attendu)**
`client/src/lib/stats.test.ts` :
```ts
import { describe, it, expect } from "vitest";
import { sumUpdates } from "./stats.js";
describe("sumUpdates", () => {
it("somme les compteurs", () => {
expect(sumUpdates({ a: 2, b: 3, c: 0 })).toBe(5);
});
it("retourne 0 pour un objet vide", () => {
expect(sumUpdates({})).toBe(0);
});
});
```
- [ ] **Step 2: Lancer le test (échec)**
Run: `rtk pnpm vitest run client/src/lib/stats.test.ts`
Expected: FAIL — module introuvable.
- [ ] **Step 3: Implémenter `client/src/lib/stats.ts`**
```ts
// client/src/lib/stats.ts
export function sumUpdates(counts: Record<string, number>): number {
return Object.values(counts).reduce((acc, n) => acc + n, 0);
}
```
- [ ] **Step 4: Lancer le test (succès)**
Run: `rtk pnpm vitest run client/src/lib/stats.test.ts`
Expected: PASS (2 tests).
- [ ] **Step 5: Commit**
```bash
rtk git add client/src/lib/stats.ts client/src/lib/stats.test.ts
rtk git commit -m "feat(ui): helper sumUpdates (TDD)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 4: Classes CSS (header, status bar, inputs, en-tête terminal)
**Files:**
- Modify: `client/src/styles/app.css`
- [ ] **Step 1: Mettre à jour `client/src/styles/app.css`**
Remplacer tout le contenu par :
```css
@import "./tokens.css";
* { box-sizing: border-box; }
html, body, #root { height: 100%; margin: 0; }
body {
font-family: var(--font-ui);
background: var(--bg-1);
color: var(--ink-1);
}
/* Ossature : header / rangée 3 volets / status bar */
.su-app { display: flex; flex-direction: column; height: 100vh; }
.su-row { flex: 1; display: flex; min-height: 0; }
.su-header {
height: 52px; flex: 0 0 52px;
display: flex; align-items: center; gap: 12px;
padding: 0 16px;
background: var(--bg-2);
border-bottom: 1px solid var(--border-1);
}
.su-header h1 { font-size: 15px; margin: 0; font-weight: 600; }
.su-spacer { flex: 1; }
.su-hermes { width: 280px; min-width: 200px; background: var(--bg-2); border-right: 1px solid var(--border-1); padding: 14px; overflow: auto; }
.su-center { flex: 1; overflow: auto; padding: 18px; }
.su-terminal-wrap { width: 360px; min-width: 320px; display: flex; flex-direction: column; background: var(--bg-0); border-left: 1px solid var(--border-1); }
.su-terminal-head { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--border-1); }
.su-terminal { flex: 1; min-height: 0; padding: 6px; }
.su-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 14px; }
/* Status bar style tmux */
.su-statusbar {
height: 26px; flex: 0 0 26px;
display: flex; align-items: stretch;
background: var(--bg-2);
border-top: 1px solid var(--border-1);
font-family: var(--font-terminal);
font-size: 11px;
}
.su-statusbar .cell { display: flex; align-items: center; padding: 0 12px; border-right: 1px solid var(--border-1); color: var(--ink-2); }
.su-statusbar .cell.mode { background: var(--accent); color: var(--bg-1); font-weight: 700; letter-spacing: 0.08em; }
.su-statusbar .clock { margin-left: auto; border-right: none; border-left: 1px solid var(--border-1); }
/* Champs de formulaire tokenisés */
.su-field {
padding: 9px 12px; font-size: 13px; font-family: var(--font-ui);
background: var(--bg-1); color: var(--ink-1);
border: 1px solid var(--border-2); border-radius: 8px;
outline: none;
}
.su-field:focus { border-color: var(--accent-soft); }
```
- [ ] **Step 2: Vérifier le build**
Run: `rtk pnpm vite build`
Expected: build OK.
- [ ] **Step 3: Commit**
```bash
rtk git add client/src/styles/app.css
rtk git commit -m "feat(ui): classes layout header/statusbar/inputs/terminal
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 5: Composant Header
**Files:**
- Create: `client/src/panels/Header.tsx`
- [ ] **Step 1: Créer `client/src/panels/Header.tsx`**
```tsx
// client/src/panels/Header.tsx
import { Button, IconButton } from "../components/ui-kit.js";
import type { Theme } from "../lib/theme.js";
interface Props {
theme: Theme;
onToggleTheme: () => void;
onAdd: () => void;
}
export function Header({ theme, onToggleTheme, onAdd }: Props) {
return (
<header className="su-header">
<h1>System Update</h1>
<div className="su-spacer" />
<Button variant="primary" icon="plus" onClick={onAdd}>Ajouter</Button>
<IconButton
icon={theme === "dark" ? "sun" : "moon"}
label={theme === "dark" ? "Thème clair" : "Thème sombre"}
onClick={onToggleTheme}
/>
</header>
);
}
```
- [ ] **Step 2: Vérifier la compilation**
Run: `rtk pnpm check`
Expected: 0 erreur.
- [ ] **Step 3: Commit**
```bash
rtk git add client/src/panels/Header.tsx
rtk git commit -m "feat(ui): header (titre, ajout, bascule thème)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 6: Composant StatusBar
**Files:**
- Create: `client/src/panels/StatusBar.tsx`
- [ ] **Step 1: Créer `client/src/panels/StatusBar.tsx`**
```tsx
// client/src/panels/StatusBar.tsx
import { useEffect, useState } from "react";
import { sumUpdates } from "../lib/stats.js";
interface Props {
machineCount: number;
counts: Record<string, number>;
}
export function StatusBar({ machineCount, counts }: Props) {
const [clock, setClock] = useState(() => new Date().toLocaleTimeString("fr-FR"));
useEffect(() => {
const id = setInterval(() => setClock(new Date().toLocaleTimeString("fr-FR")), 1000);
return () => clearInterval(id);
}, []);
return (
<footer className="su-statusbar">
<div className="cell mode">SYSTEM UPDATE</div>
<div className="cell">{machineCount} machine{machineCount > 1 ? "s" : ""}</div>
<div className="cell">{sumUpdates(counts)} update{sumUpdates(counts) > 1 ? "s" : ""}</div>
<div className="cell clock">{clock}</div>
</footer>
);
}
```
- [ ] **Step 2: Vérifier la compilation**
Run: `rtk pnpm check`
Expected: 0 erreur.
- [ ] **Step 3: Commit**
```bash
rtk git add client/src/panels/StatusBar.tsx
rtk git commit -m "feat(ui): status bar tmux (mode, compteurs, horloge live)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 7: Refonte MachineTile (StatusLed + IconButton)
**Files:**
- Modify: `client/src/features/machines/MachineTile.tsx`
- [ ] **Step 1: Remplacer le contenu de `client/src/features/machines/MachineTile.tsx`**
```tsx
// client/src/features/machines/MachineTile.tsx
import type { MachineView } from "@shared/types.js";
import { StatusLed, IconButton } from "../../components/ui-kit.js";
interface Props {
machine: MachineView;
packageCount: number;
selected: boolean;
onSelect: (id: string) => void;
onRefresh: (id: string) => void;
onUpgrade: (id: string) => void;
onReboot: (id: string) => void;
}
// Map statut machine -> statut StatusLed du design system
const LED: Record<string, "ok" | "warn" | "err" | "info" | "off"> = {
ok: "ok", updates_available: "warn", error: "err", running: "info", unknown: "off",
};
export function MachineTile({ machine, packageCount, selected, onSelect, onRefresh, onUpgrade, onReboot }: Props) {
return (
<div
className="glass interactive"
style={{ padding: 16, borderRadius: 10, border: selected ? "1px solid var(--accent-soft)" : "1px solid transparent" }}
onClick={() => onSelect(machine.id)}
>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<StatusLed status={LED[machine.status] ?? "off"} pulse={machine.status === "running"} />
<strong>{machine.name}</strong>
</div>
<div className="mono" style={{ color: "var(--ink-3)", fontSize: 12, marginTop: 4 }}>
{machine.hostname}:{machine.port} · {machine.osFamily}
</div>
<div style={{ margin: "10px 0", fontSize: 13 }}>
<span className="label">UPDATES</span>{" "}
<span className="mono">{packageCount}</span>
</div>
<div style={{ display: "flex", gap: 6 }} onClick={(e) => e.stopPropagation()}>
<IconButton icon="refresh" label="Rafraîchir" onClick={() => onRefresh(machine.id)} size={30} />
<IconButton icon="download" label="Upgrade" onClick={() => onUpgrade(machine.id)} size={30} />
<IconButton icon="power" label="Redémarrer" danger onClick={() => onReboot(machine.id)} size={30} />
</div>
</div>
);
}
```
- [ ] **Step 2: Vérifier la compilation**
Run: `rtk pnpm check`
Expected: 0 erreur. (Le prop `selected` sera fourni par le Dashboard en Task 10.)
- [ ] **Step 3: Commit**
```bash
rtk git add client/src/features/machines/MachineTile.tsx
rtk git commit -m "feat(ui): tuile machine avec StatusLed + IconButton (tooltips)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 8: Refonte AddMachineModal (Popup + Button)
**Files:**
- Modify: `client/src/features/machines/AddMachineModal.tsx`
- [ ] **Step 1: Remplacer le contenu de `client/src/features/machines/AddMachineModal.tsx`**
```tsx
// client/src/features/machines/AddMachineModal.tsx
import { useState } from "react";
import { Popup, Button, StatusLed } from "../../components/ui-kit.js";
interface Props { onClose: () => void; onCreated: () => void; }
export function AddMachineModal({ onClose, onCreated }: Props) {
const [form, setForm] = useState({ name: "", hostname: "", port: 22, username: "", password: "", sudoPassword: "" });
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const set = (k: string, v: string | number) => setForm({ ...form, [k]: v });
async function submit() {
setBusy(true); setError(null);
try {
const res = await fetch("/api/machines", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ ...form, port: Number(form.port), sudoPassword: form.sudoPassword || null }),
});
if (!res.ok) throw new Error((await res.json()).error ?? "Échec");
onCreated(); onClose();
} catch (e) { setError((e as Error).message); } finally { setBusy(false); }
}
return (
<Popup
open
onClose={onClose}
title="Ajouter une machine"
width={400}
footer={
<>
<Button variant="ghost" onClick={onClose}>Annuler</Button>
<Button variant="primary" icon="download" onClick={submit}>{busy ? "Test…" : "Ajouter"}</Button>
</>
}
>
<div style={{ display: "grid", gap: 10 }}>
<input className="su-field" placeholder="nom" value={form.name} onChange={(e) => set("name", e.target.value)} />
<input className="su-field" placeholder="hostname / IP" value={form.hostname} onChange={(e) => set("hostname", e.target.value)} />
<input className="su-field" placeholder="username" value={form.username} onChange={(e) => set("username", e.target.value)} />
<input className="su-field" placeholder="port" type="number" value={form.port} onChange={(e) => set("port", e.target.value)} />
<input className="su-field" placeholder="password" type="password" value={form.password} onChange={(e) => set("password", e.target.value)} />
<input className="su-field" placeholder="sudo password (optionnel)" type="password" value={form.sudoPassword} onChange={(e) => set("sudoPassword", e.target.value)} />
{error && (
<div style={{ display: "flex", alignItems: "center", gap: 8, color: "var(--err)", fontSize: 12 }}>
<StatusLed status="err" /> {error}
</div>
)}
</div>
</Popup>
);
}
```
- [ ] **Step 2: Vérifier la compilation**
Run: `rtk pnpm check`
Expected: 0 erreur.
- [ ] **Step 3: Commit**
```bash
rtk git add client/src/features/machines/AddMachineModal.tsx
rtk git commit -m "feat(ui): modale d'ajout avec Popup + Button du design system
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 9: Refonte TerminalPanel (machine + en-tête + bannière de séparation)
**Files:**
- Modify: `client/src/panels/TerminalPanel.tsx`
- [ ] **Step 1: Remplacer le contenu de `client/src/panels/TerminalPanel.tsx`**
```tsx
// client/src/panels/TerminalPanel.tsx
import { useEffect, useRef } from "react";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import "@xterm/xterm/css/xterm.css";
import type { MachineView } from "@shared/types.js";
import { connectOutput } from "../lib/ws.js";
import { StatusLed } from "../components/ui-kit.js";
const LED: Record<string, "ok" | "warn" | "err" | "info" | "off"> = {
ok: "ok", updates_available: "warn", error: "err", running: "info", unknown: "off",
};
export function TerminalPanel({ machine }: { machine: MachineView | null }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!ref.current) return;
const term = new Terminal({
fontFamily: "'Share Tech Mono', monospace", fontSize: 12,
theme: { background: "#1d2021", foreground: "#ebdbb2" },
convertEol: true,
});
const fit = new FitAddon();
term.loadAddon(fit);
term.open(ref.current);
fit.fit();
if (machine) {
// Bannière de séparation franche entre machines (couleur accent ANSI 33).
const bar = "─".repeat(20);
term.writeln(`\x1b[33m${bar} ${machine.name} (${machine.hostname}) ${bar}\x1b[0m`);
} else {
term.writeln("# sélectionne une machine");
}
const disconnect = machine ? connectOutput(machine.id, (c) => term.write(c)) : () => {};
return () => { disconnect(); term.dispose(); };
}, [machine?.id]);
return (
<section className="su-terminal-wrap">
<div className="su-terminal-head">
<StatusLed status={machine ? (LED[machine.status] ?? "off") : "off"} />
<span className="label">TERMINAL</span>
{machine && <span className="mono" style={{ fontSize: 12 }}>{machine.name}</span>}
{machine && <span style={{ color: "var(--ink-3)", fontSize: 11 }}>{machine.hostname}</span>}
</div>
<div className="su-terminal" ref={ref} />
</section>
);
}
```
- [ ] **Step 2: Vérifier la compilation**
Run: `rtk pnpm check`
Expected: 0 erreur. (Le prop `machine` sera fourni par `App` en Task 11.)
- [ ] **Step 3: Commit**
```bash
rtk git add client/src/panels/TerminalPanel.tsx
rtk git commit -m "feat(ui): terminal identifie la machine + bannière de séparation
Répond au retour d'usage (amelioration.md): séparation franche entre machines.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 10: Dashboard présentationnel + HermesPanel
**Files:**
- Modify: `client/src/panels/Dashboard.tsx`
- Modify: `client/src/panels/HermesPanel.tsx`
- [ ] **Step 1: Remplacer le contenu de `client/src/panels/Dashboard.tsx`**
```tsx
// client/src/panels/Dashboard.tsx
import type { MachineView } from "@shared/types.js";
import { MachineTile } from "../features/machines/MachineTile.js";
interface Props {
machines: MachineView[];
counts: Record<string, number>;
selectedId: string | null;
onSelect: (id: string) => void;
onRefresh: (id: string) => void;
onUpgrade: (id: string) => void;
onReboot: (id: string) => void;
}
export function Dashboard({ machines, counts, selectedId, onSelect, onRefresh, onUpgrade, onReboot }: Props) {
return (
<main className="su-center">
<h2 style={{ margin: "0 0 16px" }}>Machines</h2>
{machines.length === 0 && (
<p style={{ color: "var(--ink-3)" }}>Aucune machine. Clique sur « Ajouter » dans l'en-tête.</p>
)}
<div className="su-tiles">
{machines.map((m) => (
<MachineTile
key={m.id}
machine={m}
packageCount={counts[m.id] ?? 0}
selected={selectedId === m.id}
onSelect={onSelect}
onRefresh={onRefresh}
onUpgrade={onUpgrade}
onReboot={onReboot}
/>
))}
</div>
</main>
);
}
```
- [ ] **Step 2: Remplacer le contenu de `client/src/panels/HermesPanel.tsx`**
```tsx
// client/src/panels/HermesPanel.tsx
import { Icon } from "../components/ui-kit.js";
export function HermesPanel() {
return (
<aside className="su-hermes">
<div className="label" style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 12 }}>
<Icon name="bell" size={13} /> HERMES
</div>
<p style={{ color: "var(--ink-3)", fontSize: 13 }}>
Copilote d'exploitation à venir. Analyse des mises à jour, plans et rapports
seront disponibles ici dans un prochain jalon.
</p>
</aside>
);
}
```
- [ ] **Step 3: Vérifier la compilation**
Run: `rtk pnpm check`
Expected: 0 erreur.
- [ ] **Step 4: Commit**
```bash
rtk git add client/src/panels/Dashboard.tsx client/src/panels/HermesPanel.tsx
rtk git commit -m "feat(ui): Dashboard présentationnel (props) + HermesPanel iconé
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 11: App — remontée d'état, thème, header + status bar
**Files:**
- Modify: `client/src/App.tsx`
- [ ] **Step 1: Remplacer le contenu de `client/src/App.tsx`**
```tsx
// client/src/App.tsx
import { useCallback, useEffect, useState } from "react";
import type { MachineView } from "@shared/types.js";
import { api } from "./lib/api.js";
import { getInitialTheme, applyTheme, nextTheme, type Theme } from "./lib/theme.js";
import { Header } from "./panels/Header.js";
import { StatusBar } from "./panels/StatusBar.js";
import { HermesPanel } from "./panels/HermesPanel.js";
import { Dashboard } from "./panels/Dashboard.js";
import { TerminalPanel } from "./panels/TerminalPanel.js";
import { AddMachineModal } from "./features/machines/AddMachineModal.js";
export function App() {
const [machines, setMachines] = useState<MachineView[]>([]);
const [counts, setCounts] = useState<Record<string, number>>({});
const [selectedId, setSelectedId] = useState<string | null>(null);
const [adding, setAdding] = useState(false);
const [theme, setTheme] = useState<Theme>("dark");
const load = useCallback(async () => {
const ms = await api.listMachines();
setMachines(ms);
const entries = await Promise.all(
ms.map(async (m) => {
try { return [m.id, (await api.snapshot(m.id)).apt.count] as const; }
catch { return [m.id, 0] as const; }
}),
);
setCounts(Object.fromEntries(entries));
}, []);
useEffect(() => {
const initial = getInitialTheme();
setTheme(initial);
applyTheme(initial);
void load();
}, [load]);
const toggleTheme = () => {
const t = nextTheme(theme);
setTheme(t);
applyTheme(t);
};
const onRefresh = (id: string) => { setSelectedId(id); void api.refresh(id).then(load); };
const onUpgrade = (id: string) => { setSelectedId(id); void api.runAction(id, "apt_full_upgrade"); };
const onReboot = (id: string) => { setSelectedId(id); void api.runAction(id, "reboot"); };
const selected = machines.find((m) => m.id === selectedId) ?? null;
return (
<div className="su-app">
<Header theme={theme} onToggleTheme={toggleTheme} onAdd={() => setAdding(true)} />
<div className="su-row">
<HermesPanel />
<Dashboard
machines={machines}
counts={counts}
selectedId={selectedId}
onSelect={setSelectedId}
onRefresh={onRefresh}
onUpgrade={onUpgrade}
onReboot={onReboot}
/>
<TerminalPanel machine={selected} />
</div>
<StatusBar machineCount={machines.length} counts={counts} />
{adding && <AddMachineModal onClose={() => setAdding(false)} onCreated={load} />}
</div>
);
}
```
- [ ] **Step 2: Vérifier compilation + build complet**
Run: `rtk pnpm check && rtk pnpm vite build`
Expected: 0 erreur TS, build OK.
- [ ] **Step 3: Commit**
```bash
rtk git add client/src/App.tsx
rtk git commit -m "feat(ui): App orchestre état+thème, header et status bar
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 12: Vérification finale (build + tests + deux thèmes)
**Files:** aucun (vérification).
- [ ] **Step 1: Suite complète**
Run: `rtk pnpm check && rtk pnpm test && rtk pnpm build`
Expected: `check` 0 erreur ; tests verts (serveur 19 + theme 3 + stats 2 = 24) ; build OK (`dist/index.js` + `dist/client`).
- [ ] **Step 2: Vérification visuelle manuelle (utilisateur, navigateur)**
Lancer `pnpm dev`, ouvrir `http://localhost:5173`, vérifier :
- Header avec titre, bouton « Ajouter » (icône +), bascule thème (soleil/lune) qui change l'apparence et persiste après rechargement (F5).
- Les **deux thèmes** (dark ET light) restent lisibles et cohérents.
- Icônes Font Awesome affichées (pas de carré vide), polices Inter/JetBrains Mono/Share Tech Mono appliquées.
- Tuiles : StatusLed colorée selon l'état, 3 IconButton avec **tooltips** au survol, **pas de hover** (pression 3D au clic seulement), sélection visible (bordure accent).
- Modale d'ajout = Popup (titre, bouton fermer, footer Annuler/Ajouter).
- Status bar en bas : « SYSTEM UPDATE » + nb machines + nb updates + **horloge qui avance**.
- Terminal : en-tête avec nom + hostname de la machine ; en sélectionnant une autre machine, **bannière de séparation** claire (ligne accent avec nom/hostname). Plus d'UUID affiché.
- [ ] **Step 3: Commit éventuel de finition**
S'il a fallu un ajustement après vérif visuelle, le committer :
```bash
rtk git add -A
rtk git commit -m "fix(ui): ajustements après vérification visuelle des deux thèmes
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Self-Review (couverture du spec)
- **Wiring DS (exports ESM + FA + polices)** → Task 1. ✓
- **ui-kit jamais importé en test** → tests uniquement sur lib/theme + lib/stats (Tasks 2, 3) ; vitest include ajoute `client/**` mais les seuls tests client sont ces helpers purs. ✓
- **Header (titre, ajout, bascule thème)** → Task 5, câblé en Task 11. ✓
- **Status bar tmux (mode, compteurs, horloge)** → Task 6, câblée Task 11. ✓
- **Thème dark/light persisté** → Task 2 (`lib/theme`), appliqué Task 11. ✓
- **MachineTile : StatusLed + IconButton (tooltips), danger reboot** → Task 7. ✓
- **AddMachineModal : Popup + Button** → Task 8. ✓
- **Dashboard présentationnel** → Task 10. ✓
- **TerminalPanel : machine nommée + bannière de séparation (retour amelioration.md)** → Task 9. ✓
- **Remontée d'état dans App** → Task 11. ✓
- **Deux thèmes vérifiés** → Task 12 step 2. ✓
- **Tests helpers + build** → Tasks 2, 3, 12. ✓
Pas de placeholder. Noms cohérents entre tâches : `MachineTile` reçoit `selected` (Task 7) fourni par Dashboard `selectedId===m.id` (Task 10) depuis App `selectedId` (Task 11) ; `TerminalPanel` reçoit `machine` (Task 9) fourni par App `selected` (Task 11) ; helpers `getInitialTheme`/`applyTheme`/`nextTheme`/`sumUpdates` utilisés tels que définis.
@@ -0,0 +1,659 @@
# Tâche 1.9 — Phase 1 (schéma BDD socle) — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Implémenter la Phase 1 du schéma BDD cible (`tache1.9.md §14`) : étendre `machines/snapshots/executions`, créer les tables socle (`machine_state`, `machine_hardware`, `machine_metrics_latest`, `machine_events`, `important_messages`, `reports`, `raw_artifacts`), et alimenter l'état dérivé + la timeline lors des refresh/exécutions existants.
**Architecture:** Extension additive (rétro-compatible) du schéma Drizzle/SQLite. Migration générée par drizzle-kit. Un service `machineState` dérive l'état courant d'une machine depuis un snapshot/exécution et l'« upsert » dans `machine_state` ; `refreshMachine` et `runAction` (existants) sont enrichis pour peupler `machine_state`, `machine_events`, et (pour les exécutions) `reports` + `raw_artifacts`, ainsi que les nouveaux champs `kind/schema_version/important_json` des snapshots/exécutions. **Aucune modification de l'API ni du frontend** (réservé tâches 3/5).
**Tech Stack:** Drizzle ORM, better-sqlite3, drizzle-kit, vitest.
---
## Contexte & invariants
- État actuel : `server/db/schema.ts` contient `machines`, `snapshots`, `executions` (jalon 1, en prod). Voir le fichier.
- **Rétro-compatibilité stricte** : on AJOUTE des colonnes/tables ; on ne renomme ni ne supprime rien. En particulier on conserve `snapshots.checked_at` (le design tache1.9 le nomme `created_at`, mais le code jalon 1 `refresh.ts` utilise `checkedAt` — on ne casse pas).
- Les nouveaux champs sont nullable ou ont une valeur par défaut, pour que les lignes du jalon 1 restent valides.
- `payload_json` / `result_json` restent la vérité canonique ; `machine_state` n'est qu'un cache dérivé pour l'UI (jamais source de vérité métier).
- Pas de FK vers des tables non encore créées (jobs/action_requests/schedules = phases ultérieures) : `running_job_id`, `request_id`, `job_id` sont de simples colonnes `text` nullable.
- Ne pas committer (l'utilisateur gère les commits en fin de parcours). Les étapes « Commit » du template sont **remplacées par une vérification** ; ne PAS exécuter `git commit`.
> **Note exécution** : ce plan se construit sur l'état courant du working tree (qui contient du WIP non commité : feature `capabilities`, scaffold Rust). Ne pas annuler ce WIP. Les fichiers touchés ici (`server/db/*`, `server/services/*`) ne chevauchent pas le WIP `capabilities`/frontend.
---
## File Structure
```
server/db/
├─ schema.ts # MODIF : +colonnes machines/snapshots/executions, +7 tables
├─ migrations/ # +1 migration générée (drizzle-kit)
└─ schema.test.ts # NOUVEAU : test que la migration applique le schéma cible
server/services/
├─ machineState.ts # NOUVEAU : dériver + upsert machine_state, insert events
├─ machineState.test.ts # NOUVEAU : tests purs de dérivation
├─ refresh.ts # MODIF : peupler snapshot.kind/schema_version/important_json + machine_state + event
└─ execute.ts # MODIF : champs executions + machine_state + event + reports + raw_artifacts
```
---
## Task 1 : Étendre le schéma Drizzle + migration
**Files:**
- Modify: `server/db/schema.ts`
- Create: migration sous `server/db/migrations/` (générée)
- Create: `server/db/schema.test.ts`
- [ ] **Step 1 : Remplacer le contenu de `server/db/schema.ts`**
> **⚠️ Tree déplacé** : `schema.ts` contient déjà la table `apiClients` (WIP api_clients, migration `0001_api_clients.sql`). Le contenu ci-dessous **préserve `apiClients`** (et l'import `uniqueIndex`). NE PAS supprimer `apiClients`. La migration générée à l'étape suivante sera donc `0002_*` (et non `0001`).
```ts
// server/db/schema.ts
import { sqliteTable, text, integer, real, uniqueIndex } from "drizzle-orm/sqlite-core";
export const machines = sqliteTable("machines", {
id: text("id").primaryKey(),
name: text("name").notNull(),
hostname: text("hostname").notNull(),
port: integer("port").notNull().default(22),
osFamily: text("os_family").notNull().default("unknown"),
osVersion: text("os_version"),
osCodename: text("os_codename"),
arch: text("arch"),
machineKind: text("machine_kind"), // physical | vm | proxmox_host | lxc | raspberry_pi | workstation | unknown
virtualization: text("virtualization"), // none | qemu | kvm | lxc | docker | vmware | ...
hardwareProfile: text("hardware_profile"), // generic_vm | baremetal_server | raspberry_pi | gpu_server | proxmox_host | ...
username: text("username").notNull(),
encPassword: text("enc_password").notNull(),
encSudoPassword: text("enc_sudo_password"),
aptProxyMode: text("apt_proxy_mode").notNull().default("direct"),
aptProxyUrl: text("apt_proxy_url"),
status: text("status").notNull().default("unknown"),
lastCheckedAt: text("last_checked_at"),
lastSeenAt: text("last_seen_at"),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at"),
deletedAt: text("deleted_at"),
});
export const snapshots = sqliteTable("snapshots", {
id: text("id").primaryKey(),
machineId: text("machine_id").notNull().references(() => machines.id, { onDelete: "cascade" }),
kind: text("kind").notNull().default("apt_update_analyze"),
schemaVersion: integer("schema_version").notNull().default(1),
checkedAt: text("checked_at").notNull(),
status: text("status").notNull(),
payloadJson: text("payload_json").notNull(),
importantJson: text("important_json"),
rawLogPath: text("raw_log_path"),
rawArtifactId: text("raw_artifact_id"),
sourceJobId: text("source_job_id"),
});
export const executions = sqliteTable("executions", {
id: text("id").primaryKey(),
machineId: text("machine_id").notNull().references(() => machines.id, { onDelete: "cascade" }),
action: text("action").notNull(),
mode: text("mode").notNull().default("manual"),
schemaVersion: integer("schema_version").notNull().default(1),
startedAt: text("started_at").notNull(),
finishedAt: text("finished_at"),
status: text("status").notNull(),
requestId: text("request_id"),
jobId: text("job_id"),
resultJson: text("result_json"),
importantJson: text("important_json"),
reportPath: text("report_path"),
rawLogPath: text("raw_log_path"),
reportId: text("report_id"),
exitCode: integer("exit_code"),
errorKind: text("error_kind"),
errorMessage: text("error_message"),
});
export const machineState = sqliteTable("machine_state", {
machineId: text("machine_id").primaryKey().references(() => machines.id, { onDelete: "cascade" }),
status: text("status").notNull(),
aptStatus: text("apt_status"),
aptUpdatesCount: integer("apt_updates_count").notNull().default(0),
aptRebootRequired: integer("apt_reboot_required").notNull().default(0),
aptLastAnalyzeAt: text("apt_last_analyze_at"),
dockerStatus: text("docker_status"),
dockerInstalled: integer("docker_installed").notNull().default(0),
dockerStacksCount: integer("docker_stacks_count").notNull().default(0),
dockerUpdatesCount: integer("docker_updates_count").notNull().default(0),
dockerPruneAvailable: integer("docker_prune_available").notNull().default(0),
postInstallStatus: text("post_install_status"),
metricsLastCollectedAt: text("metrics_last_collected_at"),
cpuLoad1: real("cpu_load1"),
memoryUsedPercent: real("memory_used_percent"),
rootUsedPercent: real("root_used_percent"),
diskWarningsCount: integer("disk_warnings_count").notNull().default(0),
hardwareWarningsCount: integer("hardware_warnings_count").notNull().default(0),
runningJobId: text("running_job_id"),
lastErrorKind: text("last_error_kind"),
lastErrorMessage: text("last_error_message"),
updatedAt: text("updated_at").notNull(),
});
export const machineHardware = sqliteTable("machine_hardware", {
machineId: text("machine_id").primaryKey().references(() => machines.id, { onDelete: "cascade" }),
probeSnapshotId: text("probe_snapshot_id"),
cpuModel: text("cpu_model"),
cpuCores: integer("cpu_cores"),
memoryBytes: integer("memory_bytes"),
gpusJson: text("gpus_json"),
disksJson: text("disks_json"),
networkJson: text("network_json"),
firmwareJson: text("firmware_json"),
driverJson: text("driver_json"),
warningsJson: text("warnings_json"),
updatedAt: text("updated_at").notNull(),
});
export const machineMetricsLatest = sqliteTable("machine_metrics_latest", {
machineId: text("machine_id").primaryKey().references(() => machines.id, { onDelete: "cascade" }),
snapshotId: text("snapshot_id"),
collectedAt: text("collected_at").notNull(),
cpuLoad1: real("cpu_load1"),
cpuLoad5: real("cpu_load5"),
cpuCores: integer("cpu_cores"),
memoryTotalBytes: integer("memory_total_bytes"),
memoryUsedBytes: integer("memory_used_bytes"),
memoryAvailableBytes: integer("memory_available_bytes"),
memoryUsedPercent: real("memory_used_percent"),
filesystemsJson: text("filesystems_json"),
rootUsedPercent: real("root_used_percent"),
warningsJson: text("warnings_json"),
});
export const machineEvents = sqliteTable("machine_events", {
id: text("id").primaryKey(),
machineId: text("machine_id").references(() => machines.id, { onDelete: "cascade" }),
eventType: text("event_type").notNull(),
severity: text("severity").notNull(), // info | warning | error
createdAt: text("created_at").notNull(),
actorType: text("actor_type"), // user | system | schedule | hermes
actorId: text("actor_id"),
snapshotId: text("snapshot_id"),
executionId: text("execution_id"),
jobId: text("job_id"),
message: text("message"),
payloadJson: text("payload_json"),
});
export const importantMessages = sqliteTable("important_messages", {
id: text("id").primaryKey(),
machineId: text("machine_id").references(() => machines.id, { onDelete: "cascade" }),
source: text("source").notNull(), // apt | docker | post_install | ssh | system
category: text("category").notNull(), // error | warning | future_major_change | ...
severity: text("severity").notNull(),
packageName: text("package_name"),
component: text("component"),
message: text("message").notNull(),
rawLineRef: text("raw_line_ref"),
snapshotId: text("snapshot_id"),
executionId: text("execution_id"),
firstSeenAt: text("first_seen_at").notNull(),
lastSeenAt: text("last_seen_at").notNull(),
acknowledged: integer("acknowledged").notNull().default(0),
acknowledgedAt: text("acknowledged_at"),
acknowledgedBy: text("acknowledged_by"),
payloadJson: text("payload_json"),
});
export const reports = sqliteTable("reports", {
id: text("id").primaryKey(),
machineId: text("machine_id").references(() => machines.id, { onDelete: "cascade" }),
executionId: text("execution_id"),
kind: text("kind").notNull(), // machine | global | cleanup | hermes
title: text("title").notNull(),
path: text("path").notNull(),
createdAt: text("created_at").notNull(),
pinned: integer("pinned").notNull().default(0),
summaryJson: text("summary_json"),
});
export const rawArtifacts = sqliteTable("raw_artifacts", {
id: text("id").primaryKey(),
machineId: text("machine_id").references(() => machines.id, { onDelete: "cascade" }),
kind: text("kind").notNull(), // raw_log | rendered_template | export | screenshot
path: text("path").notNull(),
bytes: integer("bytes"),
sha256: text("sha256"),
createdAt: text("created_at").notNull(),
expiresAt: text("expires_at"),
pinned: integer("pinned").notNull().default(0),
redacted: integer("redacted").notNull().default(1),
retentionPolicy: text("retention_policy"), // default | failed | pinned | short
deletedAt: text("deleted_at"),
deleteReason: text("delete_reason"),
metadataJson: text("metadata_json"),
});
// --- Préexistant (WIP api_clients) : NE PAS supprimer ---
export const apiClients = sqliteTable(
"api_clients",
{
id: text("id").primaryKey(),
name: text("name").notNull(),
tokenPrefix: text("token_prefix").notNull(),
tokenHash: text("token_hash").notNull(),
scopesJson: text("scopes_json").notNull(),
createdAt: text("created_at").notNull(),
lastUsedAt: text("last_used_at"),
revokedAt: text("revoked_at"),
},
(table) => ({
tokenHashIdx: uniqueIndex("api_clients_token_hash_unique").on(table.tokenHash),
}),
);
```
> Avant d'écrire : relire l'état RÉEL de `server/db/schema.ts` (`rtk read server/db/schema.ts`). Si `apiClients` y a évolué (colonnes différentes), reprendre sa définition à l'identique plutôt que celle ci-dessus, pour ne pas régresser le WIP. N'ajouter que les colonnes étendues + les 7 nouvelles tables.
- [ ] **Step 2 : Générer la migration**
Run: `rtk pnpm db:generate`
Expected: un nouveau fichier `server/db/migrations/0002_*.sql` est créé (ALTER TABLE machines/snapshots/executions + CREATE TABLE des 7 nouvelles tables). La migration `0001_api_clients.sql` reste intacte. Aucune erreur drizzle-kit, aucun DROP de `api_clients`.
- [ ] **Step 3 : Écrire le test de migration `server/db/schema.test.ts`**
```ts
// server/db/schema.test.ts
import { describe, it, expect } from "vitest";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
function freshMigratedDb() {
const sqlite = new Database(":memory:");
const db = drizzle(sqlite);
migrate(db, { migrationsFolder: "./server/db/migrations" });
return sqlite;
}
function tableNames(sqlite: Database.Database): string[] {
return sqlite.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map((r: any) => r.name);
}
function columnNames(sqlite: Database.Database, table: string): string[] {
return sqlite.prepare(`PRAGMA table_info(${table})`).all().map((r: any) => r.name);
}
describe("schéma Phase 1", () => {
it("crée les tables socle", () => {
const sqlite = freshMigratedDb();
const tables = tableNames(sqlite);
for (const t of [
"machines", "snapshots", "executions",
"machine_state", "machine_hardware", "machine_metrics_latest",
"machine_events", "important_messages", "reports", "raw_artifacts",
]) {
expect(tables, `table ${t}`).toContain(t);
}
});
it("ajoute les colonnes étendues sans casser l'existant", () => {
const sqlite = freshMigratedDb();
expect(columnNames(sqlite, "machines")).toEqual(
expect.arrayContaining(["machine_kind", "virtualization", "hardware_profile", "os_version", "updated_at"]),
);
expect(columnNames(sqlite, "snapshots")).toEqual(
expect.arrayContaining(["kind", "schema_version", "important_json"]),
);
expect(columnNames(sqlite, "executions")).toEqual(
expect.arrayContaining(["schema_version", "error_kind", "error_message", "exit_code"]),
);
// colonnes jalon 1 conservées
expect(columnNames(sqlite, "snapshots")).toContain("checked_at");
expect(columnNames(sqlite, "machines")).toContain("enc_password");
});
});
```
- [ ] **Step 4 : Lancer le test**
Run: `rtk pnpm vitest run server/db/schema.test.ts`
Expected: PASS (2 tests). Si une colonne attendue manque, corriger `schema.ts` et régénérer la migration (`rtk pnpm db:generate`) — ne pas modifier le test.
- [ ] **Step 5 : Vérifier la compilation + non-régression**
Run: `rtk pnpm check && rtk pnpm test`
Expected: 0 erreur TS ; toute la suite verte (les tests existants ne doivent pas casser).
- [ ] **Step 6 : (pas de commit — vérification seulement)**
Vérifier `git status` : seuls `server/db/schema.ts`, `server/db/migrations/0001_*.sql`, `server/db/schema.test.ts` ajoutés à la liste des modifs. Ne PAS committer.
---
## Task 2 : Service `machineState` (dérivation + upsert + events)
**Files:**
- Create: `server/services/machineState.ts`, `server/services/machineState.test.ts`
- [ ] **Step 1 : Écrire le test (échec attendu)**
```ts
// server/services/machineState.test.ts
import { describe, it, expect } from "vitest";
import { deriveAptState } from "./machineState.js";
import type { UpdateSnapshot } from "@shared/types.js";
const snap: UpdateSnapshot = {
machineId: "m1", hostname: "h", os: { family: "debian", version: "12" },
checkedAt: "2026-06-05T10:00:00Z", status: "updates_available",
apt: { enabled: true, count: 3, rebootRequired: true, packages: [] },
};
describe("deriveAptState", () => {
it("dérive le bloc APT de machine_state depuis un snapshot", () => {
expect(deriveAptState(snap)).toEqual({
status: "updates_available",
aptStatus: "updates_available",
aptUpdatesCount: 3,
aptRebootRequired: 1,
aptLastAnalyzeAt: "2026-06-05T10:00:00Z",
});
});
it("met rebootRequired à 0 quand absent", () => {
const s = { ...snap, status: "ok" as const, apt: { ...snap.apt, count: 0, rebootRequired: false } };
expect(deriveAptState(s)).toMatchObject({ aptUpdatesCount: 0, aptRebootRequired: 0, status: "ok" });
});
});
```
- [ ] **Step 2 : Lancer (échec)**
Run: `rtk pnpm vitest run server/services/machineState.test.ts`
Expected: FAIL — module introuvable.
- [ ] **Step 3 : Implémenter `server/services/machineState.ts`**
```ts
// server/services/machineState.ts
import { randomUUID } from "node:crypto";
import { sql } from "drizzle-orm";
import { db, schema } from "../db/client.js";
import type { UpdateSnapshot } from "@shared/types.js";
export interface AptDerivedState {
status: string;
aptStatus: string;
aptUpdatesCount: number;
aptRebootRequired: number;
aptLastAnalyzeAt: string;
}
/** Dérive le bloc APT de l'état courant depuis un snapshot (fonction pure). */
export function deriveAptState(snapshot: UpdateSnapshot): AptDerivedState {
return {
status: snapshot.status,
aptStatus: snapshot.status,
aptUpdatesCount: snapshot.apt.count,
aptRebootRequired: snapshot.apt.rebootRequired ? 1 : 0,
aptLastAnalyzeAt: snapshot.checkedAt,
};
}
type MachineStateInsert = typeof schema.machineState.$inferInsert;
/** Insère ou met à jour les champs fournis de machine_state pour une machine. */
export function upsertMachineState(
machineId: string,
fields: Partial<Omit<MachineStateInsert, "machineId" | "updatedAt" | "status">> & { status: string },
): void {
const now = new Date().toISOString();
db.insert(schema.machineState)
.values({ machineId, updatedAt: now, ...fields })
.onConflictDoUpdate({
target: schema.machineState.machineId,
set: { ...fields, updatedAt: now },
})
.run();
}
/** Ajoute une ligne à la timeline machine_events. */
export function recordEvent(input: {
machineId: string;
eventType: string;
severity: "info" | "warning" | "error";
actorType?: string;
snapshotId?: string;
executionId?: string;
message?: string;
}): void {
db.insert(schema.machineEvents).values({
id: randomUUID(),
machineId: input.machineId,
eventType: input.eventType,
severity: input.severity,
createdAt: new Date().toISOString(),
actorType: input.actorType ?? "system",
snapshotId: input.snapshotId,
executionId: input.executionId,
message: input.message,
}).run();
}
/** Utilitaire interne réservé aux migrations/tests éventuels. */
export const _internal = { sql };
```
> Note : `_internal` exporte `sql` pour rester explicite sur l'import drizzle ; supprime-le si lint le signale inutilisé et retire l'import `sql` correspondant.
- [ ] **Step 4 : Lancer (succès)**
Run: `rtk pnpm vitest run server/services/machineState.test.ts`
Expected: PASS (2 tests). `deriveAptState` est pure et n'importe pas de DB au moment du test — mais le module importe `../db/client.js`. Si l'import de `db/client` fait échouer le test en environnement node (chargement better-sqlite3), refactorer en isolant la fonction pure dans le même fichier sans exécuter de requête à l'import (c'est déjà le cas : aucune requête n'est lancée à l'import). Le test n'appelle que `deriveAptState`.
- [ ] **Step 5 : Vérifier**
Run: `rtk pnpm check`
Expected: 0 erreur.
- [ ] **Step 6 : (pas de commit)**
---
## Task 3 : Peupler l'état + la timeline dans `refreshMachine`
**Files:**
- Modify: `server/services/refresh.ts`
- [ ] **Step 1 : Lire l'état actuel de `refresh.ts`**
Run: `rtk read server/services/refresh.ts`
Repère : la construction de `snapshot`, l'`insert` dans `schema.snapshots`, et l'`update` de `machines.status`.
- [ ] **Step 2 : Enrichir l'insertion du snapshot (kind/schema_version/important_json)**
Dans `refreshMachine`, remplacer l'insertion actuelle du snapshot par :
```ts
const snapshotId = randomUUID();
db.insert(schema.snapshots).values({
id: snapshotId,
machineId,
kind: "apt_update_analyze",
schemaVersion: 1,
checkedAt,
status,
payloadJson: JSON.stringify(snapshot),
importantJson: JSON.stringify(snapshot.rawHints?.logImportantLines ?? []),
}).run();
```
(Le `randomUUID` est déjà importé dans `refresh.ts`.)
- [ ] **Step 3 : Mettre à jour `machine_state` + event après le snapshot**
Ajouter, juste après l'`update` de `machines` (status/lastCheckedAt), et après avoir importé en tête de fichier
`import { deriveAptState, upsertMachineState, recordEvent } from "./machineState.js";` :
```ts
upsertMachineState(machineId, deriveAptState(snapshot));
recordEvent({
machineId,
eventType: "apt_refresh",
severity: status === "error" ? "error" : "info",
snapshotId,
message: `Refresh APT : ${snapshot.apt.count} mise(s) à jour`,
});
```
- [ ] **Step 4 : Vérifier compilation + tests**
Run: `rtk pnpm check && rtk pnpm vitest run server/services/refresh.test.ts`
Expected: 0 erreur TS ; le test existant `extractSection` reste vert (il n'importe pas la DB grâce au mock en place).
- [ ] **Step 5 : (pas de commit)**
---
## Task 4 : Enrichir `runAction` (champs executions + state + event + reports + raw_artifacts)
**Files:**
- Modify: `server/services/execute.ts`
- [ ] **Step 1 : Lire l'état actuel de `execute.ts`**
Run: `rtk read server/services/execute.ts`
Repère : l'`insert` initial dans `executions`, le bloc d'archivage (`writeFileSync` du log + rapport), l'`update` final de `executions` et de `machines`.
- [ ] **Step 2 : Importer les helpers**
En tête de `execute.ts`, ajouter :
```ts
import { randomUUID } from "node:crypto"; // déjà présent — ne pas dupliquer
import { statSync } from "node:fs";
import { upsertMachineState, recordEvent } from "./machineState.js";
```
(Si `randomUUID` est déjà importé, n'ajouter que `statSync` et la ligne `machineState`.)
- [ ] **Step 3 : Mettre `running_job_id`/status dans machine_state au démarrage**
Juste après l'`update` initial de `machines` en `status: "running"` et l'`insert` de l'exécution (status `running`), ajouter :
```ts
upsertMachineState(machineId, { status: "running", runningJobId: executionId });
```
- [ ] **Step 4 : Enrichir l'`update` final de l'exécution**
Remplacer l'`update` final de `schema.executions` par (ajout `schemaVersion`, `importantJson`, `exitCode`, `errorKind`, `errorMessage`, `reportId`) :
```ts
const reportId = randomUUID();
const exitMatch = /===SU:EXIT=(\d+)===/.exec(raw);
db.update(schema.executions).set({
finishedAt,
status,
schemaVersion: 1,
resultJson: JSON.stringify(result),
importantJson: JSON.stringify(result.importantLogLines),
reportPath,
rawLogPath,
reportId,
exitCode: exitMatch ? Number(exitMatch[1]) : null,
errorKind: status === "error" ? "execution_failed" : null,
errorMessage: status === "error" ? (result.importantLogLines.at(-1) ?? null) : null,
}).where(eq(schema.executions.id, executionId)).run();
```
- [ ] **Step 5 : Insérer `reports` + `raw_artifacts` + state + event**
Juste après l'`update` final de `machines`, ajouter :
```ts
db.insert(schema.reports).values({
id: reportId,
machineId,
executionId,
kind: "machine",
title: `${m.name}${action}`,
path: reportPath,
createdAt: finishedAt,
}).run();
db.insert(schema.rawArtifacts).values({
id: randomUUID(),
machineId,
kind: "raw_log",
path: rawLogPath,
bytes: statSync(rawLogPath).size,
createdAt: finishedAt,
retentionPolicy: status === "error" ? "failed" : "default",
}).run();
upsertMachineState(machineId, {
status: status === "error" ? "error" : "unknown",
runningJobId: null,
lastErrorKind: status === "error" ? "execution_failed" : null,
lastErrorMessage: status === "error" ? (result.importantLogLines.at(-1) ?? null) : null,
});
recordEvent({
machineId,
eventType: `action_${action}`,
severity: status === "error" ? "error" : status === "warning" ? "warning" : "info",
executionId,
message: `Action ${action} : ${status}`,
});
```
- [ ] **Step 6 : Vérifier compilation + tests**
Run: `rtk pnpm check && rtk pnpm test`
Expected: 0 erreur TS ; suite complète verte.
- [ ] **Step 7 : (pas de commit)**
---
## Task 5 : Vérification finale Phase 1
**Files:** aucun (vérification).
- [ ] **Step 1 : Suite + build**
Run: `rtk pnpm check && rtk pnpm test && rtk pnpm build`
Expected: 0 erreur TS ; tests verts (jalon 1 + schema migration + machineState + helpers existants) ; `dist/index.js` + `dist/client` produits.
- [ ] **Step 2 : Démarrage runtime + migration appliquée**
Run (clé jetable, DB jetable) :
```bash
export SU_MASTER_KEY=$(openssl rand -hex 32) SU_DB_PATH=./data/phase1-check.db SU_REPORTS_DIR=./data/phase1-reports
node dist/index.js > ./data/phase1.log 2>&1 &
sleep 3
curl -s localhost:8787/health
sqlite3 ./data/phase1-check.db ".tables" 2>/dev/null || echo "(sqlite3 absent — vérifier via le test de migration)"
kill %1 2>/dev/null
rm -rf ./data/phase1-check.db* ./data/phase1-reports ./data/phase1.log
```
Expected: `{"ok":true}` et la liste des tables inclut `machine_state`, `machine_events`, `reports`, `raw_artifacts`, etc. (migration appliquée au boot via `runMigrations()`).
- [ ] **Step 3 : Synthèse à l'utilisateur**
Reporter : tables/colonnes ajoutées, `machine_state`/`machine_events`/`reports`/`raw_artifacts` peuplés lors des refresh/exécutions, non-régression confirmée. **Ne pas committer** (l'utilisateur gère les commits en fin de parcours).
---
## Self-Review (couverture tache1.9 §14 Phase 1)
- machine_state → Task 1 (table) + Task 2/3/4 (peuplement). ✓
- machine_kind/virtualization/hardware_profile dans machines → Task 1. ✓
- machine_hardware → Task 1 (table ; producteur = tâche 4, hors Phase 1). ✓
- machine_metrics_latest → Task 1 (table ; producteur = tâche 4). ✓
- machine_events → Task 1 + Task 3/4 (peuplement). ✓
- important_messages → Task 1 (table ; peuplement fin = tâche 5/7, l'`important_json` du snapshot/exécution est déjà capturé). ✓
- reports → Task 1 + Task 4 (peuplement depuis le rapport déjà écrit). ✓
- raw_artifacts → Task 1 + Task 4 (peuplement depuis le log déjà écrit). ✓
- snapshots.kind/schema_version/important_json → Task 1 + Task 3. ✓
- executions.schema_version/important_json/error_kind/error_message → Task 1 + Task 4. ✓
Décision assumée (rétro-compat) : `snapshots.checked_at` conservé (non renommé en `created_at`) pour ne pas casser `refresh.ts`. Tables sans producteur en Phase 1 (`machine_hardware`, `machine_metrics_latest`, `important_messages`) créées vides, alimentées aux tâches 4/5/7 — conforme au principe « migration progressive ».
Pas de placeholder. Noms cohérents : `deriveAptState`/`upsertMachineState`/`recordEvent` définis Task 2 et utilisés Tasks 3-4 ; `reportId` défini Task 4 Step 4 et réutilisé Step 5.
@@ -0,0 +1,269 @@
# Tâche 1.9 — Phase 2 (sécurité credentials) — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development ou superpowers:executing-plans. Étapes en checkbox.
**Goal:** Isoler les secrets SSH dans une table dédiée `machine_credentials` (+ table `machine_host_keys`), de façon **non destructive** : nouvelle table, écriture dédiée, lecture prioritaire avec fallback sur `machines.enc_password`, et backfill des machines existantes.
**Architecture:** Ajout additif (Drizzle/SQLite, migration `0003`). `machines.enc_password`/`enc_sudo_password` sont CONSERVÉS (non droppés) comme fallback/legacy. Un service `credentials` écrit/lit `machine_credentials` ; `createMachine` y insère, `getCreds` lit `machine_credentials` puis retombe sur les colonnes `machines` si absent ; un backfill (idempotent) crée les lignes manquantes au démarrage. `machine_host_keys` est créée (schéma) pour la future vérification host key (pas de logique de vérif en Phase 2).
**Tech Stack:** Drizzle ORM, better-sqlite3, drizzle-kit, vitest.
---
## Invariants
- **Non destructif** : ne pas dropper `machines.enc_password`/`enc_sudo_password` (NOT NULL conservé). Pas de perte des machines réelles existantes.
- Secrets uniquement chiffrés (AES-256-GCM existant, `server/crypto/secrets.ts`). `machine_credentials` n'est JAMAIS exposée via l'API publique (la `MachineView` reste sans secret).
- Rétro-compatibilité : une machine sans ligne `machine_credentials` reste utilisable (fallback). Le backfill comble le manque.
- **Ne pas committer** (l'utilisateur gère les commits). Étapes « commit » remplacées par vérification.
- Tree partagé avec du WIP concurrent : ne toucher QUE `server/db/schema.ts`, migrations, `server/services/credentials.ts` (+test), `server/services/machines.ts`, et le point de backfill (`server/db/migrate.ts` ou `server/index.ts`). Relire chaque fichier avant édition (drift possible).
## File Structure
```
server/db/schema.ts # MODIF : +machine_credentials, +machine_host_keys
server/db/migrations/0003_*.sql # généré
server/services/credentials.ts # NOUVEAU : writeCredentials/readCreds/backfill
server/services/credentials.test.ts # NOUVEAU
server/services/machines.ts # MODIF : createMachine écrit credentials ; getCreds lit credentials+fallback
server/db/migrate.ts # MODIF : appeler backfill après migrate
```
---
## Task 1 : Tables `machine_credentials` + `machine_host_keys`
**Files:** Modify `server/db/schema.ts` ; generate migration ; extend `server/db/schema.test.ts`.
- [ ] **Step 1 : Relire le schéma réel**
Run: `rtk read server/db/schema.ts` (capter l'état courant, préserver tout l'existant : machines/snapshots/executions/apiClients + les 7 tables Phase 1).
- [ ] **Step 2 : Ajouter les deux tables** (à la fin de `schema.ts`, avant ou après `apiClients`, sans rien supprimer)
```ts
export const machineCredentials = sqliteTable("machine_credentials", {
machineId: text("machine_id").primaryKey().references(() => machines.id, { onDelete: "cascade" }),
authMethod: text("auth_method").notNull(), // password | ssh_key
encPassword: text("enc_password"),
encSudoPassword: text("enc_sudo_password"),
encPrivateKey: text("enc_private_key"),
encKeyPassphrase: text("enc_key_passphrase"),
sudoMode: text("sudo_mode").notNull(), // same_as_ssh | separate | none
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull(),
lastTestAt: text("last_test_at"),
status: text("status"), // ok | error | unknown
});
export const machineHostKeys = sqliteTable("machine_host_keys", {
id: text("id").primaryKey(),
machineId: text("machine_id").references(() => machines.id, { onDelete: "cascade" }),
hostname: text("hostname").notNull(),
port: integer("port").notNull(),
keyType: text("key_type"),
fingerprintSha256: text("fingerprint_sha256").notNull(),
publicKey: text("public_key"),
status: text("status").notNull(), // approved | changed | rejected | unknown
firstSeenAt: text("first_seen_at").notNull(),
lastSeenAt: text("last_seen_at").notNull(),
});
```
- [ ] **Step 3 : Générer la migration**
Run: `rtk pnpm db:generate`
Expected: `server/db/migrations/0003_*.sql` (CREATE TABLE machine_credentials + machine_host_keys uniquement). Vérifier qu'aucun DROP ni recréation de table existante n'apparaît (sinon corriger le schéma et régénérer).
- [ ] **Step 4 : Étendre `server/db/schema.test.ts`** — ajouter un test
```ts
it("crée les tables de credentials Phase 2", () => {
const sqlite = freshMigratedDb();
const tables = tableNames(sqlite);
expect(tables).toEqual(expect.arrayContaining(["machine_credentials", "machine_host_keys"]));
// machines conserve ses colonnes secrets legacy (fallback)
expect(columnNames(sqlite, "machines")).toContain("enc_password");
});
```
- [ ] **Step 5 :** Run `rtk pnpm vitest run server/db/schema.test.ts` → PASS. Puis `rtk pnpm check` → 0 erreur.
- [ ] **Step 6 : (pas de commit)**
---
## Task 2 : Service `credentials` (write / read / backfill) — TDD
**Files:** Create `server/services/credentials.ts`, `server/services/credentials.test.ts`.
- [ ] **Step 1 : Test (échec attendu)**`server/services/credentials.test.ts`
```ts
import { describe, it, expect } from "vitest";
import { resolveCreds } from "./credentials.js";
describe("resolveCreds", () => {
it("préfère la ligne machine_credentials", () => {
const out = resolveCreds(
{ encPassword: "M_PWD", encSudoPassword: null }, // machines (legacy)
{ encPassword: "C_PWD", encSudoPassword: "C_SUDO" }, // machine_credentials
);
expect(out).toEqual({ encPassword: "C_PWD", encSudoPassword: "C_SUDO" });
});
it("retombe sur machines si pas de credentials", () => {
const out = resolveCreds({ encPassword: "M_PWD", encSudoPassword: "M_SUDO" }, null);
expect(out).toEqual({ encPassword: "M_PWD", encSudoPassword: "M_SUDO" });
});
});
```
- [ ] **Step 2 :** Run `rtk pnpm vitest run server/services/credentials.test.ts` → FAIL (module manquant).
- [ ] **Step 3 : Implémenter `server/services/credentials.ts`**
```ts
// server/services/credentials.ts
import { eq } from "drizzle-orm";
import { db, schema } from "../db/client.js";
interface EncPair { encPassword: string | null; encSudoPassword: string | null; }
/** Résout la source des secrets : machine_credentials prioritaire, sinon legacy machines (fonction pure). */
export function resolveCreds(legacy: EncPair, creds: EncPair | null): EncPair {
if (creds && creds.encPassword) return { encPassword: creds.encPassword, encSudoPassword: creds.encSudoPassword };
return { encPassword: legacy.encPassword, encSudoPassword: legacy.encSudoPassword };
}
/** Écrit (insert/replace) la ligne machine_credentials pour une machine (secrets déjà chiffrés). */
export function writeCredentials(input: {
machineId: string;
encPassword: string | null;
encSudoPassword: string | null;
}): void {
const now = new Date().toISOString();
db.insert(schema.machineCredentials)
.values({
machineId: input.machineId,
authMethod: "password",
encPassword: input.encPassword,
encSudoPassword: input.encSudoPassword,
sudoMode: input.encSudoPassword ? "separate" : "same_as_ssh",
createdAt: now,
updatedAt: now,
status: "unknown",
})
.onConflictDoUpdate({
target: schema.machineCredentials.machineId,
set: { encPassword: input.encPassword, encSudoPassword: input.encSudoPassword, updatedAt: now },
})
.run();
}
/** Lit la ligne machine_credentials (ou null). */
export function readCredentials(machineId: string): EncPair | null {
const row = db.select().from(schema.machineCredentials)
.where(eq(schema.machineCredentials.machineId, machineId)).get();
return row ? { encPassword: row.encPassword, encSudoPassword: row.encSudoPassword } : null;
}
/** Backfill idempotent : crée une ligne machine_credentials pour chaque machine qui n'en a pas. */
export function backfillCredentials(): number {
const machines = db.select().from(schema.machines).all();
let created = 0;
for (const m of machines) {
if (readCredentials(m.id)) continue;
writeCredentials({ machineId: m.id, encPassword: m.encPassword, encSudoPassword: m.encSudoPassword });
created++;
}
return created;
}
```
- [ ] **Step 4 :** Run `rtk pnpm vitest run server/services/credentials.test.ts` → PASS (2). (Le test n'appelle que `resolveCreds`, pur ; pas de DB.)
- [ ] **Step 5 : (pas de commit)**
---
## Task 3 : Brancher dans `machines.ts` + backfill au démarrage
**Files:** Modify `server/services/machines.ts`, `server/db/migrate.ts`.
- [ ] **Step 1 : Relire `server/services/machines.ts`** (état réel : `getCreds`, `createMachine`).
- [ ] **Step 2 : `createMachine` écrit aussi machine_credentials**
Importer en tête : `import { writeCredentials, readCredentials, resolveCreds } from "./credentials.js";`
Après l'`insert` de la ligne `machines` (avant `return toView(row)`), ajouter :
```ts
writeCredentials({ machineId: id, encPassword: row.encPassword, encSudoPassword: row.encSudoPassword });
```
(On conserve aussi l'écriture dans `machines.enc_password` — non destructif.)
- [ ] **Step 3 : `getCreds` lit machine_credentials en priorité**
Remplacer le corps de `getCreds` par :
```ts
export function getCreds(m: MachineRow): SshCreds {
const key = env.requireMasterKey();
const { encPassword, encSudoPassword } = resolveCreds(
{ encPassword: m.encPassword, encSudoPassword: m.encSudoPassword },
readCredentials(m.id),
);
if (!encPassword) throw new Error("Aucun secret pour cette machine");
return {
hostname: m.hostname,
port: m.port,
username: m.username,
password: decryptSecret(encPassword, key),
sudoPassword: encSudoPassword ? decryptSecret(encSudoPassword, key) : null,
};
}
```
- [ ] **Step 4 : Backfill au démarrage** — dans `server/db/migrate.ts`, après `runMigrations()`, exposer et appeler le backfill. Modifier `runMigrations` pour enchaîner :
```ts
// server/db/migrate.ts
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { db } from "./client.js";
import { backfillCredentials } from "../services/credentials.js";
export function runMigrations(): void {
migrate(db, { migrationsFolder: "./server/db/migrations" });
const n = backfillCredentials();
if (n > 0) console.log(`[migrate] backfill credentials: ${n} machine(s)`);
}
```
- [ ] **Step 5 :** Run `rtk pnpm check && rtk pnpm test` → 0 erreur TS ; tests verts (48 attendus : +2 credentials). Si un test hors périmètre (WIP concurrent) casse, le signaler sans corriger.
- [ ] **Step 6 : (pas de commit)**
---
## Task 4 : Vérification finale Phase 2
- [ ] **Step 1 :** `rtk pnpm check && rtk pnpm test && rtk pnpm build` → tout vert + `dist` produit.
- [ ] **Step 2 : Boot + backfill + tables**
```bash
export SU_MASTER_KEY=$(openssl rand -hex 32) SU_DB_PATH=./data/p2.db SU_REPORTS_DIR=./data/p2-reports
node dist/index.js > ./data/p2.log 2>&1 &
sleep 3
curl -s localhost:8787/health
node -e "const D=require('better-sqlite3');const db=new D('./data/p2.db');console.log(db.prepare(\"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'machine_%'\").all().map(r=>r.name).join(', '));"
kill %1 2>/dev/null
rm -rf ./data/p2.db* ./data/p2-reports ./data/p2.log
```
Expected: `{"ok":true}` ; tables incluent `machine_credentials`, `machine_host_keys`. (Backfill = 0 sur DB neuve, normal.)
- [ ] **Step 3 :** Reporter à l'utilisateur (tables ajoutées, dual-read/backfill, non-régression). **Ne pas committer.**
---
## Self-Review (couverture tache1.9 §14 Phase 2)
- créer `machine_credentials` → Task 1. ✓
- migrer `enc_password`/`enc_sudo_password` → approche non destructive : dual-write + backfill + lecture prioritaire (Tasks 2-3). Les colonnes legacy restent comme fallback (drop = phase ultérieure de nettoyage). ✓
- créer `machine_host_keys` → Task 1 (schéma ; vérification host key = logique ultérieure). ✓
- audit événements secrets → léger : non inclus en Phase 2 (le `recordEvent` Phase 1 existe ; l'audit systématique des déchiffrements relève de tâche 7 sécurité). Noté comme suite.
Décision assumée : non destructif (pas de DROP des colonnes secrets de `machines`) pour protéger les machines réelles existantes. Noms cohérents : `resolveCreds`/`writeCredentials`/`readCredentials`/`backfillCredentials` définis Task 2, utilisés Task 3.
@@ -0,0 +1,408 @@
# Tâche 2 — SJ-0 (socle : types + réduction + résolution de profil) — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: subagent-driven-development / executing-plans. Étapes checkbox.
**Goal:** Poser le socle de la tâche 2, **purement additif** : étendre `shared/types.ts` (unions élargies + blocs optionnels, rétro-compatibles), enrichir le réducteur de lignes (préfixes Docker), et ajouter `resolveTemplate(action, osFamily)` avec fallback `base`. Aucun changement de wiring (refresh/execute inchangés).
**Architecture:** Extensions additives. Référence design : `docs/design/tache2/40-contrats-json.md` (types), `60-profils-os-machine.md` (résolution), `99-couverture-gate.md`. Tous les ajouts sont optionnels/élargis ⇒ un `UpdateSnapshot`/`ExecutionResult` du jalon 1 reste strictement valide (vérifié par `tsc`).
**Tech Stack:** TypeScript, vitest.
---
## Invariants
- **Rétro-compat stricte** : ne rien retirer/renommer. Préserver `MachineStatus`, `MachineView`, `ServerCapabilities` (WIP) et tout autre contenu actuel de `shared/types.ts`.
- **Aucun changement de comportement** : on n'altère PAS `refresh.ts`/`execute.ts` en SJ-0 (la bascule du refresh sur les nouveaux templates = SJ-1).
- Réducteur : **garder `reduceAptLines`** (imports existants dans refresh/execute) ; ajouter les préfixes Docker et un alias `reduceLines`. **Ne PAS renommer le fichier** `aptReduce.ts` (éviter de toucher les imports de refresh/execute — churn/concurrence).
- Tree partagé / WIP concurrent : ne toucher QUE `shared/types.ts`, `server/templates/aptReduce.ts` (+test), `server/templates/render.ts` (+ test resolveTemplate), et les fichiers de test. **Ne pas committer.**
## File Structure
```
shared/types.ts # MODIF : unions élargies + interfaces + champs optionnels
shared/types.test.ts # NOUVEAU : verrouille la rétro-compat (compile + runtime léger)
server/templates/aptReduce.ts # MODIF : préfixes Docker + alias reduceLines
server/templates/aptReduce.test.ts # MODIF : +cas Docker
server/templates/render.ts # MODIF : +resolveTemplate
server/templates/resolveTemplate.test.ts # NOUVEAU
```
---
## Task 1 : Étendre `shared/types.ts`
**Files:** Modify `shared/types.ts` ; Create `shared/types.test.ts`.
- [ ] **Step 1 : Relire le fichier réel** (`rtk read shared/types.ts`) pour repérer le contenu à préserver (`MachineStatus`, `MachineView`, `ServerCapabilities`, etc.).
- [ ] **Step 2 : Appliquer les extensions** (élargir les unions existantes, remplacer `AptPackage`/`UpdateSnapshot`/`ExecutionResult` par les versions étendues, AJOUTER les nouvelles interfaces). Ne pas supprimer l'existant. Contenu cible (depuis `docs/design/tache2/40-contrats-json.md`) :
```ts
export type OsFamily = "debian" | "ubuntu" | "proxmox" | "raspbian" | "unknown";
export type MachineKind =
| "physical" | "vm" | "proxmox_host" | "lxc"
| "raspberry_pi" | "workstation" | "unknown";
export type AptProxyMode = "direct" | "runtime" | "persistent";
export type ActionType =
| "apt_full_upgrade" | "reboot"
| "apt_update_analyze" | "apt_upgrade" | "apt_dist_upgrade"
| "apt_autoremove" | "apt_clean" | "reboot_verified"
| "docker_scan" | "docker_inspect_current" | "docker_pull_check"
| "docker_compose_apply" | "docker_prune_images" | "docker_compose_down"
| "machine_probe" | "post_install";
export type SnapshotStatus = "ok" | "updates_available" | "warning" | "error";
// ExecutionStatus, MachineStatus : INCHANGÉS (préserver l'existant)
export interface AptPackage {
name: string;
currentVersion: string | null;
targetVersion: string;
origin: string | null;
arch?: string;
operation?: "upgrade" | "install" | "remove" | "hold";
severityHint?: "normal" | "security";
}
export interface AptSnapshotDetail {
enabled: boolean;
count: number;
rebootRequired: boolean;
packages: AptPackage[];
status?: SnapshotStatus;
upgradeCount?: number;
distUpgradeCount?: number;
installed?: AptPackage[];
removed?: AptPackage[];
held?: string[];
rebootPkgs?: string[];
}
export interface DockerSnapshotService {
serviceName: string;
image: string;
currentImageId?: string | null;
currentDigest?: string | null;
candidateImageId?: string | null;
candidateDigest?: string | null;
currentVersion?: string | null;
candidateVersion?: string | null;
sourceUrl?: string | null;
status?: "up_to_date" | "updates_available" | "warning" | "error";
}
export interface DockerSnapshotStack {
name: string;
workingDir: string;
composeFiles: string[];
projectName?: string | null;
status: "candidate" | "enabled" | "ignored" | "error";
detectedBy?: "root_scan" | "label" | "manual";
services: DockerSnapshotService[];
}
export interface DockerSnapshot {
enabled: boolean;
installed: boolean;
count: number;
declaredRoots?: string[];
stacks: DockerSnapshotStack[];
status?: SnapshotStatus;
}
export interface SnapshotError {
source: "apt" | "docker" | "post_install" | "ssh" | "system";
kind: string;
severity: "info" | "warning" | "error";
message: string;
remediation?: string;
importantLines?: string[];
}
export interface UpdateSnapshot {
machineId: string;
hostname: string;
os: { family: OsFamily; version: string };
checkedAt: string;
status: MachineStatus;
apt: AptSnapshotDetail;
schemaVersion?: number;
kind?: "apt_update_analyze" | "docker_scan" | "reboot_check" | "combined";
machineKind?: MachineKind;
docker?: DockerSnapshot;
errors?: SnapshotError[];
rawHints?: { logImportantLines: string[] };
}
export interface AptChange {
name: string;
arch?: string;
fromVersion: string | null;
toVersion: string | null;
operation: "upgraded" | "installed" | "removed" | "unchanged";
origin?: string | null;
}
export interface AptExecutionResult {
planned: AptPackage[];
applied: AptChange[];
installed: AptChange[];
removed: AptChange[];
held: string[];
errors?: SnapshotError[];
rebootRequiredAfterRun: boolean;
}
export interface DockerImageChange {
stack: string;
serviceName?: string;
imageRef?: string;
fromImageId?: string | null;
toImageId?: string | null;
fromDigest?: string | null;
toDigest?: string | null;
operation: "pulled" | "recreated" | "pruned";
}
export interface DockerExecutionResult {
pull?: { changes: DockerImageChange[]; errors?: SnapshotError[] };
up?: { recreated: string[]; running: string[]; exited: string[]; errors?: SnapshotError[] };
prune?: { imagesDeleted: string[]; bytesReclaimed: number; errors?: SnapshotError[] };
errors?: SnapshotError[];
}
export interface RebootResult {
beforeBootId: string | null;
afterBootId: string | null;
requestedAt: string;
sshWentDownAt: string | null;
sshCameBackAt: string | null;
waitedSeconds: number;
status: "ok" | "reboot_command_failed" | "ssh_never_went_down"
| "machine_did_not_return" | "boot_id_unchanged" | "timeout";
lastRebootDurationSeconds?: number;
nextRecommendedWaitSeconds?: number;
errors?: SnapshotError[];
}
export interface PostInstallResult {
profilesRun: string[];
variablesUsed: Record<string, string | number | boolean>;
filesModified: string[];
packagesInstalled: string[];
servicesEnabled: string[];
rebootsRequested: boolean;
networkChange?: { oldEndpoint: string | null; newEndpoint: string | null; reconnectHost: string | null };
errors?: SnapshotError[];
}
export interface ExecutionResult {
executionId: string;
machineId: string;
startedAt: string;
finishedAt: string;
mode: "manual" | "scheduled" | "hermes_requested";
action: ActionType;
status: ExecutionStatus;
rebootRequiredAfterRun: boolean;
importantLogLines: string[];
rawLogRef: string;
reportRef: string;
schemaVersion?: number;
apt?: AptExecutionResult;
docker?: DockerExecutionResult;
reboot?: RebootResult;
postInstall?: PostInstallResult;
errors?: SnapshotError[];
}
```
> Préserver `MachineStatus`, `MachineView`, `ServerCapabilities` et tout autre contenu présent. Le bloc `apt` de `UpdateSnapshot` reste **requis** (forme jalon 1) ; `mode` de `ExecutionResult` était le littéral `"manual"` → l'union l'inclut.
- [ ] **Step 3 : Test de rétro-compat `shared/types.test.ts`**
```ts
import { describe, it, expect } from "vitest";
import type { UpdateSnapshot, ExecutionResult } from "./types.js";
describe("rétro-compatibilité des contrats", () => {
it("un snapshot jalon 1 (sans blocs optionnels) reste valide", () => {
const snap: UpdateSnapshot = {
machineId: "m1", hostname: "h", os: { family: "debian", version: "12" },
checkedAt: "2026-06-05T10:00:00Z", status: "ok",
apt: { enabled: true, count: 0, rebootRequired: false, packages: [] },
};
expect(snap.apt.count).toBe(0);
});
it("une exécution jalon 1 (mode manual, sans blocs) reste valide", () => {
const exec: ExecutionResult = {
executionId: "e1", machineId: "m1", startedAt: "a", finishedAt: "b",
mode: "manual", action: "apt_full_upgrade", status: "ok",
rebootRequiredAfterRun: false, importantLogLines: [], rawLogRef: "r", reportRef: "rr",
};
expect(exec.action).toBe("apt_full_upgrade");
});
it("accepte les nouveaux blocs optionnels", () => {
const snap: UpdateSnapshot = {
machineId: "m1", hostname: "h", os: { family: "proxmox", version: "8" },
checkedAt: "t", status: "updates_available",
apt: { enabled: true, count: 1, rebootRequired: false, packages: [], status: "updates_available" },
schemaVersion: 1, kind: "apt_update_analyze", machineKind: "proxmox_host",
docker: { enabled: false, installed: false, count: 0, stacks: [] },
errors: [],
};
expect(snap.docker?.installed).toBe(false);
});
});
```
- [ ] **Step 4 :** Run `rtk pnpm vitest run shared/types.test.ts` → PASS (3). Puis `rtk pnpm check`**0 erreur** (c'est le vrai test de rétro-compat : si un consommateur existant casse à cause d'un retrait/renommage, tsc le révèle). Si `check` signale une erreur dans un fichier consommateur (`refresh.ts`/`execute.ts`/`machines.ts`/WIP) causée par TON changement de types, corrige le type (rends additif) — ne casse pas les consommateurs.
- [ ] **Step 5 : (pas de commit)**
---
## Task 2 : Réducteur enrichi (préfixes Docker)
**Files:** Modify `server/templates/aptReduce.ts`, `server/templates/aptReduce.test.ts`.
- [ ] **Step 1 : Relire `server/templates/aptReduce.ts`** (état réel).
- [ ] **Step 2 : Ajouter un cas Docker au test `aptReduce.test.ts`**
```ts
it("garde aussi les lignes Docker utiles", () => {
const raw = [
"Pulling jellyfin ...",
"Status: Downloaded newer image for jellyfin/jellyfin:latest",
"Recreating jellyfin ...",
"Started jellyfin",
"blabla inutile",
"Total reclaimed space: 1.2GB",
].join("\n");
expect(reduceLines(raw)).toEqual([
"Pulling jellyfin ...",
"Status: Downloaded newer image for jellyfin/jellyfin:latest",
"Recreating jellyfin ...",
"Started jellyfin",
"Total reclaimed space: 1.2GB",
]);
});
```
Ajouter `reduceLines` à l'import existant : `import { reduceAptLines, reduceLines } from "./aptReduce.js";`
- [ ] **Step 3 : Lancer (échec attendu)**`rtk pnpm vitest run server/templates/aptReduce.test.ts` → FAIL (`reduceLines` introuvable / lignes Docker non gardées).
- [ ] **Step 4 : Étendre `server/templates/aptReduce.ts`**
```ts
// server/templates/aptReduce.ts
const PREFIXES = [
// APT / dpkg (jalon 1)
"Inst ", "Conf ", "Remv ", "Err ", "E:", "W:", "dpkg:",
// Docker (SJ-0)
"Pulling", "Digest", "Status", "Downloaded newer image", "Recreating", "Started", "Error",
];
const CONTAINS = [
"reboot-required", "REBOOT_REQUIRED",
"deleted", "Total reclaimed space",
];
/** Garde uniquement les lignes informatives (APT + Docker) d'une sortie brute. */
export function reduceLines(raw: string): string[] {
return raw
.split("\n")
.map((l) => l.trimEnd())
.filter((l) => PREFIXES.some((p) => l.startsWith(p)) || CONTAINS.some((c) => l.includes(c)));
}
/** Alias rétro-compatible (jalon 1) : même comportement, conserve les imports existants. */
export const reduceAptLines = reduceLines;
```
> Garder l'export `reduceAptLines` (utilisé par `refresh.ts`/`execute.ts`). `reduceLines` est le nouveau nom canonique.
- [ ] **Step 5 :** Run `rtk pnpm vitest run server/templates/aptReduce.test.ts` → PASS (cas APT existants + nouveau cas Docker). `rtk pnpm check` → 0 erreur.
- [ ] **Step 6 : (pas de commit)**
---
## Task 3 : `resolveTemplate(action, osFamily)`
**Files:** Modify `server/templates/render.ts` ; Create `server/templates/resolveTemplate.test.ts`.
- [ ] **Step 1 : Relire `server/templates/render.ts`** (état réel : `TEMPLATES_ROOT`, `renderTemplate`, `TemplateVars`).
- [ ] **Step 2 : Test `server/templates/resolveTemplate.test.ts`**
```ts
import { describe, it, expect } from "vitest";
import { resolveTemplate } from "./render.js";
describe("resolveTemplate", () => {
it("retombe sur apt/ quand aucun dossier OS spécifique n'existe (fonction exists fournie)", () => {
const noneExist = () => false;
expect(resolveTemplate("full-upgrade", "proxmox", noneExist)).toBe("apt/full-upgrade.sh.tpl");
expect(resolveTemplate("update-analyze", "debian", noneExist)).toBe("apt/update-analyze.sh.tpl");
});
it("choisit le template OS spécifique quand il existe", () => {
const proxmoxExists = (rel: string) => rel === "proxmox/full-upgrade.sh.tpl";
expect(resolveTemplate("full-upgrade", "proxmox", proxmoxExists)).toBe("proxmox/full-upgrade.sh.tpl");
});
it("unknown retombe toujours sur apt/", () => {
const all = () => true;
expect(resolveTemplate("clean", "unknown", all)).toBe("apt/clean.sh.tpl");
});
});
```
- [ ] **Step 3 : Lancer (échec)**`rtk pnpm vitest run server/templates/resolveTemplate.test.ts` → FAIL.
- [ ] **Step 4 : Ajouter `resolveTemplate` à `server/templates/render.ts`** (sans toucher `renderTemplate`/`TemplateVars` existants ; ajouter l'import `existsSync`) :
```ts
import { existsSync } from "node:fs";
// ... (TEMPLATES_ROOT, renderTemplate existants inchangés) ...
/** Existence par défaut d'un template relatif à templates/. */
function defaultExists(rel: string): boolean {
return existsSync(resolve(TEMPLATES_ROOT, rel));
}
/**
* Résout le chemin de template le plus spécifique pour (action, OS) :
* `<osFamily>/<action>.sh.tpl` s'il existe, sinon fallback base `apt/<action>.sh.tpl`.
* `exists` est injectable pour les tests.
*/
export function resolveTemplate(
action: string,
osFamily: string,
exists: (rel: string) => boolean = defaultExists,
): string {
const specific = `${osFamily}/${action}.sh.tpl`;
if (osFamily !== "unknown" && osFamily !== "apt" && exists(specific)) return specific;
return `apt/${action}.sh.tpl`;
}
```
> Note : `renderTemplate` accepte déjà un `relPath` (ex. `apt/full-upgrade.sh.tpl`), donc `renderTemplate(resolveTemplate(action, osFamily), vars)` fonctionnera en SJ-1 sans modifier `renderTemplate`.
- [ ] **Step 5 :** Run `rtk pnpm vitest run server/templates/resolveTemplate.test.ts` → PASS (3). `rtk pnpm check` → 0 erreur.
- [ ] **Step 6 : (pas de commit)**
---
## Task 4 : Vérification finale SJ-0
- [ ] **Step 1 :** `rtk pnpm check && rtk pnpm test && rtk pnpm build`
Expected: 0 erreur TS ; tous tests verts (49 Phase 2 + 3 types + 1 Docker reduce + 3 resolveTemplate ≈ 56) ; build OK.
- [ ] **Step 2 :** Reporter : types étendus rétro-compatibles (tsc vert = preuve), réducteur Docker, `resolveTemplate` prêt pour SJ-1. **Ne pas committer.**
---
## Self-Review (couverture SJ-0)
- Types étendus (unions + blocs optionnels) → Task 1, rétro-compat verrouillée par `tsc` + test. ✓
- Réducteur + préfixes Docker → Task 2 (`reduceLines` + alias `reduceAptLines` conservé). ✓
- `resolveTemplate(action, osFamily)` + fallback base → Task 3. ✓
- `schemaVersion` → présent dans `UpdateSnapshot`/`ExecutionResult` (optionnel). ✓
- Aucun wiring modifié (refresh/execute intacts) ⇒ non-régression jalon 1. ✓
Décisions assumées : fichier `aptReduce.ts` NON renommé (alias `reduceLines` ajouté) pour éviter de toucher les imports de refresh/execute (churn/concurrence) — le nom canonique `reduceLines` est exporté ; renommage physique reporté à un nettoyage ultérieur. `resolveTemplate` avec `exists` injectable pour testabilité des deux branches.
@@ -0,0 +1,349 @@
# Tâche 2 — SJ-1 (APT update/analyse enrichi) — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: subagent-driven-development / executing-plans. Étapes checkbox.
**Goal:** Introduire `apt/update-analyze.sh.tpl` (refresh index + simulations `upgrade` et `dist-upgrade` + held + reboot-check, non destructif), son parsing enrichi (`AptSnapshotDetail` : upgrade/dist-upgrade/installed/removed/held/rebootPkgs + statut `ok|updates_available|warning|error`), et **basculer `refreshMachine` dessus** via `resolveTemplate`, en conservant `check.sh.tpl`.
**Architecture:** Additif. Référence design : `docs/design/tache2/10-templates-apt.md §4.1` (template) et `40-contrats-json.md §3` (`AptSnapshotDetail`). Le parsing est en TS (réutilise `parseAptSimulate` SJ-0/jalon 1) ; `buildAptSnapshotDetail` est une fonction pure testée sur fixtures. Le refresh bascule sur le nouveau template via `resolveTemplate("update-analyze", osFamily)` (fallback `apt/`). `check.sh.tpl` reste en place (non supprimé). Aucune rupture : `snapshot.apt` garde ses champs jalon 1 (enabled/count/rebootRequired/packages) + champs additifs.
**Tech Stack:** TypeScript, Mustache, vitest.
---
## Invariants
- `snapshot.apt` reste de forme jalon 1 (champs requis présents) ; on l'enrichit via les champs optionnels de `AptSnapshotDetail` (SJ-0).
- `MachineStatus` (union jalon 1, sans "warning") **inchangée** : le statut `warning` vit dans `snapshot.apt.status` ; `snapshot.status` (MachineStatus) mappe warning→`updates_available`.
- `check.sh.tpl` conservé. Wiring : seul `refreshMachine` bascule sur `update-analyze`.
- Tree partagé / WIP concurrent : ne toucher QUE `server/services/aptParse.ts` (+test/fixtures), `templates/apt/update-analyze.sh.tpl`, `server/services/refresh.ts`, `server/templates/render.test.ts` éventuel. **Ne pas committer.**
## File Structure
```
server/services/aptParse.ts # MODIF : +parseAptRemovals/parseHeld/parseRebootDetail/buildAptSnapshotDetail
server/services/aptParse.test.ts # MODIF : +tests build detail
server/services/__fixtures__/apt-update-analyze.txt # NOUVEAU : sortie complète du template
templates/apt/update-analyze.sh.tpl # NOUVEAU
server/services/refresh.ts # MODIF : bascule sur update-analyze + detail
```
---
## Task 1 : Parsing enrichi APT (TDD)
**Files:** Modify `server/services/aptParse.ts`, `server/services/aptParse.test.ts` ; Create `server/services/__fixtures__/apt-update-analyze.txt`.
- [ ] **Step 1 : Créer la fixture `server/services/__fixtures__/apt-update-analyze.txt`**
```
===SU:APT_UPDATE===
Hit:1 http://deb.debian.org/debian bookworm InRelease
Reading package lists...
===SU:APT_SIM_UPGRADE===
Reading package lists...
Building dependency tree...
Inst libc6 [2.31-13] (2.31-13+deb11u5 Debian:11.6/stable [amd64])
Conf libc6 (2.31-13+deb11u5 Debian:11.6/stable [amd64])
===SU:APT_SIM_DISTUPGRADE===
Reading package lists...
Inst libc6 [2.31-13] (2.31-13+deb11u5 Debian:11.6/stable [amd64])
Inst newdep (1.0.0 Debian:11.6/stable [all])
Remv oldpkg [3.2-1]
Conf libc6 (2.31-13+deb11u5 Debian:11.6/stable [amd64])
===SU:APT_HELD===
frozenpkg
===SU:REBOOT===
REBOOT_REQUIRED=1
PKG=linux-image-amd64
===SU:EXIT=0===
```
- [ ] **Step 2 : Écrire le test (échec attendu)** — ajouter à `server/services/aptParse.test.ts`
```ts
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { parseAptRemovals, parseHeld, parseRebootDetail, buildAptSnapshotDetail } from "./aptParse.js";
const ua = readFileSync(fileURLToPath(new URL("./__fixtures__/apt-update-analyze.txt", import.meta.url)), "utf8");
function section(raw: string, start: string, end: string): string {
const s = raw.indexOf(start); if (s === -1) return "";
const from = s + start.length; const e = raw.indexOf(end, from);
return raw.slice(from, e === -1 ? undefined : e).trim();
}
describe("parseAptRemovals", () => {
it("extrait les suppressions Remv", () => {
expect(parseAptRemovals("Remv oldpkg [3.2-1]\nInst x [1] (2 Y [amd64])"))
.toEqual([{ name: "oldpkg", currentVersion: "3.2-1" }]);
});
});
describe("parseHeld", () => {
it("liste les paquets retenus non vides", () => {
expect(parseHeld("frozenpkg\n\n other ")).toEqual(["frozenpkg", "other"]);
});
});
describe("parseRebootDetail", () => {
it("lit le flag et les paquets reboot", () => {
expect(parseRebootDetail("REBOOT_REQUIRED=1\nPKG=linux-image-amd64\nPKG=foo"))
.toEqual({ rebootRequired: true, pkgs: ["linux-image-amd64", "foo"] });
expect(parseRebootDetail("REBOOT_REQUIRED=0")).toEqual({ rebootRequired: false, pkgs: [] });
});
});
describe("buildAptSnapshotDetail", () => {
it("construit le détail enrichi depuis les sections", () => {
const detail = buildAptSnapshotDetail({
upgradeSim: section(ua, "===SU:APT_SIM_UPGRADE===", "===SU:APT_SIM_DISTUPGRADE==="),
distUpgradeSim: section(ua, "===SU:APT_SIM_DISTUPGRADE===", "===SU:APT_HELD==="),
heldRaw: section(ua, "===SU:APT_HELD===", "===SU:REBOOT==="),
rebootRaw: section(ua, "===SU:REBOOT===", "===SU:EXIT"),
updateFailed: false,
});
expect(detail.enabled).toBe(true);
expect(detail.count).toBe(2); // 2 Inst en dist-upgrade
expect(detail.upgradeCount).toBe(1); // 1 Inst en upgrade
expect(detail.distUpgradeCount).toBe(2);
expect(detail.rebootRequired).toBe(true);
expect(detail.rebootPkgs).toEqual(["linux-image-amd64"]);
expect(detail.held).toEqual(["frozenpkg"]);
expect(detail.removed?.map((r) => r.name)).toEqual(["oldpkg"]);
expect(detail.installed?.map((p) => p.name)).toEqual(["newdep"]);
expect(detail.status).toBe("warning"); // car removed + held non vides
});
it("status=updates_available sans removed/held, error si update échoue", () => {
const ok = buildAptSnapshotDetail({ upgradeSim: "Inst a [1] (2 Y [amd64])", distUpgradeSim: "Inst a [1] (2 Y [amd64])", heldRaw: "", rebootRaw: "REBOOT_REQUIRED=0", updateFailed: false });
expect(ok.status).toBe("updates_available");
const err = buildAptSnapshotDetail({ upgradeSim: "", distUpgradeSim: "", heldRaw: "", rebootRaw: "REBOOT_REQUIRED=0", updateFailed: true });
expect(err.status).toBe("error");
});
});
```
- [ ] **Step 3 : Lancer (échec)**`rtk pnpm vitest run server/services/aptParse.test.ts` → FAIL.
- [ ] **Step 4 : Étendre `server/services/aptParse.ts`** (garder `parseAptSimulate`/`parseRebootRequired` existants ; ajouter) :
```ts
import type { AptPackage, AptSnapshotDetail, SnapshotStatus } from "@shared/types.js";
// ... (parseAptSimulate, parseRebootRequired existants conservés) ...
const REMV_RE = /^Remv (\S+)(?: \[([^\]]+)\])?/;
export function parseAptRemovals(raw: string): { name: string; currentVersion: string | null }[] {
const out: { name: string; currentVersion: string | null }[] = [];
for (const line of raw.split("\n")) {
const m = REMV_RE.exec(line.trimEnd());
if (m) out.push({ name: m[1]!, currentVersion: m[2] ?? null });
}
return out;
}
export function parseHeld(raw: string): string[] {
return raw.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
}
export function parseRebootDetail(raw: string): { rebootRequired: boolean; pkgs: string[] } {
const rebootRequired = /REBOOT_REQUIRED=1/.test(raw);
const pkgs: string[] = [];
for (const line of raw.split("\n")) {
const m = /^PKG=(.+)$/.exec(line.trim());
if (m) pkgs.push(m[1]!.trim());
}
return { rebootRequired, pkgs };
}
export interface AptSections {
upgradeSim: string;
distUpgradeSim: string;
heldRaw: string;
rebootRaw: string;
updateFailed: boolean;
}
export function buildAptSnapshotDetail(s: AptSections): AptSnapshotDetail {
const upgradePkgs = parseAptSimulate(s.upgradeSim);
const distPkgs = parseAptSimulate(s.distUpgradeSim);
const installed: AptPackage[] = distPkgs
.filter((p) => p.currentVersion === null)
.map((p) => ({ ...p, operation: "install" }));
const removed: AptPackage[] = parseAptRemovals(s.distUpgradeSim).map((r) => ({
name: r.name, currentVersion: r.currentVersion, targetVersion: "", origin: null, operation: "remove",
}));
const held = parseHeld(s.heldRaw);
const { rebootRequired, pkgs: rebootPkgs } = parseRebootDetail(s.rebootRaw);
let status: SnapshotStatus = "ok";
if (s.updateFailed) status = "error";
else if (removed.length > 0 || held.length > 0) status = "warning";
else if (distPkgs.length > 0) status = "updates_available";
return {
enabled: true,
count: distPkgs.length,
rebootRequired,
packages: distPkgs,
status,
upgradeCount: upgradePkgs.length,
distUpgradeCount: distPkgs.length,
installed,
removed,
held,
rebootPkgs,
};
}
```
- [ ] **Step 5 : Lancer (succès)**`rtk pnpm vitest run server/services/aptParse.test.ts` → PASS. `rtk pnpm check` → 0 erreur.
- [ ] **Step 6 : (pas de commit)**
---
## Task 2 : Template `apt/update-analyze.sh.tpl`
**Files:** Create `templates/apt/update-analyze.sh.tpl`.
- [ ] **Step 1 : Créer le template** (depuis `10-templates-apt.md §4.1`)
```sh
#!/bin/sh
# Refresh index + simulations upgrade/dist-upgrade + held + reboot-check.
# Exécuté entier sous sudo par la couche SSH. Non destructif.
export LC_ALL=C
export DEBIAN_FRONTEND=noninteractive
{{#aptProxy}}export http_proxy="{{aptProxy}}"; export https_proxy="{{aptProxy}}"
{{/aptProxy}}
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}==="
```
- [ ] **Step 2 : Vérifier le rendu**`rtk pnpm vitest run server/templates/render.test.ts` reste vert (le test existant porte sur `check.sh.tpl` ; pas de régression). Optionnellement ajouter un cas :
```ts
it("rend update-analyze.sh.tpl avec les sections attendues", () => {
const out = renderTemplate("apt/update-analyze.sh.tpl", { aptProxy: null });
expect(out).toContain("===SU:APT_SIM_DISTUPGRADE===");
expect(out).toContain("apt-mark showhold");
});
```
- [ ] **Step 3 : (pas de commit)**
---
## Task 3 : Basculer `refreshMachine` sur update-analyze
**Files:** Modify `server/services/refresh.ts`.
- [ ] **Step 1 : Relire `server/services/refresh.ts`** (état réel, incl. wiring Phase 1 machine_state/event).
- [ ] **Step 2 : Adapter les imports**
```ts
import { renderTemplate, resolveTemplate } from "../templates/render.js";
import {
parseAptSimulate, parseRebootRequired, // existants (peuvent rester importés)
buildAptSnapshotDetail,
} from "./aptParse.js";
import type { UpdateSnapshot, MachineStatus, AptSnapshotDetail } from "@shared/types.js";
```
- [ ] **Step 3 : Remplacer la construction du snapshot** dans `refreshMachine`. Remplacer le rendu + le parsing actuels (`check.sh.tpl`, `extractSection(...SIMULATE...)`) par :
```ts
const proxy = m.aptProxyMode === "runtime" ? m.aptProxyUrl : null;
const script = renderTemplate(resolveTemplate("update-analyze", m.osFamily), { aptProxy: proxy });
let raw = "";
try {
const res = await runScriptSudo(getCreds(m), script, (c) => {
raw += c;
outputHub.publish(machineId, c);
});
raw = res.stdout;
} catch (err) {
db.update(schema.machines).set({ status: "error" }).where(eq(schema.machines.id, machineId)).run();
throw err;
}
const updateExit = /===SU:EXIT=(\d+)===/.exec(raw);
const detail: AptSnapshotDetail = buildAptSnapshotDetail({
upgradeSim: extractSection(raw, "===SU:APT_SIM_UPGRADE===", "===SU:APT_SIM_DISTUPGRADE==="),
distUpgradeSim: extractSection(raw, "===SU:APT_SIM_DISTUPGRADE===", "===SU:APT_HELD==="),
heldRaw: extractSection(raw, "===SU:APT_HELD===", "===SU:REBOOT==="),
rebootRaw: extractSection(raw, "===SU:REBOOT===", "===SU:EXIT"),
updateFailed: updateExit ? Number(updateExit[1]) !== 0 : false,
});
// MachineStatus n'a pas "warning" : warning => updates_available côté machine.
const status: MachineStatus =
detail.status === "error" ? "error" : detail.count > 0 || detail.status === "warning" ? "updates_available" : "ok";
const checkedAt = new Date().toISOString();
const snapshot: UpdateSnapshot = {
machineId,
hostname: m.hostname,
os: { family: m.osFamily as UpdateSnapshot["os"]["family"], version: m.osVersion ?? "" },
checkedAt,
status,
apt: detail,
schemaVersion: 1,
kind: "apt_update_analyze",
rawHints: { logImportantLines: reduceAptLines(raw) },
};
```
> Conserver ensuite TOUT le bloc Phase 1 inchangé : insertion du snapshot (`kind`/`schemaVersion`/`importantJson`), update `machines`, `upsertMachineState(machineId, deriveAptState(snapshot))`, `recordEvent(...)`, `return snapshot;`. `deriveAptState` lit `snapshot.status`/`apt.count`/`apt.rebootRequired`/`checkedAt` — inchangé.
- [ ] **Step 4 : Vérifier**`rtk pnpm check && rtk pnpm vitest run server/services/refresh.test.ts server/services/aptParse.test.ts` → 0 erreur, tests verts (`extractSection` + parsing). Note : `check.sh.tpl` n'est plus référencé par le refresh mais reste sur disque (non supprimé), comme prévu.
- [ ] **Step 5 : (pas de commit)**
---
## Task 4 : Vérification finale SJ-1
- [ ] **Step 1 :** `rtk pnpm check && rtk pnpm test && rtk pnpm build` → 0 erreur, tous tests verts, build OK.
- [ ] **Step 2 : Boot smoke** (DB jetable) — confirmer que le serveur démarre (`/health`) avec le refresh branché sur le nouveau template (pas d'exécution SSH réelle ici) :
```bash
export SU_MASTER_KEY=$(openssl rand -hex 32) SU_DB_PATH=./data/sj1.db SU_REPORTS_DIR=./data/sj1-reports
node dist/index.js > ./data/sj1.log 2>&1 &
sleep 3; curl -s localhost:8787/health; kill %1 2>/dev/null
rm -rf ./data/sj1.db* ./data/sj1-reports ./data/sj1.log
```
Expected: `{"ok":true}`.
- [ ] **Step 3 :** Reporter. Note pour l'utilisateur : la **vérif live** (refresh réel sur une machine Debian) confirmera le parsing des vraies sorties `apt-get -s`. **Ne pas committer.**
---
## Self-Review (couverture SJ-1)
- `apt/update-analyze.sh.tpl` (update + sim upgrade + sim dist-upgrade + held + reboot-check) → Task 2. ✓
- parsing des sections + `AptSnapshotDetail` enrichi (upgrade/dist/installed/removed/held/rebootPkgs + status) → Task 1 (TDD fixtures). ✓
- statut `ok|updates_available|warning|error``buildAptSnapshotDetail`. ✓
- bascule du refresh sur update-analyze (via `resolveTemplate`), `check.sh.tpl` conservé → Task 3. ✓
- non-régression : `snapshot.apt` garde la forme jalon 1 ; `MachineStatus` inchangée (warning→updates_available) ; machine_state/events Phase 1 préservés. ✓
Décision : `count = distUpgradeCount` (toutes les mises à jour disponibles, cohérent avec le jalon 1 qui comptait la simulation full-upgrade). `warning` (removed/held) exposé dans `apt.status`, mappé `updates_available` pour `machine.status`. Noms cohérents : `parseAptRemovals`/`parseHeld`/`parseRebootDetail`/`buildAptSnapshotDetail` définis Task 1, utilisés Task 3.
@@ -0,0 +1,325 @@
# Tâche 2 — SJ-2 (APT apply + diff dpkg réel) — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: subagent-driven-development / executing-plans. Étapes checkbox.
**Goal:** Enrichir `apt/full-upgrade.sh.tpl` du snapshot dpkg avant/après, ajouter `apt/upgrade.sh.tpl`, `apt/autoremove.sh.tpl`, `apt/clean.sh.tpl`, calculer le **diff dpkg réel** (`AptExecutionResult` : applied/installed/removed), brancher les actions `apt_upgrade`/`apt_autoremove`/`apt_clean` (+ `apt_full_upgrade` enrichi) dans `runAction`, et ajouter un **timeout d'inactivité** optionnel à la couche SSH.
**Architecture:** Additif. Référence : `docs/design/tache2/10-templates-apt.md §4.2-4.4`, `40-contrats-json.md §4` (`AptExecutionResult`/`AptChange`), `50-erreurs.md` (`human_interaction_required`). Le diff dpkg est calculé en TS (`buildAptExecutionResult`, pure, TDD). `runScriptSudo` reçoit une option `inactivityTimeoutMs` (défaut 0 = désactivé ⇒ comportement jalon 1 inchangé) ; `runAction` la passe (600000) pour les actions APT. Les confirmations UI des suppressions relèvent de la tâche 3 ; SJ-2 expose `removed[]` dans le résultat.
**Tech Stack:** TypeScript, Mustache, ssh2, vitest.
---
## Invariants
- `apt_full_upgrade` et `reboot` (jalon 1) restent fonctionnels ; on **enrichit** sans casser le parsing exit/reboot existant de `execute.ts`.
- `runScriptSudo` : nouveau paramètre **optionnel** `inactivityTimeoutMs` (défaut 0 = pas de timeout) ⇒ `refreshMachine` et tout appelant existant **inchangés** de comportement.
- `ExecutionResult.apt` est optionnel (SJ-0) ⇒ une exécution sans diff reste valide.
- Tree partagé / WIP concurrent : ne toucher QUE `server/services/aptParse.ts` (+test/fixtures), `templates/apt/{full-upgrade,upgrade,autoremove,clean}.sh.tpl`, `server/ssh/client.ts`, `server/services/execute.ts`. **Ne pas committer.**
## File Structure
```
server/services/aptParse.ts # MODIF : +parseDpkgList/diffDpkg/buildAptExecutionResult
server/services/aptParse.test.ts # MODIF : +tests diff dpkg
templates/apt/full-upgrade.sh.tpl # MODIF : +DPKG_BEFORE/AFTER
templates/apt/upgrade.sh.tpl # NOUVEAU
templates/apt/autoremove.sh.tpl # NOUVEAU
templates/apt/clean.sh.tpl # NOUVEAU
server/ssh/client.ts # MODIF : +inactivityTimeoutMs (additif)
server/services/execute.ts # MODIF : actions APT + buildAptExecutionResult + timeout
```
---
## Task 1 : Diff dpkg (TDD)
**Files:** Modify `server/services/aptParse.ts`, `server/services/aptParse.test.ts`.
- [ ] **Step 1 : Test (échec attendu)** — ajouter à `aptParse.test.ts`
```ts
import { parseDpkgList, buildAptExecutionResult } from "./aptParse.js";
const BEFORE = "libc6\t2.31-13\tamd64\noldpkg\t3.2-1\tamd64\nstable\t1.0\tamd64";
const AFTER = "libc6\t2.31-14\tamd64\nnewpkg\t1.0.0\tall\nstable\t1.0\tamd64";
describe("parseDpkgList", () => {
it("indexe par package:arch", () => {
const m = parseDpkgList("libc6\t2.31-13\tamd64");
expect(m["libc6:amd64"]).toEqual({ version: "2.31-13", arch: "amd64" });
});
});
describe("buildAptExecutionResult", () => {
it("calcule le diff réel before/after", () => {
const r = buildAptExecutionResult(BEFORE, AFTER, "REBOOT_REQUIRED=1");
expect(r.applied.find((c) => c.name === "libc6")).toMatchObject({ operation: "upgraded", fromVersion: "2.31-13", toVersion: "2.31-14" });
expect(r.installed.map((c) => c.name)).toEqual(["newpkg"]);
expect(r.removed.map((c) => c.name)).toEqual(["oldpkg"]);
expect(r.applied.some((c) => c.name === "stable")).toBe(false); // unchanged exclu
expect(r.rebootRequiredAfterRun).toBe(true);
});
});
```
- [ ] **Step 2 : Lancer (échec)**`rtk pnpm vitest run server/services/aptParse.test.ts` → FAIL.
- [ ] **Step 3 : Étendre `server/services/aptParse.ts`**
```ts
import type { AptChange, AptExecutionResult } from "@shared/types.js";
export function parseDpkgList(raw: string): Record<string, { version: string; arch: string }> {
const out: Record<string, { version: string; arch: string }> = {};
for (const line of raw.split("\n")) {
const parts = line.split("\t");
if (parts.length < 3) continue;
const [name, version, arch] = [parts[0]!.trim(), parts[1]!.trim(), parts[2]!.trim()];
if (!name) continue;
out[`${name}:${arch}`] = { version, arch };
}
return out;
}
/** Diff dpkg réel before/after → AptExecutionResult (planned/held vides : portés par le snapshot). */
export function buildAptExecutionResult(beforeRaw: string, afterRaw: string, rebootRaw: string): AptExecutionResult {
const before = parseDpkgList(beforeRaw);
const after = parseDpkgList(afterRaw);
const applied: AptChange[] = [];
const installed: AptChange[] = [];
const removed: AptChange[] = [];
for (const key of Object.keys(after)) {
const [name] = key.split(":");
const a = after[key]!;
const b = before[key];
if (!b) {
const change: AptChange = { name: name!, arch: a.arch, fromVersion: null, toVersion: a.version, operation: "installed" };
installed.push(change); applied.push(change);
} else if (b.version !== a.version) {
applied.push({ name: name!, arch: a.arch, fromVersion: b.version, toVersion: a.version, operation: "upgraded" });
}
}
for (const key of Object.keys(before)) {
if (!after[key]) {
const [name] = key.split(":");
const b = before[key]!;
const change: AptChange = { name: name!, arch: b.arch, fromVersion: b.version, toVersion: null, operation: "removed" };
removed.push(change); applied.push(change);
}
}
return {
planned: [],
applied,
installed,
removed,
held: [],
rebootRequiredAfterRun: /REBOOT_REQUIRED=1/.test(rebootRaw),
};
}
```
- [ ] **Step 4 : Lancer (succès)**`rtk pnpm vitest run server/services/aptParse.test.ts` → PASS. `rtk pnpm check` → 0 erreur.
- [ ] **Step 5 : (pas de commit)**
---
## Task 2 : Templates APT (full-upgrade enrichi + upgrade/autoremove/clean)
**Files:** Modify `templates/apt/full-upgrade.sh.tpl` ; Create `upgrade.sh.tpl`, `autoremove.sh.tpl`, `clean.sh.tpl`.
- [ ] **Step 1 : Remplacer `templates/apt/full-upgrade.sh.tpl`** (ajoute DPKG_BEFORE/AFTER ; conserve REBOOT + EXIT que `execute.ts` parse déjà)
```sh
#!/bin/sh
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}==="
```
- [ ] **Step 2 : Créer `templates/apt/upgrade.sh.tpl`**
```sh
#!/bin/sh
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_UPGRADE==="
apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold 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}==="
```
- [ ] **Step 3 : Créer `templates/apt/autoremove.sh.tpl`**
```sh
#!/bin/sh
export LC_ALL=C
export DEBIAN_FRONTEND=noninteractive
echo "===SU:APT_SIM_AUTOREMOVE==="
apt-get -s -y autoremove 2>&1
echo "===SU:DPKG_BEFORE==="
dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n' 2>/dev/null
echo "===SU:APT_AUTOREMOVE==="
apt-get -y autoremove 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}==="
```
- [ ] **Step 4 : Créer `templates/apt/clean.sh.tpl`**
```sh
#!/bin/sh
export LC_ALL=C
echo "===SU:APT_CLEAN==="
BEFORE=$(du -sb /var/cache/apt/archives 2>/dev/null | awk '{print $1}')
apt-get clean 2>&1
AFTER=$(du -sb /var/cache/apt/archives 2>/dev/null | awk '{print $1}')
echo "FREED_BYTES=$((BEFORE - AFTER))"
echo "===SU:EXIT=0==="
```
- [ ] **Step 5 :** `rtk pnpm vitest run server/templates/render.test.ts` reste vert. (pas de commit)
---
## Task 3 : Timeout d'inactivité SSH (additif)
**Files:** Modify `server/ssh/client.ts`.
- [ ] **Step 1 : Relire `server/ssh/client.ts`** (signatures `runScriptSudo`, `execStream`).
- [ ] **Step 2 : Ajouter un paramètre optionnel `inactivityTimeoutMs`** (défaut 0 = désactivé) à `runScriptSudo` et `execStream`. Dans `execStream`, armer un timer réinitialisé à chaque `data`/`stderr data` ; à expiration, `stream.close()`/`conn.end()` et `reject(new Error("human_interaction_required: aucune sortie depuis " + (ms/1000) + "s"))`.
```ts
export async function runScriptSudo(
creds: SshCreds,
script: string,
onData: (chunk: string) => void,
inactivityTimeoutMs = 0,
): Promise<RunResult> {
const conn = await connect(creds);
try {
const b64 = Buffer.from(script, "utf8").toString("base64");
const cmd = `sudo -S -p '' sh -c "$(printf '%s' '${b64}' | base64 -d)"`;
return await execStream(conn, cmd, (creds.sudoPassword ?? creds.password) + "\n", onData, inactivityTimeoutMs);
} finally {
conn.end();
}
}
```
Dans `execStream(conn, command, stdinData, onData, inactivityTimeoutMs = 0)` : après obtention du `stream`,
```ts
let timer: NodeJS.Timeout | undefined;
const arm = () => {
if (!inactivityTimeoutMs) return;
clearTimeout(timer);
timer = setTimeout(() => {
stream.close();
reject(new Error(`human_interaction_required: aucune sortie depuis ${Math.round(inactivityTimeoutMs / 1000)}s`));
}, inactivityTimeoutMs);
};
arm();
```
Réinitialiser `arm()` dans les handlers `data` et `stderr data` ; `clearTimeout(timer)` dans `close`. (Garder le `runPlain` existant inchangé : il appelle `execStream` sans le 5e arg ⇒ timeout 0.)
- [ ] **Step 3 :** `rtk pnpm check` → 0 erreur. (Pas de test unitaire SSH ; vérif manuelle en live ultérieure.) (pas de commit)
---
## Task 4 : Brancher les actions APT dans `runAction`
**Files:** Modify `server/services/execute.ts`.
- [ ] **Step 1 : Relire `server/services/execute.ts`** (TEMPLATE_FOR, flux, update executions, blocs Phase 1 reports/artifacts/state/event).
- [ ] **Step 2 : Étendre `TEMPLATE_FOR`**
```ts
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",
};
```
(Adapter l'accès : `const rel = TEMPLATE_FOR[action]; if (!rel) throw new Error("Action sans template: " + action);`)
- [ ] **Step 3 : Passer le timeout d'inactivité** pour les actions APT (pas pour reboot) :
```ts
const inactivity = action === "reboot" ? 0 : 600000;
const res = await runScriptSudo(getCreds(m), script, (c) => { raw += c; outputHub.publish(machineId, c); }, inactivity);
```
- [ ] **Step 4 : Construire `result.apt` (diff dpkg) pour les actions APT applicatives.** Après calcul de `raw` et avant l'écriture du rapport, ajouter :
```ts
let aptResult: AptExecutionResult | undefined;
if (raw.includes("===SU:DPKG_BEFORE===") && raw.includes("===SU:DPKG_AFTER===")) {
aptResult = buildAptExecutionResult(
extractSection(raw, "===SU:DPKG_BEFORE===", "==="), // jusqu'au prochain marqueur
extractSection(raw, "===SU:DPKG_AFTER===", "===SU:REBOOT==="),
extractSection(raw, "===SU:REBOOT===", "===SU:EXIT"),
);
}
```
> ⚠️ `extractSection(raw, "===SU:DPKG_BEFORE===", "===")` : le 2ᵉ marqueur générique `"==="` capture jusqu'au prochain `===SU:...===`. Vérifier que `extractSection` (dans `refresh.ts`) coupe bien au 1ᵉʳ `"==="` rencontré ; sinon, utiliser le marqueur réel suivant (`"===SU:APT_FULLUPGRADE==="` / `"===SU:APT_UPGRADE==="` / `"===SU:APT_AUTOREMOVE==="`). **Préférer** le marqueur explicite : détecter lequel est présent. Implémentation robuste :
```ts
const afterBeforeMarker =
raw.includes("===SU:APT_FULLUPGRADE===") ? "===SU:APT_FULLUPGRADE===" :
raw.includes("===SU:APT_UPGRADE===") ? "===SU:APT_UPGRADE===" :
"===SU:APT_AUTOREMOVE===";
if (raw.includes("===SU:DPKG_BEFORE===") && raw.includes("===SU:DPKG_AFTER===")) {
aptResult = buildAptExecutionResult(
extractSection(raw, "===SU:DPKG_BEFORE===", afterBeforeMarker),
extractSection(raw, "===SU:DPKG_AFTER===", "===SU:REBOOT==="),
extractSection(raw, "===SU:REBOOT===", "===SU:EXIT"),
);
}
```
- [ ] **Step 5 : Attacher `aptResult` au `ExecutionResult`** : dans la construction de `result`, ajouter `...(aptResult ? { apt: aptResult } : {})`. Importer en tête : `import { parseRebootRequired, extractSection } ...` (extractSection vient de `./refresh.js` — déjà importé) et `import { buildAptExecutionResult } from "./aptParse.js";` ainsi que `import type { AptExecutionResult } from "@shared/types.js";`.
- [ ] **Step 6 : Vérifier**`rtk pnpm check && rtk pnpm test` → 0 erreur, tests verts. (pas de commit)
---
## Task 5 : Vérification finale SJ-2
- [ ] **Step 1 :** `rtk pnpm check && rtk pnpm test && rtk pnpm build` → tout vert.
- [ ] **Step 2 : Boot smoke** (DB jetable) → `/health` OK. Nettoyer.
- [ ] **Step 3 :** Reporter. Vérif live ultérieure : `apt_full_upgrade` réel sur Debian → vérifier `result.apt.applied` (diff dpkg réel) + détection removed/held + comportement du timeout. **Ne pas committer.**
---
## Self-Review (couverture SJ-2)
- Templates `upgrade`/`full-upgrade` enrichi/`autoremove`/`clean` → Task 2. ✓
- Capture `DPKG_BEFORE/AFTER` + diff réel (`AptExecutionResult`) → Task 1 + Task 4. ✓
- Timeout d'inactivité + `human_interaction_required` → Task 3 (additif, off par défaut) + Task 4 (600s pour APT). ✓
- Confirmations UI suppressions → hors périmètre (tâche 3) ; la donnée `removed[]` est exposée dans `result.apt`. ✓ (noté)
- Non-régression : `apt_full_upgrade`/`reboot` jalon 1 conservés ; `runScriptSudo` rétro-compatible (timeout 0 par défaut) ; `ExecutionResult.apt` optionnel ; blocs Phase 1 préservés. ✓
Décision : `planned`/`held` laissés vides dans `AptExecutionResult` (portés par le snapshot SJ-1, pas re-simulés à l'exécution). `extractSection` utilisé avec marqueur explicite pour `DPKG_BEFORE`. Noms cohérents : `parseDpkgList`/`buildAptExecutionResult` (Task 1) utilisés Task 4 ; `inactivityTimeoutMs` (Task 3) passé Task 4.
@@ -0,0 +1,252 @@
# Tâche 2 — SJ-3 (reboot vérifié) — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: subagent-driven-development / executing-plans. Étapes checkbox.
**Goal:** Ajouter l'action `reboot_verified` : capture du `boot_id` avant reboot, orchestration backend (attente de la coupure SSH, reconnexion avec délai adaptatif, relecture du `boot_id`), production d'un `RebootResult` (`ok` seulement si la machine revient ET le `boot_id` a changé). L'action `reboot` (jalon 1) reste inchangée.
**Architecture:** Référence `docs/design/tache2/10-templates-apt.md §4.5` + `40-contrats-json.md §4` (`RebootResult`). Le template `apt/reboot.sh.tpl` est enrichi pour émettre `===SU:BOOT_ID_BEFORE===`. Un module `server/services/rebootVerify.ts` contient : `classifyReboot(...)` (fonction **pure**, TDD) + `verifyReboot(machineId)` (orchestration réseau : poll `runPlain` jusqu'à coupure puis retour). `execute.ts` route l'action `reboot_verified` vers cette orchestration. Délai adaptatif stocké dans `machine_state` (réutilise la table Phase 1).
**Tech Stack:** TypeScript, ssh2, vitest.
---
## Invariants
- `reboot` (jalon 1) **inchangé** (toujours via `apt/reboot.sh.tpl`, fire-and-forget). `reboot_verified` est une **nouvelle** action.
- `ExecutionResult.reboot` est optionnel (SJ-0) → rétro-compatible.
- Pas de blocage indéfini : timeouts bornés (détection coupure ≤ 60 s ; retour machine ≤ 600 s par défaut).
- Tree partagé / WIP concurrent : ne toucher QUE `templates/apt/reboot.sh.tpl`, `server/services/rebootVerify.ts` (+test), `server/services/execute.ts`. **Ne pas committer.**
## File Structure
```
templates/apt/reboot.sh.tpl # MODIF : +===SU:BOOT_ID_BEFORE===
server/services/rebootVerify.ts # NOUVEAU : classifyReboot (pure) + verifyReboot (orchestration)
server/services/rebootVerify.test.ts # NOUVEAU : tests classifyReboot
server/services/execute.ts # MODIF : route action reboot_verified
```
---
## Task 1 : Template `reboot.sh.tpl` (capture boot_id)
**Files:** Modify `templates/apt/reboot.sh.tpl`.
- [ ] **Step 1 : Remplacer `templates/apt/reboot.sh.tpl`** (ajoute BOOT_ID_BEFORE ; conserve REBOOT_NOW)
```sh
#!/bin/sh
export LC_ALL=C
echo "===SU:BOOT_ID_BEFORE==="
cat /proc/sys/kernel/random/boot_id 2>/dev/null
echo "===SU:REBOOT_NOW==="
# Reboot différé pour laisser le canal SSH se fermer proprement.
nohup sh -c 'sleep 2; reboot' >/dev/null 2>&1 &
echo "reboot planifié"
```
- [ ] **Step 2 : (pas de commit)**`templates/apt/reboot.sh.tpl` reste utilisé par l'action `reboot` (jalon 1) ET `reboot_verified`.
---
## Task 2 : `classifyReboot` (pure, TDD)
**Files:** Create `server/services/rebootVerify.ts`, `server/services/rebootVerify.test.ts`.
- [ ] **Step 1 : Test (échec attendu)**`server/services/rebootVerify.test.ts`
```ts
import { describe, it, expect } from "vitest";
import { classifyReboot, parseBootIdBefore } from "./rebootVerify.js";
describe("parseBootIdBefore", () => {
it("extrait le boot_id de la sortie du template", () => {
const raw = "===SU:BOOT_ID_BEFORE===\nabcd-1234\n===SU:REBOOT_NOW===\nreboot planifié";
expect(parseBootIdBefore(raw)).toBe("abcd-1234");
});
it("retourne null si absent", () => {
expect(parseBootIdBefore("rien")).toBeNull();
});
});
describe("classifyReboot", () => {
it("ok si la machine revient avec un boot_id différent", () => {
expect(classifyReboot({ beforeBootId: "A", afterBootId: "B", wentDown: true, cameBack: true }).status).toBe("ok");
});
it("boot_id_unchanged si même boot_id", () => {
expect(classifyReboot({ beforeBootId: "A", afterBootId: "A", wentDown: true, cameBack: true }).status).toBe("boot_id_unchanged");
});
it("ssh_never_went_down si la coupure n'a pas été observée", () => {
expect(classifyReboot({ beforeBootId: "A", afterBootId: null, wentDown: false, cameBack: false }).status).toBe("ssh_never_went_down");
});
it("machine_did_not_return si coupure mais pas de retour", () => {
expect(classifyReboot({ beforeBootId: "A", afterBootId: null, wentDown: true, cameBack: false }).status).toBe("machine_did_not_return");
});
});
```
- [ ] **Step 2 : Lancer (échec)**`rtk pnpm vitest run server/services/rebootVerify.test.ts` → FAIL.
- [ ] **Step 3 : Implémenter le socle pur dans `server/services/rebootVerify.ts`**
```ts
// server/services/rebootVerify.ts
import { runPlain, type SshCreds } from "../ssh/client.js";
import type { RebootResult } from "@shared/types.js";
export function parseBootIdBefore(raw: string): string | null {
const s = raw.indexOf("===SU:BOOT_ID_BEFORE===");
if (s === -1) return null;
const from = s + "===SU:BOOT_ID_BEFORE===".length;
const e = raw.indexOf("===SU:REBOOT_NOW===", from);
const id = raw.slice(from, e === -1 ? undefined : e).trim();
return id || null;
}
export interface RebootSignals {
beforeBootId: string | null;
afterBootId: string | null;
wentDown: boolean;
cameBack: boolean;
}
/** Détermine le statut d'un reboot vérifié (fonction pure). */
export function classifyReboot(s: RebootSignals): { status: RebootResult["status"] } {
if (!s.wentDown) return { status: "ssh_never_went_down" };
if (!s.cameBack || s.afterBootId === null) return { status: "machine_did_not_return" };
if (s.beforeBootId !== null && s.afterBootId === s.beforeBootId) return { status: "boot_id_unchanged" };
return { status: "ok" };
}
async function readBootId(creds: SshCreds): Promise<string | null> {
try {
const res = await runPlain(creds, "cat /proc/sys/kernel/random/boot_id");
const id = res.stdout.trim();
return id || null;
} catch {
return null; // connexion impossible (machine down)
}
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
export interface VerifyOptions {
beforeBootId: string | null;
requestedAt: string;
downTimeoutMs?: number; // détection de la coupure
upTimeoutMs?: number; // attente du retour
pollMs?: number;
}
/**
* Orchestration : attend la coupure SSH (machine qui reboote) puis le retour,
* relit le boot_id, et classe le résultat. Réseau ; non testé unitairement.
*/
export async function verifyReboot(creds: SshCreds, opt: VerifyOptions): Promise<RebootResult> {
const downTimeoutMs = opt.downTimeoutMs ?? 60000;
const upTimeoutMs = opt.upTimeoutMs ?? 600000;
const pollMs = opt.pollMs ?? 5000;
const t0 = Date.now();
// Phase A : attendre que la machine devienne injoignable.
let wentDown = false;
let sshWentDownAt: string | null = null;
while (Date.now() - t0 < downTimeoutMs) {
const id = await readBootId(creds);
if (id === null) { wentDown = true; sshWentDownAt = new Date().toISOString(); break; }
await sleep(pollMs);
}
// Phase B : attendre le retour (seulement si on a vu la coupure).
let cameBack = false;
let sshCameBackAt: string | null = null;
let afterBootId: string | null = null;
if (wentDown) {
const tB = Date.now();
while (Date.now() - tB < upTimeoutMs) {
const id = await readBootId(creds);
if (id !== null) { cameBack = true; sshCameBackAt = new Date().toISOString(); afterBootId = id; break; }
await sleep(pollMs);
}
}
const { status } = classifyReboot({ beforeBootId: opt.beforeBootId, afterBootId, wentDown, cameBack });
const waitedSeconds = Math.round((Date.now() - t0) / 1000);
return {
beforeBootId: opt.beforeBootId,
afterBootId,
requestedAt: opt.requestedAt,
sshWentDownAt,
sshCameBackAt,
waitedSeconds,
status,
lastRebootDurationSeconds: status === "ok" ? waitedSeconds : undefined,
nextRecommendedWaitSeconds: status === "ok" ? Math.round(waitedSeconds * 1.5) + 30 : undefined,
};
}
```
- [ ] **Step 4 : Lancer (succès)**`rtk pnpm vitest run server/services/rebootVerify.test.ts` → PASS (6). `rtk pnpm check` → 0 erreur.
- [ ] **Step 5 : (pas de commit)**
---
## Task 3 : Router l'action `reboot_verified` dans `execute.ts`
**Files:** Modify `server/services/execute.ts`.
- [ ] **Step 1 : Relire `server/services/execute.ts`** (TEMPLATE_FOR, flux, blocs Phase 1).
- [ ] **Step 2 : Ajouter `reboot_verified` à `TEMPLATE_FOR`** (réutilise le même template que `reboot`)
```ts
reboot_verified: "apt/reboot.sh.tpl",
```
- [ ] **Step 3 : Après l'exécution du script (raw obtenu), lancer la vérification pour `reboot_verified`** et attacher `result.reboot`. Importer en tête :
```ts
import { parseBootIdBefore, verifyReboot } from "./rebootVerify.js";
import type { RebootResult } from "@shared/types.js";
```
Puis, après le bloc qui calcule `status`/`raw` et avant la construction de `result` (ou juste après, en enrichissant `result`), ajouter une branche :
```ts
let rebootResult: RebootResult | undefined;
if (action === "reboot_verified") {
const beforeBootId = parseBootIdBefore(raw);
rebootResult = await verifyReboot(getCreds(m), { beforeBootId, requestedAt: startedAt });
// Le statut de l'exécution suit la vérif : ok si reboot ok, sinon error.
if (rebootResult.status !== "ok") status = "error";
}
```
Puis dans la construction de `result`, ajouter `...(rebootResult ? { reboot: rebootResult } : {})` ; et conserver `rebootRequiredAfterRun` existant.
> ⚠️ `verifyReboot` est **long** (jusqu'à plusieurs minutes). C'est acceptable : `runAction` est déjà lancé en arrière-plan (la route POST renvoie 202). La sortie live reste streamée pendant l'attente n'est pas nécessaire ; on peut publier un message d'attente : `outputHub.publish(machineId, "\n[reboot] attente du redémarrage...\n")` avant `verifyReboot`.
- [ ] **Step 4 : Persister le délai adaptatif** (optionnel, simple) : après `verifyReboot`, si `rebootResult.lastRebootDurationSeconds`, l'écrire dans un event :
```ts
if (rebootResult.status === "ok") {
recordEvent({ machineId, eventType: "reboot_verified", severity: "info", executionId,
message: `Reboot vérifié en ${rebootResult.waitedSeconds}s (boot_id changé)` });
}
```
(Le stockage en colonne dédiée `machine_state` peut venir plus tard ; l'event suffit au MVP.)
- [ ] **Step 5 : Vérifier**`rtk pnpm check && rtk pnpm test` → 0 erreur, tests verts. Les blocs Phase 1 (executions/reports/rawArtifacts/state/event) restent intacts ; `reboot` (jalon 1) inchangé.
- [ ] **Step 6 : (pas de commit)**
---
## Task 4 : Vérification finale SJ-3
- [ ] **Step 1 :** `rtk pnpm check && rtk pnpm test && rtk pnpm build` → tout vert.
- [ ] **Step 2 : Boot smoke** (DB jetable) → `/health` OK. Nettoyer.
- [ ] **Step 3 :** Reporter. **Vérif live indispensable** : `reboot_verified` réel sur une machine de test (la boucle réseau attente-coupure/retour + comparaison `boot_id` ne peut être validée qu'en conditions réelles). **Ne pas committer.**
---
## Self-Review (couverture SJ-3)
- `apt/reboot.sh.tpl` capture `boot_id` → Task 1. ✓
- Orchestration backend (attente coupure → reconnexion délai adaptatif → relecture boot_id) → Task 2 (`verifyReboot`). ✓
- `RebootResult` + statuts (`ok`/`ssh_never_went_down`/`machine_did_not_return`/`boot_id_unchanged`/`timeout`) → `classifyReboot` (TDD) + `verifyReboot`. ✓
- Délai adaptatif `lastRebootDurationSeconds``nextRecommendedWaitSeconds``verifyReboot`. ✓
- Conserve l'action `reboot` jalon 1 → Task 3 (nouvelle action distincte). ✓
Décision : la boucle réseau utilise des timeouts bornés (down ≤ 60 s, up ≤ 600 s, poll 5 s) ; seule `classifyReboot` (+`parseBootIdBefore`) est testée unitairement, l'orchestration est validée en live. `timeout` (statut) est couvert par `machine_did_not_return` quand le retour n'arrive pas dans `upTimeoutMs` (mêmes conséquences ; un raffinement `timeout` explicite est notable mais non bloquant au MVP).
@@ -0,0 +1,110 @@
# Jalon 2 — Polish design system — Design
> Spec du deuxième jalon : refonte de l'UI avec le design system Gruvbox seventies.
> Statut : **validé** (2026-06-05). Langue de travail : français.
> Voir aussi : `CLAUDE.md`, `design_system/consigne_design_system.md`, jalon 1 (`docs/superpowers/specs/2026-06-04-jalon1-tranche-verticale-apt-design.md`).
## Objectif
Le jalon 1 a livré une UI fonctionnelle mais "brute" : des `<button className="interactive">` et des styles inline, sans utiliser les composants du design system. Le `ui-kit.tsx` porté n'est même pas consommable (il expose ses composants via `Object.assign(window, …)` et dépend de Font Awesome non chargé).
Ce jalon **branche correctement le design system** et **refond les écrans existants** avec ses composants, en respectant la consigne (`design_system/consigne_design_system.md`). Aucune nouvelle capacité métier : c'est un jalon qualité.
## Périmètre
**Dedans** : wiring du DS (exports ESM, Font Awesome, polices, tous bundlés offline), refonte des écrans existants avec les composants DS, ajout d'un header (titre + actions + bascule thème), ajout d'une status bar style tmux, vérification des deux thèmes (dark + light).
**Dehors** : aucune logique backend, aucune nouvelle route, pas de panneaux redimensionnables (split panes — reporté), pas de nouveaux écrans.
## Décisions verrouillées
| Sujet | Décision |
|-------|----------|
| Font Awesome | Bundlé via npm `@fortawesome/fontawesome-free` (solid), import CSS dans `main.tsx`. Offline, pas de CDN. |
| Polices | Bundlées via `@fontsource/inter`, `@fontsource/jetbrains-mono`, `@fontsource/share-tech-mono`, importées dans `main.tsx`. Offline. |
| Consommation `ui-kit` | Ajout d'exports ESM nommés. On garde `@ts-nocheck` (pas de réécriture typée) et le `Object.assign(window, …)` existant. |
| Layout | Ajout d'un header et d'une status bar. Les 3 volets restent. |
| Thème | Bascule dark/light via `lib/theme.ts` (persistance localStorage), IconButton sun/moon dans le header. |
| Tests | Helpers purs testés (`theme`, `sumUpdates`). Le reste = vérif visuelle. `ui-kit` jamais importé dans un test (touche `window`/`document` au chargement). |
## Contrainte transverse
Le design system impose (consigne) : variables CSS uniquement, composants existants réutilisés, `<Icon name=>` (jamais d'emoji/SVG custom), **pas de hover** sauf jauges (pression 3D `.interactive`), tooltips obligatoires sur IconButton isolé, polices Inter/JetBrains Mono/Share Tech Mono, labels uppercase. Tout écran doit être lisible et cohérent en **dark ET light**.
## API du design system (vérifiée dans `ui-kit.tsx`)
- `Icon({ name, size, style })``name` mappé via `ICON_MAP` vers `fa-solid fa-…`. Icônes dispo : cpu, memory, disk, network, clock, grid, list, cog, alert, bell, server, chart, bars, terminal, refresh, play, pause, power, sun, moon, search, close, chevR/L/D/U, plus, filter, download, folder, node, user.
- `Button({ children, icon, onClick, variant, size })` — variant: default/primary/ghost/danger ; size: sm/md/lg.
- `IconButton({ icon, label, onClick, active, danger, size, primary })``label` = tooltip (obligatoire).
- `StatusLed({ status, size, pulse })` — status: ok/warn/err/info/off.
- `Popup({ open, onClose, title, children, footer, width })`.
- `Toggle`, `Tooltip`, `BatteryGauge`, `RadialGauge`, `BigRadialGauge`, `TreeNav`, `Sparkline`, `LineChart` (non utilisés ici mais exportés).
## Wiring du design system
1. **`client/src/components/ui-kit.tsx`** : ajouter en fin de fichier
`export { Icon, Tooltip, IconButton, Toggle, StatusLed, BatteryGauge, RadialGauge, BigRadialGauge, Popup, Button, TreeNav, Sparkline, LineChart };`
Conserver `@ts-nocheck`, l'import React et le `Object.assign(window, …)`.
2. **`client/src/main.tsx`** : ajouter les imports CSS
`import "@fortawesome/fontawesome-free/css/all.min.css";`
`import "@fontsource/inter";` `import "@fontsource/jetbrains-mono";` `import "@fontsource/share-tech-mono";`
3. **`package.json`** : ajouter les deps `@fortawesome/fontawesome-free`, `@fontsource/inter`, `@fontsource/jetbrains-mono`, `@fontsource/share-tech-mono`.
## Layout cible
```
┌─ Header : « System Update » ............ [+ Ajouter] [☀/☾] ┐
├──────────┬─────────────────────────────┬────────────────────┤
│ Hermes │ Dashboard (tuiles machines) │ Terminal │
├──────────┴─────────────────────────────┴────────────────────┤
│ SYSTEM UPDATE · N machines · M updates · ⏱ 14:22:07 │
└──────────────────────────────────────────────────────────────┘
```
`App.tsx` orchestre : `<Header>` en haut, la rangée 3 volets au milieu (flex:1), `<StatusBar>` en bas.
**Remontée d'état dans `App`** : la liste des machines, les compteurs d'updates, la machine sélectionnée et le thème vivent désormais dans `App` (le Dashboard les reçoit en props, plus de fetch local autonome). Cela alimente le Header (action Ajouter), la StatusBar (N machines, M updates) et le TerminalPanel (machine sélectionnée). Le chargement des machines + snapshots se fait dans `App` via une fonction `load()` passée au Dashboard pour rafraîchir après action.
## Composants
### Nouveaux
- **`Header.tsx`** : titre « System Update », bouton `<Button variant="primary" icon="plus">Ajouter</Button>` (ouvre la modale via callback remonté), et `<IconButton icon={theme==="dark"?"sun":"moon"} label="Basculer le thème">`. Hauteur 48-56px, fond `--bg-2`.
- **`StatusBar.tsx`** : 1re cellule mode « SYSTEM UPDATE » fond `--accent` ; cellules suivantes séparées par `border-right: 1px solid var(--border-1)` ; nb machines, total updates ; horloge live (Share Tech Mono, tick 1s via `setInterval`, nettoyé au démontage) à droite. Hauteur 24-28px.
- **`lib/theme.ts`** :
- `type Theme = "dark" | "light"`
- `getInitialTheme(): Theme` — lit `localStorage["su-theme"]`, défaut `"dark"`, robuste si localStorage indisponible.
- `applyTheme(t: Theme): void``document.documentElement.dataset.theme = t` + persiste.
- `nextTheme(t: Theme): Theme` — bascule dark↔light (fonction pure, testable).
- **`lib/stats.ts`** : `sumUpdates(counts: Record<string, number>): number` (fonction pure, testable).
### Refondus
- **`MachineTile.tsx`** : point d'état → `<StatusLed status={machine.status} pulse={machine.status==="running"}>` ; compteur → `<span className="label">UPDATES</span> <span className="mono">{count}</span>` ; actions → `<IconButton icon="refresh" label="Rafraîchir">`, `<IconButton icon="download" label="Upgrade">`, `<IconButton icon="power" label="Redémarrer" danger>`. Conserver `className="glass"`, `onClick` sélection (les IconButton stoppent la propagation).
- **`AddMachineModal.tsx`** : enveloppé dans `<Popup open onClose title="Ajouter une machine" footer={…}>` ; footer = `<Button variant="ghost" onClick={onClose}>Annuler</Button>` + `<Button variant="primary" icon="download" onClick={submit}>Ajouter</Button>` ; champs en inputs tokenisés ; erreur affichée avec `<StatusLed status="err">` + texte `--err`. Logique de soumission inchangée (POST /api/machines).
- **`Dashboard.tsx`** : retirer le bouton « + Ajouter » local (déplacé dans le Header) ; le Dashboard expose l'ouverture de la modale via prop/état remonté à `App`. Grille de tuiles inchangée. État vide : texte `--ink-3`.
- **`HermesPanel.tsx`** : en-tête `.label` + `<Icon name="bell">` (ou autre), texte stub inchangé.
- **`TerminalPanel.tsx`** : recevoir la **machine sélectionnée** (objet `MachineView`, pas juste l'id) depuis `App`. En-tête clair au-dessus du xterm : `<StatusLed status>` + `.label` « TERMINAL » + nom de la machine (`.mono`) + hostname (`--ink-3`). **Séparation franche entre machines** (retour d'usage, `amelioration.md`) : à chaque changement de machine, écrire une bannière de séparation dans le terminal, p. ex. `\n──────── <nom> (<hostname>) ────────\n` (couleur accent), avant de rejouer le flux. Le terminal est déjà recréé par machine (`useEffect` deps) ; l'en-tête nommé + la bannière rendent le passage d'une machine à l'autre non ambigu (fini l'UUID). xterm inchangé sinon.
## Flux thème
Au montage de `App` : `applyTheme(getInitialTheme())`. État `theme` dans `App` ; le toggle du Header appelle `setTheme(nextTheme(theme))` puis `applyTheme`. `data-theme` initial dans `index.html` reste `dark` (cohérent avec le défaut).
## Gestion d'erreurs / cas limites
- `localStorage` indisponible (mode privé) → `getInitialTheme` retombe sur `"dark"`, `applyTheme` ignore l'échec de persistance (try/catch) sans casser l'UI.
- Icône inconnue → le composant `Icon` retombe déjà sur `circle-question`.
- Horloge : l'intervalle est nettoyé dans le cleanup du `useEffect`.
## Tests
- **`lib/theme.test.ts`** : `nextTheme("dark")==="light"` et inverse ; `getInitialTheme()` retombe sur `"dark"` quand localStorage vide. (localStorage mocké, pas d'import de `ui-kit`.)
- **`lib/stats.test.ts`** : `sumUpdates({a:2,b:3})===5` ; `sumUpdates({})===0`.
- Vérif build : `pnpm check` + `pnpm build` verts.
- **Vérif visuelle manuelle** (utilisateur) : dark ET light lisibles ; icônes FA affichées ; polices Inter/JetBrains Mono/Share Tech Mono appliquées ; tooltips sur les IconButton ; modale Popup OK ; status bar + horloge ; aucun hover sur boutons/tuiles (pression 3D seulement).
## Critères d'acceptation
- [ ] `ui-kit` exporte ses composants en ESM ; les écrans les importent (plus aucun `<button className="interactive">` brut dans les features).
- [ ] Font Awesome et les 3 polices sont bundlés (offline) et appliqués.
- [ ] Header avec titre, bouton Ajouter, bascule thème fonctionnelle et persistée.
- [ ] Status bar tmux avec mode, compteurs et horloge live.
- [ ] MachineTile utilise StatusLed + IconButton (tooltips) ; AddMachineModal utilise Popup + Button.
- [ ] Le terminal identifie clairement la machine courante (nom + hostname, plus d'UUID) et marque une séparation franche au passage d'une machine à l'autre.
- [ ] Les deux thèmes sont cohérents et lisibles.
- [ ] `pnpm check`, `pnpm build`, et les tests des helpers passent.
+148
View File
@@ -0,0 +1,148 @@
# Liste des tâches projet — system_update
> **But** : garder une vue claire de la numérotation des tâches et de leur périmètre.
---
## Tâches existantes
### Tâche 1.9 — Architecture BDD cible
Fichier : `tache1.9.md`
Validation : `validation_tache1.9.md`
Périmètre :
- schéma SQLite/Drizzle cible ;
- migration future PostgreSQL ;
- machines, snapshots, exécutions ;
- logs/rapports/messages importants ;
- Docker, post-install, jobs ;
- Hermes/MCP ;
- métriques, nettoyage, découverte ;
- préférences frontend ;
- app locale future.
### Tâche 2 — Moteur de templates et contrats JSON
Fichier : `tache2.md`
Périmètre :
- APT update/analyse/upgrade/reboot ;
- Docker Compose ;
- scripts custom/post-install ;
- profils OS et type de machine ;
- JSON canoniques ;
- intégration Hermes/MCP.
Validation : `validation_tache2.md`
### Tâche 3 — Frontend web, tuiles, layout, paramètres
Fichier : `tache3.md`
Validation : `validation_tache3.md`
Périmètre :
- tuiles machine extensibles ;
- layout web global header/Hermes/centre/terminal/footer ;
- volet Hermes ;
- terminal droit ;
- mode smartphone ;
- paramètres app ;
- favicon, icônes smartphone, icônes SVG spécifiques ;
- brief icônes : `consigne_icon.md`.
### Tâche 4 — Scripts post-install et installateurs
Fichier : `tache4.md`
Validation : `validation_tache4.md`
Périmètre :
- profils post-install ;
- Docker officiel ;
- partage Samba/NFS/wsdd2 ;
- dev-tools, domotique, ESP/PlatformIO ;
- détection hardware ;
- drivers/firmware ;
- métriques simples ;
- benchmark.
### Tâche 5 — Backend, historique JSON et automatisations
Fichier : `tache5.md`
Validation : `validation_tache5.md`
Périmètre :
- stockage JSON ;
- état courant machine ;
- logs/rapports/messages ;
- schedules/jobs ;
- API webapp/Hermes/app locale ;
- rétention.
### Tâche 6 — Hermes, MCP, skills et messagerie
Fichier : `tache6.md`
Validation : `validation_tache6.md`
Périmètre :
- volet Hermes web ;
- API Hermes ;
- MCP HTTP ;
- skill `system-update-ops` ;
- messagerie/TUI ;
- accès rapports/logs réduits.
### Tâche 7 — Optimisation, métriques, nettoyage, sécurité
Fichier : `tache7.md`
Validation : `validation_tache7.md`
Périmètre :
- footer métriques ;
- métriques simples par machine ;
- optimisation tokens Hermes ;
- nettoyage DB/logs ;
- découverte SSH ;
- sécurité mots de passe/secrets ;
- smartphone à brainstormer.
### Tâche 8 — App locale Rust/GNOME
Fichier : `tache8.md`
Validation : `validation_tache8.md`
Périmètre :
- application native Rust ;
- GTK4/libadwaita ;
- API commune avec backend ;
- thème Gruvbox GNOME ;
- mode sans navigateur ;
- sécurité token locale ;
- cache lecture seule ;
- notifications desktop.
---
## Fichiers transverses
- `validation_tache2.md` : gate de validation tâche 2.
- `validation_tache1.9.md`, `validation_tache3.md` à `validation_tache8.md` : gates des autres tâches.
- `coherence_taches.md` : revue de cohérence globale et ordre de développement recommandé.
- `consigne_icon.md` : brief de création icônes SVG/favicon/smartphone.
- `design_system/consigne_design_system.md` : règles design system web/Gruvbox.
- `design_system/tokens/tokens.gnome.css` : base thème pour future app GNOME.
+15 -3
View File
@@ -3,7 +3,9 @@
"version": "0.1.0",
"type": "module",
"packageManager": "pnpm@10.33.0",
"engines": { "node": ">=22" },
"engines": {
"node": ">=22"
},
"scripts": {
"dev": "pnpm run dev:server & pnpm run dev:client",
"dev:server": "tsx watch server/index.ts",
@@ -12,9 +14,14 @@
"start": "node dist/index.js",
"test": "vitest run",
"check": "tsc --noEmit",
"db:generate": "drizzle-kit generate"
"db:generate": "drizzle-kit generate",
"api-client:create": "tsx server/cli/createApiClient.ts"
},
"dependencies": {
"@fontsource/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8",
"@fontsource/share-tech-mono": "^5.2.7",
"@fortawesome/fontawesome-free": "^7.2.0",
"@hono/node-server": "^1.13.0",
"better-sqlite3": "^11.8.0",
"croner": "^9.0.0",
@@ -25,7 +32,12 @@
"ws": "^8.18.0"
},
"pnpm": {
"onlyBuiltDependencies": ["better-sqlite3", "ssh2", "cpu-features", "esbuild"]
"onlyBuiltDependencies": [
"better-sqlite3",
"ssh2",
"cpu-features",
"esbuild"
]
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.12",
+164
View File
@@ -0,0 +1,164 @@
# Plan de développement — Tâche 3
> Suivi vivant du développement lié à `tache3.md`.
> Objectif : faire évoluer la webapp vers les tuiles machine extensibles, paramètres frontend et layout dashboard cible.
---
## 0. Position actuelle
- Date de démarrage dev : 2026-06-05.
- État : démarrage après validation tâche 3.
- Validation disponible : `validation_tache3.md`, verdict **accepté avec réserves**.
- Périmètre immédiat : webapp React, design system, tuiles machine.
---
## 1. Réserves à traiter
- [ ] Clarifier logos officiels : favicon/app icon original, logos officiels uniquement pour types/outils si autorisés.
- [ ] Ajouter ou décider le composant `Checkbox`/sélection profil post-install.
- [ ] Prévoir spec mobile dédiée.
- [ ] Aligner largeurs min/max Hermes/terminal avec design system.
- [ ] Ajouter état machine erreur/hors ligne dans les maquettes et l'UI.
- [ ] Trancher sauvegarde paramètres : auto-save ou bouton save.
- [ ] Prévoir composants `Select`/`Dropdown` design system.
---
## 2. Jalons
### 3.0 — Reprise et cadrage
- [x] Vérifier que `plan_8.md` est à jour.
- [x] Mettre la tâche 8 en pause.
- [x] Relire `tache3.md` et `validation_tache3.md`.
- [x] Inspecter `MachineTile`, `Dashboard`, `App`, `ui-kit`, CSS.
### 3.1 — Tuile machine compacte/extensible
- [x] Remplacer les boutons texte système par `IconButton`.
- [x] Utiliser `StatusLed` du ui-kit.
- [x] Afficher OS/type/status/dernier check de façon compacte.
- [x] Ajouter sections repliables Docker et Post-install.
- [x] Ajouter état erreur/hors ligne lisible dans la tuile.
- [x] Ajouter CSS dédié sans inline styles excessifs.
- [x] Vérifier TypeScript/build.
### 3.2 — Layout global webapp
- [x] Ajouter header webapp.
- [x] Ajouter footer/barre de tâche avec métriques minimales.
- [x] Vérifier que Hermes/centre/terminal ne se chevauchent pas.
- [x] Préparer largeurs bornées et future redimension.
### 3.3 — Paramètres frontend
- [x] Créer vue Paramètres.
- [x] Apparence/thème/zoom/tuiles.
- [x] Layout volets Hermes/terminal.
- [x] Scripts custom, Docker roots, nettoyage logs.
- [x] Persistance backend à préparer, pas localStorage seul.
### 3.4 — Mode smartphone
- [ ] Brainstorm UX dédiée.
- [ ] Décider onglets/bottom nav.
- [ ] Définir vues prioritaires mobile.
---
## 3. Avancement du tour en cours
- [x] Contexte tâche 8 vérifié.
- [x] Tâche 3 relue.
- [x] Réserves de validation listées.
- [x] Refonte `MachineTile` premier incrément terminée.
---
## 4. Premier incrément — Tuile machine
Fichiers modifiés :
- `client/src/features/machines/MachineTile.tsx`
- `client/src/styles/app.css`
- `client/src/components/ui-kit.tsx`
Ce qui est en place :
- tuile compacte plus dense ;
- actions système en icônes avec tooltips ;
- statut via `StatusLed` ;
- résumé updates/reboot/dernier check ;
- alerte visible pour état `error` ou `unknown` ;
- sections repliables Docker et Post-install ;
- placeholders UI tant que les données Docker/Post-install ne sont pas exposées par le backend ;
- nouveaux alias icônes utiles dans `ICON_MAP`.
Vérifications :
- `tsc --noEmit` : OK.
- `vitest run` : OK, 16 fichiers de test, 42 tests.
- `vite build && tsup` : OK.
Note :
- Vite signale un warning de chunk JS > 500 kB. Non bloquant pour ce jalon, à traiter plus tard par découpage dynamique si nécessaire.
---
## 5. Deuxième incrément — Layout global
Fichiers modifiés :
- `client/src/App.tsx`
- `client/src/panels/Dashboard.tsx`
- `client/src/lib/api.ts`
- `client/src/styles/app.css`
Ce qui est en place :
- header webapp avec identité, résumé machines/updates/jobs/erreurs et toggle thème ;
- footer/barre de tâche style terminal avec machines, apt, jobs, métriques process et load ;
- appel frontend vers `/api/system/metrics` ;
- largeurs Hermes et terminal bornées avec `clamp(...)` ;
- Dashboard remonte un résumé à `App`.
Vérifications :
- `tsc --noEmit` : OK.
- `vitest run` : OK, 16 fichiers de test, 42 tests.
- `vite build && tsup` : OK.
Note :
- warning Vite chunk > 500 kB toujours présent, non bloquant.
---
## 6. Troisième incrément — Paramètres frontend
Fichiers modifiés :
- `client/src/App.tsx`
- `client/src/panels/SettingsModal.tsx`
- `client/src/styles/app.css`
Ce qui est en place :
- bouton Paramètres dans le header ;
- modale Paramètres avec navigation latérale ;
- catégories Apparence, Tuiles, Volets, Docker, Scripts, Hermes, Terminal, Nettoyage ;
- contrôles prêts à brancher à une future API `/api/settings` ;
- mention explicite côté UI que la persistance backend reste à venir.
Vérifications :
- `tsc --noEmit` : OK.
- `vitest run` : OK, 16 fichiers de test, 42 tests.
- `vite build && tsup` : OK.
Note :
- warning Vite chunk > 500 kB toujours présent, non bloquant.
+298
View File
@@ -0,0 +1,298 @@
# Plan de développement — Tâche 8
> Suivi vivant du développement lié à `tache8.md`.
> Objectif : préparer puis développer l'app locale Rust/GNOME sans casser la webapp serveur.
---
## 0. Position actuelle
- Date de démarrage : 2026-06-05.
- État : développement validé par l'utilisateur.
- Décision : le gate qui bloquait le code Rust est levé par validation utilisateur du 2026-06-05. Le scaffold Rust peut démarrer, avec une approche progressive centrée sur le client API avant l'UI GTK/libadwaita.
- Dossier dédié Rust : `app_rust/system-update-gnome/`.
---
## 1. Vision suffisante pour démarrer ?
Oui pour un premier incrément.
La direction est claire :
- l'app locale Rust/GNOME est un client du backend `system_update` ;
- elle ne fait pas de SSH direct au MVP ;
- elle consomme les mêmes JSON que la webapp ;
- elle doit découvrir les capacités du serveur avant d'afficher ses actions ;
- les secrets machines restent côté backend ;
- le token client local sera stocké côté app via trousseau système, quand le scaffold Rust sera autorisé.
Point d'environnement :
- Rust est installé.
- GTK4/libadwaita ne sont pas encore visibles via `pkg-config`, donc l'UI GNOME complète attendra les paquets système de développement.
---
## 2. Jalons
### 8.0 — API commune minimale
- [x] Relire `tache8.md` et `validation_tache8.md`.
- [x] Identifier le premier endpoint utile pour app locale.
- [x] Ajouter le type partagé `ServerCapabilities`.
- [x] Exposer `GET /api/capabilities`.
- [x] Ajouter un test du contrat capabilities.
- [x] Vérifier TypeScript/tests ciblés.
### 8.1 — Préparation sécurité client local
- [x] Définir le modèle `api_clients` côté backend.
- [x] Prévoir scopes : lecture seule, opérateur, admin, debug.
- [x] Prévoir révocation de token.
- [x] Documenter stockage token app locale via keyring.
- [x] Préparer un middleware d'auth API après validation du mode d'amorçage admin.
- [x] Ajouter une commande locale de création de token.
- [ ] Activer le middleware sur les routes après choix du mode bootstrap admin.
### 8.2 — Contrat API app locale
- [ ] Stabiliser endpoints machines/state/metrics/hardware.
- [ ] Stabiliser snapshots/executions/reports/messages.
- [ ] Clarifier pagination et erreurs structurées.
- [ ] Clarifier WebSocket/SSE pour sortie live.
- [x] Ajouter `/api/system/status`.
- [x] Ajouter `/api/system/metrics`.
### 8.3 — Scaffold Rust/GNOME
- [x] Créer workspace Rust après validation.
- [x] Utiliser un sous-dossier dédié : `app_rust/system-update-gnome/`.
- [ ] Choisir GTK4/libadwaita direct ou Relm4.
- [x] Implémenter configuration URL serveur.
- [x] Implémenter test de connexion via `/api/capabilities`.
- [ ] Stocker token via keyring.
- [x] Isoler la stratégie token dans `src/token_store.rs`.
### 8.4 — UI native MVP
- [x] Première fenêtre GTK/libadwaita derrière feature `gui`.
- [x] Champ URL serveur.
- [x] Boutons `Capabilities`, `Status`, `Metrics`.
- [x] Zone résultat JSON.
- [ ] HeaderBar + Sidebar complète.
- [ ] Liste machines.
- [ ] Tuile machine compacte.
- [ ] Détail machine.
- [ ] Lancement `apt_update_analyze`.
- [ ] Lecture rapports/logs réduits.
- [ ] Notifications desktop simples.
---
## 3. Avancement du tour en cours
- [x] Le repo a été inspecté.
- [x] Le manque prioritaire est identifié : endpoint capabilities absent.
- [x] Patch API capabilities appliqué.
- [x] Vérification TypeScript passée.
- [x] Vérification Vitest ciblée passée.
- [x] Vérification Vitest complète passée.
---
## 4. Résultat du premier incrément
Fichiers ajoutés/modifiés pour le démarrage tâche 8 :
- `shared/types.ts` : ajout du contrat partagé `ServerCapabilities`.
- `server/services/capabilities.ts` : génération du JSON de capabilities.
- `server/services/capabilities.test.ts` : test du contrat capabilities.
- `server/routes/index.ts` : exposition de `GET /api/capabilities`.
- `plan_8.md` : suivi d'avancement.
Vérifications :
- `tsc --noEmit` : OK.
- `vitest run server/services/capabilities.test.ts` : OK.
- `vitest run` : OK, 11 fichiers de test, 25 tests.
Décision de suite :
- Continuer par `8.1` et `8.2` : sécurité client local, scopes de token, erreurs structurées et endpoints stables.
- Débuter le scaffold Rust dans `app_rust/system-update-gnome`.
- Garder l'UI GTK/libadwaita pour un incrément suivant, car les bibliothèques système ne sont pas encore installées.
---
## 5. Validation utilisateur du démarrage dev
- [x] Demande reçue : "ok je valide pour que tu commences le dev".
- [x] `tache8.md` mis à jour : la tâche passe de design futur à développement progressif.
- [x] `validation_tache8.md` mis à jour : le code Rust est autorisé dans `app_rust/system-update-gnome`.
- [x] Scaffold Rust minimal créé dans `app_rust/system-update-gnome`.
- [x] `.gitignore` local ajouté pour ignorer `target/`.
- [x] `cargo fmt` passé.
- [x] `cargo test` passé : 7 tests.
---
## 6. Jalon 8.1 — Sécurité client local
Fichiers ajoutés/modifiés :
- `shared/types.ts` : scopes API et vues client API sans secret.
- `server/db/schema.ts` : table `api_clients`.
- `server/db/migrations/0001_api_clients.sql` : migration SQLite.
- `server/crypto/apiTokens.ts` : génération, préfixe, hash HMAC, vérification.
- `server/services/apiClients.ts` : création/liste/révocation côté service.
Vérifications :
- `tsc --noEmit` : OK.
- `vitest run server/crypto/apiTokens.test.ts server/services/apiClients.test.ts` : OK.
- `vitest run` : OK, 13 fichiers de test, 32 tests.
- migration SQLite temporaire : OK.
Décision :
- Ne pas exposer encore une route publique de création de token sans mécanisme admin.
- Garder `authTokens: false` dans `/api/capabilities` tant que l'auth n'est pas réellement activée.
Complément :
- `server/auth/apiAuth.ts` : middleware `requireApiScope` prêt à brancher.
- `server/auth/apiAuth.test.ts` : tests extraction Bearer.
- `app_rust/system-update-gnome/src/token_store.rs` : séparation token CLI/env/futur trousseau.
- `app_rust/system-update-gnome/docs/token-storage.md` : choix et règles du stockage token.
- `server/cli/createApiClient.ts` : création locale d'un token API sans route publique.
- `vitest run` : OK, 16 fichiers de test, 42 tests.
- `cargo test` : OK, 11 tests.
---
## 9. Passe compilation/test
Dernière passe lancée après validation du dossier projet :
- `tsc --noEmit` : OK.
- `vitest run` : OK, 16 fichiers de test, 42 tests.
- `vite build && tsup` : OK.
- `cargo fmt` : OK.
- `cargo test` : OK, 11 tests.
- `cargo build` : OK, sans warning après utilisation de l'identité keyring dans l'aide CLI.
---
## 10. Test réel client Rust ↔ backend
Backend temporaire lancé avec :
- `SU_DB_PATH=/tmp/system-update-rust-client-test.db`.
- `SU_REPORTS_DIR=/tmp/system-update-rust-client-reports`.
- `SU_PORT=8787`.
Commandes Rust testées :
- `cargo run -- capabilities` : OK, JSON capabilities reçu.
- `cargo run -- status` : OK, JSON status reçu.
- `cargo run -- metrics` : OK, JSON metrics reçu.
Nettoyage :
- backend temporaire arrêté après test ;
- vérification `/health` après arrêt : connexion refusée, donc port libéré.
---
## 11. Début interface graphique Rust
Décision :
- UI GTK4/libadwaita ajoutée derrière la feature Cargo `gui`.
- Le client CLI reste compilable sans GTK.
- Lancement prévu : `cargo run --features gui -- gui`.
Pré-requis système manquants sur la machine au moment du test :
- `pkg-config --modversion gtk4` : paquet absent.
- `pkg-config --modversion libadwaita-1` : paquet absent.
Installation à faire dans un terminal utilisateur :
- `sudo apt install libgtk-4-dev libadwaita-1-dev`.
État :
- Code GUI ajouté.
- Crates GTK/libadwaita résolues via Cargo.
- `cargo test` sans feature GUI : OK, 12 tests.
- `cargo build` sans feature GUI : OK.
- `cargo check --features gui` : bloqué par paquets système manquants (`gtk4`, `pango`, `cairo`, `glib-2.0`, `gio-2.0`, `gdk-pixbuf-2.0`, `graphene-gobject-1.0`).
Commande utilisateur à lancer dans un terminal interactif :
```bash
sudo apt install libgtk-4-dev libadwaita-1-dev
```
Puis retester :
```bash
cd /home/gilles/Documents/projet/system_update/app_rust/system-update-gnome
cargo check --features gui
cargo run --features gui -- --server http://10.0.1.137:8787 gui
```
Correction suivante :
- Bug observé : GTK recevait `--server` et affichait `Option inconnue --server`.
- Cause : `adw::Application::run()` relisait les arguments du processus.
- Fix : lancement GUI via `run_with_args::<&str>(&[])`.
- Warning supprimé : import GTK inutilisé.
- Vérification : `cargo check --features gui` OK, `cargo test` OK.
- Layout natif aligné webapp : Hermes gauche, Machines centre, terminal API droit, barre de tâche basse.
- `/api/machines` consommé par la GUI pour remplir la zone centrale.
- Commande CLI `machines` ajoutée pour tester le même endpoint hors GUI.
---
## 12. Pause tâche 8
- Date : 2026-06-05.
- Décision utilisateur : terminer la tâche 8 plus tard.
- État au moment de la pause :
- client Rust CLI fonctionnel ;
- commandes `capabilities`, `status`, `metrics`, `machines` ;
- première GUI GTK/libadwaita disponible derrière `--features gui` ;
- layout GUI rapproché de la webapp : Hermes gauche, Machines centre, terminal droit, barre basse ;
- compilation/test GUI OK après installation des paquets système ;
- prochaine reprise : améliorer UX native, keyring, vrai modèle machines/actions.
---
## 7. Notes techniques
- Le backend actuel expose déjà `/api/machines`, actions APT/reboot et WebSocket de sortie machine.
- L'app locale a besoin de savoir quelles fonctions sont réellement disponibles pour masquer les fonctions futures : Hermes, Docker, post-install, SSH interactif, settings, etc.
- `GET /api/capabilities` doit retourner un JSON stable, sans secret, exploitable par webapp, Hermes et future app Rust.
---
## 8. Jalon 8.2 — Endpoints système app locale
Fichiers ajoutés/modifiés :
- `shared/types.ts` : types `SystemStatus` et `SystemMetrics`.
- `server/services/system.ts` : status et métriques process/hôte.
- `server/routes/index.ts` : routes `GET /api/system/status` et `GET /api/system/metrics`.
- `server/services/capabilities.ts` : capabilities enrichies avec les endpoints système.
- `app_rust/system-update-gnome` : commandes CLI `status` et `metrics`.
Vérifications :
- `tsc --noEmit` : OK.
- `vitest run server/services/system.test.ts server/services/capabilities.test.ts` : OK.
- `vitest run` : OK, 14 fichiers de test, 35 tests.
- `cargo fmt` : OK.
- `cargo test` : OK, 8 tests.
+33
View File
@@ -8,6 +8,18 @@ importers:
.:
dependencies:
'@fontsource/inter':
specifier: ^5.2.8
version: 5.2.8
'@fontsource/jetbrains-mono':
specifier: ^5.2.8
version: 5.2.8
'@fontsource/share-tech-mono':
specifier: ^5.2.7
version: 5.2.7
'@fortawesome/fontawesome-free':
specifier: ^7.2.0
version: 7.2.0
'@hono/node-server':
specifier: ^1.13.0
version: 1.19.14(hono@4.12.23)
@@ -1060,6 +1072,19 @@ packages:
cpu: [x64]
os: [win32]
'@fontsource/inter@5.2.8':
resolution: {integrity: sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==}
'@fontsource/jetbrains-mono@5.2.8':
resolution: {integrity: sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ==}
'@fontsource/share-tech-mono@5.2.7':
resolution: {integrity: sha512-1JBJ6CU9u5av8aFEUOGOJkq60/IEVVOZDCmiU8X3i0skk0Pp69GngDwlBUHaTZa4G6pbF1UDrC+Fm7XSckW6TQ==}
'@fortawesome/fontawesome-free@7.2.0':
resolution: {integrity: sha512-3DguDv/oUE+7vjMeTSOjCSG+KeawgVQOHrKRnvUuqYh1mfArrh7s+s8hXW3e4RerBA1+Wh+hBqf8sJNpqNrBWg==}
engines: {node: '>=6'}
'@hono/node-server@1.19.14':
resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==}
engines: {node: '>=18.14.1'}
@@ -2687,6 +2712,14 @@ snapshots:
'@esbuild/win32-x64@0.28.0':
optional: true
'@fontsource/inter@5.2.8': {}
'@fontsource/jetbrains-mono@5.2.8': {}
'@fontsource/share-tech-mono@5.2.7': {}
'@fortawesome/fontawesome-free@7.2.0': {}
'@hono/node-server@1.19.14(hono@4.12.23)':
dependencies:
hono: 4.12.23
+18
View File
@@ -0,0 +1,18 @@
// server/auth/apiAuth.test.ts
import { describe, expect, it } from "vitest";
import { apiAuthInternals } from "./apiAuth.js";
describe("apiAuthInternals", () => {
it("extrait un token bearer", () => {
expect(apiAuthInternals.extractBearerToken("Bearer su_token")).toBe("su_token");
});
it("accepte bearer sans sensibilité à la casse", () => {
expect(apiAuthInternals.extractBearerToken("bearer su_token")).toBe("su_token");
});
it("rejette un header absent ou mal formé", () => {
expect(apiAuthInternals.extractBearerToken(null)).toBeNull();
expect(apiAuthInternals.extractBearerToken("Basic abc")).toBeNull();
});
});
+34
View File
@@ -0,0 +1,34 @@
// server/auth/apiAuth.ts
import type { MiddlewareHandler } from "hono";
import type { ApiClientScope, ApiClientView } from "@shared/types.js";
import { authenticateApiToken, hasApiScope } from "../services/apiClients.js";
export interface ApiAuthVariables {
apiClient: ApiClientView;
}
export function extractBearerToken(authorization: string | null | undefined): string | null {
if (!authorization) return null;
const match = /^Bearer\s+(.+)$/i.exec(authorization.trim());
return match?.[1]?.trim() || null;
}
export function requireApiScope(required: ApiClientScope): MiddlewareHandler<{
Variables: ApiAuthVariables;
}> {
return async (c, next) => {
const token = extractBearerToken(c.req.header("Authorization"));
if (!token) return c.json({ error: "Token API manquant" }, 401);
const client = authenticateApiToken(token);
if (!client) return c.json({ error: "Token API invalide ou révoqué" }, 401);
if (!hasApiScope(client.scopes, required)) {
return c.json({ error: "Scope API insuffisant" }, 403);
}
c.set("apiClient", client);
await next();
};
}
export const apiAuthInternals = { extractBearerToken };
+28
View File
@@ -0,0 +1,28 @@
// server/cli/createApiClient.test.ts
import { describe, expect, it } from "vitest";
import {
createApiClientCliInternals,
parseCreateApiClientArgs,
} from "./createApiClient.js";
describe("createApiClient CLI", () => {
it("parse un nom et des scopes", () => {
expect(
parseCreateApiClientArgs(["--name", "App Rust", "--scopes", "read,operate,read"]),
).toEqual({
name: "App Rust",
scopes: ["read", "operate"],
});
});
it("utilise read par défaut", () => {
expect(parseCreateApiClientArgs(["--name", "Hermes"])).toEqual({
name: "Hermes",
scopes: ["read"],
});
});
it("rejette un scope invalide", () => {
expect(() => createApiClientCliInternals.parseScopes("read,root")).toThrow("Scope invalide");
});
});
+78
View File
@@ -0,0 +1,78 @@
// server/cli/createApiClient.ts
import { pathToFileURL } from "node:url";
import type { ApiClientScope } from "@shared/types.js";
import { runMigrations } from "../db/migrate.js";
import { createApiClient } from "../services/apiClients.js";
export interface CreateApiClientCliOptions {
name: string;
scopes: ApiClientScope[];
}
const ALLOWED_SCOPES: ApiClientScope[] = ["read", "operate", "admin", "debug"];
export function parseCreateApiClientArgs(args: string[]): CreateApiClientCliOptions {
let name = "";
let scopes: ApiClientScope[] = ["read"];
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === "--name") {
i += 1;
name = args[i] ?? "";
} else if (arg === "--scopes") {
i += 1;
scopes = parseScopes(args[i] ?? "");
} else if (arg === "--help" || arg === "-h") {
throw new Error(helpText());
} else {
throw new Error(`Argument inconnu: ${arg}\n\n${helpText()}`);
}
}
if (!name.trim()) throw new Error(`--name est obligatoire\n\n${helpText()}`);
return { name: name.trim(), scopes };
}
function parseScopes(raw: string): ApiClientScope[] {
const scopes = raw
.split(",")
.map((scope) => scope.trim())
.filter(Boolean) as ApiClientScope[];
if (scopes.length === 0) return ["read"];
for (const scope of scopes) {
if (!ALLOWED_SCOPES.includes(scope)) {
throw new Error(`Scope invalide: ${scope}. Scopes valides: ${ALLOWED_SCOPES.join(", ")}`);
}
}
return [...new Set(scopes)];
}
function helpText(): string {
return [
"Usage:",
" pnpm api-client:create -- --name \"App Rust\" --scopes read,operate",
"",
"Variables requises:",
" SU_MASTER_KEY clé hex 64 caractères",
" SU_DB_PATH chemin SQLite optionnel",
].join("\n");
}
async function main(): Promise<void> {
const options = parseCreateApiClientArgs(process.argv.slice(2));
runMigrations();
const created = createApiClient(options);
console.log(JSON.stringify(created, null, 2));
}
const entrypoint = process.argv[1] ? pathToFileURL(process.argv[1]).href : "";
if (import.meta.url === entrypoint) {
main().catch((err) => {
console.error((err as Error).message);
process.exitCode = 1;
});
}
export const createApiClientCliInternals = { parseScopes, helpText };
+25
View File
@@ -0,0 +1,25 @@
// server/crypto/apiTokens.test.ts
import { describe, expect, it } from "vitest";
import { generateApiToken, hashApiToken, tokenPrefix, verifyApiToken } from "./apiTokens.js";
const PEPPER = "b".repeat(64);
describe("apiTokens", () => {
it("génère un token préfixé non trivial", () => {
const token = generateApiToken();
expect(token).toMatch(/^su_[A-Za-z0-9_-]{40,}$/);
});
it("calcule un préfixe court affichable", () => {
expect(tokenPrefix("su_abcdefghijklmnopqrstuvwxyz")).toBe("su_abcdefghi");
});
it("vérifie un token par HMAC sans stocker le token brut", () => {
const token = "su_test_token";
const hash = hashApiToken(token, PEPPER);
expect(hash).not.toContain(token);
expect(verifyApiToken(token, hash, PEPPER)).toBe(true);
expect(verifyApiToken("su_other_token", hash, PEPPER)).toBe(false);
});
});
+24
View File
@@ -0,0 +1,24 @@
// server/crypto/apiTokens.ts
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
const TOKEN_BYTES = 32;
const TOKEN_PREFIX_LENGTH = 12;
export function generateApiToken(): string {
return `su_${randomBytes(TOKEN_BYTES).toString("base64url")}`;
}
export function tokenPrefix(token: string): string {
return token.slice(0, TOKEN_PREFIX_LENGTH);
}
export function hashApiToken(token: string, pepperHex: string): string {
const pepper = Buffer.from(pepperHex, "hex");
return createHmac("sha256", pepper).update(token).digest("base64url");
}
export function verifyApiToken(token: string, expectedHash: string, pepperHex: string): boolean {
const actual = Buffer.from(hashApiToken(token, pepperHex));
const expected = Buffer.from(expectedHash);
return actual.length === expected.length && timingSafeEqual(actual, expected);
}
+3
View File
@@ -1,7 +1,10 @@
// server/db/migrate.ts
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { db } from "./client.js";
import { backfillCredentials } from "../services/credentials.js";
export function runMigrations(): void {
migrate(db, { migrationsFolder: "./server/db/migrations" });
const n = backfillCredentials();
if (n > 0) console.log(`[migrate] backfill credentials: ${n} machine(s)`);
}
+12
View File
@@ -0,0 +1,12 @@
CREATE TABLE `api_clients` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`token_prefix` text NOT NULL,
`token_hash` text NOT NULL,
`scopes_json` text NOT NULL,
`created_at` text NOT NULL,
`last_used_at` text,
`revoked_at` text
);
--> statement-breakpoint
CREATE UNIQUE INDEX `api_clients_token_hash_unique` ON `api_clients` (`token_hash`);
@@ -0,0 +1,150 @@
CREATE TABLE `important_messages` (
`id` text PRIMARY KEY NOT NULL,
`machine_id` text,
`source` text NOT NULL,
`category` text NOT NULL,
`severity` text NOT NULL,
`package_name` text,
`component` text,
`message` text NOT NULL,
`raw_line_ref` text,
`snapshot_id` text,
`execution_id` text,
`first_seen_at` text NOT NULL,
`last_seen_at` text NOT NULL,
`acknowledged` integer DEFAULT 0 NOT NULL,
`acknowledged_at` text,
`acknowledged_by` text,
`payload_json` text,
FOREIGN KEY (`machine_id`) REFERENCES `machines`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `machine_events` (
`id` text PRIMARY KEY NOT NULL,
`machine_id` text,
`event_type` text NOT NULL,
`severity` text NOT NULL,
`created_at` text NOT NULL,
`actor_type` text,
`actor_id` text,
`snapshot_id` text,
`execution_id` text,
`job_id` text,
`message` text,
`payload_json` text,
FOREIGN KEY (`machine_id`) REFERENCES `machines`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `machine_hardware` (
`machine_id` text PRIMARY KEY NOT NULL,
`probe_snapshot_id` text,
`cpu_model` text,
`cpu_cores` integer,
`memory_bytes` integer,
`gpus_json` text,
`disks_json` text,
`network_json` text,
`firmware_json` text,
`driver_json` text,
`warnings_json` text,
`updated_at` text NOT NULL,
FOREIGN KEY (`machine_id`) REFERENCES `machines`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `machine_metrics_latest` (
`machine_id` text PRIMARY KEY NOT NULL,
`snapshot_id` text,
`collected_at` text NOT NULL,
`cpu_load1` real,
`cpu_load5` real,
`cpu_cores` integer,
`memory_total_bytes` integer,
`memory_used_bytes` integer,
`memory_available_bytes` integer,
`memory_used_percent` real,
`filesystems_json` text,
`root_used_percent` real,
`warnings_json` text,
FOREIGN KEY (`machine_id`) REFERENCES `machines`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `machine_state` (
`machine_id` text PRIMARY KEY NOT NULL,
`status` text NOT NULL,
`apt_status` text,
`apt_updates_count` integer DEFAULT 0 NOT NULL,
`apt_reboot_required` integer DEFAULT 0 NOT NULL,
`apt_last_analyze_at` text,
`docker_status` text,
`docker_installed` integer DEFAULT 0 NOT NULL,
`docker_stacks_count` integer DEFAULT 0 NOT NULL,
`docker_updates_count` integer DEFAULT 0 NOT NULL,
`docker_prune_available` integer DEFAULT 0 NOT NULL,
`post_install_status` text,
`metrics_last_collected_at` text,
`cpu_load1` real,
`memory_used_percent` real,
`root_used_percent` real,
`disk_warnings_count` integer DEFAULT 0 NOT NULL,
`hardware_warnings_count` integer DEFAULT 0 NOT NULL,
`running_job_id` text,
`last_error_kind` text,
`last_error_message` text,
`updated_at` text NOT NULL,
FOREIGN KEY (`machine_id`) REFERENCES `machines`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `raw_artifacts` (
`id` text PRIMARY KEY NOT NULL,
`machine_id` text,
`kind` text NOT NULL,
`path` text NOT NULL,
`bytes` integer,
`sha256` text,
`created_at` text NOT NULL,
`expires_at` text,
`pinned` integer DEFAULT 0 NOT NULL,
`redacted` integer DEFAULT 1 NOT NULL,
`retention_policy` text,
`deleted_at` text,
`delete_reason` text,
`metadata_json` text,
FOREIGN KEY (`machine_id`) REFERENCES `machines`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `reports` (
`id` text PRIMARY KEY NOT NULL,
`machine_id` text,
`execution_id` text,
`kind` text NOT NULL,
`title` text NOT NULL,
`path` text NOT NULL,
`created_at` text NOT NULL,
`pinned` integer DEFAULT 0 NOT NULL,
`summary_json` text,
FOREIGN KEY (`machine_id`) REFERENCES `machines`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
ALTER TABLE `executions` ADD `schema_version` integer DEFAULT 1 NOT NULL;--> statement-breakpoint
ALTER TABLE `executions` ADD `request_id` text;--> statement-breakpoint
ALTER TABLE `executions` ADD `job_id` text;--> statement-breakpoint
ALTER TABLE `executions` ADD `important_json` text;--> statement-breakpoint
ALTER TABLE `executions` ADD `report_id` text;--> statement-breakpoint
ALTER TABLE `executions` ADD `exit_code` integer;--> statement-breakpoint
ALTER TABLE `executions` ADD `error_kind` text;--> statement-breakpoint
ALTER TABLE `executions` ADD `error_message` text;--> statement-breakpoint
ALTER TABLE `machines` ADD `os_version` text;--> statement-breakpoint
ALTER TABLE `machines` ADD `os_codename` text;--> statement-breakpoint
ALTER TABLE `machines` ADD `arch` text;--> statement-breakpoint
ALTER TABLE `machines` ADD `machine_kind` text;--> statement-breakpoint
ALTER TABLE `machines` ADD `virtualization` text;--> statement-breakpoint
ALTER TABLE `machines` ADD `hardware_profile` text;--> statement-breakpoint
ALTER TABLE `machines` ADD `last_seen_at` text;--> statement-breakpoint
ALTER TABLE `machines` ADD `updated_at` text;--> statement-breakpoint
ALTER TABLE `machines` ADD `deleted_at` text;--> statement-breakpoint
ALTER TABLE `snapshots` ADD `kind` text DEFAULT 'apt_update_analyze' NOT NULL;--> statement-breakpoint
ALTER TABLE `snapshots` ADD `schema_version` integer DEFAULT 1 NOT NULL;--> statement-breakpoint
ALTER TABLE `snapshots` ADD `important_json` text;--> statement-breakpoint
ALTER TABLE `snapshots` ADD `raw_log_path` text;--> statement-breakpoint
ALTER TABLE `snapshots` ADD `raw_artifact_id` text;--> statement-breakpoint
ALTER TABLE `snapshots` ADD `source_job_id` text;
@@ -0,0 +1,28 @@
CREATE TABLE `machine_credentials` (
`machine_id` text PRIMARY KEY NOT NULL,
`auth_method` text NOT NULL,
`enc_password` text,
`enc_sudo_password` text,
`enc_private_key` text,
`enc_key_passphrase` text,
`sudo_mode` text NOT NULL,
`created_at` text NOT NULL,
`updated_at` text NOT NULL,
`last_test_at` text,
`status` text,
FOREIGN KEY (`machine_id`) REFERENCES `machines`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `machine_host_keys` (
`id` text PRIMARY KEY NOT NULL,
`machine_id` text,
`hostname` text NOT NULL,
`port` integer NOT NULL,
`key_type` text,
`fingerprint_sha256` text NOT NULL,
`public_key` text,
`status` text NOT NULL,
`first_seen_at` text NOT NULL,
`last_seen_at` text NOT NULL,
FOREIGN KEY (`machine_id`) REFERENCES `machines`(`id`) ON UPDATE no action ON DELETE cascade
);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+21
View File
@@ -8,6 +8,27 @@
"when": 1780599514478,
"tag": "0000_brainy_dakota_north",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1780669000000,
"tag": "0001_api_clients",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1780669100000,
"tag": "0002_reflective_lifeguard",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1780669200000,
"tag": "0003_magical_psylocke",
"breakpoints": true
}
]
}
+59
View File
@@ -0,0 +1,59 @@
// server/db/schema.test.ts
import { describe, it, expect } from "vitest";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
function freshMigratedDb() {
const sqlite = new Database(":memory:");
const db = drizzle(sqlite);
migrate(db, { migrationsFolder: "./server/db/migrations" });
return sqlite;
}
function tableNames(sqlite: Database.Database): string[] {
return sqlite.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map((r: any) => r.name);
}
function columnNames(sqlite: Database.Database, table: string): string[] {
return sqlite.prepare(`PRAGMA table_info(${table})`).all().map((r: any) => r.name);
}
describe("schéma Phase 1", () => {
it("crée les tables socle", () => {
const sqlite = freshMigratedDb();
const tables = tableNames(sqlite);
for (const t of [
"machines", "snapshots", "executions",
"machine_state", "machine_hardware", "machine_metrics_latest",
"machine_events", "important_messages", "reports", "raw_artifacts",
]) {
expect(tables, `table ${t}`).toContain(t);
}
});
it("ajoute les colonnes étendues sans casser l'existant", () => {
const sqlite = freshMigratedDb();
expect(columnNames(sqlite, "machines")).toEqual(
expect.arrayContaining(["machine_kind", "virtualization", "hardware_profile", "os_version", "updated_at"]),
);
expect(columnNames(sqlite, "snapshots")).toEqual(
expect.arrayContaining(["kind", "schema_version", "important_json"]),
);
expect(columnNames(sqlite, "executions")).toEqual(
expect.arrayContaining(["schema_version", "error_kind", "error_message", "exit_code"]),
);
// colonnes jalon 1 conservées
expect(columnNames(sqlite, "snapshots")).toContain("checked_at");
expect(columnNames(sqlite, "machines")).toContain("enc_password");
});
});
describe("schéma Phase 2", () => {
it("crée les tables de credentials Phase 2", () => {
const sqlite = freshMigratedDb();
const tables = tableNames(sqlite);
expect(tables).toEqual(expect.arrayContaining(["machine_credentials", "machine_host_keys"]));
// machines conserve ses colonnes secrets legacy (fallback)
expect(columnNames(sqlite, "machines")).toContain("enc_password");
});
});
+190 -1
View File
@@ -1,5 +1,5 @@
// server/db/schema.ts
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
import { sqliteTable, text, integer, real, uniqueIndex } from "drizzle-orm/sqlite-core";
export const machines = sqliteTable("machines", {
id: text("id").primaryKey(),
@@ -7,6 +7,12 @@ export const machines = sqliteTable("machines", {
hostname: text("hostname").notNull(),
port: integer("port").notNull().default(22),
osFamily: text("os_family").notNull().default("unknown"),
osVersion: text("os_version"),
osCodename: text("os_codename"),
arch: text("arch"),
machineKind: text("machine_kind"), // physical | vm | proxmox_host | lxc | raspberry_pi | workstation | unknown
virtualization: text("virtualization"), // none | qemu | kvm | lxc | docker | vmware | ...
hardwareProfile: text("hardware_profile"), // generic_vm | baremetal_server | raspberry_pi | gpu_server | proxmox_host | ...
username: text("username").notNull(),
encPassword: text("enc_password").notNull(),
encSudoPassword: text("enc_sudo_password"),
@@ -14,15 +20,24 @@ export const machines = sqliteTable("machines", {
aptProxyUrl: text("apt_proxy_url"),
status: text("status").notNull().default("unknown"),
lastCheckedAt: text("last_checked_at"),
lastSeenAt: text("last_seen_at"),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at"),
deletedAt: text("deleted_at"),
});
export const snapshots = sqliteTable("snapshots", {
id: text("id").primaryKey(),
machineId: text("machine_id").notNull().references(() => machines.id, { onDelete: "cascade" }),
kind: text("kind").notNull().default("apt_update_analyze"),
schemaVersion: integer("schema_version").notNull().default(1),
checkedAt: text("checked_at").notNull(),
status: text("status").notNull(),
payloadJson: text("payload_json").notNull(),
importantJson: text("important_json"),
rawLogPath: text("raw_log_path"),
rawArtifactId: text("raw_artifact_id"),
sourceJobId: text("source_job_id"),
});
export const executions = sqliteTable("executions", {
@@ -30,10 +45,184 @@ export const executions = sqliteTable("executions", {
machineId: text("machine_id").notNull().references(() => machines.id, { onDelete: "cascade" }),
action: text("action").notNull(),
mode: text("mode").notNull().default("manual"),
schemaVersion: integer("schema_version").notNull().default(1),
startedAt: text("started_at").notNull(),
finishedAt: text("finished_at"),
status: text("status").notNull(),
requestId: text("request_id"),
jobId: text("job_id"),
resultJson: text("result_json"),
importantJson: text("important_json"),
reportPath: text("report_path"),
rawLogPath: text("raw_log_path"),
reportId: text("report_id"),
exitCode: integer("exit_code"),
errorKind: text("error_kind"),
errorMessage: text("error_message"),
});
export const machineState = sqliteTable("machine_state", {
machineId: text("machine_id").primaryKey().references(() => machines.id, { onDelete: "cascade" }),
status: text("status").notNull(),
aptStatus: text("apt_status"),
aptUpdatesCount: integer("apt_updates_count").notNull().default(0),
aptRebootRequired: integer("apt_reboot_required").notNull().default(0),
aptLastAnalyzeAt: text("apt_last_analyze_at"),
dockerStatus: text("docker_status"),
dockerInstalled: integer("docker_installed").notNull().default(0),
dockerStacksCount: integer("docker_stacks_count").notNull().default(0),
dockerUpdatesCount: integer("docker_updates_count").notNull().default(0),
dockerPruneAvailable: integer("docker_prune_available").notNull().default(0),
postInstallStatus: text("post_install_status"),
metricsLastCollectedAt: text("metrics_last_collected_at"),
cpuLoad1: real("cpu_load1"),
memoryUsedPercent: real("memory_used_percent"),
rootUsedPercent: real("root_used_percent"),
diskWarningsCount: integer("disk_warnings_count").notNull().default(0),
hardwareWarningsCount: integer("hardware_warnings_count").notNull().default(0),
runningJobId: text("running_job_id"),
lastErrorKind: text("last_error_kind"),
lastErrorMessage: text("last_error_message"),
updatedAt: text("updated_at").notNull(),
});
export const machineHardware = sqliteTable("machine_hardware", {
machineId: text("machine_id").primaryKey().references(() => machines.id, { onDelete: "cascade" }),
probeSnapshotId: text("probe_snapshot_id"),
cpuModel: text("cpu_model"),
cpuCores: integer("cpu_cores"),
memoryBytes: integer("memory_bytes"),
gpusJson: text("gpus_json"),
disksJson: text("disks_json"),
networkJson: text("network_json"),
firmwareJson: text("firmware_json"),
driverJson: text("driver_json"),
warningsJson: text("warnings_json"),
updatedAt: text("updated_at").notNull(),
});
export const machineMetricsLatest = sqliteTable("machine_metrics_latest", {
machineId: text("machine_id").primaryKey().references(() => machines.id, { onDelete: "cascade" }),
snapshotId: text("snapshot_id"),
collectedAt: text("collected_at").notNull(),
cpuLoad1: real("cpu_load1"),
cpuLoad5: real("cpu_load5"),
cpuCores: integer("cpu_cores"),
memoryTotalBytes: integer("memory_total_bytes"),
memoryUsedBytes: integer("memory_used_bytes"),
memoryAvailableBytes: integer("memory_available_bytes"),
memoryUsedPercent: real("memory_used_percent"),
filesystemsJson: text("filesystems_json"),
rootUsedPercent: real("root_used_percent"),
warningsJson: text("warnings_json"),
});
export const machineEvents = sqliteTable("machine_events", {
id: text("id").primaryKey(),
machineId: text("machine_id").references(() => machines.id, { onDelete: "cascade" }),
eventType: text("event_type").notNull(),
severity: text("severity").notNull(), // info | warning | error
createdAt: text("created_at").notNull(),
actorType: text("actor_type"), // user | system | schedule | hermes
actorId: text("actor_id"),
snapshotId: text("snapshot_id"),
executionId: text("execution_id"),
jobId: text("job_id"),
message: text("message"),
payloadJson: text("payload_json"),
});
export const importantMessages = sqliteTable("important_messages", {
id: text("id").primaryKey(),
machineId: text("machine_id").references(() => machines.id, { onDelete: "cascade" }),
source: text("source").notNull(), // apt | docker | post_install | ssh | system
category: text("category").notNull(), // error | warning | future_major_change | ...
severity: text("severity").notNull(),
packageName: text("package_name"),
component: text("component"),
message: text("message").notNull(),
rawLineRef: text("raw_line_ref"),
snapshotId: text("snapshot_id"),
executionId: text("execution_id"),
firstSeenAt: text("first_seen_at").notNull(),
lastSeenAt: text("last_seen_at").notNull(),
acknowledged: integer("acknowledged").notNull().default(0),
acknowledgedAt: text("acknowledged_at"),
acknowledgedBy: text("acknowledged_by"),
payloadJson: text("payload_json"),
});
export const reports = sqliteTable("reports", {
id: text("id").primaryKey(),
machineId: text("machine_id").references(() => machines.id, { onDelete: "cascade" }),
executionId: text("execution_id"),
kind: text("kind").notNull(), // machine | global | cleanup | hermes
title: text("title").notNull(),
path: text("path").notNull(),
createdAt: text("created_at").notNull(),
pinned: integer("pinned").notNull().default(0),
summaryJson: text("summary_json"),
});
export const rawArtifacts = sqliteTable("raw_artifacts", {
id: text("id").primaryKey(),
machineId: text("machine_id").references(() => machines.id, { onDelete: "cascade" }),
kind: text("kind").notNull(), // raw_log | rendered_template | export | screenshot
path: text("path").notNull(),
bytes: integer("bytes"),
sha256: text("sha256"),
createdAt: text("created_at").notNull(),
expiresAt: text("expires_at"),
pinned: integer("pinned").notNull().default(0),
redacted: integer("redacted").notNull().default(1),
retentionPolicy: text("retention_policy"), // default | failed | pinned | short
deletedAt: text("deleted_at"),
deleteReason: text("delete_reason"),
metadataJson: text("metadata_json"),
});
// --- Préexistant (WIP api_clients) : NE PAS supprimer ---
export const apiClients = sqliteTable(
"api_clients",
{
id: text("id").primaryKey(),
name: text("name").notNull(),
tokenPrefix: text("token_prefix").notNull(),
tokenHash: text("token_hash").notNull(),
scopesJson: text("scopes_json").notNull(),
createdAt: text("created_at").notNull(),
lastUsedAt: text("last_used_at"),
revokedAt: text("revoked_at"),
},
(table) => ({
tokenHashIdx: uniqueIndex("api_clients_token_hash_unique").on(table.tokenHash),
}),
);
// --- Phase 2 : credentials isolés (non destructif) ---
export const machineCredentials = sqliteTable("machine_credentials", {
machineId: text("machine_id").primaryKey().references(() => machines.id, { onDelete: "cascade" }),
authMethod: text("auth_method").notNull(), // password | ssh_key
encPassword: text("enc_password"),
encSudoPassword: text("enc_sudo_password"),
encPrivateKey: text("enc_private_key"),
encKeyPassphrase: text("enc_key_passphrase"),
sudoMode: text("sudo_mode").notNull(), // same_as_ssh | separate | none
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull(),
lastTestAt: text("last_test_at"),
status: text("status"), // ok | error | unknown
});
export const machineHostKeys = sqliteTable("machine_host_keys", {
id: text("id").primaryKey(),
machineId: text("machine_id").references(() => machines.id, { onDelete: "cascade" }),
hostname: text("hostname").notNull(),
port: integer("port").notNull(),
keyType: text("key_type"),
fingerprintSha256: text("fingerprint_sha256").notNull(),
publicKey: text("public_key"),
status: text("status").notNull(), // approved | changed | rejected | unknown
firstSeenAt: text("first_seen_at").notNull(),
lastSeenAt: text("last_seen_at").notNull(),
});
+4
View File
@@ -14,6 +14,10 @@ env.requireMasterKey();
runMigrations();
const app = new Hono();
app.onError((err, c) => {
console.error("[api]", err.message);
return c.json({ error: err.message || "Erreur serveur" }, 500);
});
app.route("/api", api);
app.get("/health", (c) => c.json({ ok: true }));
+5
View File
@@ -2,7 +2,12 @@
import { Hono } from "hono";
import { machinesRoutes } from "./machines.js";
import { actionsRoutes } from "./actions.js";
import { getServerCapabilities } from "../services/capabilities.js";
import { getSystemMetrics, getSystemStatus } from "../services/system.js";
export const api = new Hono();
api.get("/capabilities", (c) => c.json(getServerCapabilities()));
api.get("/system/status", (c) => c.json(getSystemStatus()));
api.get("/system/metrics", (c) => c.json(getSystemMetrics()));
api.route("/machines", machinesRoutes);
api.route("/machines", actionsRoutes);
@@ -0,0 +1,20 @@
===SU:APT_UPDATE===
Hit:1 http://deb.debian.org/debian bookworm InRelease
Reading package lists...
===SU:APT_SIM_UPGRADE===
Reading package lists...
Building dependency tree...
Inst libc6 [2.31-13] (2.31-13+deb11u5 Debian:11.6/stable [amd64])
Conf libc6 (2.31-13+deb11u5 Debian:11.6/stable [amd64])
===SU:APT_SIM_DISTUPGRADE===
Reading package lists...
Inst libc6 [2.31-13] (2.31-13+deb11u5 Debian:11.6/stable [amd64])
Inst newdep (1.0.0 Debian:11.6/stable [all])
Remv oldpkg [3.2-1]
Conf libc6 (2.31-13+deb11u5 Debian:11.6/stable [amd64])
===SU:APT_HELD===
frozenpkg
===SU:REBOOT===
REBOOT_REQUIRED=1
PKG=linux-image-amd64
===SU:EXIT=0===
+64
View File
@@ -0,0 +1,64 @@
// server/services/apiClients.test.ts
import { describe, expect, it, vi } from "vitest";
vi.mock("../db/client.js", () => ({
db: {},
schema: { apiClients: {} },
}));
vi.mock("../env.js", () => ({ env: { requireMasterKey: vi.fn() } }));
import { apiClientInternals } from "./apiClients.js";
describe("apiClientInternals", () => {
it("retombe sur read quand aucun scope n'est fourni", () => {
expect(apiClientInternals.normalizeScopes([])).toEqual(["read"]);
});
it("déduplique les scopes en gardant l'ordre", () => {
expect(apiClientInternals.normalizeScopes(["read", "operate", "read"])).toEqual([
"read",
"operate",
]);
});
it("rejette un scope inconnu", () => {
expect(() => apiClientInternals.normalizeScopes(["root" as never])).toThrow(
"Scope API inconnu: root",
);
});
it("convertit une ligne DB en vue sans token hash", () => {
const view = apiClientInternals.toView({
id: "client_1",
name: "App locale",
tokenPrefix: "su_abcdefghi",
tokenHash: "hash-secret",
scopesJson: '["read","operate"]',
createdAt: "2026-06-05T08:00:00.000Z",
lastUsedAt: null,
revokedAt: null,
});
expect(view).toEqual({
id: "client_1",
name: "App locale",
tokenPrefix: "su_abcdefghi",
scopes: ["read", "operate"],
createdAt: "2026-06-05T08:00:00.000Z",
lastUsedAt: null,
revokedAt: null,
});
expect(JSON.stringify(view)).not.toContain("hash-secret");
});
it("applique les scopes par capacité", () => {
expect(apiClientInternals.hasApiScope(["read"], "read")).toBe(true);
expect(apiClientInternals.hasApiScope(["read"], "operate")).toBe(false);
expect(apiClientInternals.hasApiScope(["operate"], "read")).toBe(true);
expect(apiClientInternals.hasApiScope(["operate"], "operate")).toBe(true);
expect(apiClientInternals.hasApiScope(["debug"], "debug")).toBe(true);
expect(apiClientInternals.hasApiScope(["admin"], "debug")).toBe(true);
expect(apiClientInternals.hasApiScope(["admin"], "admin")).toBe(true);
});
});
+118
View File
@@ -0,0 +1,118 @@
// server/services/apiClients.ts
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import type { ApiClientScope, ApiClientView, CreatedApiClient } from "@shared/types.js";
import { db, schema } from "../db/client.js";
import { env } from "../env.js";
import {
generateApiToken,
hashApiToken,
tokenPrefix,
verifyApiToken,
} from "../crypto/apiTokens.js";
type ApiClientRow = typeof schema.apiClients.$inferSelect;
const ALLOWED_SCOPES = new Set<ApiClientScope>(["read", "operate", "admin", "debug"]);
export interface CreateApiClientInput {
name: string;
scopes: ApiClientScope[];
}
function normalizeScopes(scopes: ApiClientScope[]): ApiClientScope[] {
const unique = [...new Set(scopes)];
if (unique.length === 0) return ["read"];
for (const scope of unique) {
if (!ALLOWED_SCOPES.has(scope)) throw new Error(`Scope API inconnu: ${scope}`);
}
return unique;
}
function scopesFromJson(json: string): ApiClientScope[] {
const parsed = JSON.parse(json) as ApiClientScope[];
return normalizeScopes(parsed);
}
function toView(row: ApiClientRow): ApiClientView {
return {
id: row.id,
name: row.name,
tokenPrefix: row.tokenPrefix,
scopes: scopesFromJson(row.scopesJson),
createdAt: row.createdAt,
lastUsedAt: row.lastUsedAt,
revokedAt: row.revokedAt,
};
}
export function createApiClient(input: CreateApiClientInput, now = new Date()): CreatedApiClient {
const name = input.name.trim();
if (!name) throw new Error("Le nom du client API est obligatoire");
const scopes = normalizeScopes(input.scopes);
const token = generateApiToken();
const pepper = env.requireMasterKey();
const row: ApiClientRow = {
id: randomUUID(),
name,
tokenPrefix: tokenPrefix(token),
tokenHash: hashApiToken(token, pepper),
scopesJson: JSON.stringify(scopes),
createdAt: now.toISOString(),
lastUsedAt: null,
revokedAt: null,
};
db.insert(schema.apiClients).values(row).run();
return { client: toView(row), token };
}
export function listApiClients(): ApiClientView[] {
return db.select().from(schema.apiClients).all().map(toView);
}
export function revokeApiClient(id: string, now = new Date()): ApiClientView | null {
const existing = db.select().from(schema.apiClients).where(eq(schema.apiClients.id, id)).get();
if (!existing) return null;
const revokedAt = now.toISOString();
db.update(schema.apiClients).set({ revokedAt }).where(eq(schema.apiClients.id, id)).run();
return toView({ ...existing, revokedAt });
}
export function authenticateApiToken(token: string, now = new Date()): ApiClientView | null {
const pepper = env.requireMasterKey();
const tokenHash = hashApiToken(token, pepper);
const row = db
.select()
.from(schema.apiClients)
.where(eq(schema.apiClients.tokenHash, tokenHash))
.get();
if (!row || row.revokedAt) return null;
if (!verifyApiToken(token, row.tokenHash, pepper)) return null;
const lastUsedAt = now.toISOString();
db.update(schema.apiClients)
.set({ lastUsedAt })
.where(eq(schema.apiClients.id, row.id))
.run();
return toView({ ...row, lastUsedAt });
}
export function hasApiScope(scopes: ApiClientScope[], required: ApiClientScope): boolean {
if (scopes.includes("admin")) return true;
if (required === "read") return scopes.length > 0;
if (required === "operate") return scopes.includes("operate");
if (required === "debug") return scopes.includes("debug");
return false;
}
export const apiClientInternals = {
normalizeScopes,
scopesFromJson,
toView,
hasApiScope,
};
+79 -1
View File
@@ -2,10 +2,17 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { parseAptSimulate, parseRebootRequired } from "./aptParse.js";
import { parseAptSimulate, parseRebootRequired, parseAptRemovals, parseHeld, parseRebootDetail, buildAptSnapshotDetail, parseDpkgList, buildAptExecutionResult } from "./aptParse.js";
const raw = readFileSync(fileURLToPath(new URL("./__fixtures__/apt-simulate.txt", import.meta.url)), "utf8");
const ua = readFileSync(fileURLToPath(new URL("./__fixtures__/apt-update-analyze.txt", import.meta.url)), "utf8");
function section(rawInput: string, start: string, end: string): string {
const s = rawInput.indexOf(start); if (s === -1) return "";
const from = s + start.length; const e = rawInput.indexOf(end, from);
return rawInput.slice(from, e === -1 ? undefined : e).trim();
}
describe("parseAptSimulate", () => {
it("extrait les paquets upgradables avec versions et origine", () => {
const pkgs = parseAptSimulate(raw);
@@ -28,3 +35,74 @@ describe("parseRebootRequired", () => {
expect(parseRebootRequired("rien")).toBe(false);
});
});
describe("parseAptRemovals", () => {
it("extrait les suppressions Remv", () => {
expect(parseAptRemovals("Remv oldpkg [3.2-1]\nInst x [1] (2 Y [amd64])"))
.toEqual([{ name: "oldpkg", currentVersion: "3.2-1" }]);
});
});
describe("parseHeld", () => {
it("liste les paquets retenus non vides", () => {
expect(parseHeld("frozenpkg\n\n other ")).toEqual(["frozenpkg", "other"]);
});
});
describe("parseRebootDetail", () => {
it("lit le flag et les paquets reboot", () => {
expect(parseRebootDetail("REBOOT_REQUIRED=1\nPKG=linux-image-amd64\nPKG=foo"))
.toEqual({ rebootRequired: true, pkgs: ["linux-image-amd64", "foo"] });
expect(parseRebootDetail("REBOOT_REQUIRED=0")).toEqual({ rebootRequired: false, pkgs: [] });
});
});
const BEFORE = "libc6\t2.31-13\tamd64\noldpkg\t3.2-1\tamd64\nstable\t1.0\tamd64";
const AFTER = "libc6\t2.31-14\tamd64\nnewpkg\t1.0.0\tall\nstable\t1.0\tamd64";
describe("parseDpkgList", () => {
it("indexe par package:arch", () => {
const m = parseDpkgList("libc6\t2.31-13\tamd64");
expect(m["libc6:amd64"]).toEqual({ version: "2.31-13", arch: "amd64" });
});
});
describe("buildAptExecutionResult", () => {
it("calcule le diff réel before/after", () => {
const r = buildAptExecutionResult(BEFORE, AFTER, "REBOOT_REQUIRED=1");
expect(r.applied.find((c) => c.name === "libc6")).toMatchObject({ operation: "upgraded", fromVersion: "2.31-13", toVersion: "2.31-14" });
expect(r.installed.map((c) => c.name)).toEqual(["newpkg"]);
expect(r.removed.map((c) => c.name)).toEqual(["oldpkg"]);
expect(r.applied.some((c) => c.name === "stable")).toBe(false); // unchanged exclu
expect(r.rebootRequiredAfterRun).toBe(true);
});
});
describe("buildAptSnapshotDetail", () => {
it("construit le détail enrichi depuis les sections", () => {
const detail = buildAptSnapshotDetail({
upgradeSim: section(ua, "===SU:APT_SIM_UPGRADE===", "===SU:APT_SIM_DISTUPGRADE==="),
distUpgradeSim: section(ua, "===SU:APT_SIM_DISTUPGRADE===", "===SU:APT_HELD==="),
heldRaw: section(ua, "===SU:APT_HELD===", "===SU:REBOOT==="),
rebootRaw: section(ua, "===SU:REBOOT===", "===SU:EXIT"),
updateFailed: false,
});
expect(detail.enabled).toBe(true);
expect(detail.count).toBe(2); // 2 Inst en dist-upgrade
expect(detail.upgradeCount).toBe(1); // 1 Inst en upgrade
expect(detail.distUpgradeCount).toBe(2);
expect(detail.rebootRequired).toBe(true);
expect(detail.rebootPkgs).toEqual(["linux-image-amd64"]);
expect(detail.held).toEqual(["frozenpkg"]);
expect(detail.removed?.map((r) => r.name)).toEqual(["oldpkg"]);
expect(detail.installed?.map((p) => p.name)).toEqual(["newdep"]);
expect(detail.status).toBe("warning"); // car removed + held non vides
});
it("status=updates_available sans removed/held, error si update échoue", () => {
const ok = buildAptSnapshotDetail({ upgradeSim: "Inst a [1] (2 Y [amd64])", distUpgradeSim: "Inst a [1] (2 Y [amd64])", heldRaw: "", rebootRaw: "REBOOT_REQUIRED=0", updateFailed: false });
expect(ok.status).toBe("updates_available");
const err = buildAptSnapshotDetail({ upgradeSim: "", distUpgradeSim: "", heldRaw: "", rebootRaw: "REBOOT_REQUIRED=0", updateFailed: true });
expect(err.status).toBe("error");
});
});
+115 -1
View File
@@ -1,5 +1,5 @@
// server/services/aptParse.ts
import type { AptPackage } from "@shared/types.js";
import type { AptPackage, AptSnapshotDetail, SnapshotStatus, AptChange, AptExecutionResult } from "@shared/types.js";
// Exemple de ligne:
// Inst pve-manager [8.4-1] (8.4-3 Proxmox VE:8.x [amd64])
@@ -24,3 +24,117 @@ export function parseAptSimulate(raw: string): AptPackage[] {
export function parseRebootRequired(raw: string): boolean {
return /REBOOT_REQUIRED=1/.test(raw);
}
const REMV_RE = /^Remv (\S+)(?: \[([^\]]+)\])?/;
export function parseAptRemovals(raw: string): { name: string; currentVersion: string | null }[] {
const out: { name: string; currentVersion: string | null }[] = [];
for (const line of raw.split("\n")) {
const m = REMV_RE.exec(line.trimEnd());
if (m) out.push({ name: m[1]!, currentVersion: m[2] ?? null });
}
return out;
}
export function parseHeld(raw: string): string[] {
return raw.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
}
export function parseRebootDetail(raw: string): { rebootRequired: boolean; pkgs: string[] } {
const rebootRequired = /REBOOT_REQUIRED=1/.test(raw);
const pkgs: string[] = [];
for (const line of raw.split("\n")) {
const m = /^PKG=(.+)$/.exec(line.trim());
if (m) pkgs.push(m[1]!.trim());
}
return { rebootRequired, pkgs };
}
export interface AptSections {
upgradeSim: string;
distUpgradeSim: string;
heldRaw: string;
rebootRaw: string;
updateFailed: boolean;
}
export function parseDpkgList(raw: string): Record<string, { version: string; arch: string }> {
const out: Record<string, { version: string; arch: string }> = {};
for (const line of raw.split("\n")) {
const parts = line.split("\t");
if (parts.length < 3) continue;
const [name, version, arch] = [parts[0]!.trim(), parts[1]!.trim(), parts[2]!.trim()];
if (!name) continue;
out[`${name}:${arch}`] = { version, arch };
}
return out;
}
/** Diff dpkg réel before/after → AptExecutionResult (planned/held vides : portés par le snapshot). */
export function buildAptExecutionResult(beforeRaw: string, afterRaw: string, rebootRaw: string): AptExecutionResult {
const before = parseDpkgList(beforeRaw);
const after = parseDpkgList(afterRaw);
const applied: AptChange[] = [];
const installed: AptChange[] = [];
const removed: AptChange[] = [];
for (const key of Object.keys(after)) {
const [name] = key.split(":");
const a = after[key]!;
const b = before[key];
if (!b) {
const change: AptChange = { name: name!, arch: a.arch, fromVersion: null, toVersion: a.version, operation: "installed" };
installed.push(change); applied.push(change);
} else if (b.version !== a.version) {
applied.push({ name: name!, arch: a.arch, fromVersion: b.version, toVersion: a.version, operation: "upgraded" });
}
}
for (const key of Object.keys(before)) {
if (!after[key]) {
const [name] = key.split(":");
const b = before[key]!;
const change: AptChange = { name: name!, arch: b.arch, fromVersion: b.version, toVersion: null, operation: "removed" };
removed.push(change); applied.push(change);
}
}
return {
planned: [],
applied,
installed,
removed,
held: [],
rebootRequiredAfterRun: /REBOOT_REQUIRED=1/.test(rebootRaw),
};
}
export function buildAptSnapshotDetail(s: AptSections): AptSnapshotDetail {
const upgradePkgs = parseAptSimulate(s.upgradeSim);
const distPkgs = parseAptSimulate(s.distUpgradeSim);
const installed: AptPackage[] = distPkgs
.filter((p) => p.currentVersion === null)
.map((p) => ({ ...p, operation: "install" as const }));
const removed: AptPackage[] = parseAptRemovals(s.distUpgradeSim).map((r) => ({
name: r.name, currentVersion: r.currentVersion, targetVersion: "", origin: null, operation: "remove" as const,
}));
const held = parseHeld(s.heldRaw);
const { rebootRequired, pkgs: rebootPkgs } = parseRebootDetail(s.rebootRaw);
let status: SnapshotStatus = "ok";
if (s.updateFailed) status = "error";
else if (removed.length > 0 || held.length > 0) status = "warning";
else if (distPkgs.length > 0) status = "updates_available";
return {
enabled: true,
count: distPkgs.length,
rebootRequired,
packages: distPkgs,
status,
upgradeCount: upgradePkgs.length,
distUpgradeCount: distPkgs.length,
installed,
removed,
held,
rebootPkgs,
};
}
+31
View File
@@ -0,0 +1,31 @@
// server/services/capabilities.test.ts
import { describe, expect, it } from "vitest";
import { getServerCapabilities } from "./capabilities.js";
describe("getServerCapabilities", () => {
it("publie un contrat stable sans annoncer les fonctions futures non implémentées", () => {
const caps = getServerCapabilities(new Date("2026-06-05T08:00:00.000Z"));
expect(caps).toMatchObject({
app: "system_update",
apiVersion: "1",
generatedAt: "2026-06-05T08:00:00.000Z",
features: {
machines: true,
actions: true,
terminalOutput: true,
docker: false,
hermes: false,
interactiveSsh: false,
authTokens: false,
},
endpoints: {
capabilities: "GET /api/capabilities",
systemStatus: "GET /api/system/status",
systemMetrics: "GET /api/system/metrics",
machines: "GET /api/machines",
terminalOutputWs: "WS /api/ws/machines/:id/output",
},
});
});
});
+38
View File
@@ -0,0 +1,38 @@
// server/services/capabilities.ts
import type { ServerCapabilities } from "@shared/types.js";
export function getServerCapabilities(now = new Date()): ServerCapabilities {
return {
app: "system_update",
apiVersion: "1",
generatedAt: now.toISOString(),
features: {
machines: true,
machineSnapshots: true,
actions: true,
aptFullUpgrade: true,
reboot: true,
reports: true,
terminalOutput: true,
interactiveSsh: false,
docker: false,
postInstall: false,
hermes: false,
settings: false,
scheduledJobs: false,
authTokens: false,
},
endpoints: {
capabilities: "GET /api/capabilities",
systemStatus: "GET /api/system/status",
systemMetrics: "GET /api/system/metrics",
machines: "GET /api/machines",
machineSnapshot: "GET /api/machines/:id/snapshot",
machineRefresh: "POST /api/machines/:id/refresh",
machineActions: "POST /api/machines/:id/actions",
machineExecutions: "GET /api/machines/:id/executions",
executionReport: "GET /api/machines/:id/executions/:execId/report",
terminalOutputWs: "WS /api/ws/machines/:id/output",
},
};
}
+16
View File
@@ -0,0 +1,16 @@
import { describe, it, expect } from "vitest";
import { resolveCreds } from "./credentials.js";
describe("resolveCreds", () => {
it("préfère la ligne machine_credentials", () => {
const out = resolveCreds(
{ encPassword: "M_PWD", encSudoPassword: null }, // machines (legacy)
{ encPassword: "C_PWD", encSudoPassword: "C_SUDO" }, // machine_credentials
);
expect(out).toEqual({ encPassword: "C_PWD", encSudoPassword: "C_SUDO" });
});
it("retombe sur machines si pas de credentials", () => {
const out = resolveCreds({ encPassword: "M_PWD", encSudoPassword: "M_SUDO" }, null);
expect(out).toEqual({ encPassword: "M_PWD", encSudoPassword: "M_SUDO" });
});
});
+55
View File
@@ -0,0 +1,55 @@
// server/services/credentials.ts
import { eq } from "drizzle-orm";
import { db, schema } from "../db/client.js";
interface EncPair { encPassword: string | null; encSudoPassword: string | null; }
/** Résout la source des secrets : machine_credentials prioritaire, sinon legacy machines (fonction pure). */
export function resolveCreds(legacy: EncPair, creds: EncPair | null): EncPair {
if (creds && creds.encPassword) return { encPassword: creds.encPassword, encSudoPassword: creds.encSudoPassword };
return { encPassword: legacy.encPassword, encSudoPassword: legacy.encSudoPassword };
}
/** Écrit (insert/replace) la ligne machine_credentials pour une machine (secrets déjà chiffrés). */
export function writeCredentials(input: {
machineId: string;
encPassword: string | null;
encSudoPassword: string | null;
}): void {
const now = new Date().toISOString();
db.insert(schema.machineCredentials)
.values({
machineId: input.machineId,
authMethod: "password",
encPassword: input.encPassword,
encSudoPassword: input.encSudoPassword,
sudoMode: input.encSudoPassword ? "separate" : "same_as_ssh",
createdAt: now,
updatedAt: now,
status: "unknown",
})
.onConflictDoUpdate({
target: schema.machineCredentials.machineId,
set: { encPassword: input.encPassword, encSudoPassword: input.encSudoPassword, updatedAt: now },
})
.run();
}
/** Lit la ligne machine_credentials (ou null). */
export function readCredentials(machineId: string): EncPair | null {
const row = db.select().from(schema.machineCredentials)
.where(eq(schema.machineCredentials.machineId, machineId)).get();
return row ? { encPassword: row.encPassword, encSudoPassword: row.encSudoPassword } : null;
}
/** Backfill idempotent : crée une ligne machine_credentials pour chaque machine qui n'en a pas. */
export function backfillCredentials(): number {
const machines = db.select().from(schema.machines).all();
let created = 0;
for (const m of machines) {
if (readCredentials(m.id)) continue;
writeCredentials({ machineId: m.id, encPassword: m.encPassword, encSudoPassword: m.encSudoPassword });
created++;
}
return created;
}
+102 -7
View File
@@ -1,7 +1,7 @@
// server/services/execute.ts
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { mkdirSync, writeFileSync } from "node:fs";
import { mkdirSync, writeFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { db, schema } from "../db/client.js";
import { env } from "../env.js";
@@ -9,15 +9,22 @@ import { getMachineRow, getCreds } from "./machines.js";
import { renderTemplate } from "../templates/render.js";
import { reduceAptLines } from "../templates/aptReduce.js";
import { runScriptSudo } from "../ssh/client.js";
import { parseRebootRequired } from "./aptParse.js";
import { parseRebootRequired, buildAptExecutionResult } from "./aptParse.js";
import { parseBootIdBefore, verifyReboot } from "./rebootVerify.js";
import type { RebootResult } from "@shared/types.js";
import { extractSection } from "./refresh.js";
import { buildReportMarkdown } from "./report.js";
import { outputHub } from "../ws/outputHub.js";
import type { ActionType, ExecutionResult, ExecutionStatus } from "@shared/types.js";
import { upsertMachineState, recordEvent } from "./machineState.js";
import type { ActionType, AptExecutionResult, ExecutionResult, ExecutionStatus } from "@shared/types.js";
const TEMPLATE_FOR: Record<ActionType, string> = {
const TEMPLATE_FOR: Partial<Record<ActionType, string>> = {
apt_full_upgrade: "apt/full-upgrade.sh.tpl",
apt_upgrade: "apt/upgrade.sh.tpl",
apt_autoremove: "apt/autoremove.sh.tpl",
apt_clean: "apt/clean.sh.tpl",
reboot: "apt/reboot.sh.tpl",
reboot_verified: "apt/reboot.sh.tpl",
};
export async function runAction(machineId: string, action: ActionType): Promise<ExecutionResult> {
@@ -31,9 +38,14 @@ export async function runAction(machineId: string, action: ActionType): Promise<
db.insert(schema.executions).values({
id: executionId, machineId, action, mode: "manual", startedAt, status: "running",
}).run();
upsertMachineState(machineId, { status: "running", runningJobId: executionId });
const proxy = m.aptProxyMode === "runtime" ? m.aptProxyUrl : null;
const script = renderTemplate(TEMPLATE_FOR[action], { aptProxy: proxy });
const rel = TEMPLATE_FOR[action];
if (!rel) throw new Error("Action sans template: " + action);
const script = renderTemplate(rel, { aptProxy: proxy });
const inactivity = action === "reboot" ? 0 : 600000;
let raw = "";
let status: ExecutionStatus = "ok";
@@ -41,7 +53,7 @@ export async function runAction(machineId: string, action: ActionType): Promise<
const res = await runScriptSudo(getCreds(m), script, (c) => {
raw += c;
outputHub.publish(machineId, c);
});
}, inactivity);
raw = res.stdout;
if (/===SU:EXIT=\d+===/.test(raw) && !/===SU:EXIT=0===/.test(raw)) {
status = "error";
@@ -51,9 +63,41 @@ export async function runAction(machineId: string, action: ActionType): Promise<
raw += `\n[ERREUR] ${(err as Error).message}\n`;
}
// Vérification réseau du reboot (nouvelle action reboot_verified, jalon SJ-3).
let rebootResult: RebootResult | undefined;
if (action === "reboot_verified") {
const beforeBootId = parseBootIdBefore(raw);
outputHub.publish(machineId, "\n[reboot] attente du redémarrage...\n");
rebootResult = await verifyReboot(getCreds(m), { beforeBootId, requestedAt: startedAt });
if (rebootResult.status !== "ok") status = "error";
if (rebootResult.status === "ok") {
recordEvent({
machineId,
eventType: "reboot_verified",
severity: "info",
executionId,
message: `Reboot vérifié en ${rebootResult.waitedSeconds}s (boot_id changé)`,
});
}
}
const finishedAt = new Date().toISOString();
const rebootRequired = parseRebootRequired(extractSection(raw, "===SU:REBOOT===", "===SU:EXIT") || raw);
// Diff dpkg réel (si le template a émis DPKG_BEFORE + DPKG_AFTER).
let aptResult: AptExecutionResult | undefined;
if (raw.includes("===SU:DPKG_BEFORE===") && raw.includes("===SU:DPKG_AFTER===")) {
const afterBeforeMarker =
raw.includes("===SU:APT_FULLUPGRADE===") ? "===SU:APT_FULLUPGRADE===" :
raw.includes("===SU:APT_UPGRADE===") ? "===SU:APT_UPGRADE===" :
"===SU:APT_AUTOREMOVE===";
aptResult = buildAptExecutionResult(
extractSection(raw, "===SU:DPKG_BEFORE===", afterBeforeMarker),
extractSection(raw, "===SU:DPKG_AFTER===", "===SU:REBOOT==="),
extractSection(raw, "===SU:REBOOT===", "===SU:EXIT"),
);
}
// Archivage log brut + rapport.
const dir = join(env.reportsDir, machineId);
mkdirSync(dir, { recursive: true });
@@ -66,15 +110,66 @@ export async function runAction(machineId: string, action: ActionType): Promise<
rebootRequiredAfterRun: rebootRequired,
importantLogLines: reduceAptLines(raw),
rawLogRef: rawLogPath, reportRef: reportPath,
...(aptResult ? { apt: aptResult } : {}),
...(rebootResult ? { reboot: rebootResult } : {}),
};
writeFileSync(reportPath, buildReportMarkdown(result, m.name), "utf8");
const reportId = randomUUID();
const exitMatch = /===SU:EXIT=(\d+)===/.exec(raw);
db.update(schema.executions).set({
finishedAt, status, resultJson: JSON.stringify(result), reportPath, rawLogPath,
finishedAt,
status,
schemaVersion: 1,
resultJson: JSON.stringify(result),
importantJson: JSON.stringify(result.importantLogLines),
reportPath,
rawLogPath,
reportId,
exitCode: exitMatch ? Number(exitMatch[1]) : null,
errorKind: status === "error" ? "execution_failed" : null,
errorMessage: status === "error" ? (result.importantLogLines.at(-1) ?? null) : null,
}).where(eq(schema.executions.id, executionId)).run();
db.update(schema.machines).set({ status: status === "error" ? "error" : "unknown" })
.where(eq(schema.machines.id, machineId)).run();
db.insert(schema.reports).values({
id: reportId,
machineId,
executionId,
kind: "machine",
title: `${m.name}${action}`,
path: reportPath,
createdAt: finishedAt,
}).run();
db.insert(schema.rawArtifacts).values({
id: randomUUID(),
machineId,
kind: "raw_log",
path: rawLogPath,
bytes: statSync(rawLogPath).size,
createdAt: finishedAt,
retentionPolicy: status === "error" ? "failed" : "default",
}).run();
upsertMachineState(machineId, {
status: status === "error" ? "error" : "unknown",
runningJobId: null,
lastErrorKind: status === "error" ? "execution_failed" : null,
lastErrorMessage: status === "error" ? (result.importantLogLines.at(-1) ?? null) : null,
});
const execSeverity: "info" | "warning" | "error" =
status === "error" ? "error" : (status as string) === "warning" ? "warning" : "info";
recordEvent({
machineId,
eventType: `action_${action}`,
severity: execSeverity,
executionId,
message: `Action ${action} : ${status}`,
});
outputHub.publish(machineId, `\n===SU:DONE status=${status}===\n`);
return result;
}
+27
View File
@@ -0,0 +1,27 @@
// server/services/machineState.test.ts
import { describe, it, expect } from "vitest";
import { deriveAptState } from "./machineState.js";
import type { UpdateSnapshot } from "@shared/types.js";
const snap: UpdateSnapshot = {
machineId: "m1", hostname: "h", os: { family: "debian", version: "12" },
checkedAt: "2026-06-05T10:00:00Z", status: "updates_available",
apt: { enabled: true, count: 3, rebootRequired: true, packages: [] },
};
describe("deriveAptState", () => {
it("dérive le bloc APT de machine_state depuis un snapshot", () => {
expect(deriveAptState(snap)).toEqual({
status: "updates_available",
aptStatus: "updates_available",
aptUpdatesCount: 3,
aptRebootRequired: 1,
aptLastAnalyzeAt: "2026-06-05T10:00:00Z",
});
});
it("met rebootRequired à 0 quand absent", () => {
const s = { ...snap, status: "ok" as const, apt: { ...snap.apt, count: 0, rebootRequired: false } };
expect(deriveAptState(s)).toMatchObject({ aptUpdatesCount: 0, aptRebootRequired: 0, status: "ok" });
});
});
+63
View File
@@ -0,0 +1,63 @@
// server/services/machineState.ts
import { randomUUID } from "node:crypto";
import { db, schema } from "../db/client.js";
import type { UpdateSnapshot } from "@shared/types.js";
export interface AptDerivedState {
status: string;
aptStatus: string;
aptUpdatesCount: number;
aptRebootRequired: number;
aptLastAnalyzeAt: string;
}
/** Dérive le bloc APT de l'état courant depuis un snapshot (fonction pure). */
export function deriveAptState(snapshot: UpdateSnapshot): AptDerivedState {
return {
status: snapshot.status,
aptStatus: snapshot.status,
aptUpdatesCount: snapshot.apt.count,
aptRebootRequired: snapshot.apt.rebootRequired ? 1 : 0,
aptLastAnalyzeAt: snapshot.checkedAt,
};
}
type MachineStateInsert = typeof schema.machineState.$inferInsert;
/** Insère ou met à jour les champs fournis de machine_state pour une machine. */
export function upsertMachineState(
machineId: string,
fields: Partial<Omit<MachineStateInsert, "machineId" | "updatedAt" | "status">> & { status: string },
): void {
const now = new Date().toISOString();
db.insert(schema.machineState)
.values({ machineId, updatedAt: now, ...fields })
.onConflictDoUpdate({
target: schema.machineState.machineId,
set: { ...fields, updatedAt: now },
})
.run();
}
/** Ajoute une ligne à la timeline machine_events. */
export function recordEvent(input: {
machineId: string;
eventType: string;
severity: "info" | "warning" | "error";
actorType?: string;
snapshotId?: string;
executionId?: string;
message?: string;
}): void {
db.insert(schema.machineEvents).values({
id: randomUUID(),
machineId: input.machineId,
eventType: input.eventType,
severity: input.severity,
createdAt: new Date().toISOString(),
actorType: input.actorType ?? "system",
snapshotId: input.snapshotId,
executionId: input.executionId,
message: input.message,
}).run();
}
+20 -3
View File
@@ -6,6 +6,7 @@ import { encryptSecret, decryptSecret } from "../crypto/secrets.js";
import { env } from "../env.js";
import { runPlain, type SshCreds } from "../ssh/client.js";
import type { MachineView, OsFamily } from "@shared/types.js";
import { writeCredentials, readCredentials, resolveCreds } from "./credentials.js";
export interface CreateMachineInput {
name: string;
@@ -37,12 +38,17 @@ function toView(m: MachineRow): MachineView {
export function getCreds(m: MachineRow): SshCreds {
const key = env.requireMasterKey();
const { encPassword, encSudoPassword } = resolveCreds(
{ encPassword: m.encPassword, encSudoPassword: m.encSudoPassword },
readCredentials(m.id),
);
if (!encPassword) throw new Error("Aucun secret pour cette machine");
return {
hostname: m.hostname,
port: m.port,
username: m.username,
password: decryptSecret(m.encPassword, key),
sudoPassword: m.encSudoPassword ? decryptSecret(m.encSudoPassword, key) : null,
password: decryptSecret(encPassword, key),
sudoPassword: encSudoPassword ? decryptSecret(encSudoPassword, key) : null,
};
}
@@ -82,12 +88,19 @@ export async function createMachine(input: CreateMachineInput): Promise<MachineV
};
const os = await testConnection(creds); // lève si la connexion échoue
const id = randomUUID();
const now = new Date().toISOString();
const row: MachineRow = {
id,
name: input.name,
hostname: input.hostname,
port: input.port,
osFamily: os.family,
osVersion: os.version || null,
osCodename: null,
arch: null,
machineKind: null,
virtualization: null,
hardwareProfile: null,
username: input.username,
encPassword: encryptSecret(input.password, key),
encSudoPassword: input.sudoPassword ? encryptSecret(input.sudoPassword, key) : null,
@@ -95,9 +108,13 @@ export async function createMachine(input: CreateMachineInput): Promise<MachineV
aptProxyUrl: input.aptProxyUrl ?? null,
status: "unknown",
lastCheckedAt: null,
createdAt: new Date().toISOString(),
lastSeenAt: null,
createdAt: now,
updatedAt: now,
deletedAt: null,
};
db.insert(schema.machines).values(row).run();
writeCredentials({ machineId: id, encPassword: row.encPassword, encSudoPassword: row.encSudoPassword });
return toView(row);
}
+27
View File
@@ -0,0 +1,27 @@
import { describe, it, expect } from "vitest";
import { classifyReboot, parseBootIdBefore } from "./rebootVerify.js";
describe("parseBootIdBefore", () => {
it("extrait le boot_id de la sortie du template", () => {
const raw = "===SU:BOOT_ID_BEFORE===\nabcd-1234\n===SU:REBOOT_NOW===\nreboot planifié";
expect(parseBootIdBefore(raw)).toBe("abcd-1234");
});
it("retourne null si absent", () => {
expect(parseBootIdBefore("rien")).toBeNull();
});
});
describe("classifyReboot", () => {
it("ok si la machine revient avec un boot_id différent", () => {
expect(classifyReboot({ beforeBootId: "A", afterBootId: "B", wentDown: true, cameBack: true }).status).toBe("ok");
});
it("boot_id_unchanged si même boot_id", () => {
expect(classifyReboot({ beforeBootId: "A", afterBootId: "A", wentDown: true, cameBack: true }).status).toBe("boot_id_unchanged");
});
it("ssh_never_went_down si la coupure n'a pas été observée", () => {
expect(classifyReboot({ beforeBootId: "A", afterBootId: null, wentDown: false, cameBack: false }).status).toBe("ssh_never_went_down");
});
it("machine_did_not_return si coupure mais pas de retour", () => {
expect(classifyReboot({ beforeBootId: "A", afterBootId: null, wentDown: true, cameBack: false }).status).toBe("machine_did_not_return");
});
});
+94
View File
@@ -0,0 +1,94 @@
// server/services/rebootVerify.ts
import { runPlain, type SshCreds } from "../ssh/client.js";
import type { RebootResult } from "@shared/types.js";
export function parseBootIdBefore(raw: string): string | null {
const s = raw.indexOf("===SU:BOOT_ID_BEFORE===");
if (s === -1) return null;
const from = s + "===SU:BOOT_ID_BEFORE===".length;
const e = raw.indexOf("===SU:REBOOT_NOW===", from);
const id = raw.slice(from, e === -1 ? undefined : e).trim();
return id || null;
}
export interface RebootSignals {
beforeBootId: string | null;
afterBootId: string | null;
wentDown: boolean;
cameBack: boolean;
}
/** Détermine le statut d'un reboot vérifié (fonction pure). */
export function classifyReboot(s: RebootSignals): { status: RebootResult["status"] } {
if (!s.wentDown) return { status: "ssh_never_went_down" };
if (!s.cameBack || s.afterBootId === null) return { status: "machine_did_not_return" };
if (s.beforeBootId !== null && s.afterBootId === s.beforeBootId) return { status: "boot_id_unchanged" };
return { status: "ok" };
}
async function readBootId(creds: SshCreds): Promise<string | null> {
try {
const res = await runPlain(creds, "cat /proc/sys/kernel/random/boot_id");
const id = res.stdout.trim();
return id || null;
} catch {
return null; // connexion impossible (machine down)
}
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
export interface VerifyOptions {
beforeBootId: string | null;
requestedAt: string;
downTimeoutMs?: number; // détection de la coupure
upTimeoutMs?: number; // attente du retour
pollMs?: number;
}
/**
* Orchestration : attend la coupure SSH (machine qui reboote) puis le retour,
* relit le boot_id, et classe le résultat. Réseau ; non testé unitairement.
*/
export async function verifyReboot(creds: SshCreds, opt: VerifyOptions): Promise<RebootResult> {
const downTimeoutMs = opt.downTimeoutMs ?? 60000;
const upTimeoutMs = opt.upTimeoutMs ?? 600000;
const pollMs = opt.pollMs ?? 5000;
const t0 = Date.now();
// Phase A : attendre que la machine devienne injoignable.
let wentDown = false;
let sshWentDownAt: string | null = null;
while (Date.now() - t0 < downTimeoutMs) {
const id = await readBootId(creds);
if (id === null) { wentDown = true; sshWentDownAt = new Date().toISOString(); break; }
await sleep(pollMs);
}
// Phase B : attendre le retour (seulement si on a vu la coupure).
let cameBack = false;
let sshCameBackAt: string | null = null;
let afterBootId: string | null = null;
if (wentDown) {
const tB = Date.now();
while (Date.now() - tB < upTimeoutMs) {
const id = await readBootId(creds);
if (id !== null) { cameBack = true; sshCameBackAt = new Date().toISOString(); afterBootId = id; break; }
await sleep(pollMs);
}
}
const { status } = classifyReboot({ beforeBootId: opt.beforeBootId, afterBootId, wentDown, cameBack });
const waitedSeconds = Math.round((Date.now() - t0) / 1000);
return {
beforeBootId: opt.beforeBootId,
afterBootId,
requestedAt: opt.requestedAt,
sshWentDownAt,
sshCameBackAt,
waitedSeconds,
status,
lastRebootDurationSeconds: status === "ok" ? waitedSeconds : undefined,
nextRecommendedWaitSeconds: status === "ok" ? Math.round(waitedSeconds * 1.5) + 30 : undefined,
};
}
+35 -12
View File
@@ -3,12 +3,13 @@ import { randomUUID } from "node:crypto";
import { eq, desc } from "drizzle-orm";
import { db, schema } from "../db/client.js";
import { getMachineRow, getCreds } from "./machines.js";
import { renderTemplate } from "../templates/render.js";
import { renderTemplate, resolveTemplate } from "../templates/render.js";
import { reduceAptLines } from "../templates/aptReduce.js";
import { runScriptSudo } from "../ssh/client.js";
import { parseAptSimulate, parseRebootRequired } from "./aptParse.js";
import { buildAptSnapshotDetail } from "./aptParse.js";
import { outputHub } from "../ws/outputHub.js";
import type { UpdateSnapshot, MachineStatus } from "@shared/types.js";
import { deriveAptState, upsertMachineState, recordEvent } from "./machineState.js";
import type { UpdateSnapshot, MachineStatus, AptSnapshotDetail } from "@shared/types.js";
/** Extrait la section entre deux marqueurs ===SU:X=== d'une sortie de script. */
export function extractSection(raw: string, start: string, end: string): string {
@@ -27,7 +28,7 @@ export async function refreshMachine(machineId: string): Promise<UpdateSnapshot>
outputHub.clear(machineId);
const proxy = m.aptProxyMode === "runtime" ? m.aptProxyUrl : null;
const script = renderTemplate("apt/check.sh.tpl", { aptProxy: proxy });
const script = renderTemplate(resolveTemplate("update-analyze", m.osFamily), { aptProxy: proxy });
let raw = "";
try {
@@ -41,32 +42,54 @@ export async function refreshMachine(machineId: string): Promise<UpdateSnapshot>
throw err;
}
const simulate = extractSection(raw, "===SU:SIMULATE===", "===SU:REBOOT===");
const rebootSection = extractSection(raw, "===SU:REBOOT===", "===SU:END===");
const packages = parseAptSimulate(simulate);
const rebootRequired = parseRebootRequired(rebootSection);
const status: MachineStatus = packages.length > 0 ? "updates_available" : "ok";
const updateExit = /===SU:EXIT=(\d+)===/.exec(raw);
const detail: AptSnapshotDetail = buildAptSnapshotDetail({
upgradeSim: extractSection(raw, "===SU:APT_SIM_UPGRADE===", "===SU:APT_SIM_DISTUPGRADE==="),
distUpgradeSim: extractSection(raw, "===SU:APT_SIM_DISTUPGRADE===", "===SU:APT_HELD==="),
heldRaw: extractSection(raw, "===SU:APT_HELD===", "===SU:REBOOT==="),
rebootRaw: extractSection(raw, "===SU:REBOOT===", "===SU:EXIT"),
updateFailed: updateExit ? Number(updateExit[1]) !== 0 : false,
});
// MachineStatus n'a pas "warning" : warning => updates_available côté machine.
const status: MachineStatus =
detail.status === "error" ? "error" : detail.count > 0 || detail.status === "warning" ? "updates_available" : "ok";
const checkedAt = new Date().toISOString();
const snapshot: UpdateSnapshot = {
machineId,
hostname: m.hostname,
os: { family: m.osFamily as UpdateSnapshot["os"]["family"], version: "" },
os: { family: m.osFamily as UpdateSnapshot["os"]["family"], version: m.osVersion ?? "" },
checkedAt,
status,
apt: { enabled: true, count: packages.length, rebootRequired, packages },
apt: detail,
schemaVersion: 1,
kind: "apt_update_analyze",
rawHints: { logImportantLines: reduceAptLines(raw) },
};
const snapshotId = randomUUID();
db.insert(schema.snapshots).values({
id: randomUUID(),
id: snapshotId,
machineId,
kind: "apt_update_analyze",
schemaVersion: 1,
checkedAt,
status,
payloadJson: JSON.stringify(snapshot),
importantJson: JSON.stringify(snapshot.rawHints?.logImportantLines ?? []),
}).run();
db.update(schema.machines).set({ status, lastCheckedAt: checkedAt }).where(eq(schema.machines.id, machineId)).run();
upsertMachineState(machineId, deriveAptState(snapshot));
recordEvent({
machineId,
eventType: "apt_refresh",
severity: "info",
snapshotId,
message: `Refresh APT : ${snapshot.apt.count} mise(s) à jour`,
});
return snapshot;
}
+30
View File
@@ -0,0 +1,30 @@
// server/services/system.test.ts
import { describe, expect, it } from "vitest";
import { getSystemMetrics, getSystemStatus, systemInternals } from "./system.js";
describe("system service", () => {
it("publie un status serveur stable", () => {
const status = getSystemStatus(new Date("2026-06-05T10:00:00.000Z"));
expect(status).toMatchObject({
app: "system_update",
version: "0.1.0",
apiVersion: "1",
serverTime: "2026-06-05T10:00:00.000Z",
});
expect(status.uptimeSeconds).toBeGreaterThanOrEqual(0);
});
it("publie des métriques serveur sans secret", () => {
const metrics = getSystemMetrics(new Date("2026-06-05T10:00:00.000Z"));
expect(metrics.collectedAt).toBe("2026-06-05T10:00:00.000Z");
expect(metrics.process.rssMb).toBeGreaterThan(0);
expect(metrics.host.totalMemoryMb).toBeGreaterThan(0);
expect(JSON.stringify(metrics)).not.toContain("password");
});
it("arrondit à deux décimales", () => {
expect(systemInternals.round2(12.345)).toBe(12.35);
});
});
+44
View File
@@ -0,0 +1,44 @@
// server/services/system.ts
import os from "node:os";
import type { SystemMetrics, SystemStatus } from "@shared/types.js";
const APP_VERSION = "0.1.0";
const MB = 1024 * 1024;
function round2(value: number): number {
return Math.round(value * 100) / 100;
}
export function getSystemStatus(now = new Date()): SystemStatus {
return {
app: "system_update",
version: APP_VERSION,
apiVersion: "1",
serverTime: now.toISOString(),
uptimeSeconds: Math.round(process.uptime()),
};
}
export function getSystemMetrics(now = new Date()): SystemMetrics {
const memory = process.memoryUsage();
const load = os.loadavg();
return {
collectedAt: now.toISOString(),
process: {
uptimeSeconds: Math.round(process.uptime()),
rssMb: round2(memory.rss / MB),
heapUsedMb: round2(memory.heapUsed / MB),
heapTotalMb: round2(memory.heapTotal / MB),
},
host: {
loadAverage1m: round2(load[0] ?? 0),
loadAverage5m: round2(load[1] ?? 0),
loadAverage15m: round2(load[2] ?? 0),
totalMemoryMb: round2(os.totalmem() / MB),
freeMemoryMb: round2(os.freemem() / MB),
},
};
}
export const systemInternals = { round2 };
+24 -2
View File
@@ -44,17 +44,20 @@ export async function runPlain(creds: SshCreds, command: string): Promise<RunRes
* Exécute un script shell sous sudo. Le script est encodé en base64 pour éviter
* tout problème de quoting; le mot de passe sudo est poussé sur stdin (sudo -S -p '').
* `onData` reçoit chaque chunk de sortie pour le streaming live.
* `inactivityTimeoutMs` (défaut 0 = désactivé) : si aucune sortie n'est reçue pendant
* cette durée, la connexion est fermée et une erreur `human_interaction_required` est levée.
*/
export async function runScriptSudo(
creds: SshCreds,
script: string,
onData: (chunk: string) => void,
inactivityTimeoutMs = 0,
): Promise<RunResult> {
const conn = await connect(creds);
try {
const b64 = Buffer.from(script, "utf8").toString("base64");
const cmd = `sudo -S -p '' sh -c "$(printf '%s' '${b64}' | base64 -d)"`;
return await execStream(conn, cmd, (creds.sudoPassword ?? creds.password) + "\n", onData);
return await execStream(conn, cmd, (creds.sudoPassword ?? creds.password) + "\n", onData, inactivityTimeoutMs);
} finally {
conn.end();
}
@@ -65,26 +68,45 @@ function execStream(
command: string,
stdinData: string | null,
onData: (chunk: string) => void,
inactivityTimeoutMs = 0,
): Promise<RunResult> {
return new Promise((resolve, reject) => {
conn.exec(command, { pty: false }, (err, stream) => {
if (err) return reject(err);
let stdout = "";
let code = 0;
let timer: ReturnType<typeof setTimeout> | undefined;
const arm = () => {
if (!inactivityTimeoutMs) return;
clearTimeout(timer);
timer = setTimeout(() => {
stream.close();
conn.end();
reject(new Error(`human_interaction_required: aucune sortie depuis ${Math.round(inactivityTimeoutMs / 1000)}s`));
}, inactivityTimeoutMs);
};
arm();
if (stdinData) {
stream.write(stdinData);
}
stream
.on("close", (c: number) => resolve({ stdout, code: c ?? code }))
.on("close", (c: number) => {
clearTimeout(timer);
resolve({ stdout, code: c ?? code });
})
.on("data", (d: Buffer) => {
const s = d.toString("utf8");
stdout += s;
onData(s);
arm();
});
stream.stderr.on("data", (d: Buffer) => {
const s = d.toString("utf8");
stdout += s;
onData(s);
arm();
});
});
});
+19 -1
View File
@@ -1,6 +1,6 @@
// server/templates/aptReduce.test.ts
import { describe, it, expect } from "vitest";
import { reduceAptLines } from "./aptReduce.js";
import { reduceAptLines, reduceLines } from "./aptReduce.js";
describe("reduceAptLines", () => {
it("ne garde que les lignes utiles", () => {
@@ -26,4 +26,22 @@ describe("reduceAptLines", () => {
it("retourne un tableau vide si rien d'utile", () => {
expect(reduceAptLines("Reading package lists...\nDone")).toEqual([]);
});
it("garde aussi les lignes Docker utiles", () => {
const raw = [
"Pulling jellyfin ...",
"Status: Downloaded newer image for jellyfin/jellyfin:latest",
"Recreating jellyfin ...",
"Started jellyfin",
"blabla inutile",
"Total reclaimed space: 1.2GB",
].join("\n");
expect(reduceLines(raw)).toEqual([
"Pulling jellyfin ...",
"Status: Downloaded newer image for jellyfin/jellyfin:latest",
"Recreating jellyfin ...",
"Started jellyfin",
"Total reclaimed space: 1.2GB",
]);
});
});
+15 -4
View File
@@ -1,11 +1,22 @@
// server/templates/aptReduce.ts
const PREFIXES = ["Inst ", "Conf ", "Remv ", "Err ", "E:", "W:", "dpkg:"];
const CONTAINS = ["reboot-required", "REBOOT_REQUIRED"];
const PREFIXES = [
// APT / dpkg (jalon 1)
"Inst ", "Conf ", "Remv ", "Err ", "E:", "W:", "dpkg:",
// Docker (SJ-0)
"Pulling", "Digest", "Status", "Downloaded newer image", "Recreating", "Started", "Error",
];
const CONTAINS = [
"reboot-required", "REBOOT_REQUIRED",
"deleted", "Total reclaimed space",
];
/** Garde uniquement les lignes informatives d'une sortie APT brute. */
export function reduceAptLines(raw: string): string[] {
/** Garde uniquement les lignes informatives (APT + Docker) d'une sortie brute. */
export function reduceLines(raw: string): string[] {
return raw
.split("\n")
.map((l) => l.trimEnd())
.filter((l) => PREFIXES.some((p) => l.startsWith(p)) || CONTAINS.some((c) => l.includes(c)));
}
/** Alias rétro-compatible (jalon 1) : même comportement, conserve les imports existants. */
export const reduceAptLines = reduceLines;
+6
View File
@@ -14,4 +14,10 @@ describe("renderTemplate", () => {
const out = renderTemplate("apt/check.sh.tpl", { aptProxy: "http://cache:3142" });
expect(out).toContain('http_proxy="http://cache:3142"');
});
it("rend update-analyze.sh.tpl avec les sections attendues", () => {
const out = renderTemplate("apt/update-analyze.sh.tpl", { aptProxy: null });
expect(out).toContain("===SU:APT_SIM_DISTUPGRADE===");
expect(out).toContain("apt-mark showhold");
});
});
+21 -1
View File
@@ -1,6 +1,6 @@
// server/templates/render.ts
import Mustache from "mustache";
import { readFileSync } from "node:fs";
import { readFileSync, existsSync } from "node:fs";
import { resolve } from "node:path";
const TEMPLATES_ROOT = resolve(process.cwd(), "templates");
@@ -14,3 +14,23 @@ export function renderTemplate(relPath: string, vars: TemplateVars): string {
// Mustache échappe le HTML par défaut; on désactive (ce sont des scripts shell).
return Mustache.render(tpl, vars, {}, { escape: (s) => s });
}
/** Existence par défaut d'un template relatif à templates/. */
function defaultExists(rel: string): boolean {
return existsSync(resolve(TEMPLATES_ROOT, rel));
}
/**
* Résout le chemin de template le plus spécifique pour (action, OS) :
* `<osFamily>/<action>.sh.tpl` s'il existe, sinon fallback base `apt/<action>.sh.tpl`.
* `exists` est injectable pour les tests.
*/
export function resolveTemplate(
action: string,
osFamily: string,
exists: (rel: string) => boolean = defaultExists,
): string {
const specific = `${osFamily}/${action}.sh.tpl`;
if (osFamily !== "unknown" && osFamily !== "apt" && exists(specific)) return specific;
return `apt/${action}.sh.tpl`;
}
+20
View File
@@ -0,0 +1,20 @@
import { describe, it, expect } from "vitest";
import { resolveTemplate } from "./render.js";
describe("resolveTemplate", () => {
it("retombe sur apt/ quand aucun dossier OS spécifique n'existe (fonction exists fournie)", () => {
const noneExist = () => false;
expect(resolveTemplate("full-upgrade", "proxmox", noneExist)).toBe("apt/full-upgrade.sh.tpl");
expect(resolveTemplate("update-analyze", "debian", noneExist)).toBe("apt/update-analyze.sh.tpl");
});
it("choisit le template OS spécifique quand il existe", () => {
const proxmoxExists = (rel: string) => rel === "proxmox/full-upgrade.sh.tpl";
expect(resolveTemplate("full-upgrade", "proxmox", proxmoxExists)).toBe("proxmox/full-upgrade.sh.tpl");
});
it("unknown retombe toujours sur apt/", () => {
const all = () => true;
expect(resolveTemplate("clean", "unknown", all)).toBe("apt/clean.sh.tpl");
});
});
+34
View File
@@ -0,0 +1,34 @@
import { describe, it, expect } from "vitest";
import type { UpdateSnapshot, ExecutionResult } from "./types.js";
describe("rétro-compatibilité des contrats", () => {
it("un snapshot jalon 1 (sans blocs optionnels) reste valide", () => {
const snap: UpdateSnapshot = {
machineId: "m1", hostname: "h", os: { family: "debian", version: "12" },
checkedAt: "2026-06-05T10:00:00Z", status: "ok",
apt: { enabled: true, count: 0, rebootRequired: false, packages: [] },
};
expect(snap.apt.count).toBe(0);
});
it("une exécution jalon 1 (mode manual, sans blocs) reste valide", () => {
const exec: ExecutionResult = {
executionId: "e1", machineId: "m1", startedAt: "a", finishedAt: "b",
mode: "manual", action: "apt_full_upgrade", status: "ok",
rebootRequiredAfterRun: false, importantLogLines: [], rawLogRef: "r", reportRef: "rr",
};
expect(exec.action).toBe("apt_full_upgrade");
});
it("accepte les nouveaux blocs optionnels", () => {
const snap: UpdateSnapshot = {
machineId: "m1", hostname: "h", os: { family: "proxmox", version: "8" },
checkedAt: "t", status: "updates_available",
apt: { enabled: true, count: 1, rebootRequired: false, packages: [], status: "updates_available" },
schemaVersion: 1, kind: "apt_update_analyze", machineKind: "proxmox_host",
docker: { enabled: false, installed: false, count: 0, stacks: [] },
errors: [],
};
expect(snap.docker?.installed).toBe(false);
});
});
+222 -10
View File
@@ -1,15 +1,143 @@
// shared/types.ts
export type OsFamily = "debian" | "ubuntu" | "unknown";
export type OsFamily = "debian" | "ubuntu" | "proxmox" | "raspbian" | "unknown";
export type MachineStatus = "unknown" | "ok" | "updates_available" | "error" | "running";
export type AptProxyMode = "direct" | "runtime";
export type ActionType = "apt_full_upgrade" | "reboot";
export type AptProxyMode = "direct" | "runtime" | "persistent";
export type ActionType =
| "apt_full_upgrade" | "reboot"
| "apt_update_analyze" | "apt_upgrade" | "apt_dist_upgrade"
| "apt_autoremove" | "apt_clean" | "reboot_verified"
| "docker_scan" | "docker_inspect_current" | "docker_pull_check"
| "docker_compose_apply" | "docker_prune_images" | "docker_compose_down"
| "machine_probe" | "post_install";
export type ExecutionStatus = "ok" | "warning" | "error";
export type ApiClientScope = "read" | "operate" | "admin" | "debug";
export type MachineKind =
| "physical" | "vm" | "proxmox_host" | "lxc"
| "raspberry_pi" | "workstation" | "unknown";
export type SnapshotStatus = "ok" | "updates_available" | "warning" | "error";
export interface ServerCapabilities {
app: "system_update";
apiVersion: "1";
generatedAt: string;
features: {
machines: boolean;
machineSnapshots: boolean;
actions: boolean;
aptFullUpgrade: boolean;
reboot: boolean;
reports: boolean;
terminalOutput: boolean;
interactiveSsh: boolean;
docker: boolean;
postInstall: boolean;
hermes: boolean;
settings: boolean;
scheduledJobs: boolean;
authTokens: boolean;
};
endpoints: {
capabilities: string;
systemStatus: string;
systemMetrics: string;
machines: string;
machineSnapshot: string;
machineRefresh: string;
machineActions: string;
machineExecutions: string;
executionReport: string;
terminalOutputWs: string;
};
}
export interface SystemStatus {
app: "system_update";
version: string;
apiVersion: "1";
serverTime: string;
uptimeSeconds: number;
}
export interface SystemMetrics {
collectedAt: string;
process: {
uptimeSeconds: number;
rssMb: number;
heapUsedMb: number;
heapTotalMb: number;
};
host: {
loadAverage1m: number;
loadAverage5m: number;
loadAverage15m: number;
totalMemoryMb: number;
freeMemoryMb: number;
};
}
export interface AptPackage {
name: string;
currentVersion: string | null;
targetVersion: string;
origin: string | null;
arch?: string;
operation?: "upgrade" | "install" | "remove" | "hold";
severityHint?: "normal" | "security";
}
export interface AptSnapshotDetail {
enabled: boolean;
count: number;
rebootRequired: boolean;
packages: AptPackage[];
status?: SnapshotStatus;
upgradeCount?: number;
distUpgradeCount?: number;
installed?: AptPackage[];
removed?: AptPackage[];
held?: string[];
rebootPkgs?: string[];
}
export interface DockerSnapshotService {
serviceName: string;
image: string;
currentImageId?: string | null;
currentDigest?: string | null;
candidateImageId?: string | null;
candidateDigest?: string | null;
currentVersion?: string | null;
candidateVersion?: string | null;
sourceUrl?: string | null;
status?: "up_to_date" | "updates_available" | "warning" | "error";
}
export interface DockerSnapshotStack {
name: string;
workingDir: string;
composeFiles: string[];
projectName?: string | null;
status: "candidate" | "enabled" | "ignored" | "error";
detectedBy?: "root_scan" | "label" | "manual";
services: DockerSnapshotService[];
}
export interface DockerSnapshot {
enabled: boolean;
installed: boolean;
count: number;
declaredRoots?: string[];
stacks: DockerSnapshotStack[];
status?: SnapshotStatus;
}
export interface SnapshotError {
source: "apt" | "docker" | "post_install" | "ssh" | "system";
kind: string;
severity: "info" | "warning" | "error";
message: string;
remediation?: string;
importantLines?: string[];
}
export interface UpdateSnapshot {
@@ -18,27 +146,95 @@ export interface UpdateSnapshot {
os: { family: OsFamily; version: string };
checkedAt: string; // ISO 8601
status: MachineStatus;
apt: {
enabled: boolean;
count: number;
rebootRequired: boolean;
packages: AptPackage[];
};
apt: AptSnapshotDetail;
schemaVersion?: number;
kind?: "apt_update_analyze" | "docker_scan" | "reboot_check" | "combined";
machineKind?: MachineKind;
docker?: DockerSnapshot;
errors?: SnapshotError[];
rawHints?: { logImportantLines: string[] };
}
export interface AptChange {
name: string;
arch?: string;
fromVersion: string | null;
toVersion: string | null;
operation: "upgraded" | "installed" | "removed" | "unchanged";
origin?: string | null;
}
export interface AptExecutionResult {
planned: AptPackage[];
applied: AptChange[];
installed: AptChange[];
removed: AptChange[];
held: string[];
errors?: SnapshotError[];
rebootRequiredAfterRun: boolean;
}
export interface DockerImageChange {
stack: string;
serviceName?: string;
imageRef?: string;
fromImageId?: string | null;
toImageId?: string | null;
fromDigest?: string | null;
toDigest?: string | null;
operation: "pulled" | "recreated" | "pruned";
}
export interface DockerExecutionResult {
pull?: { changes: DockerImageChange[]; errors?: SnapshotError[] };
up?: { recreated: string[]; running: string[]; exited: string[]; errors?: SnapshotError[] };
prune?: { imagesDeleted: string[]; bytesReclaimed: number; errors?: SnapshotError[] };
errors?: SnapshotError[];
}
export interface RebootResult {
beforeBootId: string | null;
afterBootId: string | null;
requestedAt: string;
sshWentDownAt: string | null;
sshCameBackAt: string | null;
waitedSeconds: number;
status: "ok" | "reboot_command_failed" | "ssh_never_went_down"
| "machine_did_not_return" | "boot_id_unchanged" | "timeout";
lastRebootDurationSeconds?: number;
nextRecommendedWaitSeconds?: number;
errors?: SnapshotError[];
}
export interface PostInstallResult {
profilesRun: string[];
variablesUsed: Record<string, string | number | boolean>;
filesModified: string[];
packagesInstalled: string[];
servicesEnabled: string[];
rebootsRequested: boolean;
networkChange?: { oldEndpoint: string | null; newEndpoint: string | null; reconnectHost: string | null };
errors?: SnapshotError[];
}
export interface ExecutionResult {
executionId: string;
machineId: string;
startedAt: string;
finishedAt: string;
mode: "manual";
mode: "manual" | "scheduled" | "hermes_requested";
action: ActionType;
status: ExecutionStatus;
rebootRequiredAfterRun: boolean;
importantLogLines: string[];
rawLogRef: string;
reportRef: string;
schemaVersion?: number;
apt?: AptExecutionResult;
docker?: DockerExecutionResult;
reboot?: RebootResult;
postInstall?: PostInstallResult;
errors?: SnapshotError[];
}
/** Vue machine renvoyée par l'API — NE CONTIENT JAMAIS de secret. */
@@ -54,3 +250,19 @@ export interface MachineView {
status: MachineStatus;
lastCheckedAt: string | null;
}
/** Client API local/Hermes — ne contient jamais le token brut. */
export interface ApiClientView {
id: string;
name: string;
tokenPrefix: string;
scopes: ApiClientScope[];
createdAt: string;
lastUsedAt: string | null;
revokedAt: string | null;
}
export interface CreatedApiClient {
client: ApiClientView;
token: string;
}
+1335
View File
File diff suppressed because it is too large Load Diff
+241
View File
@@ -0,0 +1,241 @@
# Consigne de dev — Moteur de templates de mise à jour (investigation & brainstorming)
> **Type** : mission d'**investigation + brainstorming + design** (PAS d'implémentation).
> **Destinataire** : un agent de développement autonome (Claude Code ou équivalent), sans contexte préalable du projet.
> **Langue** : français.
> **Livrable final attendu** : un (ou plusieurs) document(s) de design/spec prêts à passer en plan d'implémentation — pas de code de production à ce stade.
---
## 0. Comment aborder cette mission
Tu travailles dans le dépôt `system_update`. **Commence par lire** ces fichiers (ils contiennent tout le contexte) :
- `CLAUDE.md` — règles du projet (sécurité, langue, architecture, rôle d'Hermes/MCP).
- `deep-research-report(7).md` — étude d'architecture (contrats JSON canoniques, flux, sémantique APT/Docker, réduction déterministe pour LLM).
- `docs/superpowers/specs/2026-06-04-jalon1-tranche-verticale-apt-design.md` — ce qui est déjà construit (jalon 1).
- `ajout.md` — périmètre du volet Hermes et du serveur MCP.
- Code existant à étudier (le jalon 1 est en prod, validé sur Debian + Ubuntu réels) :
- `templates/apt/``check.sh.tpl`, `full-upgrade.sh.tpl`, `reboot.sh.tpl` (convention actuelle des templates).
- `server/templates/render.ts` (rendu Mustache), `server/templates/aptReduce.ts` (réducteur de lignes).
- `server/services/aptParse.ts` (parsing de la sortie APT), `server/services/refresh.ts`, `server/services/execute.ts`.
- `server/ssh/client.ts` (exécution SSH : sudo -S, script en base64, streaming).
- `shared/types.ts` (types JSON canoniques `UpdateSnapshot`, `ExecutionResult`).
- Dépôts de référence (lecture seule, **inspiration et non copie** — vérifier les licences, `linux-update-dashboard` est en AGPL-3.0) :
- `nas-ops/` — scripts Bash JSON-friendly : `nas-system-update`, `nas-system-upgrade`, `nas-docker-pull`, `nas-docker-up`, `nas-docker-prune`. **La meilleure référence pour cette mission.**
- `linux-update-dashboard/` — orchestration web SSH multi-machines.
**Méthode imposée** : utilise le workflow superpowers — `brainstorming` (explorer, poser des questions une à une), puis `writing-plans` plus tard. Ici, arrête-toi au **design/spec validé**. Ne code pas le moteur final dans cette mission.
> ### ⛔ Périmètre strict — ne pas déborder
> Tu travailles **exclusivement sur cette tâche 2** (le design du moteur de templates décrit ici).
> - **N'implémente pas** le code de production, ne refactore pas l'existant, ne touche pas au jalon 1 ni au jalon 2 (polish design system) en cours.
> - **Ne démarre aucun autre chantier** non listé dans ce document, même s'il te paraît utile : note-le comme suggestion dans tes livrables, mais ne l'exécute pas.
> - Si une question dépasse le cadre de la tâche 2, **liste-la et arrête-toi** plutôt que d'élargir le périmètre.
> - En cas de doute sur le périmètre, **demande** plutôt que de présumer.
> ### 📝 Clôture obligatoire
> **À la fin de ta mission, mets à jour ce fichier `tache2.md`** : ajoute une section **« État d'avancement / Ce qui a été fait »** en bas, récapitulant — les livrables produits (avec chemins), les décisions prises, les questions tranchées, ce qui reste ouvert, et les sous-jalons recommandés. Ce fichier doit refléter l'état réel de la mission une fois terminée.
---
## 1. Contexte produit (résumé)
Webapp de mise à jour distante de machines Linux (Debian, Ubuntu, Proxmox, Raspberry Pi OS) + Docker Compose, pilotée par **SSH agentless**. Le jalon 1 a prouvé l'ossature sur un cas minimal : ajout machine → refresh APT → tuile → `full-upgrade`/`reboot` avec terminal live → rapport Markdown archivé.
**Principe directeur** : la logique métier « comment mettre à jour » vit dans des **templates shell versionnés sur disque** (esprit `nas-ops`), rendus en Mustache et poussés en SSH. Le backend orchestre, parse les sorties en **JSON canonique**, archive logs + rapports. Hermes (copilote IA, via serveur MCP) **analyse** les JSON mais **n'exécute jamais** de SSH et **ne reçoit jamais de secret**.
**Conventions techniques actuelles à respecter / questionner** :
- Les templates émettent des marqueurs de section `===SU:XXX===` ; le backend extrait les sections et parse en TS (`aptParse.ts`).
- Exécution : `LC_ALL=C`, `DEBIAN_FRONTEND=noninteractive`, script entier lancé sous `sudo -S` (mot de passe sur stdin), encodé base64 pour éviter le quoting.
- Réduction déterministe des logs **avant** tout envoi à un LLM (`aptReduce.ts` : ne garde que `Inst/Conf/Remv/Err/E:/W:/dpkg:/reboot-required`).
- Deux messages JSON canoniques : **snapshot de disponibilité** (ce qui *sera* appliqué) et **résultat d'exécution** (ce qui *a été* appliqué). Voir `shared/types.ts` et le rapport.
---
## 2. Objectif de la mission
Concevoir (investigation + design, pas implémentation) le **moteur de templates de mise à jour complet** et les **contrats de données** associés, couvrant cinq axes :
### Axe A — Templates APT complets et OS-aware
Concevoir l'inventaire et le contenu des templates pour : `update` (refresh index, déjà partiellement là), `upgrade`, `full-upgrade`, `dist-upgrade`, `clean`, `autoremove`, plus `reboot`/`reboot-check`.
- Clarifier la **sémantique exacte** de chaque commande (s'appuyer sur le manpage APT cité dans le rapport) : `upgrade` n'enlève/installe pas de paquets, `dist-upgrade`/`full-upgrade` gèrent les changements de dépendances, `clean` vide le cache, `autoremove` retire les dépendances inutiles.
- **Profils par OS** : Debian, Ubuntu, Proxmox (`apt update` puis `apt dist-upgrade`, dépôts pve), Raspberry Pi OS. Le moteur doit être *profile-aware*, pas du collage de commandes. Proposer un mécanisme de profils + overrides par machine.
- **Profils par type de machine** : distinguer machine physique, VM, hôte Proxmox, LXC/container, Raspberry Pi, serveur GPU/workstation. Ce type influence les scripts proposés : firmware, drivers, benchmark, reboot, outils Proxmox, détection hardware.
- Gérer le **proxy APT** (apt-cacher-ng) : modes direct / temporaire à l'exécution / persistant dans `/etc/apt/apt.conf.d/`.
Spécificités à intégrer :
- Debian récent : vérifier les composants APT nécessaires (`main`, `contrib`, `non-free`, `non-free-firmware`) avant de proposer firmware/drivers propriétaires ;
- Ubuntu : prévoir `ubuntu-drivers` pour analyse/proposition drivers, surtout NVIDIA/GPU ;
- Proxmox : utiliser le profil Proxmox dédié, ne pas le traiter comme une Debian générique ; contrôler dépôts PVE, meta-package `proxmox-ve`, kernel PVE, éventuel Ceph, puis `apt-get dist-upgrade` ;
- Raspberry Pi OS : profil dédié, attention firmware/kernel, espace disque avant upgrade, usage de `full-upgrade` ;
- VM : privilégier outils invités (`qemu-guest-agent`, `open-vm-tools` selon hyperviseur), éviter drivers GPU/firmware non pertinents sauf passthrough ;
- machine physique : proposer détection hardware, firmware, SMART/disques, sensors, drivers GPU et benchmarks.
### Axe B — Capture des mises à jour *prévues* et *appliquées* (pour Hermes)
- **Avant** : produire le snapshot des updates disponibles (paquets, versions courante→cible, origine, reboot requis). Déjà amorcé pour `full-upgrade` simulé ; étendre et fiabiliser.
- **Après** : produire un résultat d'exécution avec le **diff réel avant/après** (paquets effectivement modifiés, versions finales, erreurs résiduelles, reboot requis après coup).
- Définir comment ces données alimentent **Hermes** : déduplication par empreinte fonctionnelle (`os_family + package + from + to + origin`), lignes importantes seulement, log brut archivé à part. Réfléchir au format consommable par le serveur MCP.
### Axe C — Gestion des erreurs
- Taxonomie des erreurs APT/dpkg : lock occupé, dépôt injoignable, conflit de paquets, `dpkg --configure -a` requis, espace disque, clé GPG, etc.
- Stratégie : codes de sortie normalisés, capture des lignes d'erreur pertinentes, statut `ok|warning|error`, conseils de remédiation (sans auto-réparation dangereuse non validée).
- Robustesse : idempotence, reprise, opérations longues qui survivent à une coupure SSH (le jalon 1 prévoyait `nohup` + fichier exit-code — à généraliser ou non, à trancher).
### Axe D — Docker Compose
Concevoir les templates/contrats pour : **scan** des stacks Compose, `pull`, `up -d` (avec `--remove-orphans`), `down`, **prune des images inutilisées** (`docker image prune` / `system prune`, en distinguant le sûr du destructif).
- Détection des stacks : via labels des conteneurs en cours (`com.docker.compose.project.working_dir`, comme `nas-ops`) **et** fallback par scan des **répertoires Compose déclarés depuis le frontend** (stacks non démarrées).
- Détection des updates d'image : comparer les IDs/digests avant/après `pull` ; lire les labels de version/source quand ils existent. JSON compact listant uniquement les conteneurs réellement concernés.
- Étendre les schémas JSON canoniques au volet `docker` (le rapport en donne un exemple).
- Sécurité du `prune` : exiger une validation explicite côté webapp (action destructive).
### Axe E — Scripts personnalisés (post-install, installation de paquets)
- Modèle pour des **scripts custom versionnés** : installation de paquets ad hoc, hooks **post-install**, configuration réseau, etc.
- Overrides par machine, variables de contexte, prévisualisation avant exécution (`preview_template`).
- Garde-fous : validation opérateur, pas de secret dans les scripts, traçabilité (rapport + log).
---
## 3. Questions d'investigation à trancher
Produire des réponses argumentées (MVP recommandé / alternatives / risques) pour :
1. **JSON-in-shell vs parsing-in-TS** : `nas-ops` produit le JSON dans le script ; le jalon 1 parse en TS. Quelle stratégie pour la suite ? (cohérence, testabilité, robustesse multi-OS). Trancher et justifier.
2. **Structure des profils OS** : fichiers de templates par profil ? héritage/override ? convention de nommage et d'arborescence sous `templates/`.
3. **Structure des profils machine** : choix manuel à l'ajout vs détection automatique (`systemd-detect-virt`, `/etc/os-release`, `dmidecode`, `lspci`, `/proc/cpuinfo`) ; règles de correction utilisateur.
4. **Capture avant/après** : comment obtenir un diff fiable des paquets réellement appliqués (parser `apt-get` ? interroger dpkg ? snapshot dpkg avant/après ?).
5. **Contrats JSON** : quelles extensions exactes à `UpdateSnapshot` et `ExecutionResult` (`shared/types.ts`) pour Docker, erreurs, scripts custom, hardware et métriques ? Proposer les types.
6. **Idempotence & opérations longues** : généraliser l'exécution détachée (`nohup` + exit-code) ? Comment suivre la progression et reprendre ?
7. **Sécurité Docker `prune` / scripts custom** : où placer la barrière de validation, comment éviter toute fuite de secret vers Hermes/MCP.
8. **Surface MCP** : quels nouveaux outils/contrats exposer à Hermes pour ces capacités (cf. `list_templates`, `preview_template`, `run_action`…), en gardant la surface minimale.
---
## 4. Livrables attendus de cette mission
À produire sous `docs/` (proposer l'emplacement), en français :
1. **Inventaire des templates** (APT + Docker + custom) : nom, rôle, OS ciblés, variables, marqueurs de sortie, sémantique.
2. **Contenu proposé** des templates clés (au moins en pseudo-shell réaliste), cohérent avec la convention `===SU:XXX===` existante.
3. **Schémas JSON canoniques étendus** (snapshot + résultat) couvrant APT, Docker, erreurs, scripts custom — avec règles de déduplication et de réduction pour Hermes.
4. **Taxonomie des erreurs** + stratégie de gestion et de remédiation.
5. **Modèle des profils OS et des overrides par machine.**
6. **Modèle des profils machine** : physique, VM, hôte Proxmox, LXC, Raspberry Pi, serveur GPU, workstation.
7. **Modèle des scripts personnalisés** (post-install, install paquets, hardware, drivers, métriques) avec garde-fous.
8. **Note de sécurité** : ce qui ne doit jamais atteindre Hermes/MCP, validations requises pour les actions destructives.
9. **Découpage en sous-jalons** implémentables indépendamment (chacun = un cycle spec → plan → implémentation), avec ordre recommandé.
---
## 5. Contraintes (non négociables)
- **Sécurité** : aucun secret (mot de passe, sudo, token, clé) ne transite vers Hermes/MCP ni dans un prompt LLM ; jamais de secret en clair dans logs/UI/retours. Actions destructives (prune, down, reboot, dist-upgrade) → validation explicite côté webapp.
- **Réduction déterministe** avant tout appel LLM ; log brut complet archivé séparément.
- **Templates versionnés sur disque** (éditables depuis le frontend mais sauvegardés comme ressources de projet, revues Git) ; pas de commandes critiques uniquement en base.
- **OS profile-aware** ; ne pas casser le jalon 1 existant (Debian/Ubuntu refresh + full-upgrade + reboot fonctionnent en prod).
- **Esprit du projet** : le backend orchestre, les scripts shell portent la logique métier, le JSON canonique est le langage commun frontend/MCP/Hermes.
- Rester dans une **surface MCP minimale** et des fichiers à responsabilité unique.
---
## 6. Définition de « terminé » pour cette mission
- Tous les axes A→E couverts par un design argumenté.
- Les 8 questions d'investigation tranchées (MVP/alternatives/risques).
- Les livrables de la §4 rédigés et cohérents entre eux.
- Un découpage en sous-jalons priorisé, prêt à passer en `writing-plans`.
- Aucune implémentation de production livrée (cette mission s'arrête au design validé).
- Le fichier `tache2.md` mis à jour avec la section « État d'avancement / Ce qui a été fait » (cf. clôture obligatoire ci-dessus).
> **Étape suivante (hors de cette mission)** : tes livrables seront passés au crible de `validation_tache2.md` (grille de validation + gate). **Aucune phase de développement ne démarre tant que ce gate n'est pas ✅ Accepté.** Conçois donc tes livrables pour qu'ils soient vérifiables contre cette grille (complétude, périmètre respecté, cohérence et intégration avec l'appli existante, non-régression).
---
## 7. Technos à utiliser — checklist
- [ ] Bash POSIX-ish pour templates shell, avec `LC_ALL=C`.
- [ ] Mustache pour rendu de templates.
- [ ] TypeScript pour parsing, réduction et contrats JSON.
- [ ] `apt-get` en scripts, pas `apt` interactif.
- [ ] `dpkg-query` pour diff avant/après.
- [ ] Docker CLI + Docker Compose plugin pour stacks.
- [ ] SSH existant `server/ssh/client.ts`, `sudo -S`, base64 script.
- [ ] Réduction déterministe avant Hermes/MCP.
- [ ] Logs bruts archivés séparément des JSON.
## 8. URLs utiles
- `apt-get` manpage : https://manpages.debian.org/apt-get
- `dpkg-query` manpage : https://manpages.debian.org/dpkg-query
- `dpkg` manpage : https://manpages.debian.org/dpkg
- Debian non-free firmware : https://www.debian.org/releases/bookworm/amd64/ch02s02.en.html
- Proxmox system updates : https://pve.proxmox.com/wiki/System_Software_Updates
- Raspberry Pi OS software updates : https://www.raspberrypi.com/documentation/usage/terminal/
- Docker Compose CLI : https://docs.docker.com/reference/cli/docker/compose/
- Docker Compose pull : https://docs.docker.com/reference/cli/docker/compose/pull/
- Docker Compose up : https://docs.docker.com/reference/cli/docker/compose/up/
- Docker image prune : https://docs.docker.com/reference/cli/docker/image/prune/
- Docker Engine Debian : https://docs.docker.com/engine/install/debian/
- Mustache : https://mustache.github.io/
## 9. Liens parent/enfant avec les autres tâches
- Parents :
- `tache1.9.md` pour stockage et indexation.
- Enfants :
- `tache4.md` pour scripts post-install détaillés.
- `tache5.md` pour exécution backend, jobs et API.
- `tache3.md` pour affichage des snapshots/actions.
- `tache6.md` pour analyse Hermes des JSON.
- `tache7.md` pour réduction tokens/nettoyage.
- Validation : `validation_tache2.md`.
---
## 10. État d'avancement / Ce qui a été fait
> **Statut** : mission de design **terminée**. Aucun code de production écrit (uniquement des `.md` sous `docs/design/tache2/` + cette section). Aucun commit. Prêt à repasser le gate `validation_tache2.md`.
### Livrables produits (chemins)
Tous sous `docs/design/tache2/` :
- `00-synthese.md` — vue d'ensemble, cartographie des livrables, décisions structurantes, alignement `tache1.9.md`, hors-scope.
- `10-templates-apt.md` — sémantique APT clarifiée, inventaire des templates APT, variables Mustache, pseudo-shell réaliste (update-analyze/upgrade/full-upgrade/autoremove/clean/reboot), profils OS, migration non-régressive du jalon 1.
- `20-docker.md` — méthode Docker par SSH, inventaire + pseudo-shell des 6 templates `docker/*` (scan/inspect/pull-check/apply/prune/down), flux 1→8, réduction Hermes, insertion webapp.
- `30-scripts-custom.md` — modèle moteur post-install (manifestes, champs dynamiques, garde-fous), profils attendus, pseudo-shell des templates custom, renvoi catalogue détaillé à la tâche 4.
- `40-contrats-json.md` — schémas JSON canoniques étendus + **types TypeScript rétro-compatibles** (extensions de `shared/types.ts`), déduplication, réduction déterministe, mapping vers les tables `tache1.9.md`.
- `50-erreurs.md` — taxonomie des erreurs APT/dpkg/Docker/réseau + codes normalisés + remédiation + interactions humaines + idempotence/reprise.
- `60-profils-os-machine.md` — modèle profils OS (arborescence + fallback `base`), type machine, overrides par machine, `machine_probe`, proxy apt-cacher-ng (direct/runtime/persistent).
- `70-securite.md` — frontière Hermes/MCP, actions destructives + validations, surface MCP minimale, traçabilité.
- `80-sous-jalons.md` — découpage en 10 sous-jalons (SJ-0→SJ-9) priorisé, prêt pour `writing-plans`.
- `90-questions-investigation.md` — les **8 questions §3 tranchées** (MVP/alternatives/risques).
- `99-couverture-gate.md` — auto-évaluation case par case de `validation_tache2.md` (§1→§8).
### Décisions prises (résumé)
1. Parsing **hybride à dominante TS** (marqueurs `===SU:XXX===` + TSV `dpkg-query` + `docker --format json`), rétro-compatible jalon 1.
2. Profils OS = **un fichier complet par profil** sous `templates/<osFamily>/`, **fallback `base`** (Debian/Ubuntu inchangés).
3. Type machine = **choix manuel + `machine_probe`** de correction (jamais auto-appliquée).
4. Diff réel = **snapshot dpkg before/after**, l'exit code ne suffit jamais.
5. Extensions de `shared/types.ts` = **unions élargies + blocs optionnels** (`docker`/`errors`/`reboot`/`postInstall`/champs apt), `schemaVersion?`. Payload jalon 1 reste valide.
6. Opérations longues = `nohup` + exit-code pour les actions applicatives ; **reboot vérifié** via boot_id + délai adaptatif ; refresh synchrone.
7. Sécurité = barrière `action_requests` côté webapp + nettoyage des secrets ; Hermes propose, ne déclenche jamais.
8. Surface MCP = **v1 conservée (8 outils)**, capacités nouvelles via `run_action` filtré ; aucune primitive SSH exposée.
### 8 questions d'investigation — tranchées
Q1 JSON-in-shell vs TS · Q2 structure profils OS · Q3 profils machine · Q4 capture avant/après · Q5 extensions JSON · Q6 idempotence/opérations longues · Q7 sécurité prune/scripts · Q8 surface MCP. Détail dans `90-questions-investigation.md`.
### Ce qui reste ouvert
- Exécution `pnpm check/test/build` (non-régression §4) : à lancer par l'orchestrateur (aucun code touché ⇒ attendu vert).
- Rendu UI fin des snapshots/actions : **tâche 3** (le design pose les contrats/exigences).
- Conflit délimiteurs Mustache vs Go-templates Docker (`{{ }}`) : choix à figer en implémentation (SJ-4).
- Catalogue détaillé des scripts post-install : **tâche 4** (mécanisme moteur posé ici).
- File de jobs persistante / API d'action : **tâche 5**.
### Sous-jalons recommandés (ordre)
SJ-0 socle types/réduction/résolution (bloquant) → SJ-1 APT update/analyse → SJ-2 APT upgrade + diff dpkg → SJ-3 reboot vérifié ; en parallèle SJ-4 Docker scan/inspect → SJ-5 pull-check → SJ-6 apply/prune/down ; transversal SJ-7 profils Proxmox/RPi + proxy persistent ; puis SJ-8 post-install bootstrap/identité → SJ-9 post-install Docker officiel/partages/VM tools. Détail dans `80-sous-jalons.md`.
+835
View File
@@ -0,0 +1,835 @@
# Consigne de dev — Design des tuiles machine et paramètres frontend
> **Type** : mission de **design UX/UI + spec frontend** (PAS d'implémentation).
> **Langue** : français.
> **Livrable final attendu** : document(s) de design/spec prêts à passer en plan d'implémentation.
---
## 0. Contexte
Le projet `system_update` est une webapp de mise à jour distante de machines Linux, Docker Compose et scripts post-install, pilotée par SSH agentless.
Le jalon actuel affiche des tuiles machine simples : nom, IP, OS, compteur APT, boutons `Refresh`, `Upgrade`, `Reboot`. La prochaine étape UI doit transformer chaque tuile en **cockpit machine compact et extensible**, compatible avec :
- APT update/analyse/upgrade/reboot ;
- Docker Compose : installation Docker, paramétrage des roots Compose, scan, stacks, upgrade, prune ;
- Post-install : profils cochables, champs dynamiques, preview, exécution ;
- Design system Gruvbox seventies.
À lire avant de travailler :
- `CLAUDE.md`
- `design_system/consigne_design_system.md`
- `docs/superpowers/specs/2026-06-04-jalon1-tranche-verticale-apt-design.md`
- `docs/superpowers/specs/2026-06-05-jalon2-polish-design-system-design.md`
- `validation_tache2.md`
- `client/src/App.tsx`
- `client/src/panels/Dashboard.tsx`
- `client/src/features/machines/MachineTile.tsx`
- `client/src/components/ui-kit.tsx`
- `client/src/styles/app.css`
---
## 1. Objectif
Concevoir le design des **tuiles machine extensibles** et des paramètres frontend liés.
La tuile doit rester lisible par défaut, puis s'agrandir automatiquement quand l'utilisateur ouvre les sections Docker ou Post-install.
Par défaut, on voit uniquement le bloc système :
- statut machine ;
- nom ;
- IP/port ;
- OS ;
- compteur updates ;
- dernier check ;
- reboot requis ;
- actions système en icônes.
Deux sections doivent être repliables :
- **Docker**
- **Post-install**
Quand une section s'ouvre, la tuile s'agrandit en hauteur. Si le contenu devient important, le design doit prévoir une variante `expanded` qui peut occuper toute la largeur de la grille (`grid-column: 1 / -1`) sans masquer les autres volets.
---
## 2. Contraintes design system
Respect strict de `design_system/consigne_design_system.md` :
- variables CSS uniquement ;
- composants existants du `ui-kit` ;
- icônes via `<Icon>` / `<IconButton>`, jamais emoji/SVG inline ;
- tooltips obligatoires sur icônes seules ;
- pas de hover décoratif, seulement pression `.interactive` ;
- polices Inter / JetBrains Mono / Share Tech Mono ;
- labels uppercase `.label` ;
- tuiles `glass`, rayon 10-12px ;
- UI dense, lisible dark + light ;
- ne pas utiliser `window.alert` / `confirm`, utiliser `<Popup>`.
Les boutons texte doivent être réservés aux commandes explicites longues ou primaires. Dès que possible, remplacer par des icônes :
- analyse/refresh : `refresh`
- upgrade/apply : `download` ou alias à ajouter si besoin
- reboot/power : `power`
- paramètres : `cog`
- rapport/log : `list` ou `terminal`
- ouvrir/fermer section : `chevD` / `chevR`
- installer/ajouter : `plus` ou `download`
- erreur : `alert`
- dossier/roots compose : `folder`
Si une icône manque, proposer l'ajout d'un alias dans `ICON_MAP` du ui-kit, sans inventer de SVG.
---
## 3. Identité app, favicon et icônes
La webapp doit disposer d'une identité visuelle propre, compatible desktop et smartphone.
Assets à prévoir :
- `favicon.svg` : favicon principal vectoriel ;
- `favicon.ico` : fallback navigateur ;
- `apple-touch-icon.png` : icône smartphone/tablette ;
- `web-app-manifest` ou équivalent futur : icônes `192x192` et `512x512` ;
- icône monochrome/masque si l'app devient PWA ;
- icône large éventuelle pour page d'accueil/launcher.
Direction visuelle :
- thème Gruvbox seventies ;
- symbole simple et lisible en petit format ;
- évoquer monitoring système + update + machines ;
- éviter les logos de distributions ou marques propriétaires ;
- éviter emoji, gradient SaaS, mascotte, illustration complexe ;
- formes robustes : terminal, serveur, flèche d'update, LED, grille machine.
Un fichier dédié [consigne_icon.md](/home/gilles/Documents/projet/system_update/consigne_icon.md) doit servir de brief à un agent de création SVG.
### Icônes applicatives utiles
Le frontend doit d'abord utiliser Font Awesome via `Icon`/`IconButton`. Les SVG spécifiques ne sont à créer que si Font Awesome ne couvre pas assez bien le besoin ou si l'app a besoin d'un pictogramme propriétaire.
Alias à prévoir ou vérifier dans `ICON_MAP` :
```text
Navigation/layout
- app-logo
- hermes
- machines
- settings
- terminal
- logs
- report
- copy
- open-external
- fullscreen
- collapse
Actions système
- refresh
- analyze
- upgrade
- full-upgrade
- reboot
- stop
- dry-run
- approve
- reject
Machine/profil
- server
- vm
- physical-host
- proxmox
- raspberry-pi
- container
- gpu
- cpu
- memory
- disk
- network
- health
APT/Docker/Post-install
- package
- repository
- firmware
- driver
- docker
- compose-stack
- image
- prune
- script
- profile
Sécurité/états
- ok
- warning
- error
- locked
- secret
- key
- shield
- disconnected
- running
```
Les icônes spécifiques SVG candidates :
- `app-logo` : identité de l'application ;
- `hermes` : si l'agent a une identité visuelle locale distincte ;
- `proxmox` : pictogramme générique d'hyperviseur, sans reprendre le logo officiel ;
- `compose-stack` : pile de conteneurs/stacks, plus précis que `server` ;
- `firmware-driver` : composant matériel + puce ;
- `machine-probe` : loupe + serveur ;
- `reboot-verified` : power + check.
---
## 4. Layout global de la webapp
Le design cible reste un dashboard 3 zones avec header et footer fixes.
```text
┌──────────────────────────────────────────────────────────────────────────────┐
│ HEADER System Update [search] [scan ssh] [+ machine] [theme] [⚙] │
├──────────────────┬─────────────────────────────────────┬───────────────────┤
│ HERMES │ MACHINES │ TERMINAL │
│ chat clair │ grille de tuiles │ logs/action SSH │
│ │ │ │
│ user message │ ┌─────────────────────────────────┐ │ vm_mqtt │
│ hermes answer │ │ machine tile compact/expanded │ │ live output │
│ command blocks │ └─────────────────────────────────┘ │ search/filter │
│ action cards │ ┌─────────────────────────────────┐ │ report/log links │
│ │ │ machine tile compact/expanded │ │ │
├──────────────────┴─────────────────────────────────────┴───────────────────┤
│ FOOTER mode · machines · apt · docker · jobs · cpu · ram · db · hermes · ts │
└──────────────────────────────────────────────────────────────────────────────┘
```
Contraintes :
- le volet Hermes gauche ne masque jamais les tuiles ;
- le terminal droit ne recouvre jamais la section centrale ;
- les largeurs sont redimensionnables mais bornées ;
- le centre garde `min-width: 0` et scrolle indépendamment ;
- sur mobile, ce layout devient des onglets/pages, pas trois colonnes compressées.
---
## 5. Tuile machine — structure cible
### Ajout machine — sélection OS et type
Lors de l'ajout d'une machine, le frontend doit prévoir deux champs distincts :
- **OS** : Debian, Ubuntu, Proxmox VE, Raspberry Pi OS, autre Linux ;
- **Type de machine** : VM, machine physique, hôte Proxmox, LXC/container, Raspberry Pi, serveur GPU/workstation.
Le formulaire doit permettre :
- choix manuel rapide ;
- bouton d'auto-détection après test SSH ;
- affichage du résultat détecté avec possibilité de correction ;
- explication courte des conséquences, sans texte long : les scripts proposés changent selon le profil ;
- avertissement si incohérence, par exemple OS Proxmox mais type VM, ou Raspberry Pi OS sans architecture ARM.
Exemple UX :
```text
Ajouter machine
┌──────────────────────────────────────────────┐
│ Nom [ vm_mqtt ] │
│ Hôte/IP [ 10.0.0.3 ] │
│ OS [ Debian v ] │
│ Type machine [ VM v ] │
│ │
│ [tester SSH] [détecter OS/hardware] │
└──────────────────────────────────────────────┘
```
Après détection, la tuile peut afficher un badge compact :
```text
debian · vm · amd64 · qemu
proxmox · physical · zfs
raspios · raspberry_pi · arm64
```
### État compact
```text
┌────────────────────────────────────────────────────────────────────┐
│ ● vm_mqtt debian 13 · vm · amd64 · qemu │
│ 10.0.0.3:22 last check 06:42 │
├────────────────────────────────────────────────────────────────────┤
│ SYSTEM APT 4 updates Reboot no Warnings 1 │
│ HEALTH CPU 0.08/4c RAM 26% / 29% │
│ │
│ [refresh] analyse [download] upgrade [power] reboot [list] log │
│ │
│ ▸ Docker 1 stack update · prune ready │
│ ▸ Post-install 0 selected · 3 recommended │
│ ▸ Hardware VM tools ok · no GPU │
└────────────────────────────────────────────────────────────────────┘
```
### État complet hôte physique / Proxmox
```text
┌────────────────────────────────────────────────────────────────────┐
│ ● proxmox-01 proxmox 9 · physical · amd64 │
│ 10.0.0.10:22 zfs · gpu detected · last 06:42 │
├────────────────────────────────────────────────────────────────────┤
│ SYSTEM APT 12 updates Reboot yes Warnings 2 │
│ HEALTH CPU 0.42/16c RAM 41% / 68% /tank 72% │
│ ALERTS repo warning · firmware update available │
│ │
│ [refresh] [download] dist-upgrade [power] reboot [list] [cog] │
│ │
│ ▾ Docker 3 stacks · 1 update │
│ ok mqtt up-to-date │
│ warn frigate image update available │
│ [refresh] pull-check [download] apply [list] report │
│ ok paperless up-to-date │
│ [prune] images │
│ │
│ ▾ Post-install 2 recommended │
│ [ ] firmware tools physical host │
│ [ ] gpu drivers NVIDIA detected │
│ [ ] benchmark tools optional │
│ │
│ ▾ Hardware │
│ CPU 16c · RAM 64G · GPU NVIDIA · disks 4 · smart ok │
└────────────────────────────────────────────────────────────────────┘
```
### État Docker déplié
```text
┌────────────────────────────────────────────────────────────────────┐
│ ● vm_mqtt debian · 10.0.0.3:22 │
│ SYSTEM Updates: 4 Reboot: no Last check: 06:42 │
│ [↻] [⇩] [⏻] [▤] │
│ │
│ ▾ Docker installed · 3 stacks │
│ Roots: /home/gilles/docker, /opt/stacks [⚙] [↻] │
│ │
│ ✓ homeassistant 1 update available [⇩] │
│ ✓ mqtt up to date [✓] │
│ ⚠ paperless pull error [!] │
│ │
│ Prune images: 2 old images · 740 MB reclaimable [🧹] │
│ │
│ ▸ Post-install 0 profile selected │
└────────────────────────────────────────────────────────────────────┘
```
Le rendu final doit utiliser des icônes Font Awesome via le design system, pas les symboles ASCII ci-dessus.
### État Post-install déplié
```text
┌────────────────────────────────────────────────────────────────────┐
│ ● debian-new debian · 10.0.2.81:22│
│ SYSTEM Updates: unknown Reboot: unknown │
│ [↻] analyse │
│ │
│ ▸ Docker not installed [⇩] │
│ ▾ Post-install │
│ ☑ Hostname + IP statique risk: net │
│ Hostname [ debian-docker-01 ] │
│ Interface [ ens18 ▼ ] │
│ Address [ 10.0.4.25/24 ] │
│ Gateway [ 10.0.0.1 ] │
│ DNS [ 10.0.0.1, 10.0.0.10 ] │
│ │
│ ☑ Base tools nano less tmux htop ncdu rsync... │
│ ☐ Sharing samba nfs avahi wsdd2 │
│ ☑ Docker officiel user: gilles · dir: /home/gilles/docker │
│ │
│ [preview] [run selected] │
└────────────────────────────────────────────────────────────────────┘
```
Les cases à cocher doivent être de vrais contrôles du design system (`Toggle` ou checkbox stylée), pas du texte décoratif.
---
## 6. Section système
La section système est toujours visible.
Elle doit afficher :
- statut : `ok`, `updates_available`, `running`, `warning`, `error`, `unknown` ;
- machine : nom, hostname/IP, port, OS family/version ;
- APT :
- updates prévues ;
- dernier `apt_update_analyze` ;
- reboot requis ;
- erreurs éventuelles ;
- lien rapport/log si disponible.
Actions :
- analyser (`apt_update_analyze`) ;
- upgrade simple ;
- full/dist-upgrade si disponible ;
- reboot vérifié ;
- rapport/log.
Règles :
- `upgrade`, `full/dist-upgrade`, `reboot` demandent confirmation UI ;
- actions désactivées si une action est déjà en cours ;
- action d'upgrade désactivée si aucune analyse récente n'existe, ou affichée avec warning explicite ;
- les erreurs doivent être lisibles directement dans la tuile sans ouvrir le terminal.
---
## 7. Section Docker
La section Docker est repliée par défaut.
Cas à couvrir :
### Docker non installé
Afficher :
- statut `Docker absent` ;
- action installer Docker officiel ;
- info courte : "installation via script officiel enregistré".
Action :
- bouton icône + tooltip `Installer Docker officiel`.
- confirmation obligatoire.
### Docker installé, stacks non configurés
Afficher :
- statut `Docker installé`;
- bouton paramètres section Docker ;
- bouton scan ;
- message : "Aucun root Compose validé".
Paramètres Docker :
- roots Compose par machine ;
- profondeur de scan ;
- stacks activés/désactivés ;
- chemins ignorés ;
- mode prune prudent/agressif.
### Stacks Compose détectés/configurés
Afficher une liste compacte :
- nom stack ;
- chemin court ;
- statut check ;
- nombre de services ;
- update dispo ;
- erreur éventuelle ;
- action upgrade si update dispo.
Bouton `docker prune` :
- actif uniquement après analyse Docker ;
- indique l'espace récupérable si connu ;
- confirmation obligatoire ;
- mode agressif séparé et clairement marqué comme risqué.
---
## 8. Section Post-install
La section Post-install est repliée par défaut.
Elle contient des profils cochables :
- `bootstrap_root`
- `identity_network`
- `base_tools`
- `network_tools`
- `dev_git`
- `sharing`
- `docker_official`
- `home_automation`
- `dev_tools`
- `embedded_esp_platformio`
- `terminal_customization`
- `vm_guest_tools`
- `storage_health`
- `media_tools`
- `security_audit`
- `security_lab` (high risk)
- `backup_sync`
- `monitoring`
- `network_services`
Quand un profil est coché :
- il déplie ses champs obligatoires ;
- les champs peuvent être préremplis ;
- le bouton run reste désactivé tant que les champs requis sont invalides ;
- une preview du script rendu est disponible ;
- les actions à risque demandent confirmation.
Exemple Hostname + réseau :
```text
┌────────────────────────────────────────────────────────────────────┐
│ POST-INSTALL · Hostname + IP statique │
│ risk: network_change · reboot required │
├────────────────────────────────────────────────────────────────────┤
│ ☑ Activer ce script │
│ Hostname [ debian-docker-01 ] │
│ Domaine local [ home ] │
│ Interface [ ens18 ▼ ] │
│ Adresse statique [ 10.0.4.25/24 ] │
│ Passerelle [ 10.0.0.1 ] │
│ DNS [ 10.0.0.1, 10.0.0.10 ] │
│ Reconnexion via [ 10.0.4.25 ] │
│ [preview] [run selected] │
└────────────────────────────────────────────────────────────────────┘
```
---
## 9. Paramètres frontend à concevoir
Créer une spec pour les écrans/sections de paramètres suivants :
Les paramètres frontend persistants doivent être sauvegardés côté backend/BDD :
- thème ;
- densité ;
- zoom ;
- taille/densité des tuiles ;
- sections ouvertes par défaut ;
- largeur des volets Hermes et terminal ;
- activation du terminal SSH interactif ;
- préférences de filtres terminal.
Le navigateur peut garder une copie locale temporaire pour éviter un flash visuel au chargement, mais la source durable doit rester en BDD.
### Vue globale onglet Paramètres
L'onglet paramètres doit rester dense et opérationnel, sans page marketing.
```text
┌──────────────────────────────────────────────────────────────────────────────┐
│ HEADER System Update [save] [close] │
├──────────────────┬───────────────────────────────────────────────────────────┤
│ SETTINGS NAV │ PARAMETRES │
│ │ │
│ > Général │ Général │
│ Apparence │ Theme [dark v] Density [compact v] Zoom [100%] │
│ Machines │ Panels Hermes [280px] Terminal [420px] │
│ Docker │ │
│ Scripts │ Icônes / application │
│ Hermes/MCP │ Favicon [preview] Smartphone icon [preview] │
│ Terminal SSH │ App name [System Update] PWA enabled [off] │
│ Nettoyage │ │
│ Sécurité │ Docker │
│ Découverte │ Default scan depth [4] Prune mode [safe v] │
│ │ │
│ │ Scripts d'installation │
│ │ [Docker officiel] [Node] [Rust] [PlatformIO] [custom +] │
│ │ │
│ │ Terminal SSH │
│ │ Enable interactive SSH [off] Record sessions [off] │
├──────────────────┴───────────────────────────────────────────────────────────┤
│ FOOTER settings · unsaved changes 0 · db ok · hermes connected · 06:42 │
└──────────────────────────────────────────────────────────────────────────────┘
```
Catégories minimales :
- Général/apparence ;
- Machines et valeurs par défaut ;
- Docker ;
- Scripts d'installation ;
- Hermes/MCP ;
- Terminal SSH ;
- Logs/rapports/nettoyage ;
- Sécurité/secrets ;
- Découverte réseau ;
- Icônes/application.
### Terminal volet droit
Le volet terminal droit doit être amélioré comme un vrai outil d'exploitation, pas seulement une zone xterm brute.
Fonctions à concevoir :
- en-tête clair avec machine sélectionnée : nom, IP, statut, action courante ;
- séparation visuelle forte quand on change de machine ;
- replay du buffer récent ;
- autoscroll activable/désactivable ;
- bouton pause/reprendre flux ;
- bouton clear local ;
- bouton copier sélection ;
- recherche dans le terminal ;
- filtre lignes importantes ;
- mode log brut / mode réduit ;
- indication WebSocket connecté/déconnecté ;
- lien rapport/log brut après fin d'exécution ;
- état "aucune machine sélectionnée" utile ;
- redimensionnement robuste avec xterm fit ;
- terminal plein écran ou drawer dédié sur petit écran.
Deux modes doivent être distingués :
1. **Mode exécution suivie** : terminal attaché aux actions lancées par la webapp (`apt`, Docker, post-install, reboot).
2. **Mode SSH interactif** : ouverture d'un vrai shell SSH vers la machine sélectionnée, avec saisie de commandes par l'utilisateur.
Le mode SSH interactif doit être clairement identifié comme plus risqué :
- bouton d'ouverture explicite ;
- indication machine/IP/utilisateur en permanence ;
- confirmation si l'utilisateur ouvre un shell root ou sudo ;
- journalisation de l'ouverture/fermeture de session ;
- pas d'envoi automatique des commandes tapées à Hermes ;
- masquage/censure des secrets dans le replay UI quand c'est possible ;
- désactivation possible par paramètre global.
Actions en icônes avec tooltips :
- `terminal` : focus terminal ;
- `pause` / `play` : pause/reprendre ;
- `search` : chercher ;
- `close` : clear local ou fermer drawer selon contexte ;
- `download` : rapport/log ;
- `alert` : erreurs filtrées.
Le terminal ne doit jamais afficher de secret. Si une sortie contient un motif sensible, le design doit prévoir une étape backend de masquage avant diffusion UI.
### Volet Hermes gauche
Le volet Hermes doit présenter une discussion claire, distincte du terminal :
- bulles ou lignes différenciées `Utilisateur` / `Hermes` / `Système` ;
- horodatage discret ;
- état streaming / génération en cours ;
- bouton copier sur chaque message ;
- sélection et copier-coller natifs dans le texte ;
- historique scrollable sans masquer le dashboard ;
- zone de saisie stable, multi-ligne, avec envoi contrôlé ;
- rendu lisible des blocs de commande ;
- bouton copier sur chaque bloc de commande ;
- actions proposées affichées comme cartes séparées, jamais comme texte ambigu ;
- lien vers rapports, logs réduits, snapshots ou exécutions quand Hermes les cite.
Les commandes proposées par Hermes doivent être faciles à relire et copier :
```text
Hermes
┌────────────────────────────────────┐
│ Commande proposée │
│ apt-get update │
│ apt-get -s dist-upgrade │
│ [copier] │
└────────────────────────────────────┘
```
Une commande copiée depuis Hermes ne doit pas être exécutée automatiquement. Toute action SSH lancée par la webapp passe par les boutons/actions validées.
### Mode smartphone / responsive
Prévoir un brainstorming frontend spécifique pour le mode smartphone avant implémentation. Le layout desktop 3 volets ne peut pas simplement être compressé.
Questions à trancher :
- navigation par tabs ou bottom bar : `Hermes`, `Machines`, `Terminal` ;
- terminal en drawer plein écran ou page dédiée ;
- tuile machine en carte verticale ;
- sections Docker/Post-install repliées par défaut ;
- actions dangereuses en popup plein écran ;
- clavier mobile pour champs IP/CIDR/hostname ;
- taille minimale des touch targets ;
- comportement du terminal avec clavier virtuel ;
- conservation de la lisibilité dark/light ;
- possibilité de masquer Hermes ou terminal pour garder le dashboard lisible.
Proposition MVP mobile :
```text
┌────────────────────────────┐
│ System Update [☰] │
├────────────────────────────┤
│ Machines │
│ ┌────────────────────────┐ │
│ │ ● vm_mqtt │ │
│ │ APT 4 · Docker 1 │ │
│ │ [↻] [⇩] [⏻] [▤] │ │
│ │ ▸ Docker │ │
│ │ ▸ Post-install │ │
│ └────────────────────────┘ │
├────────────────────────────┤
│ [Hermes] [Machines] [Term] │
└────────────────────────────┘
```
Cette partie doit produire une spec dédiée : breakpoints, écrans, composants, interactions tactiles, gestion terminal et validations.
### Paramètres Docker par machine
- roots Compose ;
- profondeur de scan ;
- stacks validés ;
- stacks ignorés ;
- mode prune ;
- dernier scan ;
- erreurs.
### Paramètres scripts d'installation
Catalogue global de scripts réutilisables :
```text
Paramètres
└─ Scripts dinstallation
├─ Docker officiel Debian
├─ Rust via rustup
├─ Node.js via nvm / NodeSource
├─ Python uv
├─ PlatformIO / ESP
├─ Personnalisation terminal
├─ Script perso domotique
└─ ...
```
Chaque script doit afficher :
- nom ;
- catégorie ;
- source ;
- statut enabled/draft ;
- variables attendues ;
- niveau de risque ;
- preview ;
- dernière utilisation ;
- JSON retour attendu.
### Paramètres affichage tuile
- sections ouvertes par défaut ou non ;
- densité compacte/confort ;
- affichage des chemins complets ou courts ;
- seuil de fraîcheur d'une analyse APT/Docker ;
- comportement expanded full-width.
---
## 10. JSON et intégration API
Même si cette tâche est frontend, le design doit décrire les données nécessaires.
Chaque interrogation/action affichée dans la tuile doit correspondre à un échange JSON :
- snapshot machine ;
- snapshot OS/profil machine ;
- snapshot hardware ;
- snapshot métriques simples ;
- snapshot APT ;
- snapshot Docker ;
- manifeste post-install ;
- preview template ;
- résultat d'exécution ;
- erreurs structurées.
- messages importants extraits des logs : warnings, dépréciations, évolutions futures, erreurs ;
- références de rapport/log consultables.
Le frontend ne doit jamais parser du log brut pour décider l'état d'une tuile. Il consomme le JSON canonique produit par la webapp/backend.
Hermes/MCP ne reçoivent que le JSON réduit, jamais de secret.
---
## 11. Livrables attendus
À produire sous `docs/` :
1. Design détaillé des tuiles machine.
2. États UI : compact, Docker ouvert, Post-install ouvert, erreur, running, machine sans Docker.
3. ASCII draw final + maquettes textuelles.
4. Liste des composants design system à utiliser.
5. Liste des icônes nécessaires et alias à ajouter au ui-kit.
6. Spécification favicon, icônes smartphone et assets PWA.
7. Vue globale page web : header, volet Hermes, centre, terminal, footer.
8. Vue globale onglet paramètres.
9. Spécification des paramètres frontend.
10. Contrats JSON nécessaires côté frontend.
11. Spec UX du volet Hermes : discussion, copier-coller, blocs de commandes, cartes de validation.
12. Spec UX du terminal droit : exécution suivie vs SSH interactif.
13. Découpage en sous-jalons d'implémentation.
---
## 12. Définition de terminé
- Le design respecte le design system.
- La tuile répond aux besoins APT, Docker et Post-install.
- Les sections s'ouvrent sans masquer la zone centrale ni le terminal.
- Les actions dangereuses sont clairement identifiées.
- Les champs dynamiques post-install sont spécifiés.
- Les vues globales dashboard et paramètres sont spécifiées.
- Les besoins favicon, icônes smartphone et icônes SVG spécifiques sont listés.
- Les échanges JSON nécessaires sont listés.
- Le volet Hermes permet une discussion lisible, des messages copiables et des commandes copiables sans exécution automatique.
- Le terminal SSH interactif est distingué du terminal de logs d'exécution et reste désactivable.
- Aucun code de production n'est livré pendant cette mission de design.
---
## 13. Technos à utiliser — checklist
- [ ] React + TypeScript.
- [ ] Vite pour build frontend.
- [ ] Design system `Gruvbox seventies`.
- [ ] CSS variables uniquement, dark/light.
- [ ] `ui-kit` existant avant création de composants.
- [ ] Font Awesome via `Icon`/`IconButton`.
- [ ] SVG custom uniquement pour icônes spécifiques validées dans `consigne_icon.md`.
- [ ] xterm.js pour terminal web.
- [ ] WebSocket/SSE pour flux live.
- [ ] Web App Manifest + favicon/app icons si PWA prévue.
- [ ] API backend uniquement, pas de parsing log brut côté client.
## 14. URLs utiles
- React TypeScript : https://react.dev/learn/typescript
- Vite : https://vite.dev/guide/
- Font Awesome : https://fontawesome.com/docs
- xterm.js : https://xtermjs.org/
- MDN Web App Manifest : https://developer.mozilla.org/en-US/docs/Web/Manifest
- MDN Responsive design : https://developer.mozilla.org/en-US/docs/Learn_web_development/Core/CSS_layout/Responsive_Design
- MDN WebSocket : https://developer.mozilla.org/en-US/docs/Web/API/WebSocket
- MDN Server-sent events : https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events
## 15. Liens parent/enfant avec les autres tâches
- Parents :
- `tache1.9.md` pour paramètres frontend et état courant.
- `tache2.md` pour contrats JSON APT/Docker.
- `tache4.md` pour profils/scripts à afficher.
- `tache5.md` pour API et WebSocket/SSE.
- Enfants :
- `tache6.md` pour volet Hermes.
- `tache7.md` pour métriques/footer/mobile.
- `tache8.md` pour reprise partielle en app Rust/GNOME.
- `consigne_icon.md` pour création icônes.
- Validation : `validation_tache3.md`.
+910
View File
@@ -0,0 +1,910 @@
# Consigne de dev — Amélioration des scripts, profils post-install et installateurs
> **Type** : mission d'**investigation + design scripts + contrats JSON** (PAS d'implémentation).
> **Langue** : français.
> **Livrable final attendu** : spec prête à passer en plan d'implémentation.
---
## 0. Contexte
Cette tâche regroupe tout ce qui concerne l'amélioration des scripts d'installation, post-install, configuration système, outils, profils réutilisables et installateurs externes.
Principe non négociable :
> Chaque action ou interrogation sur un hôte doit produire un échange JSON clair entre la machine et la webapp.
Les scripts peuvent streamer du log vers le terminal, mais l'état métier consommé par la webapp, Hermes et MCP doit être un JSON canonique.
À lire avant de travailler :
- `CLAUDE.md`
- `deep-research-report(7).md`
- `tache2.md`
- `validation_tache2.md`
- `templates/apt/*.tpl`
- `server/ssh/client.ts`
- `server/templates/render.ts`
- `shared/types.ts`
---
## 1. Objectif
Concevoir un système de scripts post-install et d'installateurs réutilisables pour préparer des machines Debian/Ubuntu/Proxmox/Raspberry Pi OS après ajout dans la webapp.
Cas concret prioritaire :
- VM Debian 13 installée via netinstall CLI ;
- IP initiale via DHCP ;
- connexion SSH sur l'IP DHCP ;
- élévation root ou sudo ;
- installation de prérequis ;
- changement éventuel hostname/domaine `.home` ;
- configuration IP statique ;
- reboot vérifié ;
- installation de groupes de paquets ;
- installation Docker officiel ;
- installation d'outils optionnels ;
- personnalisation terminal.
---
## 2. Modèle général des scripts
Un script ne doit jamais poser de questions interactives pendant l'exécution SSH.
Toute variable nécessaire est demandée par la webapp avant exécution :
- champs texte ;
- select ;
- multi-select ;
- checkbox/toggle ;
- IP/CIDR ;
- chemin ;
- utilisateur ;
- confirmation de risque.
Chaque script ou profil fournit un manifeste :
```json
{
"id": "identity_network",
"label": "Hostname + IP statique",
"description": "Configure le hostname, le domaine et l'IP statique de la machine.",
"category": "post_install",
"risk": "network_change",
"requiresConfirmation": true,
"fields": [],
"defaults": {},
"validations": [],
"steps": [],
"expectedJsonResult": "postInstall"
}
```
Cycle recommandé d'un installateur :
```text
precheck
→ install
→ configure
→ initialize
→ verify
→ report JSON
```
Le JSON de retour doit détailler chaque étape :
```json
{
"action": "install_recipe",
"recipeId": "docker_official_debian",
"status": "ok",
"steps": [
{ "id": "precheck", "status": "ok" },
{ "id": "install", "status": "ok" },
{ "id": "configure", "status": "ok" },
{ "id": "initialize", "status": "ok" },
{ "id": "verify", "status": "ok" }
],
"requiresRelogin": true,
"requiresReboot": true,
"errors": []
}
```
---
## 3. Profils post-install prioritaires
### `bootstrap_root`
Préparation minimale :
- `sudo`
- `resolvconf`
- `ca-certificates`
- `curl`
- ajout utilisateur au groupe `sudo`
- vérification sudo.
### `identity_network`
Configuration identité/réseau :
- hostname ;
- domaine/search `.home` ;
- `/etc/hosts` ;
- `/etc/network/interfaces` ;
- IP statique `10.0.x.y/22` ;
- gateway `10.0.0.1` ;
- DNS `10.0.0.1`, `10.0.0.10` ;
- reconnexion sur nouvelle IP ;
- reboot vérifié si nécessaire.
Variables :
```json
{
"newHostname": "debian-docker-01",
"domain": "home",
"interfaceName": "ens18",
"staticAddress": "10.0.4.25/22",
"gateway": "10.0.0.1",
"dnsNameservers": ["10.0.0.1", "10.0.0.10"],
"reconnectHost": "10.0.4.25"
}
```
### `base_tools`
Paquets de base, sans `vim` :
- `nano`
- `less`
- `bash-completion`
- `tmux`
- `screen`
- `htop`
- `iotop`
- `ncdu`
- `tree`
- `rsync`
- `unzip`
- `zip`
- `tar`
### `machine_probe`
Détection OS, virtualisation, matériel et capacités.
Ce script doit être proposé juste après l'ajout machine et relançable depuis les paramètres machine.
Commandes/outils possibles :
- `/etc/os-release`
- `uname -a`
- `dpkg --print-architecture`
- `systemd-detect-virt`
- `hostnamectl`
- `lscpu`
- `lsblk`
- `findmnt`
- `lspci` via `pciutils`
- `lsusb` via `usbutils`
- `dmidecode` seulement si utile et disponible ;
- `/proc/cpuinfo` pour Raspberry Pi ;
- `pveversion` et `pvesh` si Proxmox ;
- état `qemu-guest-agent`/guest tools si VM.
JSON attendu :
```json
{
"action": "machine_probe",
"status": "ok",
"os": {
"family": "debian",
"version": "13",
"codename": "trixie",
"arch": "amd64"
},
"machine": {
"kind": "vm",
"virtualization": "qemu",
"hypervisor": "kvm",
"raspberryPi": false,
"proxmoxHost": false
},
"hardware": {
"cpuModel": "Intel...",
"cpuCores": 4,
"memoryBytes": 4294967296,
"gpus": [],
"disks": []
},
"recommendations": [
{
"profileId": "vm_guest_tools",
"reason": "QEMU/KVM détecté"
}
]
}
```
Le résultat met à jour le profil OS/type machine proposé dans l'UI, mais l'utilisateur doit pouvoir corriger manuellement.
### `machine_metrics_simple`
Remontée légère CPU/RAM/disque pour tuiles, footer et Hermes.
Commandes possibles :
- charge CPU : `/proc/loadavg`, `uptime`, `nproc` ;
- mémoire : `free -b` ou `/proc/meminfo` ;
- disque : `df -h` et `df -B1` ;
- inodes : `df -i` ;
- uptime : `/proc/uptime` ;
- température optionnelle : `sensors` si installé, Raspberry Pi via `vcgencmd` si disponible.
JSON attendu :
```json
{
"action": "machine_metrics_simple",
"status": "ok",
"collectedAt": "ISO",
"cpu": {
"load1": 0.08,
"load5": 0.12,
"cores": 4
},
"memory": {
"totalBytes": 4294967296,
"usedBytes": 1123456789,
"availableBytes": 2987654321
},
"filesystems": [
{
"mount": "/",
"fstype": "ext4",
"sizeBytes": 32100000000,
"usedBytes": 9300000000,
"usedPercent": 29
}
],
"warnings": []
}
```
Ce script doit être non destructif, rapide, lançable en tâche planifiée, et ne doit pas nécessiter d'installation lourde.
### `apt_repositories`
Analyse et configuration contrôlée des dépôts selon OS.
Besoins :
- Debian : vérifier `main`, `contrib`, `non-free`, `non-free-firmware` selon les profils firmware/drivers ;
- Ubuntu : vérifier `main`, `universe`, `restricted`, `multiverse` selon besoins drivers ;
- Proxmox : vérifier dépôts PVE enterprise/no-subscription, dépôts Debian compatibles, warnings si repo absent/incohérent ;
- Raspberry Pi OS : vérifier dépôts Raspberry Pi OS et Debian associés, sans remplacer par une Debian générique.
Le script doit d'abord produire une analyse, puis proposer une action séparée et validée pour modifier les dépôts.
### `firmware_tools`
Profil machine physique/portable/serveur.
Paquets et outils possibles selon OS :
- `fwupd`
- `pciutils`
- `usbutils`
- `dmidecode`
- `lshw`
- `lm-sensors`
- `smartmontools`
- firmwares Debian selon matériel détecté.
Règles :
- jamais installer du firmware propriétaire sans profil/repo compatible et validation ;
- sur VM, proposer seulement si passthrough ou matériel réel détecté ;
- sur Proxmox bare-metal, tenir compte des recommandations firmware host.
### `gpu_drivers`
Installation/diagnostic GPU par constructeur.
Sous-profils :
- `gpu_nvidia`
- `gpu_amd`
- `gpu_intel`
- `gpu_intel_arc`
Principe :
- première étape obligatoire : `machine_probe` + détection GPU ;
- deuxième étape : analyse repos/paquets disponibles ;
- troisième étape : proposition d'installation selon OS.
Contraintes :
- Debian : les drivers/firmwares peuvent nécessiter `contrib`, `non-free`, `non-free-firmware` ;
- Ubuntu : utiliser `ubuntu-drivers` quand disponible pour recommander les pilotes NVIDIA/GPU ;
- AMD/Intel : privilégier le stack kernel/Mesa/firmware de la distribution avant scripts externes ;
- Intel Arc : vérifier kernel/firmware/Mesa suffisamment récents avant installation ;
- Proxmox : drivers GPU seulement si besoin host/passthrough/transcodage, jamais par défaut ;
- VM : ne pas proposer sauf GPU passthrough détecté.
### `benchmark_tools`
Outils de test et benchmark, optionnels et jamais installés par défaut.
Catégories :
- CPU : `sysbench`, `stress-ng` ;
- disque : `fio`, `hdparm` en lecture contrôlée ;
- réseau : `iperf3` ;
- monitoring ponctuel : `sysstat` selon disponibilité ;
- hardware : `hardinfo` seulement si pertinent avec interface graphique, sinon éviter.
Règles :
- les benchmarks peuvent charger la machine : confirmation explicite ;
- pas de test disque destructif ;
- rapport JSON avec commande, durée, score, erreurs ;
- utile surtout pour machine physique, Proxmox host, serveur media ou dev.
### `network_tools`
- `iproute2`
- `iputils-ping`
- `dnsutils`
- `traceroute`
- `net-tools` optionnel
- `tcpdump`
- `nmap`
- `mtr-tiny`
- `lsof`
- `netcat-openbsd`
`nmap` est ici classé comme **outil réseau d'administration** pour découverte locale, diagnostic et inventaire contrôlé. Les usages plus intrusifs ou offensifs relèvent du profil `security_lab`, jamais installé par défaut.
### `dev_git`
- `git`
- `curl`
- `wget`
- `jq`
- `yq`
- `gnupg`
- `lsb-release`
- `build-essential` optionnel.
### `sharing`
Partage réseau :
- Samba ;
- NFS ;
- mDNS/Avahi ;
- `wsdd2`.
Sous-profils :
- `sharing_samba`
- `sharing_nfs`
- `sharing_mdns`
- `sharing_wsdd2`
### `docker_official`
Installation Docker via documentation officielle Debian :
- ajout clé GPG officielle dans `/etc/apt/keyrings` ;
- fichier `docker.sources` ;
- `docker-ce`
- `docker-ce-cli`
- `containerd.io`
- `docker-buildx-plugin`
- `docker-compose-plugin`
- `usermod -aG docker <user>`
- création dossier `/home/gilles/docker` ou variable ;
- enable/start service ;
- verify `docker version` + `docker compose version`;
- reboot ou relogin selon besoin.
Docker doit être modélisé comme **installateur externe officiel**, pas comme simple groupe de paquets Debian.
---
## 4. Profils additionnels à prévoir
### `home_automation`
- `mosquitto`
- `mosquitto-clients`
- `bluetooth`
- `bluez`
- `avahi-daemon`
- `dbus`
- `jq`
- `curl`
- `socat`
- `ser2net`
- Zigbee2MQTT via script externe optionnel.
### `dev_tools`
Paquets Debian :
- `git`
- `build-essential`
- `pkg-config`
- `cmake`
- `python3`
- `python3-venv`
- `python3-pip`
- `pipx`
Installateurs externes optionnels :
- Python `uv`
- Rust via `rustup`
- Node.js via `nvm` ou NodeSource
- npm récent si besoin.
### `embedded_esp_platformio`
- `python3-venv`
- `pipx`
- PlatformIO via `pipx` ou script validé ;
- `esptool`
- `openocd`
- `avrdude`
- `dfu-util`
- `cmake`
- `ninja-build`
- `build-essential`
- ajout utilisateur aux groupes `dialout`, `plugdev`;
- règles udev ESP/USB si nécessaires ;
- relogin/reboot requis.
### `dev_ide`
Profil desktop optionnel :
- VS Code ou VSCodium ;
- CLI `code` ;
- extensions optionnelles Python, C/C++, PlatformIO, ESP-IDF.
### `storage_health`
- `smartmontools`
- `nvme-cli`
- `hdparm`
- `sdparm`
- `lsscsi`
- `sg3-utils`
- `parted`
- `gdisk`
- `fio`
### `media_tools`
- `ffmpeg`
- `mediainfo`
- `imagemagick`
- `mpv` optionnel
- `vlc` optionnel, surtout desktop
- `alsa-utils`
- `pipewire-utils` ou `pulseaudio-utils` selon système.
### `security_audit`
Pour audit légitime :
- `nmap`
- `whois`
- `dnsutils`
- `tcpdump`
- `tshark`
- `lynis`
- `testssl.sh` via script externe optionnel.
### `security_lab`
Profil high-risk, jamais par défaut :
- `nmap`
- `masscan`
- `hydra`
- `john`
- `hashcat`
- `gobuster`
- `dirsearch`
- `sqlmap`
- `metasploit-framework`
- wordlists.
L'UI doit afficher un avertissement clair : usage uniquement sur systèmes autorisés/lab.
Distinction attendue dans la spec :
- `network_tools/nmap` : scan simple et local, ex. port SSH `22`, inventaire de machines autorisées.
- `security_audit/nmap` : audit défensif, réseau appartenant à l'utilisateur.
- `security_lab/nmap` : scénarios offensifs/lab, high-risk, confirmation explicite, jamais inclus dans une installation standard.
### `backup_sync`
- `restic`
- `borgbackup`
- `rclone`
- `rsync`
- `syncthing`
- `duplicity`
### `monitoring`
- `prometheus-node-exporter`
- `lm-sensors`
- `smartmontools`
- `collectd`
- Netdata via script externe optionnel.
### `network_services`
- `nginx`
- `caddy`
- `certbot`
- `wireguard-tools`
- Tailscale via script externe optionnel
- `openssh-server`
- `fail2ban`
### `vm_guest_tools`
- `qemu-guest-agent`
- `open-vm-tools`
- choix selon hyperviseur.
---
## 5. Scripts de partage — exemples attendus
### Samba
Variables UI :
```json
{
"shareName": "docker-share",
"path": "/home/gilles/docker",
"validUsers": ["gilles"],
"forceGroup": "gilles",
"readOnly": false,
"browseable": true,
"createDirectory": true,
"enableMdns": true,
"enableWsdd2": true
}
```
Retour JSON :
```json
{
"action": "post_install_sharing_samba",
"status": "ok",
"changed": true,
"packagesInstalled": ["samba", "avahi-daemon", "libnss-mdns", "wsdd2"],
"filesChanged": ["/etc/samba/smb.conf"],
"directoriesCreated": ["/home/gilles/docker"],
"services": [
{ "name": "smbd", "enabled": true, "active": true },
{ "name": "nmbd", "enabled": true, "active": true },
{ "name": "avahi-daemon", "enabled": true, "active": true },
{ "name": "wsdd2", "enabled": true, "active": true }
],
"errors": []
}
```
### NFS
Variables UI :
```json
{
"exportName": "docker-nfs",
"path": "/home/gilles/docker",
"allowedNetwork": "10.0.0.0/22",
"access": "rw",
"syncMode": "sync",
"rootSquash": true,
"createDirectory": true
}
```
Retour JSON :
```json
{
"action": "post_install_sharing_nfs",
"status": "ok",
"changed": true,
"packagesInstalled": ["nfs-kernel-server"],
"filesChanged": ["/etc/exports"],
"exports": [
{
"path": "/home/gilles/docker",
"allowedNetwork": "10.0.0.0/22",
"options": ["rw", "sync", "root_squash"]
}
],
"services": [
{ "name": "nfs-kernel-server", "enabled": true, "active": true }
],
"errors": []
}
```
---
## 6. Installateurs externes réutilisables
Créer une spec pour une section paramètres webapp :
```text
Paramètres
└─ Scripts dinstallation
├─ Docker officiel Debian
├─ Rust via rustup
├─ Node.js via nvm / NodeSource
├─ Python uv
├─ PlatformIO / ESP
├─ Personnalisation terminal
├─ Zigbee2MQTT
└─ Script perso
```
Chaque installateur doit être :
- versionné sur disque ;
- rendu via Mustache ;
- prévisualisable ;
- validé par formulaire ;
- journalisé ;
- traçable dans un rapport ;
- accompagné d'un JSON résultat ;
- non interactif ;
- sans secret en clair.
Règles de sécurité :
- pas de `curl | sh` opaque sans justification ;
- URL officielle documentée ;
- checksum/signature si disponible ;
- confirmation obligatoire ;
- rollback ou sauvegarde quand un fichier système est modifié ;
- erreur structurée si une décision manque.
---
## 7. Initialisation complémentaire des outils
Prévoir que certains installateurs nécessitent des commandes après installation.
Chaque recette doit pouvoir déclarer :
- `precheck`
- `install`
- `configure`
- `initialize`
- `verify`
- `postNotes`
Exemples :
- Docker : enable service, usermod docker, créer dossier Compose, vérifier `docker compose`.
- Rust : installer toolchain, vérifier `cargo`, ajouter PATH utilisateur.
- Node : installer nvm/NodeSource, vérifier `node`, `npm`, `corepack`.
- PlatformIO : installer via pipx, vérifier `pio`, ajouter user à `dialout`.
- Samba : écrire partage, `testparm`, restart service, vérifier service actif.
- NFS : écrire exports, `exportfs -ra`, vérifier exports.
---
## 8. Personnalisation terminal
Créer un profil `terminal_customization`.
Fonctions :
- MOTD ;
- message d'accueil SSH ;
- prompt bash ;
- couleurs/style ;
- aliases ;
- affichage hostname ;
- affichage IP ;
- affichage branche Git ;
- bannière maintenance ;
- configuration par utilisateur.
Variables UI :
```json
{
"targetUser": "gilles",
"theme": "gruvbox",
"showHostname": true,
"showIp": true,
"showGitBranch": true,
"enableMotd": true,
"welcomeMessage": "Bienvenue sur {{hostname}}",
"aliases": ["ll", "la", "update", "dps", "dcu"]
}
```
Retour JSON :
```json
{
"action": "post_install_terminal_customization",
"status": "ok",
"changed": true,
"targetUser": "gilles",
"filesChanged": [
"/home/gilles/.bashrc",
"/etc/update-motd.d/99-system-update"
],
"backupFiles": [
"/home/gilles/.bashrc.su-backup-{{backupDate}}"
],
"verify": {
"bashrcSyntax": "ok",
"motdScriptExecutable": true
},
"errors": []
}
```
---
## 9. JSON canonique
Tout script doit produire un résultat structuré.
Champs communs :
```json
{
"action": "post_install_profile",
"profileId": "base_tools",
"status": "ok",
"startedAt": "ISO",
"finishedAt": "ISO",
"changed": true,
"steps": [],
"packagesInstalled": [],
"filesChanged": [],
"services": [],
"requiresReboot": false,
"requiresRelogin": false,
"errors": []
}
```
Le log brut reste archivé. Hermes/MCP ne reçoivent que le JSON réduit et les lignes importantes.
---
## 10. Erreurs à prévoir
Taxonomie minimale :
- `missing_required_input`
- `unsupported_os`
- `unsupported_architecture`
- `network_unreachable`
- `apt_update_failed`
- `package_install_failed`
- `external_download_failed`
- `signature_verification_failed`
- `service_enable_failed`
- `service_start_failed`
- `verify_failed`
- `network_config_invalid`
- `reconnect_failed`
- `user_not_found`
- `permission_denied`
- `human_interaction_required`
- `timeout`
Aucune auto-réparation dangereuse sans validation explicite.
---
## 11. Livrables attendus
À produire sous `docs/` :
1. Catalogue complet des profils post-install.
2. Manifeste type d'un profil/script.
3. Modèle de recette `precheck/install/configure/initialize/verify`.
4. Spec des installateurs externes réutilisables.
5. Spec des scripts réseau, partage Samba/NFS/wsdd2.
6. Spec Docker officiel.
7. Spec détection machine/hardware, métriques simples, firmware, drivers GPU et benchmark.
8. Spec dev tools, embedded ESP/PlatformIO, terminal customization.
9. JSON canoniques d'entrée/sortie.
10. Taxonomie d'erreurs.
11. Découpage en sous-jalons.
---
## 12. Définition de terminé
- Les profils sont classés et optionnels.
- Docker est traité comme installateur externe officiel.
- Les scripts nécessitant des champs UI sont modélisés.
- Les scripts tiennent compte du couple OS/type machine.
- La détection hardware et les métriques simples sont prévues.
- Les drivers/firmware/benchmark restent optionnels et validés explicitement.
- Les étapes complémentaires d'initialisation sont prévues.
- Les retours JSON sont spécifiés.
- Les secrets sont exclus des logs/UI/MCP.
- Aucun code de production n'est livré pendant cette mission de design.
---
## 13. Technos à utiliser — checklist
- [ ] Bash templates versionnés sur disque.
- [ ] Mustache pour variables de scripts.
- [ ] JSON canonique en sortie de chaque script.
- [ ] `apt-get` non interactif pour paquets Debian/Ubuntu.
- [ ] Docker Engine dépôt officiel pour Docker.
- [ ] `systemd`/services pour enable/start/verify quand disponible.
- [ ] `systemd-detect-virt`, `/etc/os-release`, `lspci`, `lsusb`, `lsblk`, `df`, `free` pour détection.
- [ ] `qemu-guest-agent` / `open-vm-tools` selon VM.
- [ ] `smartmontools`, `lm-sensors`, `fwupd` optionnels pour physique.
- [ ] `ubuntu-drivers` pour recommandations GPU Ubuntu.
- [ ] `rustup`, `nvm`/NodeSource, `uv`, `pipx`, PlatformIO selon profils dev.
- [ ] Aucune commande interactive pendant SSH.
## 14. URLs utiles
- Docker Engine Debian : https://docs.docker.com/engine/install/debian/
- Docker Compose plugin Linux : https://docs.docker.com/compose/install/linux/
- Debian NetworkConfiguration : https://wiki.debian.org/NetworkConfiguration
- Debian Handbook network config : https://www.debian.org/doc/manuals/debian-handbook/sect.network-config
- Samba documentation : https://www.samba.org/samba/docs/
- Debian NFS server wiki : https://wiki.debian.org/NFSServerSetup
- Avahi : https://www.avahi.org/
- Rustup : https://rustup.rs/
- NodeSource distributions : https://github.com/nodesource/distributions
- nvm : https://github.com/nvm-sh/nvm
- uv : https://docs.astral.sh/uv/
- pipx : https://pipx.pypa.io/
- PlatformIO : https://docs.platformio.org/
- Ubuntu NVIDIA drivers : https://ubuntu.com/server/docs/nvidia-drivers-installation
- fwupd : https://fwupd.org/
- smartmontools : https://www.smartmontools.org/
## 15. Liens parent/enfant avec les autres tâches
- Parents :
- `tache2.md` pour moteur templates et sécurité scripts.
- `tache1.9.md` pour stockage profils/recettes/versions.
- Enfants :
- `tache5.md` pour exécution, stockage résultats, API.
- `tache3.md` pour formulaires de profils et paramètres.
- `tache6.md` pour analyse Hermes des erreurs scripts.
- `tache7.md` pour métriques simples, sécurité secrets, nettoyage.
- Validation : `validation_tache4.md`.
+532
View File
@@ -0,0 +1,532 @@
# Consigne de dev — Backend, historique JSON et automatisations
> **Type** : mission d'**investigation + design backend** (PAS d'implémentation).
> **Langue** : français.
> **Livrable final attendu** : spec backend prête à passer en plan d'implémentation.
---
## 0. Contexte
La webapp `system_update` exécute des scripts SSH agentless sur des machines Linux et reçoit des sorties normalisées en JSON canonique :
- snapshots APT ;
- résultats d'exécution APT ;
- snapshots Docker ;
- résultats Docker ;
- profils post-install ;
- rapports ;
- erreurs structurées ;
- état reboot/reconnexion.
Cette tâche vise le backend : **sauvegarde, historisation, automatisations planifiées, icônes/statuts machine, conservation et API interne**.
À lire avant de travailler :
- `CLAUDE.md`
- `tache1.9.md`
- `tache2.md`
- `tache3.md`
- `tache4.md`
- `validation_tache2.md`
- `shared/types.ts`
- `server/db/schema.ts`
- `server/services/refresh.ts`
- `server/services/execute.ts`
- `server/jobs/worker.ts`
- `server/ws/outputHub.ts`
---
## 1. Objectif
Concevoir le backend qui stocke et exploite tous les échanges JSON entre machine et webapp.
Le backend doit permettre :
- historiser chaque interrogation/action machine ;
- relier snapshot, exécution, log brut et rapport Markdown ;
- afficher l'état actuel des icônes/statuts par machine ;
- planifier des tâches automatiques, par exemple `update/analyse` de toutes les machines à heure précise ;
- déclencher les refresh Docker/APT/post-install selon un planning ;
- gérer erreurs, retries, verrouillage et idempotence ;
- exposer une API stable pour l'UI, Hermes/MCP et les rapports.
---
## 2. Données à sauvegarder
Chaque échange machine ↔ webapp doit être sauvegardé sous forme structurée.
### Snapshots
- `machine_snapshot`
- `machine_probe_snapshot`
- `machine_metrics_snapshot`
- `apt_update_analyze_snapshot`
- `docker_scan_snapshot`
- `docker_pull_check_snapshot`
- `post_install_manifest_snapshot`
- `reboot_check_snapshot`
Champs communs :
```json
{
"id": "snap_x",
"machineId": "machine_x",
"kind": "apt_update_analyze",
"createdAt": "ISO",
"status": "ok",
"payload": {},
"importantLines": [],
"rawLogRef": "reports/machine_x/snap_x.log"
}
```
### Exécutions
- `apt_upgrade`
- `apt_full_upgrade`
- `apt_autoremove`
- `apt_clean`
- `docker_apply`
- `docker_prune`
- `post_install_profile`
- `reboot_verified`
Champs communs :
```json
{
"executionId": "exec_x",
"machineId": "machine_x",
"action": "apt_upgrade",
"mode": "manual",
"startedAt": "ISO",
"finishedAt": "ISO",
"status": "ok",
"payload": {},
"rawLogRef": "reports/machine_x/exec_x.log",
"reportRef": "reports/machine_x/exec_x.md"
}
```
### Logs, rapports et messages importants
Les logs bruts et rapports doivent être accessibles à Hermes, mais par références contrôlées :
- le JSON canonique complet reste en BDD ;
- le log brut complet reste dans `reports/<machineId>/...log` ou dans un stockage d'artefacts ;
- le rapport Markdown reste dans `reports/<machineId>/...md` ;
- la BDD garde `rawLogRef`, `reportRef`, taille, checksum, dates, statut de rétention ;
- Hermes reçoit par défaut un résumé réduit + des références, pas le log complet.
Le backend doit extraire et stocker les messages importants rencontrés dans les sorties APT/Docker/scripts :
- erreurs bloquantes : `E:`, `dpkg: error`, lock APT, maintainer script en échec ;
- warnings opérationnels : `W:`, dépôt obsolète, signature, clé GPG, service non redémarré ;
- messages d'évolution future : annonce de changement majeur Debian/Ubuntu, sécurité paquet, dépréciation de dépôt, changement de politique de paquet ;
- messages demandant analyse agent : évolution sécurité, migration de version majeure, configuration legacy, composant bientôt non supporté.
Ces messages doivent être stockés comme objets structurés, pas seulement comme lignes de log :
```json
{
"messageId": "msg_x",
"machineId": "machine_x",
"source": "apt",
"category": "future_major_change",
"severity": "warning",
"packageName": "openssh-server",
"message": "résumé nettoyé sans secret",
"rawLineRef": "artifact_x#line_381",
"snapshotId": "snap_x",
"executionId": null,
"createdAt": "ISO",
"acknowledged": false
}
```
Objectif :
- afficher ces warnings dans la tuile machine ;
- permettre à Hermes de rechercher les évolutions importantes sans relire tous les logs ;
- garder une trace d'un warning même si le prochain `apt update` ne l'affiche plus ;
- générer des rapports de veille, par exemple "risques Debian à traiter avant la prochaine version majeure".
### Événements machine
Prévoir une table ou collection d'événements :
- machine ajoutée ;
- connexion testée ;
- snapshot créé ;
- update disponible ;
- action lancée ;
- action terminée ;
- erreur ;
- reboot demandé ;
- machine revenue ;
- rapport créé ;
- notification envoyée.
Ces événements alimentent :
- timeline machine ;
- audit ;
- Hermes ;
- notifications ;
- rapports globaux.
---
## 3. État courant machine
Le backend doit dériver un état courant par machine à partir des derniers snapshots/exécutions.
État machine minimal :
```json
{
"machineId": "machine_x",
"status": "ok",
"apt": {
"status": "updates_available",
"updatesCount": 4,
"lastAnalyzeAt": "ISO",
"rebootRequired": false
},
"docker": {
"status": "updates_available",
"installed": true,
"stacksCount": 3,
"updatesCount": 1,
"lastScanAt": "ISO",
"pruneAvailable": true
},
"postInstall": {
"availableProfiles": 12,
"pendingProfiles": 0,
"lastRunAt": "ISO"
},
"profile": {
"osFamily": "debian",
"osVersion": "13",
"osCodename": "trixie",
"arch": "amd64",
"machineKind": "vm",
"virtualization": "qemu",
"hardwareProfile": "generic_vm"
},
"metrics": {
"lastCollectedAt": "ISO",
"cpuLoad1": 0.08,
"memoryUsedPercent": 26,
"rootUsedPercent": 29,
"diskWarnings": 0
},
"lastError": null
}
```
Cet état sert à mettre à jour les icônes dans les tuiles machine :
- LED machine ;
- badge APT update ;
- badge Docker update ;
- reboot requis ;
- erreur ;
- action running ;
- prune disponible ;
- warning matériel/driver/repo ;
- alerte disque/RAM simple.
---
## 4. Automatisations backend
### Besoin prioritaire
Planifier automatiquement :
- `apt_update_analyze` de toutes les machines à heure précise ;
- `machine_metrics_simple` périodique sur toutes les machines ou les machines sélectionnées ;
- éventuellement Docker scan/pull-check selon configuration ;
- mise à jour des icônes si des updates sont disponibles ;
- notification ou rapport si anomalies.
Exemple :
```json
{
"id": "schedule_daily_update_analyze",
"name": "Analyse quotidienne",
"enabled": true,
"schedule": "0 6 * * *",
"timezone": "Europe/Paris",
"scope": {
"machineIds": "all",
"tags": []
},
"actions": [
"apt_update_analyze",
"machine_metrics_simple",
"docker_scan"
],
"concurrency": 2,
"notifyOn": ["updates_available", "error"]
}
```
### Moteur de planification
MVP recommandé :
- `croner` déjà présent dans le projet ;
- jobs in-process ;
- persistance des schedules en DB ;
- verrou machine pour éviter deux actions simultanées sur la même machine.
Alternative future :
- PostgreSQL + `pg-boss` si besoin de jobs distribués, reprise robuste, retries persistants.
Le design doit trancher MVP / futur.
---
## 5. Verrouillage et idempotence
Règles :
- une machine ne peut exécuter qu'une action à risque à la fois ;
- un refresh APT ne doit pas courir pendant un upgrade ;
- Docker scan peut être autorisé si aucune action Docker destructive n'est en cours ;
- post-install réseau bloque toute autre action machine ;
- reboot bloque tout jusqu'à reconnexion ou timeout.
Prévoir :
- table `machine_locks` ou statut job courant ;
- `idempotencyKey` pour éviter les doubles clics ;
- retries contrôlés ;
- timeout global ;
- timeout d'inactivité ;
- reprise après redémarrage serveur selon le moteur choisi.
---
## 6. API backend attendue
À spécifier :
```text
GET /api/capabilities
GET /api/system/status
GET /api/system/metrics
GET /api/machines
GET /api/machines/:id/state
GET /api/machines/:id/hardware
GET /api/machines/:id/metrics
GET /api/machines/:id/snapshots
GET /api/machines/:id/snapshots/:snapshotId
GET /api/machines/:id/executions
GET /api/machines/:id/executions/:executionId
GET /api/machines/:id/events
GET /api/machines/:id/messages
POST /api/machines/:id/actions
GET /api/artifacts/:artifactId
GET /api/artifacts/:artifactId/important-lines
GET /api/reports
GET /api/reports/:id
GET /api/messages
GET /api/schedules
POST /api/schedules
PATCH /api/schedules/:id
POST /api/schedules/:id/run-now
POST /api/schedules/:id/pause
POST /api/schedules/:id/resume
DELETE /api/schedules/:id
GET /api/settings
PATCH /api/settings
GET /api/events
WS /api/ws/machines/:id/output
GET /api/search
```
Clients visés :
- frontend web ;
- Hermes/MCP via backend ;
- future app locale Rust/GNOME ;
- scripts d'administration internes éventuels.
Chaque endpoint doit garantir :
- aucun secret ;
- JSON stable ;
- erreurs structurées ;
- pagination sur historiques longs ;
- filtres par machine, action, statut, dates.
- versionnement API.
### Authentification clients API
Prévoir pour la future app locale :
- tokens de clients distincts des credentials machines ;
- scopes : `read`, `operate`, `admin`, `debug_logs` ;
- révocation ;
- rotation ;
- audit des appels ;
- stockage local côté app via trousseau système, jamais dans le navigateur.
Exemple capabilities :
```json
{
"apiVersion": "1",
"features": {
"machines": true,
"actions": true,
"terminalOutput": true,
"interactiveSsh": false,
"hermes": true,
"settings": true
}
}
```
---
## 7. Rapports et rétention
Prévoir une politique :
- log brut complet conservé sous `reports/`;
- JSON canonique conservé en DB ;
- rapport Markdown lié à l'exécution ;
- rétention configurable ;
- purge manuelle ;
- export global.
Les logs bruts ne sont jamais envoyés à Hermes sans réduction déterministe.
Règles spécifiques Hermes :
- Hermes peut lire les rapports Markdown et les JSON réduits via API/MCP ;
- Hermes peut demander des extraits ciblés de log par `artifactId`, plage de lignes ou recherche ;
- accès log complet uniquement si l'utilisateur le demande explicitement ou si le rapport d'erreur l'exige ;
- toute sortie est masquée côté backend avant exposition ;
- chaque lecture Hermes est auditée.
Nettoyage :
- purge automatique des vieux logs selon paramètres ;
- conservation plus longue des logs d'échec et warnings non acquittés ;
- conservation des rapports épinglés ;
- dry-run obligatoire pour purge manuelle ;
- export possible avant suppression ;
- suppression en BDD et fichiers cohérente, avec vérification des chemins.
---
## 8. Notifications et intégration Hermes
Le backend doit pouvoir notifier :
- UI via WebSocket/SSE ;
- Hermes via webhook ou MCP selon tâche 6 ;
- messagerie via Hermes gateway si configuré.
Exemples :
- "3 machines ont des updates APT disponibles" ;
- "Docker prune possible sur vm_mqtt" ;
- "Upgrade terminé avec erreur dpkg" ;
- "Machine revenue après reboot en 74s".
Les notifications doivent contenir uniquement JSON réduit et références de rapport.
---
## 9. Livrables attendus
À produire sous `docs/` :
1. Schéma de données backend.
2. Modèle d'événements machine.
3. Modèle d'état courant machine.
4. Spécification des schedules automatisés.
5. Règles de verrouillage/idempotence.
6. API backend.
7. Modèle des messages importants extraits des logs.
8. Modèle snapshots hardware/profil machine/métriques simples.
9. Politique de rétention/logs/rapports/messages.
10. Intégration avec UI et Hermes.
11. Découpage en sous-jalons.
---
## 10. Définition de terminé
- Tous les JSON machine ↔ webapp sont historisables.
- L'état courant des tuiles peut être dérivé sans parser les logs.
- Les tâches automatiques sont spécifiées.
- Les métriques simples et le profil OS/type machine sont historisables.
- Les actions concurrentes sont sécurisées.
- Les rapports et logs ont une politique claire.
- Les warnings importants, dépréciations et annonces d'évolution future sont historisés et consultables.
- Aucun secret ne sort du backend.
- Aucun code de production n'est livré pendant cette mission de design.
---
## 11. Technos à utiliser — checklist
- [ ] Node.js runtime.
- [ ] Hono pour API HTTP.
- [ ] Drizzle ORM + SQLite.
- [ ] WebSocket ou SSE pour events/live output.
- [ ] `croner` pour schedules MVP.
- [ ] Worker in-process au MVP.
- [ ] Verrous machine persistants.
- [ ] JSON canonique versionné.
- [ ] Stockage fichiers pour reports/logs/artifacts.
- [ ] Docker Compose pour packaging final webserver.
- [ ] Variables d'environnement pour configuration.
- [ ] Secrets chiffrés avec master key hors image.
## 12. URLs utiles
- Hono Node.js : https://hono.dev/docs/getting-started/nodejs
- Hono middleware : https://hono.dev/docs/guides/middleware
- Drizzle SQLite : https://orm.drizzle.team/docs/get-started-sqlite
- SQLite WAL : https://www.sqlite.org/wal.html
- Croner : https://github.com/Hexagon/croner
- MDN WebSocket : https://developer.mozilla.org/en-US/docs/Web/API/WebSocket
- MDN Server-sent events : https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events
- Docker Compose : https://docs.docker.com/compose/
- Compose file reference : https://docs.docker.com/reference/compose-file/
- Docker volumes : https://docs.docker.com/engine/storage/volumes/
- Node.js Docker images : https://hub.docker.com/_/node
## 13. Liens parent/enfant avec les autres tâches
- Parents :
- `tache1.9.md` pour schéma BDD.
- `tache2.md` pour actions/templates/JSON.
- `tache4.md` pour scripts post-install.
- Enfants :
- `tache3.md` consomme les API.
- `tache6.md` consomme API/MCP.
- `tache7.md` ajoute métriques/nettoyage/sécurité.
- `tache8.md` consomme API/capabilities.
- Validation : `validation_tache5.md`.

Some files were not shown because too many files have changed in this diff Show More