Compare commits
10 Commits
1e1be7f627
...
08919752e3
| Author | SHA1 | Date | |
|---|---|---|---|
| 08919752e3 | |||
| 0fbca06d3d | |||
| f9ce991ec5 | |||
| cebe991601 | |||
| b9699bfb8f | |||
| d3bf4a9fd2 | |||
| f8a8478749 | |||
| 1310bc1637 | |||
| 8d105b63ec | |||
| 50df83fda1 |
@@ -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
|
||||
|
||||
@@ -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 ?
|
||||
@@ -0,0 +1 @@
|
||||
target/
|
||||
Generated
+728
@@ -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"
|
||||
@@ -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"]
|
||||
@@ -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`.
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(>k::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(¢er);
|
||||
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: >k::FlowBox,
|
||||
refresh_button: >k::Button,
|
||||
add_button: >k::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(>k::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: >k::TextView,
|
||||
server_entry: >k::Entry,
|
||||
capabilities: >k::Button,
|
||||
status: >k::Button,
|
||||
metrics: >k::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: >k::Label, task_metrics: >k::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: >k::Button,
|
||||
server_entry: >k::Entry,
|
||||
token: Option<String>,
|
||||
terminal: >k::TextView,
|
||||
machines_flow: >k::FlowBox,
|
||||
task_status: >k::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: >k::Entry,
|
||||
token: Option<String>,
|
||||
terminal: >k::TextView,
|
||||
machines_flow: >k::FlowBox,
|
||||
task_status: >k::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(>k::Button::with_label("Refresh"));
|
||||
buttons.append(>k::Button::with_label("Upgrade"));
|
||||
buttons.append(>k::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: >k::Button,
|
||||
server_entry: >k::Entry,
|
||||
token: Option<String>,
|
||||
terminal: >k::TextView,
|
||||
task_status: >k::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");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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) : "--";
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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
@@ -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" }),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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`).
|
||||
@@ -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`).
|
||||
@@ -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`).
|
||||
@@ -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`).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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 |
|
||||
|---|---|---|
|
||||
| Q1–Q8 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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Generated
+33
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)`);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
|
||||
@@ -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===
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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`.
|
||||
@@ -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 d’installation
|
||||
├─ 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`.
|
||||
@@ -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 d’installation
|
||||
├─ 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`.
|
||||
@@ -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
Reference in New Issue
Block a user