From 0a6914c936e26387c2aceaabf82210beced066ce Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Tue, 13 Oct 2026 07:30:09 +0200 Subject: [PATCH] =?UTF-8?q?Fa=C3=A7ade=20Rust=20loki-rs=20:=20proxy=20deva?= =?UTF-8?q?nt=20le=20Go=20et=20mise=20=C3=A0=20jour=20au=20d=C3=A9marrage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Premier lot de la migration vers Rust. Le binaire loki-rs : - serve : prend le port public (8090), lance `loki web 8091` en écoute locale et lui relaie tout — HTTP, flux SSE trame par trame, WebSocket. Les en-têtes de bout en bout passent intacts (Host, Authorization, Origin, Sec-Fetch-*, X-Forwarded-Proto : le Go en tire ses contrôles d'accès) ; X-Loki-Via entrant est effacé, un client ne doit jamais pouvoir se faire passer pour le tunnel. Go mort : relancé avec attente croissante, 502 lisible entre-temps. - self-update : lit la release « rolling », installe loki et loki-rs dans $LOKI_HOME/bin// après vérification SHA-256, bascule par réécriture atomique de release.json. Les versions s'ordonnent par numéro de run CI ; une ABI d'image différente n'est jamais installée. Toute erreur laisse l'état précédent, le démarrage n'est jamais bloqué. Le HTTPS passe par curl (présent dans l'image) : pas de pile TLS ici. - which-bin : dossier d'où l'entrypoint prend les deux binaires, toujours ensemble. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 3 + rust/Cargo.lock | 670 +++++++++++++++++++++++++++++++ rust/Cargo.toml | 7 + rust/loki-rs/Cargo.toml | 23 ++ rust/loki-rs/src/child.rs | 158 ++++++++ rust/loki-rs/src/lib.rs | 9 + rust/loki-rs/src/main.rs | 151 +++++++ rust/loki-rs/src/proxy.rs | 229 +++++++++++ rust/loki-rs/src/update/mod.rs | 258 ++++++++++++ rust/loki-rs/src/update/state.rs | 151 +++++++ rust/loki-rs/src/update/sums.rs | 49 +++ rust/loki-rs/tests/common/mod.rs | 76 ++++ rust/loki-rs/tests/proxy.rs | 206 ++++++++++ rust/loki-rs/tests/update.rs | 148 +++++++ 14 files changed, 2138 insertions(+) create mode 100644 rust/Cargo.lock create mode 100644 rust/Cargo.toml create mode 100644 rust/loki-rs/Cargo.toml create mode 100644 rust/loki-rs/src/child.rs create mode 100644 rust/loki-rs/src/lib.rs create mode 100644 rust/loki-rs/src/main.rs create mode 100644 rust/loki-rs/src/proxy.rs create mode 100644 rust/loki-rs/src/update/mod.rs create mode 100644 rust/loki-rs/src/update/state.rs create mode 100644 rust/loki-rs/src/update/sums.rs create mode 100644 rust/loki-rs/tests/common/mod.rs create mode 100644 rust/loki-rs/tests/proxy.rs create mode 100644 rust/loki-rs/tests/update.rs diff --git a/.gitignore b/.gitignore index a1b0047..336a780 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ Thumbs.db .pytest_cache/ __pycache__/ node_modules/ + +# Rust (façade loki-rs) +rust/target/ diff --git a/rust/Cargo.lock b/rust/Cargo.lock new file mode 100644 index 0000000..a40f881 --- /dev/null +++ b/rust/Cargo.lock @@ -0,0 +1,670 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-macro", + "futures-sink", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "libc", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "loki-rs" +version = "0.1.0" +dependencies = [ + "bytes", + "futures-util", + "hex", + "http-body-util", + "hyper", + "hyper-util", + "libc", + "serde", + "serde_json", + "sha2", + "tempfile", + "tokio", + "tokio-tungstenite", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio" +version = "1.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[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 = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 0000000..1c5093b --- /dev/null +++ b/rust/Cargo.toml @@ -0,0 +1,7 @@ +[workspace] +resolver = "2" +members = ["loki-rs"] + +[profile.release] +strip = true +lto = "thin" diff --git a/rust/loki-rs/Cargo.toml b/rust/loki-rs/Cargo.toml new file mode 100644 index 0000000..fd8756e --- /dev/null +++ b/rust/loki-rs/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "loki-rs" +version = "0.1.0" +edition = "2024" + +[dependencies] +bytes = "1.12.1" +hex = "0.4.3" +http-body-util = "0.1.5" +hyper = { version = "1.12.0", features = ["http1", "server", "client"] } +hyper-util = { version = "0.1.21", features = ["tokio", "server", "client-legacy", "http1"] } +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.154" +sha2 = "0.11.1" +tokio = { version = "1.53.2", features = ["rt-multi-thread", "macros", "net", "io-util", "time", "signal", "process", "sync", "fs"] } + +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +[dev-dependencies] +futures-util = "0.3.34" +tempfile = "3.27.0" +tokio-tungstenite = "0.30.0" diff --git a/rust/loki-rs/src/child.rs b/rust/loki-rs/src/child.rs new file mode 100644 index 0000000..c499b44 --- /dev/null +++ b/rust/loki-rs/src/child.rs @@ -0,0 +1,158 @@ +//! Supervision du serveur Go (`loki web`) lancé derrière la façade. +//! +//! Le Go reste propriétaire de presque tout au lot 1 : s'il meurt, la façade +//! le relance (attente croissante 1 s → 30 s, remise à 1 s après une minute +//! de vie) et répond 502 entre-temps au lieu de tomber avec lui. + +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::time::{Duration, Instant}; +use tokio::process::{Child, Command}; +use tokio::sync::oneshot; + +const MIN_DELAY: Duration = Duration::from_secs(1); +const MAX_DELAY: Duration = Duration::from_secs(30); +const STABLE_AFTER: Duration = Duration::from_secs(60); + +/// Attente avant la relance suivante, connaissant la précédente et la durée +/// de vie du processus qui vient de mourir. +pub fn next_delay(prev: Duration, lived: Duration) -> Duration { + if lived >= STABLE_AFTER { + MIN_DELAY + } else { + (prev * 2).clamp(MIN_DELAY, MAX_DELAY) + } +} + +/// Binaire Go à lancer : `LOKI_GO_BIN`, sinon `loki` à côté de l'exécutable +/// courant (les deux viennent toujours du même dossier, cf. `which-bin`). +pub fn go_bin(env_value: Option, exe: &Path) -> PathBuf { + if let Some(p) = env_value.filter(|v| !v.trim().is_empty()) { + return PathBuf::from(p); + } + let name = if cfg!(windows) { "loki.exe" } else { "loki" }; + exe.parent().unwrap_or(Path::new(".")).join(name) +} + +pub struct Supervisor { + stop: Option>, + done: tokio::task::JoinHandle<()>, +} + +impl Supervisor { + /// Lance ` web ` en écoute locale et le maintient en vie. + pub fn spawn(go_bin: PathBuf, port: u16) -> Supervisor { + let (tx, mut rx) = oneshot::channel::(); + let done = tokio::spawn(async move { + let mut delay = Duration::ZERO; + loop { + let started = Instant::now(); + let mut child = match launch(&go_bin, port) { + Ok(c) => c, + Err(e) => { + eprintln!( + "[loki-rs] lancement de {} impossible : {e}", + go_bin.display() + ); + delay = next_delay(delay, Duration::ZERO); + tokio::select! { + _ = tokio::time::sleep(delay) => continue, + _ = &mut rx => return, + } + } + }; + tokio::select! { + status = child.wait() => { + let lived = started.elapsed(); + delay = next_delay(delay, lived); + let status = match status { + Ok(s) => s.to_string(), + Err(e) => e.to_string(), + }; + eprintln!( + "[loki-rs] serveur Go arrêté ({status}) après {lived:?} — relance dans {delay:?}" + ); + tokio::select! { + _ = tokio::time::sleep(delay) => {} + _ = &mut rx => return, + } + } + grace = &mut rx => { + stop_child(&mut child, grace.unwrap_or(MIN_DELAY)).await; + return; + } + } + } + }); + Supervisor { + stop: Some(tx), + done, + } + } + + /// Arrête le Go : SIGTERM, puis SIGKILL passé `grace`. + pub async fn shutdown(mut self, grace: Duration) { + if let Some(tx) = self.stop.take() { + let _ = tx.send(grace); + } + let _ = (&mut self.done).await; + } +} + +fn launch(go_bin: &Path, port: u16) -> std::io::Result { + Command::new(go_bin) + .arg("web") + .arg(port.to_string()) + .env("LOKI_WEB_BIND", "127.0.0.1") + // stdin fermé : `loki web` ne doit jamais attendre une réponse au + // clavier (résolution de conflit de port). + .stdin(Stdio::null()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .kill_on_drop(true) + .spawn() +} + +async fn stop_child(child: &mut Child, grace: Duration) { + #[cfg(unix)] + if let Some(pid) = child.id() { + // SAFETY: kill(2) sur le PID de notre propre enfant, pas encore récolté. + unsafe { + libc::kill(pid as libc::pid_t, libc::SIGTERM); + } + if tokio::time::timeout(grace, child.wait()).await.is_ok() { + return; + } + } + #[cfg(not(unix))] + let _ = grace; + let _ = child.kill().await; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn attente_croissante_puis_remise_a_zero() { + let s = Duration::from_secs; + assert_eq!(next_delay(Duration::ZERO, s(0)), s(1)); + assert_eq!(next_delay(s(1), s(2)), s(2)); + assert_eq!(next_delay(s(16), s(2)), s(30)); + assert_eq!(next_delay(s(30), s(2)), s(30)); + assert_eq!(next_delay(s(30), s(61)), s(1)); + } + + #[test] + fn binaire_go_par_defaut_a_cote_de_l_exe() { + let exe = Path::new("/usr/local/bin/loki-rs"); + let name = if cfg!(windows) { "loki.exe" } else { "loki" }; + let dir = Path::new("/usr/local/bin"); + assert_eq!(go_bin(None, exe), dir.join(name)); + assert_eq!(go_bin(Some(" ".into()), exe), dir.join(name)); + assert_eq!( + go_bin(Some("/x/loki".into()), exe), + PathBuf::from("/x/loki") + ); + } +} diff --git a/rust/loki-rs/src/lib.rs b/rust/loki-rs/src/lib.rs new file mode 100644 index 0000000..cebf1af --- /dev/null +++ b/rust/loki-rs/src/lib.rs @@ -0,0 +1,9 @@ +//! loki-rs — façade Rust de Loki (lot 1 de la migration Go → Rust). +//! +//! - `proxy` : façade HTTP/SSE/WebSocket devant le serveur Go ; +//! - `child` : supervision du processus `loki web` (Go) ; +//! - `update` : mise à jour des binaires au démarrage du conteneur. + +pub mod child; +pub mod proxy; +pub mod update; diff --git a/rust/loki-rs/src/main.rs b/rust/loki-rs/src/main.rs new file mode 100644 index 0000000..c0a7a61 --- /dev/null +++ b/rust/loki-rs/src/main.rs @@ -0,0 +1,151 @@ +//! loki-rs — façade Rust de Loki. +//! +//! ```text +//! loki-rs serve façade sur LOKI_WEB_PORT (8090), Go sur 127.0.0.1:LOKI_GO_PORT (8091) +//! loki-rs self-update met à jour les binaires dans $LOKI_HOME/bin (jamais bloquant) +//! loki-rs which-bin dossier d'où prendre loki et loki-rs +//! loki-rs version +//! ``` + +use loki_rs::{child, proxy, update}; +use std::net::SocketAddr; +use std::path::PathBuf; +use std::process::ExitCode; +use std::time::{Duration, Instant}; + +fn main() -> ExitCode { + let cmd = std::env::args().nth(1).unwrap_or_default(); + match cmd.as_str() { + "serve" => serve(), + "self-update" => { + let outcome = update::run(&update::UpdateConfig::from_env()); + eprintln!("[loki-rs update] {}", outcome.message()); + ExitCode::SUCCESS + } + "which-bin" => { + let cfg = update::UpdateConfig::from_env(); + let dir = update::state::select_dir( + &cfg.data_bin, + &image_dir(), + cfg.image_build, + cfg.image_abi, + ); + println!("{}", dir.display()); + ExitCode::SUCCESS + } + "version" | "--version" | "-V" => { + let cfg = update::UpdateConfig::from_env(); + println!( + "loki-rs {} (build image {}, ABI {})", + env!("CARGO_PKG_VERSION"), + cfg.image_build, + cfg.image_abi + ); + ExitCode::SUCCESS + } + _ => { + eprintln!("usage : loki-rs serve | self-update | which-bin | version"); + ExitCode::from(2) + } + } +} + +fn exe() -> PathBuf { + std::env::current_exe().unwrap_or_else(|_| PathBuf::from("loki-rs")) +} + +fn image_dir() -> PathBuf { + exe() + .parent() + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")) +} + +fn env_port(key: &str, default: u16) -> u16 { + std::env::var(key) + .ok() + .and_then(|v| v.trim().parse().ok()) + .unwrap_or(default) +} + +fn serve() -> ExitCode { + let rt = match tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + { + Ok(rt) => rt, + Err(e) => { + eprintln!("[loki-rs] runtime : {e}"); + return ExitCode::FAILURE; + } + }; + rt.block_on(async { + let web_port = env_port("LOKI_WEB_PORT", 8090); + let go_port = env_port("LOKI_GO_PORT", 8091); + let go_bin = child::go_bin(std::env::var("LOKI_GO_BIN").ok(), &exe()); + let backend = SocketAddr::from(([127, 0, 0, 1], go_port)); + + eprintln!( + "[loki-rs] serveur Go : {} web {go_port} (local)", + go_bin.display() + ); + let sup = child::Supervisor::spawn(go_bin, go_port); + wait_for(backend, Duration::from_secs(30)).await; + + let addr = SocketAddr::from(([0, 0, 0, 0], web_port)); + let listener = match tokio::net::TcpListener::bind(addr).await { + Ok(l) => l, + Err(e) => { + eprintln!("[loki-rs] écoute sur {addr} impossible : {e}"); + sup.shutdown(Duration::from_secs(10)).await; + return ExitCode::FAILURE; + } + }; + eprintln!("[loki-rs] façade : http://{addr} → {backend}"); + let res = proxy::serve(listener, backend, shutdown_signal()).await; + eprintln!("[loki-rs] arrêt demandé — arrêt du serveur Go"); + sup.shutdown(Duration::from_secs(10)).await; + match res { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("[loki-rs] {e}"); + ExitCode::FAILURE + } + } + }) +} + +/// Attend que le Go accepte les connexions, sans bloquer au-delà de `max` : +/// la façade ouvre quand même et répond 502 en attendant. +async fn wait_for(addr: SocketAddr, max: Duration) { + let t0 = Instant::now(); + while t0.elapsed() < max { + if tokio::net::TcpStream::connect(addr).await.is_ok() { + return; + } + tokio::time::sleep(Duration::from_millis(200)).await; + } + eprintln!("[loki-rs] le serveur Go ne répond pas encore — façade ouverte quand même"); +} + +async fn shutdown_signal() { + #[cfg(unix)] + { + use tokio::signal::unix::{SignalKind, signal}; + match signal(SignalKind::terminate()) { + Ok(mut term) => { + tokio::select! { + _ = term.recv() => {} + _ = tokio::signal::ctrl_c() => {} + } + } + Err(_) => { + let _ = tokio::signal::ctrl_c().await; + } + } + } + #[cfg(not(unix))] + { + let _ = tokio::signal::ctrl_c().await; + } +} diff --git a/rust/loki-rs/src/proxy.rs b/rust/loki-rs/src/proxy.rs new file mode 100644 index 0000000..3210f69 --- /dev/null +++ b/rust/loki-rs/src/proxy.rs @@ -0,0 +1,229 @@ +//! Façade HTTP : reçoit tout le trafic public et le relaie au serveur Go +//! (`loki web` sur 127.0.0.1). Les lots suivants de la migration y brancheront +//! les routes réécrites en Rust ; au lot 1, tout est relayé. +//! +//! Règles, héritées de `web_server.go` : +//! - aucun tampon : un flux SSE (chat, `/v1` en streaming) part trame par trame ; +//! - aucun délai d'écriture : un flux reste ouvert tant que la page l'écoute ; +//! - en-têtes de bout en bout transmis intacts (`Host`, `Authorization`, +//! `Origin`, `Sec-Fetch-*`, `X-Forwarded-Proto`…) : le Go s'en sert pour ses +//! contrôles d'accès ; seuls les en-têtes hop-by-hop sont retirés ; +//! - `X-Loki-Via` entrant effacé : il marque le trafic du tunnel du relais et +//! dispense du contrôle d'hôte anti-DNS-rebinding — un client ne doit jamais +//! pouvoir le poser lui-même. + +use bytes::Bytes; +use http_body_util::{BodyExt, Full, combinators::BoxBody}; +use hyper::body::Incoming; +use hyper::header::{self, HeaderMap, HeaderName, HeaderValue}; +use hyper::service::service_fn; +use hyper::{Request, Response, StatusCode, Uri}; +use hyper_util::client::legacy::{Client, connect::HttpConnector}; +use hyper_util::rt::{TokioExecutor, TokioIo}; +use std::convert::Infallible; +use std::future::Future; +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; +use tokio::net::TcpListener; + +pub type Body = BoxBody; + +/// En-tête posé par le tunnel du relais côté Go (`viaTunnelHeader`). +pub const VIA_TUNNEL: &str = "x-loki-via"; + +const HOP_BY_HOP: [&str; 8] = [ + "connection", + "keep-alive", + "proxy-connection", + "proxy-authenticate", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]; + +struct Proxy { + client: Client, + backend: SocketAddr, +} + +/// Sert la façade sur `listener` jusqu'à ce que `shutdown` se termine. +pub async fn serve( + listener: TcpListener, + backend: SocketAddr, + shutdown: impl Future, +) -> std::io::Result<()> { + let client = Client::builder(TokioExecutor::new()).build_http(); + let proxy = Arc::new(Proxy { client, backend }); + tokio::pin!(shutdown); + loop { + tokio::select! { + _ = &mut shutdown => return Ok(()), + res = listener.accept() => { + let stream = match res { + Ok((s, _)) => s, + Err(e) => { + // EMFILE & co : on ne lâche pas l'écoute pour autant. + eprintln!("[loki-rs] accept : {e}"); + tokio::time::sleep(Duration::from_millis(50)).await; + continue; + } + }; + let _ = stream.set_nodelay(true); + let p = proxy.clone(); + tokio::spawn(async move { + let svc = service_fn(move |req| { + let p = p.clone(); + async move { Ok::<_, Infallible>(p.handle(req).await) } + }); + let _ = hyper::server::conn::http1::Builder::new() + .timer(hyper_util::rt::TokioTimer::new()) + .header_read_timeout(Duration::from_secs(10)) + .serve_connection(TokioIo::new(stream), svc) + .with_upgrades() + .await; + }); + } + } + } +} + +impl Proxy { + async fn handle(&self, mut req: Request) -> Response { + let upgrade = upgrade_protocol(req.headers()); + strip_hop_by_hop(req.headers_mut()); + req.headers_mut().remove(VIA_TUNNEL); + let client_upgrade = upgrade.as_ref().map(|proto| { + let h = req.headers_mut(); + h.insert(header::CONNECTION, HeaderValue::from_static("upgrade")); + h.insert(header::UPGRADE, proto.clone()); + hyper::upgrade::on(&mut req) + }); + + let pq = req + .uri() + .path_and_query() + .map(|p| p.as_str()) + .unwrap_or("/"); + let uri: Uri = match format!("http://{}{}", self.backend, pq).parse() { + Ok(u) => u, + Err(_) => return plain(StatusCode::BAD_REQUEST, "URI invalide"), + }; + *req.uri_mut() = uri; + + let mut resp = match self.client.request(req).await { + Ok(r) => r, + Err(e) => { + eprintln!("[loki-rs] backend Go injoignable : {e}"); + return plain( + StatusCode::BAD_GATEWAY, + "Loki (serveur Go) redémarre — réessaie dans un instant.", + ); + } + }; + + if let Some(client_upgrade) = client_upgrade + && resp.status() == StatusCode::SWITCHING_PROTOCOLS + { + let backend_upgrade = hyper::upgrade::on(&mut resp); + tokio::spawn(async move { + match (client_upgrade.await, backend_upgrade.await) { + (Ok(a), Ok(b)) => { + let (mut a, mut b) = (TokioIo::new(a), TokioIo::new(b)); + let _ = tokio::io::copy_bidirectional(&mut a, &mut b).await; + } + (Err(e), _) | (_, Err(e)) => eprintln!("[loki-rs] upgrade : {e}"), + } + }); + // 101 : Connection/Upgrade font partie de la poignée de main. + return resp.map(BodyExt::boxed); + } + strip_hop_by_hop(resp.headers_mut()); + resp.map(BodyExt::boxed) + } +} + +/// Protocole demandé si la requête est une demande d'upgrade (WebSocket). +fn upgrade_protocol(h: &HeaderMap) -> Option { + let wants = h + .get_all(header::CONNECTION) + .iter() + .filter_map(|v| v.to_str().ok()) + .flat_map(|s| s.split(',')) + .any(|t| t.trim().eq_ignore_ascii_case("upgrade")); + if wants { + h.get(header::UPGRADE).cloned() + } else { + None + } +} + +/// Retire les en-têtes propres à une connexion (RFC 9110 §7.6.1), y compris +/// ceux que `Connection` désigne. +pub fn strip_hop_by_hop(h: &mut HeaderMap) { + let listed: Vec = h + .get_all(header::CONNECTION) + .iter() + .filter_map(|v| v.to_str().ok()) + .flat_map(|s| s.split(',')) + .filter_map(|t| HeaderName::from_bytes(t.trim().as_bytes()).ok()) + .collect(); + for n in listed { + h.remove(n); + } + for n in HOP_BY_HOP { + h.remove(n); + } +} + +fn plain(status: StatusCode, msg: &'static str) -> Response { + let mut r = Response::new( + Full::new(Bytes::from_static(msg.as_bytes())) + .map_err(|n| match n {}) + .boxed(), + ); + *r.status_mut() = status; + r.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/plain; charset=utf-8"), + ); + r +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn retire_les_hop_by_hop_et_ceux_listes() { + let mut h = HeaderMap::new(); + h.insert( + "connection", + HeaderValue::from_static("keep-alive, x-perso"), + ); + h.insert("keep-alive", HeaderValue::from_static("timeout=5")); + h.insert("x-perso", HeaderValue::from_static("1")); + h.insert("transfer-encoding", HeaderValue::from_static("chunked")); + h.insert("authorization", HeaderValue::from_static("Bearer k")); + strip_hop_by_hop(&mut h); + assert_eq!(h.len(), 1); + assert!(h.contains_key("authorization")); + } + + #[test] + fn detecte_l_upgrade() { + let mut h = HeaderMap::new(); + h.insert( + "connection", + HeaderValue::from_static("keep-alive, Upgrade"), + ); + h.insert("upgrade", HeaderValue::from_static("websocket")); + assert_eq!( + upgrade_protocol(&h), + Some(HeaderValue::from_static("websocket")) + ); + h.insert("connection", HeaderValue::from_static("keep-alive")); + assert_eq!(upgrade_protocol(&h), None); + } +} diff --git a/rust/loki-rs/src/update/mod.rs b/rust/loki-rs/src/update/mod.rs new file mode 100644 index 0000000..0e3bae1 --- /dev/null +++ b/rust/loki-rs/src/update/mod.rs @@ -0,0 +1,258 @@ +//! Mise à jour des binaires Loki au démarrage du conteneur. +//! +//! La CI publie à chaque push sur `main` une release GitHub `rolling` : +//! `loki-linux`, `loki-rs-linux`, `SHA256SUMS` puis `manifest.json` (en +//! dernier). Au boot, [`run`] compare le manifeste au build en service et, si +//! besoin, installe les deux binaires dans `/bin//` (voir +//! [`state`]). Rien ici ne doit bloquer le démarrage : toute erreur se solde +//! par [`Outcome::Failed`] et l'ancien état reste en place. +//! +//! Le transfert HTTPS passe par `curl` (présent dans l'image) : le binaire +//! n'embarque ainsi aucune pile TLS. + +pub mod state; +pub mod sums; + +use sha2::{Digest, Sha256}; +use std::fs; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::Duration; + +use state::{Decision, Manifest}; + +pub const DEFAULT_URL: &str = "https://github.com/R0m1k3/Loki/releases/download/rolling"; +pub const IMAGE_ABI_FILE: &str = "/etc/loki/IMAGE_ABI"; + +/// Nom de l'asset publié → nom du binaire installé. +pub const ASSETS: [(&str, &str); 2] = [ + ("loki-linux", state::GO_BIN), + ("loki-rs-linux", state::RS_BIN), +]; + +const TMP_DIR: &str = ".tmp-download"; + +#[derive(Debug, Clone)] +pub struct UpdateConfig { + pub enabled: bool, + pub base_url: String, + pub data_bin: PathBuf, + pub image_build: u64, + pub image_abi: u32, + /// Durée maximale du téléchargement du manifeste et des sommes. + pub small_timeout: Duration, + /// Durée maximale de chaque binaire (un transfert figé est coupé bien + /// avant, cf. `--speed-limit`). + pub big_timeout: Duration, +} + +impl UpdateConfig { + pub fn from_env() -> Self { + let env = |k: &str| std::env::var(k).ok().filter(|v| !v.trim().is_empty()); + let home = env("LOKI_HOME").unwrap_or_else(|| "/data".into()); + let image_abi = fs::read_to_string(IMAGE_ABI_FILE) + .ok() + .or_else(|| env("LOKI_IMAGE_ABI")) + .and_then(|s| s.trim().parse().ok()) + .unwrap_or(0); + UpdateConfig { + enabled: !matches!( + env("LOKI_AUTO_UPDATE").as_deref().map(str::trim), + Some("0" | "false" | "off" | "no") + ), + base_url: env("LOKI_UPDATE_URL").unwrap_or_else(|| DEFAULT_URL.into()), + data_bin: Path::new(&home).join("bin"), + image_build: env("LOKI_BUILD") + .and_then(|s| s.trim().parse().ok()) + .unwrap_or(0), + image_abi, + small_timeout: Duration::from_secs(20), + big_timeout: Duration::from_secs(600), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Outcome { + Disabled, + UpToDate(u64), + /// Version publiée incompatible avec l'image : il faut tirer l'image. + AbiMismatch { + remote_abi: u32, + image_abi: u32, + }, + Installed(u64), + Failed(String), +} + +impl Outcome { + /// Ligne de journal pour l'entrypoint. + pub fn message(&self) -> String { + match self { + Outcome::Disabled => "mise à jour au démarrage désactivée (LOKI_AUTO_UPDATE=0)".into(), + Outcome::UpToDate(b) => format!("binaires à jour (build {b})"), + Outcome::AbiMismatch { + remote_abi, + image_abi, + } => format!( + "nouvelle version disponible mais elle exige une nouvelle image \ + (ABI {remote_abi}, image {image_abi}) : docker compose pull" + ), + Outcome::Installed(b) => format!("build {b} installé — actif dès ce démarrage"), + Outcome::Failed(e) => { + format!("mise à jour impossible ({e}) — binaires actuels conservés") + } + } + } +} + +pub fn run(cfg: &UpdateConfig) -> Outcome { + if !cfg.enabled { + return Outcome::Disabled; + } + if let Err(e) = fs::create_dir_all(&cfg.data_bin) { + return Outcome::Failed(format!("{}: {e}", cfg.data_bin.display())); + } + let tmp = cfg.data_bin.join(TMP_DIR); + let _ = fs::remove_dir_all(&tmp); + let res = fs::create_dir_all(&tmp) + .map_err(|e| format!("{}: {e}", tmp.display())) + .and_then(|_| run_in(cfg, &tmp)); + let _ = fs::remove_dir_all(&tmp); + res.unwrap_or_else(Outcome::Failed) +} + +fn run_in(cfg: &UpdateConfig, tmp: &Path) -> Result { + let current = state::current(&cfg.data_bin, cfg.image_build, cfg.image_abi) + .map(|(m, _)| m.build) + .unwrap_or(cfg.image_build); + + let man_path = tmp.join("manifest.json"); + fetch(&cfg.base_url, "manifest.json", &man_path, cfg.small_timeout)?; + let remote = state::read_manifest(&man_path).ok_or("manifest.json illisible")?; + + match state::decide(&remote, current, cfg.image_abi) { + Decision::AbiMismatch => { + return Ok(Outcome::AbiMismatch { + remote_abi: remote.abi, + image_abi: cfg.image_abi, + }); + } + Decision::UpToDate => { + prune(&cfg.data_bin, current); + return Ok(Outcome::UpToDate(current)); + } + Decision::Install => {} + } + + let sums_path = tmp.join("SHA256SUMS"); + fetch(&cfg.base_url, "SHA256SUMS", &sums_path, cfg.small_timeout)?; + let sums_text = fs::read_to_string(&sums_path).map_err(|e| format!("SHA256SUMS: {e}"))?; + let sums = sums::parse(&sums_text); + + let stage = tmp.join("stage"); + fs::create_dir_all(&stage).map_err(|e| e.to_string())?; + for (asset, local) in ASSETS { + let want = sums + .get(asset) + .ok_or(format!("SHA256SUMS sans entrée pour {asset}"))?; + let dst = stage.join(local); + fetch(&cfg.base_url, asset, &dst, cfg.big_timeout)?; + let got = sha256_file(&dst).map_err(|e| format!("{asset}: {e}"))?; + if &got != want { + return Err(format!("empreinte de {asset} invalide")); + } + make_executable(&dst).map_err(|e| format!("{asset}: {e}"))?; + } + write_manifest(&stage.join(state::RELEASE_FILE), &remote)?; + + let dest = cfg.data_bin.join(remote.build.to_string()); + let _ = fs::remove_dir_all(&dest); + fs::rename(&stage, &dest).map_err(|e| format!("{}: {e}", dest.display()))?; + + // Bascule : release.json réécrit en dernier, par renommage atomique. + let pointer = cfg.data_bin.join(state::RELEASE_FILE); + let pointer_tmp = cfg.data_bin.join("release.json.tmp"); + write_manifest(&pointer_tmp, &remote)?; + fs::rename(&pointer_tmp, &pointer).map_err(|e| format!("{}: {e}", pointer.display()))?; + + prune(&cfg.data_bin, remote.build); + Ok(Outcome::Installed(remote.build)) +} + +/// Télécharge `/` dans `dst` via curl. `--speed-limit` coupe un +/// transfert figé (moins de 10 Ko/s pendant 20 s) bien avant `timeout`. +fn fetch(base: &str, name: &str, dst: &Path, timeout: Duration) -> Result<(), String> { + let url = format!("{}/{}", base.trim_end_matches('/'), name); + let out = Command::new("curl") + .args([ + "-fsSL", + "--connect-timeout", + "10", + "--speed-limit", + "10240", + "--speed-time", + "20", + ]) + .arg("--max-time") + .arg(timeout.as_secs().max(1).to_string()) + .arg("-o") + .arg(dst) + .arg(&url) + .output() + .map_err(|e| format!("curl introuvable : {e}"))?; + if !out.status.success() { + let err = String::from_utf8_lossy(&out.stderr); + return Err(format!("{name} : {}", err.trim())); + } + Ok(()) +} + +pub fn sha256_file(path: &Path) -> std::io::Result { + let mut f = fs::File::open(path)?; + let mut h = Sha256::new(); + let mut buf = [0u8; 64 * 1024]; + loop { + let n = f.read(&mut buf)?; + if n == 0 { + break; + } + h.update(&buf[..n]); + } + Ok(hex::encode(h.finalize())) +} + +fn write_manifest(path: &Path, m: &Manifest) -> Result<(), String> { + let text = serde_json::to_string(m).map_err(|e| e.to_string())?; + fs::write(path, text).map_err(|e| format!("{}: {e}", path.display())) +} + +#[cfg(unix)] +fn make_executable(path: &Path) -> std::io::Result<()> { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o755)) +} + +#[cfg(not(unix))] +fn make_executable(_: &Path) -> std::io::Result<()> { + Ok(()) +} + +/// Supprime les builds autres que `keep` (un seul build est gardé : celui en +/// service). Ne touche qu'aux dossiers au nom purement numérique. +fn prune(data_bin: &Path, keep: u64) { + let Ok(rd) = fs::read_dir(data_bin) else { + return; + }; + for e in rd.flatten() { + let name = e.file_name(); + let Some(name) = name.to_str() else { continue }; + if let Ok(n) = name.parse::() + && n != keep + && e.path().is_dir() + { + let _ = fs::remove_dir_all(e.path()); + } + } +} diff --git a/rust/loki-rs/src/update/state.rs b/rust/loki-rs/src/update/state.rs new file mode 100644 index 0000000..c943c96 --- /dev/null +++ b/rust/loki-rs/src/update/state.rs @@ -0,0 +1,151 @@ +//! État d'installation des binaires mis à jour, sous `/bin` : +//! +//! ```text +//! bin/release.json manifeste du build actif (écrit en DERNIER) +//! bin//loki binaire Go +//! bin//loki-rs binaire Rust +//! ``` +//! +//! La bascule d'une version à l'autre se résume à réécrire `release.json` : +//! un arrêt brutal pendant une mise à jour laisse l'ancien build intact. + +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +pub const RELEASE_FILE: &str = "release.json"; +pub const GO_BIN: &str = "loki"; +pub const RS_BIN: &str = "loki-rs"; + +/// Contenu de `manifest.json` (release) et de `release.json` (installé). +/// +/// `build` est le numéro de run de la CI : il ne fait que croître, c'est lui +/// qui ordonne les versions (un commit ne s'ordonne pas). `abi` est l'ABI +/// d'image exigée par ces binaires. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Manifest { + pub build: u64, + pub abi: u32, + #[serde(default)] + pub commit: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Decision { + Install, + UpToDate, + AbiMismatch, +} + +pub fn read_manifest(path: &Path) -> Option { + let text = std::fs::read_to_string(path).ok()?; + serde_json::from_str(&text).ok() +} + +/// Build installé dans `data_bin` et utilisable avec cette image : même ABI, +/// plus récent que les binaires de l'image, et les deux binaires présents. +pub fn current(data_bin: &Path, image_build: u64, image_abi: u32) -> Option<(Manifest, PathBuf)> { + let m = read_manifest(&data_bin.join(RELEASE_FILE))?; + if m.abi != image_abi || m.build <= image_build { + return None; + } + let dir = data_bin.join(m.build.to_string()); + if !dir.join(GO_BIN).is_file() || !dir.join(RS_BIN).is_file() { + return None; + } + Some((m, dir)) +} + +/// Dossier dont l'entrypoint doit prendre `loki` et `loki-rs` : toujours les +/// deux du même endroit, jamais un Go d'une version avec un Rust d'une autre. +pub fn select_dir(data_bin: &Path, image_dir: &Path, image_build: u64, image_abi: u32) -> PathBuf { + match current(data_bin, image_build, image_abi) { + Some((_, dir)) => dir, + None => image_dir.to_path_buf(), + } +} + +/// Faut-il installer `remote`, sachant que le build en service est `current_build` ? +pub fn decide(remote: &Manifest, current_build: u64, image_abi: u32) -> Decision { + if remote.abi != image_abi { + Decision::AbiMismatch + } else if remote.build <= current_build { + Decision::UpToDate + } else { + Decision::Install + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn m(build: u64, abi: u32) -> Manifest { + Manifest { + build, + abi, + commit: "abc".into(), + } + } + + fn install(data_bin: &Path, man: &Manifest, bins: &[&str]) { + let dir = data_bin.join(man.build.to_string()); + std::fs::create_dir_all(&dir).unwrap(); + for b in bins { + std::fs::write(dir.join(b), b"x").unwrap(); + } + std::fs::write( + data_bin.join(RELEASE_FILE), + serde_json::to_string(man).unwrap(), + ) + .unwrap(); + } + + #[test] + fn decision() { + assert_eq!(decide(&m(10, 2), 5, 1), Decision::AbiMismatch); + assert_eq!(decide(&m(5, 1), 5, 1), Decision::UpToDate); + assert_eq!(decide(&m(4, 1), 5, 1), Decision::UpToDate); + assert_eq!(decide(&m(6, 1), 5, 1), Decision::Install); + } + + #[test] + fn selection_sans_installation() { + let t = tempfile::tempdir().unwrap(); + let img = Path::new("/usr/local/bin"); + assert_eq!(select_dir(t.path(), img, 3, 1), img); + } + + #[test] + fn selection_installation_valide() { + let t = tempfile::tempdir().unwrap(); + install(t.path(), &m(7, 1), &[GO_BIN, RS_BIN]); + let img = Path::new("/usr/local/bin"); + assert_eq!(select_dir(t.path(), img, 3, 1), t.path().join("7")); + } + + #[test] + fn selection_refuse_abi_differente() { + let t = tempfile::tempdir().unwrap(); + install(t.path(), &m(7, 2), &[GO_BIN, RS_BIN]); + let img = Path::new("/img"); + assert_eq!(select_dir(t.path(), img, 3, 1), img); + } + + #[test] + fn selection_refuse_plus_ancien_que_l_image() { + // Image tirée à la main plus récente que /data/bin : l'image gagne. + let t = tempfile::tempdir().unwrap(); + install(t.path(), &m(7, 1), &[GO_BIN, RS_BIN]); + let img = Path::new("/img"); + assert_eq!(select_dir(t.path(), img, 7, 1), img); + assert_eq!(select_dir(t.path(), img, 9, 1), img); + } + + #[test] + fn selection_refuse_binaire_manquant() { + let t = tempfile::tempdir().unwrap(); + install(t.path(), &m(7, 1), &[GO_BIN]); + let img = Path::new("/img"); + assert_eq!(select_dir(t.path(), img, 3, 1), img); + } +} diff --git a/rust/loki-rs/src/update/sums.rs b/rust/loki-rs/src/update/sums.rs new file mode 100644 index 0000000..71ff39a --- /dev/null +++ b/rust/loki-rs/src/update/sums.rs @@ -0,0 +1,49 @@ +//! Lecture du fichier SHA256SUMS publié avec chaque release (format +//! `sha256sum` : " ", ou " *" en mode binaire). + +use std::collections::HashMap; + +/// Associe chaque nom de fichier à son empreinte SHA-256 en hexadécimal +/// minuscule. Les lignes vides ou mal formées sont ignorées : une entrée +/// manquante fera échouer la vérification du fichier concerné, pas la lecture. +pub fn parse(text: &str) -> HashMap { + let mut out = HashMap::new(); + for line in text.lines() { + let mut it = line.split_whitespace(); + let (Some(hash), Some(name), None) = (it.next(), it.next(), it.next()) else { + continue; + }; + if hash.len() != 64 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) { + continue; + } + out.insert( + name.trim_start_matches('*').to_string(), + hash.to_ascii_lowercase(), + ); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + const A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + #[test] + fn lit_les_deux_formats() { + let up = A.to_ascii_uppercase(); + let text = format!("{A} loki-linux\n{up} *loki-rs-linux\n"); + let m = parse(&text); + assert_eq!(m.get("loki-linux").map(String::as_str), Some(A)); + assert_eq!(m.get("loki-rs-linux").map(String::as_str), Some(A)); + } + + #[test] + fn ignore_les_lignes_cassees() { + let text = format!("\nzz loki-linux\n{A}\n{A} a b\n{A} ok\n"); + let m = parse(&text); + assert_eq!(m.len(), 1); + assert!(m.contains_key("ok")); + } +} diff --git a/rust/loki-rs/tests/common/mod.rs b/rust/loki-rs/tests/common/mod.rs new file mode 100644 index 0000000..7131f6c --- /dev/null +++ b/rust/loki-rs/tests/common/mod.rs @@ -0,0 +1,76 @@ +//! Serveur HTTP minimal pour les tests : sert des fichiers en mémoire et +//! journalise les chemins demandés. +#![allow(dead_code)] + +use bytes::Bytes; +use http_body_util::Full; +use hyper::{Request, Response, StatusCode, body::Incoming, service::service_fn}; +use hyper_util::rt::TokioIo; +use std::collections::HashMap; +use std::net::SocketAddr; +use std::sync::{Arc, Mutex}; + +pub struct FileServer { + pub addr: SocketAddr, + pub hits: Arc>>, + _rt: tokio::runtime::Runtime, +} + +impl FileServer { + pub fn url(&self) -> String { + format!("http://{}/rolling", self.addr) + } + pub fn hits(&self) -> Vec { + self.hits.lock().unwrap().clone() + } +} + +pub fn serve_files(files: HashMap>) -> FileServer { + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .unwrap(); + let files = Arc::new(files); + let hits = Arc::new(Mutex::new(Vec::new())); + let listener = rt + .block_on(tokio::net::TcpListener::bind("127.0.0.1:0")) + .unwrap(); + let addr = listener.local_addr().unwrap(); + let h = hits.clone(); + rt.spawn(async move { + loop { + let Ok((stream, _)) = listener.accept().await else { + return; + }; + let files = files.clone(); + let h = h.clone(); + tokio::spawn(async move { + let svc = service_fn(move |req: Request| { + let files = files.clone(); + let h = h.clone(); + async move { + let path = req.uri().path().trim_start_matches("/rolling/").to_string(); + h.lock().unwrap().push(path.clone()); + let resp = match files.get(&path) { + Some(b) => Response::new(Full::new(Bytes::from(b.clone()))), + None => { + let mut r = Response::new(Full::new(Bytes::from_static(b"404"))); + *r.status_mut() = StatusCode::NOT_FOUND; + r + } + }; + Ok::<_, std::convert::Infallible>(resp) + } + }); + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection(TokioIo::new(stream), svc) + .await; + }); + } + }); + FileServer { + addr, + hits, + _rt: rt, + } +} diff --git a/rust/loki-rs/tests/proxy.rs b/rust/loki-rs/tests/proxy.rs new file mode 100644 index 0000000..ab80072 --- /dev/null +++ b/rust/loki-rs/tests/proxy.rs @@ -0,0 +1,206 @@ +use bytes::Bytes; +use futures_util::{SinkExt, StreamExt}; +use http_body_util::{BodyExt, Empty, Full, StreamBody, combinators::BoxBody}; +use hyper::body::{Frame, Incoming}; +use hyper::{Request, Response, StatusCode, service::service_fn}; +use hyper_util::client::legacy::Client; +use hyper_util::rt::{TokioExecutor, TokioIo}; +use std::convert::Infallible; +use std::net::SocketAddr; +use std::time::{Duration, Instant}; +use tokio::net::TcpListener; +use tokio::sync::oneshot; +use tokio_tungstenite::tungstenite::Message; + +type B = BoxBody; + +/// Backend factice : /echo renvoie les en-têtes reçus, /sse émet un événement, +/// attend 500 ms, puis un second ; /post renvoie le corps reçu. +async fn backend() -> SocketAddr { + let l = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = l.local_addr().unwrap(); + tokio::spawn(async move { + loop { + let (s, _) = l.accept().await.unwrap(); + tokio::spawn(async move { + let svc = service_fn(|req: Request| async move { + let resp: Response = match req.uri().path() { + "/echo" => { + let mut lines: Vec = req + .headers() + .iter() + .map(|(k, v)| format!("{}: {}", k, v.to_str().unwrap())) + .collect(); + lines.push(format!("uri: {}", req.uri())); + Response::new(Full::new(Bytes::from(lines.join("\n"))).boxed()) + } + "/post" => { + let body = req.into_body().collect().await.unwrap().to_bytes(); + Response::new(Full::new(body).boxed()) + } + "/sse" => { + let (tx, rx) = tokio::sync::mpsc::channel::(4); + tokio::spawn(async move { + tx.send(Bytes::from_static(b"data: un\n\n")).await.unwrap(); + tokio::time::sleep(Duration::from_millis(500)).await; + let _ = tx.send(Bytes::from_static(b"data: deux\n\n")).await; + }); + let stream = futures_util::stream::unfold(rx, |mut rx| async move { + rx.recv() + .await + .map(|b| (Ok::<_, Infallible>(Frame::data(b)), rx)) + }); + let mut r = Response::new(BodyExt::boxed(StreamBody::new(stream))); + r.headers_mut() + .insert("content-type", "text/event-stream".parse().unwrap()); + r + } + _ => Response::new(Empty::new().boxed()), + }; + Ok::<_, Infallible>(resp) + }); + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection(TokioIo::new(s), svc) + .await; + }); + } + }); + addr +} + +async fn ws_backend() -> SocketAddr { + let l = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = l.local_addr().unwrap(); + tokio::spawn(async move { + while let Ok((s, _)) = l.accept().await { + tokio::spawn(async move { + let mut ws = tokio_tungstenite::accept_async(s).await.unwrap(); + while let Some(Ok(m)) = ws.next().await { + if m.is_text() { + ws.send(m).await.unwrap(); + } + } + }); + } + }); + addr +} + +/// Lance la façade devant `backend`, rend son adresse. +async fn facade(backend: SocketAddr) -> (SocketAddr, oneshot::Sender<()>) { + let l = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = l.local_addr().unwrap(); + let (tx, rx) = oneshot::channel::<()>(); + tokio::spawn(loki_rs::proxy::serve(l, backend, async move { + let _ = rx.await; + })); + (addr, tx) +} + +fn client() -> Client> { + Client::builder(TokioExecutor::new()).build_http() +} + +#[tokio::test] +async fn transmet_les_en_tetes_et_efface_x_loki_via() { + let (f, _stop) = facade(backend().await).await; + let req = Request::get(format!("http://{f}/echo?a=1")) + .header("host", "loki.exemple.fr") + .header("authorization", "Bearer secret") + .header("origin", "https://loki.exemple.fr") + .header("x-forwarded-proto", "https") + .header("sec-fetch-site", "same-origin") + .header("x-loki-via", "tunnel") + .body(Full::new(Bytes::new())) + .unwrap(); + let resp = client().request(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = String::from_utf8( + resp.into_body() + .collect() + .await + .unwrap() + .to_bytes() + .to_vec(), + ) + .unwrap(); + for want in [ + "host: loki.exemple.fr", + "authorization: Bearer secret", + "origin: https://loki.exemple.fr", + "x-forwarded-proto: https", + "sec-fetch-site: same-origin", + "uri: /echo?a=1", + ] { + assert!( + body.lines().any(|l| l == want), + "manque « {want} » dans :\n{body}" + ); + } + assert!( + !body.contains("x-loki-via"), + "X-Loki-Via a traversé :\n{body}" + ); +} + +#[tokio::test] +async fn relaie_le_corps() { + let (f, _stop) = facade(backend().await).await; + let req = Request::post(format!("http://{f}/post")) + .body(Full::new(Bytes::from_static(b"{\"x\":1}"))) + .unwrap(); + let resp = client().request(req).await.unwrap(); + let body = resp.into_body().collect().await.unwrap().to_bytes(); + assert_eq!(&body[..], b"{\"x\":1}"); +} + +#[tokio::test] +async fn sse_sans_tampon() { + let (f, _stop) = facade(backend().await).await; + let t0 = Instant::now(); + let req = Request::get(format!("http://{f}/sse")) + .body(Full::new(Bytes::new())) + .unwrap(); + let mut body = client().request(req).await.unwrap().into_body(); + let first = body.frame().await.unwrap().unwrap().into_data().unwrap(); + let elapsed = t0.elapsed(); + assert_eq!(&first[..], b"data: un\n\n"); + assert!( + elapsed < Duration::from_millis(300), + "premier événement reçu après {elapsed:?}" + ); + let second = body.frame().await.unwrap().unwrap().into_data().unwrap(); + assert_eq!(&second[..], b"data: deux\n\n"); +} + +#[tokio::test] +async fn websocket_relaye() { + let (f, _stop) = facade(ws_backend().await).await; + let (mut ws, resp) = tokio_tungstenite::connect_async(format!("ws://{f}/api/node/ws")) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::SWITCHING_PROTOCOLS); + ws.send(Message::text("bonjour")).await.unwrap(); + let got = tokio::time::timeout(Duration::from_secs(5), ws.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert_eq!(got.into_text().unwrap().as_str(), "bonjour"); +} + +#[tokio::test] +async fn backend_absent_donne_502() { + // Port réservé puis libéré : personne n'y écoute. + let dead = TcpListener::bind("127.0.0.1:0") + .await + .unwrap() + .local_addr() + .unwrap(); + let (f, _stop) = facade(dead).await; + let req = Request::get(format!("http://{f}/")) + .body(Full::new(Bytes::new())) + .unwrap(); + let resp = client().request(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_GATEWAY); +} diff --git a/rust/loki-rs/tests/update.rs b/rust/loki-rs/tests/update.rs new file mode 100644 index 0000000..c5b2375 --- /dev/null +++ b/rust/loki-rs/tests/update.rs @@ -0,0 +1,148 @@ +mod common; + +use loki_rs::update::{self, Outcome, UpdateConfig, sha256_file, state}; +use std::collections::HashMap; +use std::path::Path; +use std::time::Duration; + +const GO: &[u8] = b"binaire go"; +const RS: &[u8] = b"binaire rust"; + +fn sha(b: &[u8]) -> String { + let t = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(t.path(), b).unwrap(); + sha256_file(t.path()).unwrap() +} + +fn release( + build: u64, + abi: u32, + go: &[u8], + rs: &[u8], + sums_go: Option<&str>, +) -> HashMap> { + let sums = format!( + "{} loki-linux\n{} loki-rs-linux\n", + sums_go.map(str::to_string).unwrap_or_else(|| sha(go)), + sha(rs) + ); + HashMap::from([ + ( + "manifest.json".into(), + format!(r#"{{"build":{build},"abi":{abi},"commit":"c{build}"}}"#).into_bytes(), + ), + ("SHA256SUMS".into(), sums.into_bytes()), + ("loki-linux".into(), go.to_vec()), + ("loki-rs-linux".into(), rs.to_vec()), + ]) +} + +fn cfg(url: String, data_bin: &Path) -> UpdateConfig { + UpdateConfig { + enabled: true, + base_url: url, + data_bin: data_bin.to_path_buf(), + image_build: 3, + image_abi: 1, + small_timeout: Duration::from_secs(10), + big_timeout: Duration::from_secs(10), + } +} + +#[test] +fn installe_puis_reconnait_a_jour() { + let srv = common::serve_files(release(7, 1, GO, RS, None)); + let t = tempfile::tempdir().unwrap(); + let c = cfg(srv.url(), t.path()); + + assert_eq!(update::run(&c), Outcome::Installed(7)); + let dir = state::select_dir(t.path(), Path::new("/img"), 3, 1); + assert_eq!(dir, t.path().join("7")); + assert_eq!(std::fs::read(dir.join("loki")).unwrap(), GO); + assert_eq!(std::fs::read(dir.join("loki-rs")).unwrap(), RS); + assert!(!t.path().join(".tmp-download").exists()); + + assert_eq!(update::run(&c), Outcome::UpToDate(7)); +} + +#[test] +fn nouveau_build_remplace_et_nettoie_l_ancien() { + let t = tempfile::tempdir().unwrap(); + let srv = common::serve_files(release(7, 1, GO, RS, None)); + assert_eq!( + update::run(&cfg(srv.url(), t.path())), + Outcome::Installed(7) + ); + let srv2 = common::serve_files(release(9, 1, b"go 9", b"rs 9", None)); + assert_eq!( + update::run(&cfg(srv2.url(), t.path())), + Outcome::Installed(9) + ); + assert!(!t.path().join("7").exists()); + assert_eq!( + state::select_dir(t.path(), Path::new("/img"), 3, 1), + t.path().join("9") + ); +} + +#[test] +fn empreinte_fausse_ne_change_rien() { + let t = tempfile::tempdir().unwrap(); + let srv = common::serve_files(release(7, 1, GO, RS, None)); + assert_eq!( + update::run(&cfg(srv.url(), t.path())), + Outcome::Installed(7) + ); + + let bad = "0".repeat(64); + let srv2 = common::serve_files(release(9, 1, b"go 9", b"rs 9", Some(&bad))); + assert!(matches!( + update::run(&cfg(srv2.url(), t.path())), + Outcome::Failed(_) + )); + assert_eq!( + state::select_dir(t.path(), Path::new("/img"), 3, 1), + t.path().join("7") + ); + assert!(!t.path().join("9").exists()); + assert!(!t.path().join(".tmp-download").exists()); +} + +#[test] +fn abi_differente_ne_telecharge_que_le_manifeste() { + let t = tempfile::tempdir().unwrap(); + let srv = common::serve_files(release(7, 2, GO, RS, None)); + assert_eq!( + update::run(&cfg(srv.url(), t.path())), + Outcome::AbiMismatch { + remote_abi: 2, + image_abi: 1 + } + ); + assert_eq!(srv.hits(), vec!["manifest.json".to_string()]); +} + +#[test] +fn image_plus_recente_que_la_release() { + let t = tempfile::tempdir().unwrap(); + let srv = common::serve_files(release(3, 1, GO, RS, None)); + assert_eq!(update::run(&cfg(srv.url(), t.path())), Outcome::UpToDate(3)); +} + +#[test] +fn serveur_absent() { + let t = tempfile::tempdir().unwrap(); + let mut c = cfg("http://127.0.0.1:9/rolling".into(), t.path()); + c.small_timeout = Duration::from_secs(3); + assert!(matches!(update::run(&c), Outcome::Failed(_))); +} + +#[test] +fn desactive_sans_requete() { + let t = tempfile::tempdir().unwrap(); + let srv = common::serve_files(release(7, 1, GO, RS, None)); + let mut c = cfg(srv.url(), t.path()); + c.enabled = false; + assert_eq!(update::run(&c), Outcome::Disabled); + assert!(srv.hits().is_empty()); +}