From 25e31c1c26e51ea20a4d4545672c406a0f2e94dc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 20:01:37 +0000 Subject: [PATCH] =?UTF-8?q?Lot=2021=20:=20mise=20=C3=A0=20jour=20de=20Loki?= =?UTF-8?q?=20tenue=20par=20la=20fa=C3=A7ade=20(LOKI=5FUPDATE=3Drust)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit En conteneur, l'image se met à jour par Docker : /api/update rend la version du Go sans mise à jour disponible et la note Docker, /api/update/apply la refuse (409). La façade sert ces deux routes quand LOKI_UPDATE=rust et LOKI_CONTAINER=1 (sysupdate.rs) ; hors conteneur, loki-rs ne passe pas la variable et le Go garde le remplacement du binaire. Parité : parity_update_test.go tire du Go les quatre réponses en conteneur, rejouées par tests/sysupdate.rs ; TestFacadeMiseAJour côté Go. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv --- README.md | 1 + ...rust-migration-lot21-mise-a-jour-design.md | 26 ++++++++ internal/loki/facade_chat.go | 5 +- internal/loki/facade_chat_test.go | 22 +++++++ internal/loki/facade_update.go | 26 ++++++++ internal/loki/parity_update_test.go | 65 +++++++++++++++++++ rust/loki-rs/src/api.rs | 3 + rust/loki-rs/src/lib.rs | 1 + rust/loki-rs/src/main.rs | 6 ++ rust/loki-rs/src/proxy.rs | 7 ++ rust/loki-rs/src/sysupdate.rs | 56 ++++++++++++++++ rust/loki-rs/tests/common/fakego.rs | 2 + rust/loki-rs/tests/lot2.rs | 1 + rust/loki-rs/tests/parity_update/update.json | 42 ++++++++++++ rust/loki-rs/tests/proxy.rs | 1 + rust/loki-rs/tests/sysupdate.rs | 55 ++++++++++++++++ 16 files changed, 318 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/specs/2026-10-15-rust-migration-lot21-mise-a-jour-design.md create mode 100644 internal/loki/facade_update.go create mode 100644 internal/loki/parity_update_test.go create mode 100644 rust/loki-rs/src/sysupdate.rs create mode 100644 rust/loki-rs/tests/parity_update/update.json create mode 100644 rust/loki-rs/tests/sysupdate.rs diff --git a/README.md b/README.md index ae8eb86..cd63ba7 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,7 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur : | `LOKI_MEM` | `rust` : la mémoire (éditeur des pages, index, déplacement entre projets, trackers, mode mémoire, interrupteurs du mode agent) est tenue par la façade Rust ; le chiffrement reste au Go (expérimental, migration en cours) | Go | | `LOKI_SERVE` | `rust` : la ligne de commande de llama-server est composée par la façade Rust (`loki-rs engine serve`) au lieu de `loki serve` ; Strata et l'optimiseur restent au Go (expérimental, migration en cours) | Go | | `LOKI_PRESETS` | `rust` : les presets (liste, éditeur et aperçus, suppression, placement auto, presets externes, bascule) sont tenus par la façade Rust ; l'application au moteur reste au Go (expérimental, migration en cours) | Go | +| `LOKI_UPDATE` | `rust` : la mise à jour de Loki (`/api/update`, `/api/update/apply`) est servie par la façade Rust ; en conteneur, l'image se met à jour par Docker (expérimental, migration en cours) | Go | | `LOKI_MCP` | `rust` : les serveurs MCP — configuration, catalogue, état, pool de sessions stdio et HTTP — sont tenus par la façade Rust ; le Go lui demande les outils MCP de l'agent et leurs appels (expérimental, migration en cours) | Go | | `LOKI_PROJECTS` | `rust` : le hub des projets (liste et comptes, création, renommage, description, suppression, bascule) est tenu par la façade Rust ; les discussions restent à qui tient la conversation (expérimental, migration en cours) | Go | | `LOKI_SYSTEM` | `rust` : les interrupteurs système — accès internet et Crawl4AI, pilotage du navigateur, clé de complétion, écoute réseau du moteur — sont tenus par la façade Rust ; le navigateur piloté reste au Go (expérimental, migration en cours) | Go | diff --git a/docs/superpowers/specs/2026-10-15-rust-migration-lot21-mise-a-jour-design.md b/docs/superpowers/specs/2026-10-15-rust-migration-lot21-mise-a-jour-design.md new file mode 100644 index 0000000..d0bb674 --- /dev/null +++ b/docs/superpowers/specs/2026-10-15-rust-migration-lot21-mise-a-jour-design.md @@ -0,0 +1,26 @@ +# Lot 21 — mise à jour de Loki tenue par la façade + +## Contexte + +`/api/update` (version disponible) et `/api/update/apply` (téléchargement et +remplacement du binaire) vivaient dans `sys_update.go`. En conteneur +(`LOKI_CONTAINER=1`), le Go n'y fait rien. L'image se met à jour par Docker, +et les deux routes le disent (`containerUpdateNote`) : +- `/api/update` : `{current, available: false, note}` ; +- `/api/update/apply` : 409 `{ok: false, error: note}`. + +## Le lot + +La façade ne tourne qu'en conteneur. Elle tient ces deux routes quand +`LOKI_UPDATE=rust` (`sysupdate.rs`, expérimental) et `LOKI_CONTAINER=1`. +Sinon `loki-rs` ne passe pas la variable au Go, qui garde le téléchargement +et le remplacement du binaire hors conteneur. + +La version annoncée est celle du Go (`/internal/v1/auth`). Les binaires de +la façade suivent toujours `loki-rs self-update`. + +## Parité + +`internal/loki/parity_update_test.go` tire du Go les quatre réponses en +conteneur (GET et POST sur chaque route). `tests/sysupdate.rs` les rejoue par +la façade, version du Go comprise. diff --git a/internal/loki/facade_chat.go b/internal/loki/facade_chat.go index 9279121..75bb605 100644 --- a/internal/loki/facade_chat.go +++ b/internal/loki/facade_chat.go @@ -150,7 +150,8 @@ var facadeChatRoutes = map[string]bool{ // de la discussion (facade_files.go), les notifications (facade_push.go), la // mémoire (facade_mem.go), les presets (facade_presets.go), le pilotage du // moteur (facade_engine.go), les interrupteurs système (facade_system.go), les -// projets (facade_projects.go), les serveurs MCP (facade_mcp.go). +// projets (facade_projects.go), les serveurs MCP (facade_mcp.go), la mise à +// jour (facade_update.go). // Posé APRÈS requireWebAuth : seule une requête authentifiée ici part, avec le // jeton interne qui en tient lieu là-bas. func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc { @@ -179,6 +180,8 @@ func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc { held, unavailable = facadeProjects, "projets indisponibles : " case facadeMCPRoutes[path]: held, unavailable = facadeMCP, "serveurs MCP indisponibles : " + case facadeUpdateRoutes[path]: + held, unavailable = facadeUpdate, "mise à jour indisponible : " default: return h } diff --git a/internal/loki/facade_chat_test.go b/internal/loki/facade_chat_test.go index 847ef57..7d8b627 100644 --- a/internal/loki/facade_chat_test.go +++ b/internal/loki/facade_chat_test.go @@ -905,3 +905,25 @@ func TestFacadeMCP(t *testing.T) { t.Fatalf("outils sans façade : %v", tools) } } + +// La mise à jour (LOKI_UPDATE=rust, lot 21) part à la façade. +func TestFacadeMiseAJour(t *testing.T) { + testHome(t) + f := demarrerFacade(t) + t.Setenv(facadeChatEnv, "") + local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") } + for _, on := range []string{"", "rust"} { + t.Setenv(facadeUpdateEnv, on) + for p := range facadeUpdateRoutes { + w := httptest.NewRecorder() + withFacadeChat(p, local)(w, httptest.NewRequest("GET", p, nil)) + want := "go" + if on == "rust" { + want = f.reply + } + if w.Body.String() != want { + t.Fatalf("%s (LOKI_UPDATE=%q) : %q", p, on, w.Body.String()) + } + } + } +} diff --git a/internal/loki/facade_update.go b/internal/loki/facade_update.go new file mode 100644 index 0000000..528b577 --- /dev/null +++ b/internal/loki/facade_update.go @@ -0,0 +1,26 @@ +package loki + +import ( + "os" + "strings" +) + +// facade_update.go — mise à jour de Loki tenue par la façade Rust +// (LOKI_UPDATE=rust, lot 21 de la migration ; expérimental, le Go reste le +// défaut) : /api/update et /api/update/apply. La façade ne tourne qu'en +// conteneur, où l'image se met à jour par Docker : les deux routes le disent +// (containerUpdateNote). Hors conteneur, loki-rs ne passe pas la variable. + +const facadeUpdateEnv = "LOKI_UPDATE" + +// facadeUpdate : la façade tient-elle la mise à jour ? +func facadeUpdate() bool { + return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeUpdateEnv)), "rust") && + facadeAddr() != "" && internalToken() != "" +} + +// facadeUpdateRoutes : les routes que la façade sert alors (sysupdate.rs). +var facadeUpdateRoutes = map[string]bool{ + "/api/update": true, + "/api/update/apply": true, +} diff --git a/internal/loki/parity_update_test.go b/internal/loki/parity_update_test.go new file mode 100644 index 0000000..7e776a0 --- /dev/null +++ b/internal/loki/parity_update_test.go @@ -0,0 +1,65 @@ +package loki + +import ( + "encoding/json" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// parity_update_test.go — journal de référence de la mise à jour de Loki en +// conteneur (lot 21 de la migration Rust) : l'image se met à jour par Docker, +// les deux routes le disent. Rangé dans +// rust/loki-rs/tests/parity_update/update.json ; tests/sysupdate.rs le rejoue +// par la façade. +// +// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteMiseAJour + +const parityUpdateFile = "../../rust/loki-rs/tests/parity_update/update.json" + +func TestPariteMiseAJour(t *testing.T) { + testHome(t) + t.Setenv("LOKI_CONTAINER", "1") + var steps []map[string]any + for _, c := range []struct{ method, path, body string }{ + {"GET", "/api/update", ""}, + {"POST", "/api/update", "{}"}, + {"POST", "/api/update/apply", "{}"}, + {"GET", "/api/update/apply", ""}, + } { + rec := httptest.NewRecorder() + req := httptest.NewRequest(c.method, c.path, strings.NewReader(c.body)) + if c.path == "/api/update" { + handleUpdateCheck(rec, req) + } else { + handleUpdateApply(rec, req) + } + var out any + _ = json.Unmarshal([]byte(strings.ReplaceAll(rec.Body.String(), Version, "{{VERSION}}")), &out) + steps = append(steps, map[string]any{"method": c.method, "path": c.path, "status": rec.Code, "out": out}) + } + got, err := parityJSON(map[string]any{"steps": steps}) + if err != nil { + t.Fatal(err) + } + if os.Getenv("LOKI_PARITY_UPDATE") == "1" { + if err := os.MkdirAll(filepath.Dir(parityUpdateFile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(parityUpdateFile, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(parityUpdateFile) + if err != nil { + t.Fatalf("journal de référence absent (%v) : LOKI_PARITY_UPDATE=1 pour le créer", err) + } + if string(got) != string(want) { + t.Errorf("la mise à jour en conteneur ne produit plus le journal rangé dans %s.\n"+ + "Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteMiseAJour.\n--- obtenu ---\n%s", + parityUpdateFile, got) + } +} diff --git a/rust/loki-rs/src/api.rs b/rust/loki-rs/src/api.rs index a0b13f7..d3a5567 100644 --- a/rust/loki-rs/src/api.rs +++ b/rust/loki-rs/src/api.rs @@ -50,6 +50,8 @@ pub struct Ctx { pub projects: bool, /// Serveurs MCP tenus par la façade (`LOKI_MCP=rust`). pub mcp: Option>, + /// Mise à jour de Loki tenue par la façade (`LOKI_UPDATE=rust`). + pub update: bool, /// Jeton de l'API interne : une requête qui le porte vient du Go. pub internal_token: String, } @@ -70,6 +72,7 @@ impl Ctx { || self.system.is_some() || self.projects || self.mcp.is_some() + || self.update } /// La requête vient du Go (jeton interne valide) : déjà authentifiée par diff --git a/rust/loki-rs/src/lib.rs b/rust/loki-rs/src/lib.rs index eb95de7..56424dc 100644 --- a/rust/loki-rs/src/lib.rs +++ b/rust/loki-rs/src/lib.rs @@ -40,6 +40,7 @@ pub mod settings; pub mod store; pub mod sysinfo; pub mod system; +pub mod sysupdate; pub mod tools; pub mod ui; pub mod update; diff --git a/rust/loki-rs/src/main.rs b/rust/loki-rs/src/main.rs index a150f4c..099d51f 100644 --- a/rust/loki-rs/src/main.rs +++ b/rust/loki-rs/src/main.rs @@ -159,6 +159,10 @@ fn serve() -> ExitCode { // LOKI_MCP=rust : les serveurs MCP (le Go demande alors à la façade // les outils MCP et leurs appels). let mcp_rust = held_by_facade("LOKI_MCP"); + // LOKI_UPDATE=rust : la mise à jour de Loki, en conteneur seulement + // (l'image se met à jour par Docker). + let update_rust = held_by_facade("LOKI_UPDATE") + && loki_rs::sysupdate::usable(std::env::var("LOKI_CONTAINER").ok().as_deref()); let engine_rust = held_by_facade("LOKI_ENGINE") && match loki_rs::enginectl::usable( std::env::var("LOKI_CONTAINER").ok().as_deref(), @@ -189,6 +193,7 @@ fn serve() -> ExitCode { ("LOKI_SYSTEM", system_rust), ("LOKI_PROJECTS", projects_rust), ("LOKI_MCP", mcp_rust), + ("LOKI_UPDATE", update_rust), ]; for (var, on) in held { if on { @@ -294,6 +299,7 @@ fn serve() -> ExitCode { engine_ctl, system: system_rust.then(|| std::sync::Arc::new(loki_rs::system::System::new())), projects: projects_rust, + update: update_rust, mcp: mcp_rust.then(|| std::sync::Arc::new(loki_rs::mcp::Mcp::new(mem_home))), }; let res = proxy::serve(listener, cfg, shutdown_signal()).await; diff --git a/rust/loki-rs/src/proxy.rs b/rust/loki-rs/src/proxy.rs index b6a56db..d014718 100644 --- a/rust/loki-rs/src/proxy.rs +++ b/rust/loki-rs/src/proxy.rs @@ -90,6 +90,9 @@ pub struct Config { /// Serveurs MCP (pool de sessions, configuration, catalogue) tenus par la /// façade (`LOKI_MCP=rust`) ; `None` : au Go. pub mcp: Option>, + /// Mise à jour de Loki tenue par la façade (`LOKI_UPDATE=rust`, en + /// conteneur). + pub update: bool, } struct Proxy { @@ -128,6 +131,7 @@ pub async fn serve( system: cfg.system, projects: cfg.projects, mcp: cfg.mcp, + update: cfg.update, internal_token: cfg.internal_token.clone(), }, }); @@ -226,6 +230,9 @@ impl Proxy { { return chatapi::serve(&self.ctx, chat, req, from_go).await; } + if self.ctx.update && crate::sysupdate::handles(path) { + return crate::sysupdate::serve(&self.ctx, req, from_go).await; + } if let Some(m) = &self.ctx.mcp && crate::mcp::handles(path) { diff --git a/rust/loki-rs/src/sysupdate.rs b/rust/loki-rs/src/sysupdate.rs new file mode 100644 index 0000000..e342178 --- /dev/null +++ b/rust/loki-rs/src/sysupdate.rs @@ -0,0 +1,56 @@ +//! Mise à jour de Loki tenue par la façade (lot 21 de la migration, +//! `LOKI_UPDATE=rust` ; expérimental, le Go reste le défaut) — port de +//! `handleUpdateCheck` et `handleUpdateApply` (`sys_update.go`) en conteneur. +//! +//! La façade ne tourne qu'en conteneur (`LOKI_CONTAINER=1`), où l'image se met +//! à jour par Docker : les deux routes le disent, sans rien télécharger. Les +//! binaires de la façade, eux, suivent `loki-rs self-update`. Hors conteneur, +//! la façade ne tient pas ce lot (décidé au démarrage). + +use hyper::body::Incoming; +use hyper::{Request, Response, StatusCode}; +use serde_json::json; + +use crate::api::Ctx; +use crate::auth::json as send_json; +use crate::proxy::Body; + +/// `containerUpdateNote`. +pub const CONTAINER_UPDATE_NOTE: &str = + "Mise à jour par l'image Docker : docker compose pull && docker compose up -d"; + +/// La façade peut-elle tenir ce lot (`containerManaged`) ? +pub fn usable(container_env: Option<&str>) -> bool { + container_env == Some("1") +} + +/// Les routes servies par ce module. +pub fn handles(path: &str) -> bool { + matches!(path, "/api/update" | "/api/update/apply") +} + +/// `from_go` : remise par le Go (tunnel), déjà authentifiée par lui. +pub async fn serve(ctx: &Ctx, req: Request, from_go: bool) -> Response { + let version = if from_go { + ctx.internal + .auth() + .await + .map(|i| i.version) + .unwrap_or_default() + } else { + match ctx.guard(req.headers()).await { + Ok(v) => v, + Err(refus) => return *refus, + } + }; + if req.uri().path() == "/api/update" { + return send_json( + StatusCode::OK, + &json!({ "current": version, "available": false, "note": CONTAINER_UPDATE_NOTE }), + ); + } + send_json( + StatusCode::CONFLICT, + &json!({ "ok": false, "error": CONTAINER_UPDATE_NOTE }), + ) +} diff --git a/rust/loki-rs/tests/common/fakego.rs b/rust/loki-rs/tests/common/fakego.rs index 4466dcf..17f5603 100644 --- a/rust/loki-rs/tests/common/fakego.rs +++ b/rust/loki-rs/tests/common/fakego.rs @@ -634,6 +634,7 @@ pub struct Opts { pub system: Option>, pub projects: bool, pub mcp: Option>, + pub update: bool, } /// Comme [`start`], avec la conversation tenue par la façade @@ -734,6 +735,7 @@ pub async fn start_with( system: opts.system, projects: opts.projects, mcp: opts.mcp, + update: opts.update, }; tokio::spawn(loki_rs::proxy::serve(fl, cfg, async move { let _ = rx.await; diff --git a/rust/loki-rs/tests/lot2.rs b/rust/loki-rs/tests/lot2.rs index e57a6f0..26d8c2e 100644 --- a/rust/loki-rs/tests/lot2.rs +++ b/rust/loki-rs/tests/lot2.rs @@ -105,6 +105,7 @@ async fn banc(cle: Option<&str>) -> Banc { system: None, projects: false, mcp: None, + update: false, }; tokio::spawn(loki_rs::proxy::serve(l, cfg, async move { let _ = rx.await; diff --git a/rust/loki-rs/tests/parity_update/update.json b/rust/loki-rs/tests/parity_update/update.json new file mode 100644 index 0000000..245e479 --- /dev/null +++ b/rust/loki-rs/tests/parity_update/update.json @@ -0,0 +1,42 @@ +{ + "steps": [ + { + "method": "GET", + "out": { + "available": false, + "current": "{{VERSION}}", + "note": "Mise à jour par l'image Docker : docker compose pull && docker compose up -d" + }, + "path": "/api/update", + "status": 200 + }, + { + "method": "POST", + "out": { + "available": false, + "current": "{{VERSION}}", + "note": "Mise à jour par l'image Docker : docker compose pull && docker compose up -d" + }, + "path": "/api/update", + "status": 200 + }, + { + "method": "POST", + "out": { + "error": "Mise à jour par l'image Docker : docker compose pull && docker compose up -d", + "ok": false + }, + "path": "/api/update/apply", + "status": 409 + }, + { + "method": "GET", + "out": { + "error": "Mise à jour par l'image Docker : docker compose pull && docker compose up -d", + "ok": false + }, + "path": "/api/update/apply", + "status": 409 + } + ] +} diff --git a/rust/loki-rs/tests/proxy.rs b/rust/loki-rs/tests/proxy.rs index 09bbf47..7ce058c 100644 --- a/rust/loki-rs/tests/proxy.rs +++ b/rust/loki-rs/tests/proxy.rs @@ -111,6 +111,7 @@ async fn facade(backend: SocketAddr) -> (SocketAddr, oneshot::Sender<()>) { system: None, projects: false, mcp: None, + update: false, }; tokio::spawn(loki_rs::proxy::serve(l, cfg, async move { let _ = rx.await; diff --git a/rust/loki-rs/tests/sysupdate.rs b/rust/loki-rs/tests/sysupdate.rs new file mode 100644 index 0000000..d56c316 --- /dev/null +++ b/rust/loki-rs/tests/sysupdate.rs @@ -0,0 +1,55 @@ +//! Lot 21 : mise à jour de Loki tenue par la façade (`LOKI_UPDATE=rust`, en +//! conteneur). Le journal tiré du Go (`internal/loki/parity_update_test.go` → +//! `tests/parity_update/update.json`) est rejoué par la façade. + +mod common; + +use common::fakego; +use serde_json::Value; + +#[tokio::test] +async fn parite_en_conteneur() { + let fx: Value = serde_json::from_str(include_str!("parity_update/update.json")).unwrap(); + let tmp = tempfile::tempdir().unwrap(); + let (facade, _go, _stop, _) = fakego::start_with( + tmp.path().to_path_buf(), + fakego::Opts { + update: true, + ..Default::default() + }, + ) + .await; + for st in fx["steps"].as_array().unwrap() { + let (code, b) = fakego::send( + facade, + st["method"].as_str().unwrap(), + st["path"].as_str().unwrap(), + &[], + "", + ) + .await; + assert_eq!(code.as_u16() as i64, st["status"].as_i64().unwrap(), "{st}"); + // La version est celle du Go (0.15.0 pour le faux Go). + let out: Value = + serde_json::from_str(&String::from_utf8_lossy(&b).replace("0.15.0", "{{VERSION}}")) + .unwrap(); + assert_eq!(out, st["out"], "{st}"); + } +} + +#[test] +fn seulement_en_conteneur() { + assert!(loki_rs::sysupdate::usable(Some("1"))); + assert!(!loki_rs::sysupdate::usable(Some("0"))); + assert!(!loki_rs::sysupdate::usable(None)); +} + +#[tokio::test] +async fn sans_la_variable_la_mise_a_jour_reste_au_go() { + let tmp = tempfile::tempdir().unwrap(); + let (facade, _go, _stop) = fakego::start(tmp.path().to_path_buf()).await; + for p in ["/api/update", "/api/update/apply"] { + let (_, _, b) = fakego::get(facade, p, &[]).await; + assert_eq!(&b[..], format!("go:{p}").as_bytes()); + } +}