diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 9154b3659e..6a36a10ada 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -52,8 +52,10 @@ dependencies = [ "chrono", "cidr", "curl", + "env_logger", "futures-util", "home", + "httpmock", "jsonschema", "jsonwebtoken", "log", @@ -242,6 +244,35 @@ version = "1.0.86" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" +[[package]] +name = "ascii-canvas" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8824ecca2e851cec16968d54a01dd372ef8f95b244fb84b84e70128be347c3c6" +dependencies = [ + "term", +] + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "async-attributes" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3203e79f4dd9bdda415ed03cf14dae5a2bf775c683a00f94e9cd1faf0f596e5" +dependencies = [ + "quote", + "syn 1.0.109", +] + [[package]] name = "async-broadcast" version = "0.5.1" @@ -252,6 +283,17 @@ dependencies = [ "futures-core", ] +[[package]] +name = "async-channel" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" +dependencies = [ + "concurrent-queue", + "event-listener 2.5.3", + "futures-core", +] + [[package]] name = "async-channel" version = "2.3.1" @@ -277,6 +319,34 @@ dependencies = [ "tokio", ] +[[package]] +name = "async-executor" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7ebdfa2ebdab6b1760375fa7d6f382b9f486eac35fc994625a00e89280bdbb7" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand 2.1.0", + "futures-lite 2.3.0", + "slab", +] + +[[package]] +name = "async-global-executor" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" +dependencies = [ + "async-channel 2.3.1", + "async-executor", + "async-io 2.3.3", + "async-lock 3.4.0", + "blocking", + "futures-lite 2.3.0", + "once_cell", +] + [[package]] name = "async-io" version = "1.13.0" @@ -336,6 +406,15 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-object-pool" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeb901c30ebc2fc4ab46395bbfbdba9542c16559d853645d75190c3056caf3bc" +dependencies = [ + "async-std", +] + [[package]] name = "async-process" version = "1.8.1" @@ -382,6 +461,34 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "async-std" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62565bb4402e926b29953c785397c6dc0391b7b446e45008b0049eb43cec6f5d" +dependencies = [ + "async-attributes", + "async-channel 1.9.0", + "async-global-executor", + "async-io 1.13.0", + "async-lock 2.8.0", + "async-process", + "crossbeam-utils", + "futures-channel", + "futures-core", + "futures-io", + "futures-lite 1.13.0", + "gloo-timers", + "kv-log-macro", + "log", + "memchr", + "once_cell", + "pin-project-lite", + "pin-utils", + "slab", + "wasm-bindgen-futures", +] + [[package]] name = "async-stream" version = "0.3.5" @@ -548,6 +655,17 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "basic-cookies" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67bd8fd42c16bdb08688243dc5f0cc117a3ca9efeeaba3a345a18a6159ad96f7" +dependencies = [ + "lalrpop", + "lalrpop-util", + "regex", +] + [[package]] name = "bindgen" version = "0.69.4" @@ -557,7 +675,7 @@ dependencies = [ "bitflags 2.6.0", "cexpr", "clang-sys", - "itertools", + "itertools 0.12.1", "lazy_static", "lazycell", "proc-macro2", @@ -619,7 +737,7 @@ version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703f41c54fc768e63e091340b424302bb1c29ef4aa0c7f10fe849dfb114d29ea" dependencies = [ - "async-channel", + "async-channel 2.3.1", "async-task", "futures-io", "futures-lite 2.3.0", @@ -1076,6 +1194,27 @@ dependencies = [ "subtle", ] +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + [[package]] name = "dlv-list" version = "0.5.2" @@ -1097,6 +1236,15 @@ version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" +[[package]] +name = "ena" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d248bdd43ce613d87415282f69b9bb99d947d290b10962dd6c56233312c2ad5" +dependencies = [ + "log", +] + [[package]] name = "encode_unicode" version = "0.3.6" @@ -1133,6 +1281,29 @@ dependencies = [ "syn 2.0.72", ] +[[package]] +name = "env_filter" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f2c92ceda6ceec50f43169f9ee8424fe2db276791afde7b2cd8bc084cb376ab" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13fa619b91fb2381732789fc5de83b45675e882f66623b7d8cb4f643017018d" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "humantime", + "log", +] + [[package]] name = "equivalent" version = "1.0.1" @@ -1212,6 +1383,12 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a" +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "flate2" version = "1.0.30" @@ -1310,7 +1487,10 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52527eb5074e35e9339c6b4e8d12600c7128b68fb25dcb9fa9dec18f7c25f3a5" dependencies = [ + "fastrand 2.1.0", "futures-core", + "futures-io", + "parking", "pin-project-lite", ] @@ -1426,6 +1606,18 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" +[[package]] +name = "gloo-timers" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b995a66bb87bebce9a0f4a95aed01daca4872c050bfcb21653361c03bc35e5c" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "h2" version = "0.3.26" @@ -1622,6 +1814,40 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "httpmock" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08ec9586ee0910472dec1a1f0f8acf52f0fdde93aea74d70d4a3107b4be0fd5b" +dependencies = [ + "assert-json-diff", + "async-object-pool", + "async-std", + "async-trait", + "base64 0.21.7", + "basic-cookies", + "crossbeam-utils", + "form_urlencoded", + "futures-util", + "hyper 0.14.30", + "lazy_static", + "levenshtein", + "log", + "regex", + "serde", + "serde_json", + "serde_regex", + "similar", + "tokio", + "url", +] + +[[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + [[package]] name = "hyper" version = "0.14.30" @@ -1874,6 +2100,15 @@ dependencies = [ "nom", ] +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.12.1" @@ -1951,6 +2186,46 @@ dependencies = [ "simple_asn1", ] +[[package]] +name = "kv-log-macro" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" +dependencies = [ + "log", +] + +[[package]] +name = "lalrpop" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55cb077ad656299f160924eb2912aa147d7339ea7d69e1b5517326fdcec3c1ca" +dependencies = [ + "ascii-canvas", + "bit-set", + "ena", + "itertools 0.11.0", + "lalrpop-util", + "petgraph", + "pico-args", + "regex", + "regex-syntax", + "string_cache", + "term", + "tiny-keccak", + "unicode-xid", + "walkdir", +] + +[[package]] +name = "lalrpop-util" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507460a910eb7b32ee961886ff48539633b788a36b65692b95f225b844c82553" +dependencies = [ + "regex-automata", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1963,12 +2238,28 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" +[[package]] +name = "levenshtein" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db13adb97ab515a3691f56e4dbab09283d0b86cb45abd991d8634a9d6f501760" + [[package]] name = "libc" version = "0.2.155" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" +[[package]] +name = "libredox" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +dependencies = [ + "bitflags 2.6.0", + "libc", +] + [[package]] name = "libsystemd" version = "0.7.0" @@ -2045,6 +2336,9 @@ name = "log" version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +dependencies = [ + "value-bag", +] [[package]] name = "macaddr" @@ -2166,6 +2460,12 @@ dependencies = [ "tempfile", ] +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + [[package]] name = "newline-converter" version = "0.3.0" @@ -2563,13 +2863,23 @@ dependencies = [ "sha2", ] +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset", + "indexmap 2.2.6", +] + [[package]] name = "phf" version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc" dependencies = [ - "phf_shared", + "phf_shared 0.11.2", ] [[package]] @@ -2579,7 +2889,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8d39688d359e6b34654d328e262234662d16cc0f60ec8dcbe5e718709342a5a" dependencies = [ "phf_generator", - "phf_shared", + "phf_shared 0.11.2", ] [[package]] @@ -2588,10 +2898,19 @@ version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48e4cc64c2ad9ebe670cb8fd69dd50ae301650392e81c05f9bfcb2d5bdbc24b0" dependencies = [ - "phf_shared", + "phf_shared 0.11.2", "rand", ] +[[package]] +name = "phf_shared" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +dependencies = [ + "siphasher", +] + [[package]] name = "phf_shared" version = "0.11.2" @@ -2601,6 +2920,12 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + [[package]] name = "pin-project" version = "1.1.5" @@ -2699,6 +3024,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -2816,6 +3147,17 @@ dependencies = [ "bitflags 2.6.0", ] +[[package]] +name = "redox_users" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd283d9651eeda4b2a83a43c1c91b266c40fd76ecd39a50a8c630ae69dc72891" +dependencies = [ + "getrandom", + "libredox", + "thiserror", +] + [[package]] name = "regex" version = "1.10.5" @@ -3067,6 +3409,15 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.23" @@ -3147,6 +3498,16 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_regex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8136f1a4ea815d7eac4101cfd0b16dc0cb5e1fe1b8609dfd728058656b7badf" +dependencies = [ + "regex", + "serde", +] + [[package]] name = "serde_repr" version = "0.1.19" @@ -3289,6 +3650,12 @@ dependencies = [ "libc", ] +[[package]] +name = "similar" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1de1d4f81173b03af4c0cbed3c898f6bff5b870e4a7f5d6f4057d62a7a4b686e" + [[package]] name = "simple_asn1" version = "0.6.2" @@ -3354,6 +3721,19 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "string_cache" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f91138e76242f575eb1d3b38b4f1362f10d3a43f47d182a5b359af488a02293b" +dependencies = [ + "new_debug_unreachable", + "once_cell", + "parking_lot", + "phf_shared 0.10.0", + "precomputed-hash", +] + [[package]] name = "strsim" version = "0.11.1" @@ -3449,6 +3829,17 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "term" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f" +dependencies = [ + "dirs-next", + "rustversion", + "winapi", +] + [[package]] name = "terminal_size" version = "0.3.0" @@ -3914,6 +4305,12 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0336d538f7abc86d282a4189614dfaa90810dfc2c6f6427eaf88e16311dd225d" +[[package]] +name = "unicode-xid" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f962df74c8c05a667b5ee8bcf162993134c104e96440b663c8daa176dc772d8c" + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -4000,6 +4397,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" +[[package]] +name = "value-bag" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a84c137d37ab0142f0f2ddfe332651fdbf252e7b7dbb4e67b6c1f1b2e925101" + [[package]] name = "vcpkg" version = "0.2.15" @@ -4018,6 +4421,16 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -4125,6 +4538,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d4cc384e1e73b93bafa6fb4f1df8c41695c8a91cf9c4c64358067d15a7b6c6b" +dependencies = [ + "windows-sys 0.52.0", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" diff --git a/rust/agama-cli/src/config.rs b/rust/agama-cli/src/config.rs index 538d05b886..7ae2e2c1ab 100644 --- a/rust/agama-cli/src/config.rs +++ b/rust/agama-cli/src/config.rs @@ -43,7 +43,7 @@ pub enum ConfigCommands { pub async fn run(subcommand: ConfigCommands) -> anyhow::Result<()> { let Some(token) = AuthToken::find() else { - println!("You need to login for generating a valid token"); + println!("You need to login for generating a valid token: agama auth login"); return Ok(()); }; diff --git a/rust/agama-cli/src/profile.rs b/rust/agama-cli/src/profile.rs index c264e777d4..d2f0aa559b 100644 --- a/rust/agama-cli/src/profile.rs +++ b/rust/agama-cli/src/profile.rs @@ -38,7 +38,7 @@ pub enum ProfileCommands { /// Evaluate a profile, injecting the hardware information from D-Bus /// /// For an example of Jsonnet-based profile, see - /// https://github.com/openSUSE/agama/blob/master/rust/agama-lib/share/examples/profile.jsonnet + /// Evaluate { /// Path to jsonnet file. path: PathBuf, diff --git a/rust/agama-cli/src/questions.rs b/rust/agama-cli/src/questions.rs index 6d0d9c05a8..07d60e1198 100644 --- a/rust/agama-cli/src/questions.rs +++ b/rust/agama-cli/src/questions.rs @@ -15,7 +15,7 @@ pub enum QuestionsCommands { /// mode or change the answer in automatic mode. /// /// Please check Agama documentation for more details and examples: - /// https://github.com/openSUSE/agama/blob/master/doc/questions.md + /// Answers { /// Path to a file containing the answers in YAML format. path: String, @@ -55,7 +55,7 @@ async fn set_answers(proxy: Questions1Proxy<'_>, path: String) -> Result<(), Ser } async fn list_questions() -> Result<(), ServiceError> { - let client = HTTPClient::new().await?; + let client = HTTPClient::new()?; let questions = client.list_questions().await?; // FIXME: if performance is bad, we can skip converting json from http to struct and then // serialize it, but it won't be pretty string @@ -66,7 +66,7 @@ async fn list_questions() -> Result<(), ServiceError> { } async fn ask_question() -> Result<(), ServiceError> { - let client = HTTPClient::new().await?; + let client = HTTPClient::new()?; let question = serde_json::from_reader(std::io::stdin())?; let created_question = client.create_question(&question).await?; diff --git a/rust/agama-lib/Cargo.toml b/rust/agama-lib/Cargo.toml index e6101ba679..c50ea7f2ad 100644 --- a/rust/agama-lib/Cargo.toml +++ b/rust/agama-lib/Cargo.toml @@ -28,3 +28,7 @@ curl = { version = "0.4.44", features = ["protocol-ftp"] } jsonwebtoken = "9.3.0" chrono = { version = "0.4.38", default-features = false, features = ["now", "std", "alloc", "clock"] } home = "0.5.9" + +[dev-dependencies] +httpmock = "0.7.0" +env_logger = "0.11.5" diff --git a/rust/agama-lib/src/auth.rs b/rust/agama-lib/src/auth.rs index ab48f8a399..b2da8b288c 100644 --- a/rust/agama-lib/src/auth.rs +++ b/rust/agama-lib/src/auth.rs @@ -166,7 +166,7 @@ impl Display for AuthToken { /// Claims that are included in the token. /// -/// See https://datatracker.ietf.org/doc/html/rfc7519 for reference. +/// See for reference. #[derive(Debug, Serialize, Deserialize)] pub struct TokenClaims { pub exp: i64, diff --git a/rust/agama-lib/src/base_http_client.rs b/rust/agama-lib/src/base_http_client.rs index 1f78c2e040..a080ac4420 100644 --- a/rust/agama-lib/src/base_http_client.rs +++ b/rust/agama-lib/src/base_http_client.rs @@ -56,7 +56,7 @@ impl BaseHTTPClient { let mut headers = header::HeaderMap::new(); // just use generic anyhow error here as Bearer format is constructed by us, so failures can come only from token let value = header::HeaderValue::from_str(format!("Bearer {}", token).as_str()) - .map_err(|e| anyhow::Error::new(e))?; + .map_err(anyhow::Error::new)?; headers.insert(header::AUTHORIZATION, value); @@ -66,40 +66,36 @@ impl BaseHTTPClient { Ok(client) } - /// Simple wrapper around [`Response`] to get object from response. - /// - /// If a complete [`Response`] is needed, use the [`Self::get_response`] method. - /// - /// Arguments: - /// - /// * `path`: path relative to HTTP API like `/questions` - pub async fn get(&self, path: &str) -> Result { - let response = self.get_response(path).await?; - if response.status().is_success() { - response.json::().await.map_err(|e| e.into()) - } else { - Err(self.build_backend_error(response).await) - } + fn url(&self, path: &str) -> String { + self.base_url.clone() + path } - /// Calls GET method on the given path and returns [`Response`] that can be further - /// processed. - /// - /// If only simple object from JSON is required, use method get. + /// Simple wrapper around [`Response`] to get object from response. /// /// Arguments: /// /// * `path`: path relative to HTTP API like `/questions` - pub async fn get_response(&self, path: &str) -> Result { - self.client + pub async fn get(&self, path: &str) -> Result + where + T: DeserializeOwned, + { + let response: Result<_, ServiceError> = self + .client .get(self.url(path)) .send() .await - .map_err(|e| e.into()) + .map_err(|e| e.into()); + self.deserialize_or_error(response?).await } - fn url(&self, path: &str) -> String { - self.base_url.clone() + path + pub async fn post(&self, path: &str, object: &impl Serialize) -> Result + where + T: DeserializeOwned, + { + let response = self + .request_response(reqwest::Method::POST, path, object) + .await?; + self.deserialize_or_error(response).await } /// post object to given path and report error if response is not success @@ -108,35 +104,67 @@ impl BaseHTTPClient { /// /// * `path`: path relative to HTTP API like `/questions` /// * `object`: Object that can be serialiazed to JSON as body of request. - pub async fn post(&self, path: &str, object: &impl Serialize) -> Result<(), ServiceError> { - let response = self.post_response(path, object).await?; - if response.status().is_success() { - Ok(()) - } else { - Err(self.build_backend_error(response).await) - } + pub async fn post_void(&self, path: &str, object: &impl Serialize) -> Result<(), ServiceError> { + let response = self + .request_response(reqwest::Method::POST, path, object) + .await?; + self.unit_or_error(response).await } - /// post object to given path and returns server response. Reports error only if failed to send - /// request, but if server returns e.g. 500, it will be in Ok result. + /// put object to given path, deserializes the response /// - /// In general unless specific response handling is needed, simple post should be used. + /// Arguments: + /// + /// * `path`: path relative to HTTP API like `/users/first` + /// * `object`: Object that can be serialiazed to JSON as body of request. + pub async fn put(&self, path: &str, object: &impl Serialize) -> Result + where + T: DeserializeOwned, + { + let response = self + .request_response(reqwest::Method::PUT, path, object) + .await?; + self.deserialize_or_error(response).await + } + + /// put object to given path and report error if response is not success /// /// Arguments: /// - /// * `path`: path relative to HTTP API like `/questions` + /// * `path`: path relative to HTTP API like `/users/first` + /// * `object`: Object that can be serialiazed to JSON as body of request. + pub async fn put_void(&self, path: &str, object: &impl Serialize) -> Result<(), ServiceError> { + let response = self + .request_response(reqwest::Method::PUT, path, object) + .await?; + self.unit_or_error(response).await + } + + /// patch object at given path and report error if response is not success + /// + /// Arguments: + /// + /// * `path`: path relative to HTTP API like `/users/first` /// * `object`: Object that can be serialiazed to JSON as body of request. - pub async fn post_response( + pub async fn patch(&self, path: &str, object: &impl Serialize) -> Result + where + T: DeserializeOwned, + { + let response = self + .request_response(reqwest::Method::PATCH, path, object) + .await?; + self.deserialize_or_error(response).await + } + + pub async fn patch_void( &self, path: &str, object: &impl Serialize, - ) -> Result { - self.client - .post(self.url(path)) - .json(object) - .send() - .await - .map_err(|e| e.into()) + ) -> Result<(), ServiceError> { + let response = self + .request_response(reqwest::Method::PATCH, path, object) + .await?; + self.unit_or_error(response).await } /// delete call on given path and report error if failed @@ -144,41 +172,86 @@ impl BaseHTTPClient { /// Arguments: /// /// * `path`: path relative to HTTP API like `/questions/1` - pub async fn delete(&self, path: &str) -> Result<(), ServiceError> { - let response = self.delete_response(path).await?; - if response.status().is_success() { - Ok(()) - } else { - Err(self.build_backend_error(response).await) - } + pub async fn delete_void(&self, path: &str) -> Result<(), ServiceError> { + let response: Result<_, ServiceError> = self + .client + .delete(self.url(path)) + .send() + .await + .map_err(|e| e.into()); + self.unit_or_error(response?).await } - /// delete call on given path and returns server response. Reports error only if failed to send + /// POST/PUT/PATCH an object to a given path and returns server response. + /// Reports Err only if failed to send /// request, but if server returns e.g. 500, it will be in Ok result. /// - /// In general unless specific response handling is needed, simple delete should be used. - /// TODO: do not need variant with request body? if so, then create additional method. + /// In general unless specific response handling is needed, simple post should be used. /// /// Arguments: /// - /// * `path`: path relative to HTTP API like `/questions/1` - pub async fn delete_response(&self, path: &str) -> Result { + /// * `method`: for example `reqwest::Method::PUT` + /// * `path`: path relative to HTTP API like `/questions` + /// * `object`: Object that can be serialiazed to JSON as body of request. + async fn request_response( + &self, + method: reqwest::Method, + path: &str, + object: &impl Serialize, + ) -> Result { self.client - .delete(self.url(path)) + .request(method, self.url(path)) + .json(object) .send() .await .map_err(|e| e.into()) } + /// Return deserialized JSON body as `Ok(T)` or an `Err` with [`ServiceError::BackendError`] + async fn deserialize_or_error(&self, response: Response) -> Result + where + T: DeserializeOwned, + { + // DEBUG: This dbg is nice but it omits the body, thus we try harder below + // let response = dbg!(response); + + if response.status().is_success() { + // We'd like to simply: + // response.json::().await.map_err(|e| e.into()) + // BUT also peek into the response text, in case something is wrong + // so this copies the implementation from the above and adds a debug part + + let bytes_r: Result<_, ServiceError> = response.bytes().await.map_err(|e| e.into()); + let bytes = bytes_r?; + + // DEBUG: (we expect JSON so dbg! would escape too much, eprintln! is better) + // let text = String::from_utf8_lossy(&bytes); + // eprintln!("Response body: {}", text); + + serde_json::from_slice(&bytes).map_err(|e| e.into()) + } else { + Err(self.build_backend_error(response).await) + } + } + + /// Return `Ok(())` or an `Err` with [`ServiceError::BackendError`] + async fn unit_or_error(&self, response: Response) -> Result<(), ServiceError> { + if response.status().is_success() { + Ok(()) + } else { + Err(self.build_backend_error(response).await) + } + } + const NO_TEXT: &'static str = "(Failed to extract error text from HTTP response)"; - /// Builds [`BackendError`] from response. + /// Builds [`ServiceError::BackendError`] from response. /// /// It contains also processing of response body, that is why it has to be async. /// /// Arguments: /// /// * `response`: response from which generate error - pub async fn build_backend_error(&self, response: Response) -> ServiceError { + async fn build_backend_error(&self, response: Response) -> ServiceError { let code = response.status().as_u16(); let text = response .text() diff --git a/rust/agama-lib/src/dbus.rs b/rust/agama-lib/src/dbus.rs index 6ff2e6eab4..3d07027a5e 100644 --- a/rust/agama-lib/src/dbus.rs +++ b/rust/agama-lib/src/dbus.rs @@ -58,7 +58,7 @@ macro_rules! property_from_dbus { /// NOTE: we could follow a different approach like building our own type (e.g. /// using the newtype idiom) and offering a better API. /// -/// * `source`: hash map containing non-onwed values ([zbus::zvariant::Value]). +/// * `source`: hash map containing non-onwed values ([enum@zbus::zvariant::Value]). pub fn to_owned_hash(source: &HashMap<&str, Value<'_>>) -> HashMap { let mut owned = HashMap::new(); for (key, value) in source.iter() { diff --git a/rust/agama-lib/src/lib.rs b/rust/agama-lib/src/lib.rs index 3dfffd3e07..1bbc4c48c8 100644 --- a/rust/agama-lib/src/lib.rs +++ b/rust/agama-lib/src/lib.rs @@ -3,7 +3,7 @@ //! This library offers an API to interact with Agama services. At this point, the library allows: //! //! * Reading and writing [installation settings](install_settings::InstallSettings). -//! * Monitoring the [progress](progress). +//! * Monitoring the [progress]. //! * Triggering actions through the [manager] (e.g., starting installation). //! //! ## Handling installation settings diff --git a/rust/agama-lib/src/localization.rs b/rust/agama-lib/src/localization.rs index 65bb1ae8bc..a53402db98 100644 --- a/rust/agama-lib/src/localization.rs +++ b/rust/agama-lib/src/localization.rs @@ -1,11 +1,12 @@ //! Implements support for handling the localization settings -mod client; +mod http_client; +pub mod model; mod proxies; mod settings; mod store; -pub use client::LocalizationClient; +pub use http_client::LocalizationHTTPClient; pub use proxies::LocaleProxy; pub use settings::LocalizationSettings; pub use store::LocalizationStore; diff --git a/rust/agama-lib/src/localization/client.rs b/rust/agama-lib/src/localization/client.rs deleted file mode 100644 index 2b15ea2c78..0000000000 --- a/rust/agama-lib/src/localization/client.rs +++ /dev/null @@ -1,54 +0,0 @@ -use super::proxies::LocaleProxy; -use crate::error::ServiceError; -use zbus::Connection; - -/// D-Bus client for the software service -#[derive(Clone)] -pub struct LocalizationClient<'a> { - localization_proxy: LocaleProxy<'a>, -} - -impl<'a> LocalizationClient<'a> { - pub async fn new(connection: Connection) -> Result, ServiceError> { - Ok(Self { - localization_proxy: LocaleProxy::new(&connection).await?, - }) - } - - pub async fn language(&self) -> Result, ServiceError> { - let locales = self.localization_proxy.locales().await?; - let mut iter = locales.into_iter(); - let first = iter.next(); - // may be None - Ok(first) - } - - pub async fn locales(&self) -> zbus::Result> { - self.localization_proxy.locales().await - } - - pub async fn keyboard(&self) -> Result { - Ok(self.localization_proxy.keymap().await?) - } - - pub async fn timezone(&self) -> Result { - Ok(self.localization_proxy.timezone().await?) - } - - pub async fn set_language(&self, language: &str) -> zbus::Result<()> { - let locales = [language]; - self.localization_proxy.set_locales(&locales).await - } - - pub async fn set_locales(&self, locales: &[&str]) -> zbus::Result<()> { - self.localization_proxy.set_locales(locales).await - } - - pub async fn set_keyboard(&self, keyboard: &str) -> zbus::Result<()> { - self.localization_proxy.set_keymap(keyboard).await - } - - pub async fn set_timezone(&self, timezone: &str) -> zbus::Result<()> { - self.localization_proxy.set_timezone(timezone).await - } -} diff --git a/rust/agama-lib/src/localization/http_client.rs b/rust/agama-lib/src/localization/http_client.rs new file mode 100644 index 0000000000..a40adae13c --- /dev/null +++ b/rust/agama-lib/src/localization/http_client.rs @@ -0,0 +1,26 @@ +use super::model::LocaleConfig; +use crate::{base_http_client::BaseHTTPClient, error::ServiceError}; + +pub struct LocalizationHTTPClient { + client: BaseHTTPClient, +} + +impl LocalizationHTTPClient { + pub fn new() -> Result { + Ok(Self { + client: BaseHTTPClient::new()?, + }) + } + + pub fn new_with_base(base: BaseHTTPClient) -> Result { + Ok(Self { client: base }) + } + + pub async fn get_config(&self) -> Result { + self.client.get("/l10n/config").await + } + + pub async fn set_config(&self, config: &LocaleConfig) -> Result<(), ServiceError> { + self.client.patch_void("/l10n/config", config).await + } +} diff --git a/rust/agama-lib/src/localization/model.rs b/rust/agama-lib/src/localization/model.rs new file mode 100644 index 0000000000..0fd38c4661 --- /dev/null +++ b/rust/agama-lib/src/localization/model.rs @@ -0,0 +1,16 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct LocaleConfig { + /// Locales to install in the target system + pub locales: Option>, + /// Keymap for the target system + pub keymap: Option, + /// Timezone for the target system + pub timezone: Option, + /// User-interface locale. It is actually not related to the `locales` property. + pub ui_locale: Option, + /// User-interface locale. It is relevant only on local installations. + pub ui_keymap: Option, +} diff --git a/rust/agama-lib/src/localization/settings.rs b/rust/agama-lib/src/localization/settings.rs index a3692c8ddf..d153c9706b 100644 --- a/rust/agama-lib/src/localization/settings.rs +++ b/rust/agama-lib/src/localization/settings.rs @@ -3,7 +3,8 @@ use serde::{Deserialize, Serialize}; /// Localization settings for the system being installed (not the UI) -#[derive(Debug, Default, Serialize, Deserialize)] +/// FIXME: this one is close to CLI. A possible duplicate close to HTTP is LocaleConfig +#[derive(Debug, Default, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct LocalizationSettings { /// like "en_US.UTF-8" diff --git a/rust/agama-lib/src/localization/store.rs b/rust/agama-lib/src/localization/store.rs index 8ae25f7f43..b74d1a91d8 100644 --- a/rust/agama-lib/src/localization/store.rs +++ b/rust/agama-lib/src/localization/store.rs @@ -1,51 +1,150 @@ //! Implements the store for the localization settings. // TODO: for an overview see crate::store (?) -use super::{LocalizationClient, LocalizationSettings}; +use super::{LocalizationHTTPClient, LocalizationSettings}; use crate::error::ServiceError; -use zbus::Connection; +use crate::localization::model::LocaleConfig; /// Loads and stores the storage settings from/to the D-Bus service. -pub struct LocalizationStore<'a> { - localization_client: LocalizationClient<'a>, +pub struct LocalizationStore { + localization_client: LocalizationHTTPClient, } -impl<'a> LocalizationStore<'a> { - pub async fn new(connection: Connection) -> Result, ServiceError> { +impl LocalizationStore { + pub fn new() -> Result { Ok(Self { - localization_client: LocalizationClient::new(connection).await?, + localization_client: LocalizationHTTPClient::new()?, }) } + pub fn new_with_client( + client: LocalizationHTTPClient, + ) -> Result { + Ok(Self { + localization_client: client, + }) + } + + /// Consume *v* and return its first element, or None. + /// This is similar to VecDeque::pop_front but it consumes the whole Vec. + fn chestburster(mut v: Vec) -> Option { + if v.is_empty() { + None + } else { + Some(v.swap_remove(0)) + } + } + pub async fn load(&self) -> Result { - // TODO: we should use a single D-Bus call with Properties.GetAll - // but LocaleProxy does not have it, only get_property for individual methods - // and properties_proxy is private + let config = self.localization_client.get_config().await?; - let opt_language = self.localization_client.language().await?; - let keyboard = self.localization_client.keyboard().await?; - let timezone = self.localization_client.timezone().await?; + let opt_language = config.locales.and_then(Self::chestburster); + let opt_keyboard = config.keymap; + let opt_timezone = config.timezone; Ok(LocalizationSettings { language: opt_language, - keyboard: Some(keyboard), - timezone: Some(timezone), + keyboard: opt_keyboard, + timezone: opt_timezone, }) } pub async fn store(&self, settings: &LocalizationSettings) -> Result<(), ServiceError> { - if let Some(language) = &settings.language { - self.localization_client.set_language(language).await?; - } + // clones are necessary as we have different structs owning their data + let opt_language = settings.language.clone(); + let opt_keymap = settings.keyboard.clone(); + let opt_timezone = settings.timezone.clone(); - if let Some(keyboard) = &settings.keyboard { - self.localization_client.set_keyboard(keyboard).await?; - } + let config = LocaleConfig { + locales: opt_language.map(|s| vec![s]), + keymap: opt_keymap, + timezone: opt_timezone, + ui_locale: None, + ui_keymap: None, + }; + self.localization_client.set_config(&config).await + } +} - if let Some(timezone) = &settings.timezone { - self.localization_client.set_timezone(timezone).await?; - } +#[cfg(test)] +mod test { + use super::*; + use crate::base_http_client::BaseHTTPClient; + use httpmock::prelude::*; + use httpmock::Method::PATCH; + use std::error::Error; + use tokio::test; // without this, "error: async functions cannot be used for tests" + + async fn localization_store( + mock_server_url: String, + ) -> Result { + let mut bhc = BaseHTTPClient::default(); + bhc.base_url = mock_server_url; + let client = LocalizationHTTPClient::new_with_base(bhc)?; + LocalizationStore::new_with_client(client) + } + + #[test] + async fn test_getting_l10n() -> Result<(), Box> { + let server = MockServer::start(); + let l10n_mock = server.mock(|when, then| { + when.method(GET).path("/api/l10n/config"); + then.status(200) + .header("content-type", "application/json") + .body( + r#"{ + "locales": ["fr_FR.UTF-8"], + "keymap": "fr(dvorak)", + "timezone": "Europe/Paris" + }"#, + ); + }); + let url = server.url("/api"); + + let store = localization_store(url).await?; + let settings = store.load().await?; + + let expected = LocalizationSettings { + language: Some("fr_FR.UTF-8".to_owned()), + keyboard: Some("fr(dvorak)".to_owned()), + timezone: Some("Europe/Paris".to_owned()), + }; + // main assertion + assert_eq!(settings, expected); + + // Ensure the specified mock was called exactly one time (or fail with a detailed error description). + l10n_mock.assert(); + Ok(()) + } + + #[test] + async fn test_setting_l10n() -> Result<(), Box> { + let server = MockServer::start(); + let l10n_mock = server.mock(|when, then| { + when.method(PATCH) + .path("/api/l10n/config") + .header("content-type", "application/json") + .body( + r#"{"locales":["fr_FR.UTF-8"],"keymap":"fr(dvorak)","timezone":"Europe/Paris","uiLocale":null,"uiKeymap":null}"# + ); + then.status(204); + }); + let url = server.url("/api"); + + let store = localization_store(url).await?; + + let settings = LocalizationSettings { + language: Some("fr_FR.UTF-8".to_owned()), + keyboard: Some("fr(dvorak)".to_owned()), + timezone: Some("Europe/Paris".to_owned()), + }; + let result = store.store(&settings).await; + + // main assertion + result?; + // Ensure the specified mock was called exactly one time (or fail with a detailed error description). + l10n_mock.assert(); Ok(()) } } diff --git a/rust/agama-lib/src/questions/http_client.rs b/rust/agama-lib/src/questions/http_client.rs index 0be917d658..a075038ea6 100644 --- a/rust/agama-lib/src/questions/http_client.rs +++ b/rust/agama-lib/src/questions/http_client.rs @@ -12,7 +12,7 @@ pub struct HTTPClient { } impl HTTPClient { - pub async fn new() -> Result { + pub fn new() -> Result { Ok(Self { client: BaseHTTPClient::new()?, }) @@ -24,26 +24,22 @@ impl HTTPClient { /// Creates question and return newly created question including id pub async fn create_question(&self, question: &Question) -> Result { - let response = self.client.post_response("/questions", question).await?; - if response.status().is_success() { - let question = response.json().await?; - Ok(question) - } else { - Err(self.client.build_backend_error(response).await) - } + self.client.post("/questions", question).await } /// non blocking varient of checking if question has already answer pub async fn try_answer(&self, question_id: u32) -> Result, ServiceError> { let path = format!("/questions/{}/answer", question_id); - let response = self.client.get_response(path.as_str()).await?; - if response.status() == StatusCode::NOT_FOUND { - Ok(None) - } else if response.status().is_success() { - let answer = response.json().await?; - Ok(answer) - } else { - Err(self.client.build_backend_error(response).await) + let result: Result, _> = self.client.get(path.as_str()).await; + match result { + Err(ServiceError::BackendError(code, ref _body_s)) => { + if code == StatusCode::NOT_FOUND { + Ok(None) // no answer yet, fine + } else { + result // pass error + } + } + _ => result, // pass answer } } @@ -64,6 +60,157 @@ impl HTTPClient { pub async fn delete_question(&self, question_id: u32) -> Result<(), ServiceError> { let path = format!("/questions/{}", question_id); - self.client.delete(path.as_str()).await + self.client.delete_void(path.as_str()).await + } +} + +#[cfg(test)] +mod test { + use super::model::{GenericAnswer, GenericQuestion}; + use super::*; + use crate::base_http_client::BaseHTTPClient; + use httpmock::prelude::*; + use std::collections::HashMap; + use std::error::Error; + use tokio::test; // without this, "error: async functions cannot be used for tests" + + fn questions_client(mock_server_url: String) -> HTTPClient { + let mut bhc = BaseHTTPClient::default(); + bhc.base_url = mock_server_url; + HTTPClient { client: bhc } + } + + #[test] + async fn test_list_questions() -> Result<(), Box> { + let server = MockServer::start(); + let client = questions_client(server.url("/api")); + + let mock = server.mock(|when, then| { + when.method(GET).path("/api/questions"); + then.status(200) + .header("content-type", "application/json") + .body( + r#"[ + { + "generic": { + "id": 42, + "class": "foo", + "text": "Shape", + "options": ["bouba","kiki"], + "defaultOption": "bouba", + "data": { "a": "A" } + }, + "withPassword":null + } + ]"#, + ); + }); + + let expected: Vec = vec![Question { + generic: GenericQuestion { + id: Some(42), + class: "foo".to_owned(), + text: "Shape".to_owned(), + options: vec!["bouba".to_owned(), "kiki".to_owned()], + default_option: "bouba".to_owned(), + data: HashMap::from([("a".to_owned(), "A".to_owned())]), + }, + with_password: None, + }]; + let actual = client.list_questions().await?; + assert_eq!(actual, expected); + + mock.assert(); + Ok(()) + } + + #[test] + async fn test_create_question() -> Result<(), Box> { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(POST) + .path("/api/questions") + .header("content-type", "application/json") + .body( + r#"{"generic":{"id":null,"class":"fiction.hamlet","text":"To be or not to be","options":["to be","not to be"],"defaultOption":"to be","data":{"a":"A"}},"withPassword":null}"# + ); + then.status(200) + .header("content-type", "application/json") + .body( + r#"{ + "generic": { + "id": 7, + "class": "fiction.hamlet", + "text": "To be or not to be", + "options": ["to be","not to be"], + "defaultOption": "to be", + "data": { "a": "A" } + }, + "withPassword":null + }"#, + ); + }); + let client = questions_client(server.url("/api")); + + let posted_question = Question { + generic: GenericQuestion { + id: None, + class: "fiction.hamlet".to_owned(), + text: "To be or not to be".to_owned(), + options: vec!["to be".to_owned(), "not to be".to_owned()], + default_option: "to be".to_owned(), + data: HashMap::from([("a".to_owned(), "A".to_owned())]), + }, + with_password: None, + }; + let mut expected_question = posted_question.clone(); + expected_question.generic.id = Some(7); + + let actual = client.create_question(&posted_question).await?; + assert_eq!(actual, expected_question); + + // Ensure the specified mock was called exactly one time (or fail with a detailed error description). + mock.assert(); + Ok(()) + } + + #[test] + async fn test_try_answer() -> Result<(), Box> { + let server = MockServer::start(); + let client = questions_client(server.url("/api")); + + let mock = server.mock(|when, then| { + when.method(GET).path("/api/questions/42/answer"); + then.status(200) + .header("content-type", "application/json") + .body( + r#"{ + "generic": { + "answer": "maybe" + }, + "withPassword":null + }"#, + ); + }); + + let expected = Some(Answer { + generic: GenericAnswer { + answer: "maybe".to_owned(), + }, + with_password: None, + }); + let actual = client.try_answer(42).await?; + assert_eq!(actual, expected); + + let mock2 = server.mock(|when, then| { + when.method(GET).path("/api/questions/666/answer"); + then.status(404); + }); + let actual = client.try_answer(666).await?; + assert_eq!(actual, None); + + mock.assert(); + mock2.assert(); + Ok(()) } } diff --git a/rust/agama-lib/src/questions/model.rs b/rust/agama-lib/src/questions/model.rs index df35c0763f..b80966a793 100644 --- a/rust/agama-lib/src/questions/model.rs +++ b/rust/agama-lib/src/questions/model.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; -#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct Question { pub generic: GenericQuestion, @@ -16,7 +16,7 @@ pub struct Question { /// API which has both as attributes, but web API separate /// question and its answer. So here it is split into GenericQuestion /// and GenericAnswer -#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct GenericQuestion { /// id is optional as newly created questions does not have it assigned @@ -38,11 +38,11 @@ pub struct GenericQuestion { /// Also note that question is empty as QuestionWithPassword does not /// provide more details for question, but require additional answer. /// Can be potentionally extended in future e.g. with list of allowed characters? -#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct QuestionWithPassword {} -#[derive(Default, Clone, Serialize, Deserialize, utoipa::ToSchema)] +#[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct Answer { pub generic: GenericAnswer, @@ -50,14 +50,14 @@ pub struct Answer { } /// Answer needed for GenericQuestion -#[derive(Default, Clone, Serialize, Deserialize, utoipa::ToSchema)] +#[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct GenericAnswer { pub answer: String, } /// Answer needed for Password specific questions. -#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct PasswordAnswer { pub password: String, diff --git a/rust/agama-lib/src/store.rs b/rust/agama-lib/src/store.rs index 9add8349c6..addf98f8ae 100644 --- a/rust/agama-lib/src/store.rs +++ b/rust/agama-lib/src/store.rs @@ -16,12 +16,12 @@ use zbus::Connection; /// /// This struct uses the default connection built by [connection function](super::connection). pub struct Store<'a> { - users: UsersStore<'a>, + users: UsersStore, network: NetworkStore, product: ProductStore<'a>, software: SoftwareStore<'a>, storage: StorageStore<'a>, - localization: LocalizationStore<'a>, + localization: LocalizationStore, } impl<'a> Store<'a> { @@ -30,8 +30,8 @@ impl<'a> Store<'a> { http_client: reqwest::Client, ) -> Result, ServiceError> { Ok(Self { - localization: LocalizationStore::new(connection.clone()).await?, - users: UsersStore::new(connection.clone()).await?, + localization: LocalizationStore::new()?, + users: UsersStore::new()?, network: NetworkStore::new(http_client).await?, product: ProductStore::new(connection.clone()).await?, software: SoftwareStore::new(connection.clone()).await?, diff --git a/rust/agama-lib/src/users.rs b/rust/agama-lib/src/users.rs index 9ee6a72b5a..21e4b4b9f3 100644 --- a/rust/agama-lib/src/users.rs +++ b/rust/agama-lib/src/users.rs @@ -1,10 +1,13 @@ //! Implements support for handling the users settings mod client; +mod http_client; +pub mod model; pub mod proxies; mod settings; mod store; pub use client::{FirstUser, UsersClient}; +pub use http_client::UsersHTTPClient; pub use settings::{FirstUserSettings, RootUserSettings, UserSettings}; pub use store::UsersStore; diff --git a/rust/agama-lib/src/users/http_client.rs b/rust/agama-lib/src/users/http_client.rs new file mode 100644 index 0000000000..0e5a63da83 --- /dev/null +++ b/rust/agama-lib/src/users/http_client.rs @@ -0,0 +1,78 @@ +use super::client::FirstUser; +use crate::users::model::{RootConfig, RootPatchSettings}; +use crate::{base_http_client::BaseHTTPClient, error::ServiceError}; + +pub struct UsersHTTPClient { + client: BaseHTTPClient, +} + +impl UsersHTTPClient { + pub fn new() -> Result { + Ok(Self { + client: BaseHTTPClient::new()?, + }) + } + + pub fn new_with_base(client: BaseHTTPClient) -> Result { + Ok(Self { client }) + } + + /// Returns the settings for first non admin user + pub async fn first_user(&self) -> Result { + self.client.get("/users/first").await + } + + /// Set the configuration for the first user + pub async fn set_first_user(&self, first_user: &FirstUser) -> Result<(), ServiceError> { + let result = self.client.put_void("/users/first", first_user).await; + if let Err(ServiceError::BackendError(422, ref issues_s)) = result { + let issues: Vec = serde_json::from_str(issues_s)?; + return Err(ServiceError::WrongUser(issues)); + } + result + } + + async fn root_config(&self) -> Result { + self.client.get("/users/root").await + } + + /// Whether the root password is set or not + pub async fn is_root_password(&self) -> Result { + let root_config = self.root_config().await?; + Ok(root_config.password) + } + + /// SetRootPassword method. + /// Returns 0 if successful (always, for current backend) + pub async fn set_root_password( + &self, + value: &str, + encrypted: bool, + ) -> Result { + let rps = RootPatchSettings { + sshkey: None, + password: Some(value.to_owned()), + password_encrypted: Some(encrypted), + }; + let ret = self.client.patch("/users/root", &rps).await?; + Ok(ret) + } + + /// Returns the SSH key for the root user + pub async fn root_ssh_key(&self) -> Result { + let root_config = self.root_config().await?; + Ok(root_config.sshkey) + } + + /// SetRootSSHKey method. + /// Returns 0 if successful (always, for current backend) + pub async fn set_root_sshkey(&self, value: &str) -> Result { + let rps = RootPatchSettings { + sshkey: Some(value.to_owned()), + password: None, + password_encrypted: None, + }; + let ret = self.client.patch("/users/root", &rps).await?; + Ok(ret) + } +} diff --git a/rust/agama-lib/src/users/model.rs b/rust/agama-lib/src/users/model.rs new file mode 100644 index 0000000000..062768bd81 --- /dev/null +++ b/rust/agama-lib/src/users/model.rs @@ -0,0 +1,20 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] +pub struct RootConfig { + /// returns if password for root is set or not + pub password: bool, + /// empty string mean no sshkey is specified + pub sshkey: String, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct RootPatchSettings { + /// empty string here means remove ssh key for root + pub sshkey: Option, + /// empty string here means remove password for root + pub password: Option, + /// specify if patched password is provided in encrypted form + pub password_encrypted: Option, +} diff --git a/rust/agama-lib/src/users/settings.rs b/rust/agama-lib/src/users/settings.rs index c808e17d70..f9c6b76a9a 100644 --- a/rust/agama-lib/src/users/settings.rs +++ b/rust/agama-lib/src/users/settings.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; /// User settings /// /// Holds the user settings for the installation. -#[derive(Debug, Default, Serialize, Deserialize)] +#[derive(Debug, Default, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct UserSettings { #[serde(rename = "user")] @@ -14,7 +14,7 @@ pub struct UserSettings { /// First user settings /// /// Holds the settings for the first user. -#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct FirstUserSettings { /// First user's full name @@ -30,7 +30,7 @@ pub struct FirstUserSettings { /// Root user settings /// /// Holds the settings for the root user. -#[derive(Debug, Default, Serialize, Deserialize)] +#[derive(Debug, Default, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RootUserSettings { /// Root's password (in clear text) diff --git a/rust/agama-lib/src/users/store.rs b/rust/agama-lib/src/users/store.rs index 6c34f0b2a1..85c52a7928 100644 --- a/rust/agama-lib/src/users/store.rs +++ b/rust/agama-lib/src/users/store.rs @@ -1,16 +1,21 @@ -use super::{FirstUser, FirstUserSettings, RootUserSettings, UserSettings, UsersClient}; +use super::{FirstUser, FirstUserSettings, RootUserSettings, UserSettings, UsersHTTPClient}; use crate::error::ServiceError; -use zbus::Connection; /// Loads and stores the users settings from/to the D-Bus service. -pub struct UsersStore<'a> { - users_client: UsersClient<'a>, +pub struct UsersStore { + users_client: UsersHTTPClient, } -impl<'a> UsersStore<'a> { - pub async fn new(connection: Connection) -> Result, ServiceError> { +impl UsersStore { + pub fn new() -> Result { Ok(Self { - users_client: UsersClient::new(connection).await?, + users_client: UsersHTTPClient::new()?, + }) + } + + pub fn new_with_client(client: UsersHTTPClient) -> Result { + Ok(Self { + users_client: client, }) } @@ -53,10 +58,7 @@ impl<'a> UsersStore<'a> { password: settings.password.clone().unwrap_or_default(), ..Default::default() }; - let (success, issues) = self.users_client.set_first_user(&first_user).await?; - if !success { - return Err(ServiceError::WrongUser(issues)); - } + self.users_client.set_first_user(&first_user).await?; Ok(()) } @@ -74,3 +76,136 @@ impl<'a> UsersStore<'a> { Ok(()) } } + +#[cfg(test)] +mod test { + use super::*; + use crate::base_http_client::BaseHTTPClient; + use httpmock::prelude::*; + use httpmock::Method::PATCH; + use std::error::Error; + use tokio::test; // without this, "error: async functions cannot be used for tests" + + fn users_store(mock_server_url: String) -> Result { + let mut bhc = BaseHTTPClient::default(); + bhc.base_url = mock_server_url; + let client = UsersHTTPClient::new_with_base(bhc)?; + UsersStore::new_with_client(client) + } + + #[test] + async fn test_getting_users() -> Result<(), Box> { + let server = MockServer::start(); + let user_mock = server.mock(|when, then| { + when.method(GET).path("/api/users/first"); + then.status(200) + .header("content-type", "application/json") + .body( + r#"{ + "fullName": "Tux", + "userName": "tux", + "password": "fish", + "autologin": true, + "data": {} + }"#, + ); + }); + let root_mock = server.mock(|when, then| { + when.method(GET).path("/api/users/root"); + then.status(200) + .header("content-type", "application/json") + .body( + r#"{ + "sshkey": "keykeykey", + "password": true + }"#, + ); + }); + let url = server.url("/api"); + + let store = users_store(url)?; + let settings = store.load().await?; + + let first_user = FirstUserSettings { + full_name: Some("Tux".to_owned()), + user_name: Some("tux".to_owned()), + password: Some("fish".to_owned()), + autologin: Some(true), + }; + let root_user = RootUserSettings { + // FIXME this is weird: no matter what HTTP reports, we end up with None + password: None, + ssh_public_key: Some("keykeykey".to_owned()), + }; + let expected = UserSettings { + first_user: Some(first_user), + root: Some(root_user), + }; + + // main assertion + assert_eq!(settings, expected); + + // Ensure the specified mock was called exactly one time (or fail with a detailed error description). + user_mock.assert(); + root_mock.assert(); + + Ok(()) + } + + #[test] + async fn test_setting_users() -> Result<(), Box> { + let server = MockServer::start(); + let user_mock = server.mock(|when, then| { + when.method(PUT) + .path("/api/users/first") + .header("content-type", "application/json") + .body( + r#"{"fullName":"Tux","userName":"tux","password":"fish","autologin":true,"data":{}}"# + ); + then.status(200); + }); + // note that we use 2 requests for root + let root_mock = server.mock(|when, then| { + when.method(PATCH) + .path("/api/users/root") + .header("content-type", "application/json") + .body(r#"{"sshkey":null,"password":"1234","passwordEncrypted":false}"#); + then.status(200).body("0"); + }); + let root_mock2 = server.mock(|when, then| { + when.method(PATCH) + .path("/api/users/root") + .header("content-type", "application/json") + .body(r#"{"sshkey":"keykeykey","password":null,"passwordEncrypted":null}"#); + then.status(200).body("0"); + }); + let url = server.url("/api"); + + let store = users_store(url)?; + + let first_user = FirstUserSettings { + full_name: Some("Tux".to_owned()), + user_name: Some("tux".to_owned()), + password: Some("fish".to_owned()), + autologin: Some(true), + }; + let root_user = RootUserSettings { + password: Some("1234".to_owned()), + ssh_public_key: Some("keykeykey".to_owned()), + }; + let settings = UserSettings { + first_user: Some(first_user), + root: Some(root_user), + }; + let result = store.store(&settings).await; + + // main assertion + result?; + + // Ensure the specified mock was called exactly one time (or fail with a detailed error description). + user_mock.assert(); + root_mock.assert(); + root_mock2.assert(); + Ok(()) + } +} diff --git a/rust/agama-locale-data/src/keyboard/xkb_config_registry.rs b/rust/agama-locale-data/src/keyboard/xkb_config_registry.rs index a462f5cffe..62ff1cea88 100644 --- a/rust/agama-locale-data/src/keyboard/xkb_config_registry.rs +++ b/rust/agama-locale-data/src/keyboard/xkb_config_registry.rs @@ -1,6 +1,6 @@ //! This module aims to read the information in the X Keyboard Configuration Database. //! -//! https://freedesktop.org/Software/XKeyboardConfig +//! use quick_xml::de::from_str; use serde::Deserialize; diff --git a/rust/agama-server/src/l10n.rs b/rust/agama-server/src/l10n.rs index 3ef747cd47..51d4c135b9 100644 --- a/rust/agama-server/src/l10n.rs +++ b/rust/agama-server/src/l10n.rs @@ -7,10 +7,10 @@ mod locale; mod timezone; pub mod web; +pub use agama_lib::localization::model::LocaleConfig; pub use dbus::export_dbus_objects; pub use error::LocaleError; pub use keyboard::Keymap; pub use l10n::L10n; pub use locale::LocaleEntry; pub use timezone::TimezoneEntry; -pub use web::LocaleConfig; diff --git a/rust/agama-server/src/l10n/locale.rs b/rust/agama-server/src/l10n/locale.rs index 4ea85bc03d..b6821ac88f 100644 --- a/rust/agama-server/src/l10n/locale.rs +++ b/rust/agama-server/src/l10n/locale.rs @@ -147,7 +147,10 @@ mod tests { db.read("de").unwrap(); let found_locales = db.entries(); let spanish: LocaleId = "es_ES".try_into().unwrap(); - let found = found_locales.iter().find(|l| l.id == spanish).unwrap(); + let found = found_locales + .iter() + .find(|l| l.id == spanish) + .expect("Spanish locale not found?! Suggestion: zypper in glibc-locale"); assert_eq!(&found.language, "Spanisch"); assert_eq!(&found.territory, "Spanien"); } diff --git a/rust/agama-server/src/l10n/web.rs b/rust/agama-server/src/l10n/web.rs index dfdab552de..4f668afe50 100644 --- a/rust/agama-server/src/l10n/web.rs +++ b/rust/agama-server/src/l10n/web.rs @@ -8,7 +8,8 @@ use crate::{ web::{Event, EventsSender}, }; use agama_lib::{ - error::ServiceError, localization::LocaleProxy, proxies::LocaleProxy as ManagerLocaleProxy, + error::ServiceError, localization::model::LocaleConfig, localization::LocaleProxy, + proxies::LocaleProxy as ManagerLocaleProxy, }; use agama_locale_data::LocaleId; use axum::{ @@ -18,7 +19,6 @@ use axum::{ routing::{get, patch}, Json, Router, }; -use serde::{Deserialize, Serialize}; use std::sync::Arc; use tokio::sync::RwLock; @@ -66,21 +66,6 @@ async fn locales(State(state): State>) -> Json> Json(locales) } -#[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct LocaleConfig { - /// Locales to install in the target system - locales: Option>, - /// Keymap for the target system - keymap: Option, - /// Timezone for the target system - timezone: Option, - /// User-interface locale. It is actually not related to the `locales` property. - ui_locale: Option, - /// User-interface locale. It is relevant only on local installations. - ui_keymap: Option, -} - #[utoipa::path( get, path = "/timezones", diff --git a/rust/agama-server/src/users/web.rs b/rust/agama-server/src/users/web.rs index 487c51add2..47992886c7 100644 --- a/rust/agama-server/src/users/web.rs +++ b/rust/agama-server/src/users/web.rs @@ -13,10 +13,13 @@ use crate::{ }; use agama_lib::{ error::ServiceError, - users::{proxies::Users1Proxy, FirstUser, UsersClient}, + users::{ + model::{RootConfig, RootPatchSettings}, + proxies::Users1Proxy, + FirstUser, UsersClient, + }, }; -use axum::{extract::State, routing::get, Json, Router}; -use serde::{Deserialize, Serialize}; +use axum::{extract::State, http::StatusCode, response::IntoResponse, routing::get, Json, Router}; use tokio_stream::{Stream, StreamExt}; #[derive(Clone)] @@ -154,13 +157,22 @@ async fn remove_first_user(State(state): State>) -> Result<(), Er #[utoipa::path(put, path = "/users/first", responses( (status = 200, description = "Sets the first user"), (status = 400, description = "The D-Bus service could not perform the action"), + (status = 422, description = "Invalid first user. Details are in body", body = Vec), ))] async fn set_first_user( State(state): State>, Json(config): Json, -) -> Result<(), Error> { - state.users.set_first_user(&config).await?; - Ok(()) +) -> Result { + // issues: for example, trying to use a system user id; empty password + // success: simply issues.is_empty() + let (_success, issues) = state.users.set_first_user(&config).await?; + let status = if issues.is_empty() { + StatusCode::OK + } else { + StatusCode::UNPROCESSABLE_ENTITY + }; + + Ok((status, Json(issues).into_response())) } #[utoipa::path(get, path = "/users/first", responses( @@ -171,17 +183,6 @@ async fn get_user_config(State(state): State>) -> Result, - /// empty string here means remove password for root - pub password: Option, - /// specify if patched password is provided in encrypted form - pub password_encrypted: Option, -} - #[utoipa::path(patch, path = "/users/root", responses( (status = 200, description = "Root configuration is modified", body = RootPatchSettings), (status = 400, description = "The D-Bus service could not perform the action"), @@ -189,29 +190,27 @@ pub struct RootPatchSettings { async fn patch_root( State(state): State>, Json(config): Json, -) -> Result<(), Error> { +) -> Result { + let mut retcode1 = 0; if let Some(key) = config.sshkey { - state.users.set_root_sshkey(&key).await?; + retcode1 = state.users.set_root_sshkey(&key).await?; } + + let mut retcode2 = 0; if let Some(password) = config.password { - if password.is_empty() { - state.users.remove_root_password().await?; + retcode2 = if password.is_empty() { + state.users.remove_root_password().await? } else { state .users .set_root_password(&password, config.password_encrypted == Some(true)) - .await?; + .await? } } - Ok(()) -} -#[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] -pub struct RootConfig { - /// returns if password for root is set or not - password: bool, - /// empty string mean no sshkey is specified - sshkey: String, + let retcode: u32 = if retcode1 != 0 { retcode1 } else { retcode2 }; + + Ok(Json(retcode)) } #[utoipa::path(get, path = "/users/root", responses( diff --git a/rust/agama-server/src/web/docs.rs b/rust/agama-server/src/web/docs.rs index af9d28eacd..b913f3974c 100644 --- a/rust/agama-server/src/web/docs.rs +++ b/rust/agama-server/src/web/docs.rs @@ -96,7 +96,7 @@ use utoipa::OpenApi; schemas(crate::l10n::Keymap), schemas(crate::l10n::LocaleEntry), schemas(crate::l10n::TimezoneEntry), - schemas(crate::l10n::web::LocaleConfig), + schemas(agama_lib::localization::model::LocaleConfig), schemas(crate::manager::web::InstallerStatus), schemas(crate::network::model::Connection), schemas(crate::network::model::Device), @@ -113,8 +113,8 @@ use utoipa::OpenApi; schemas(crate::storage::web::iscsi::InitiatorParams), schemas(crate::storage::web::iscsi::LoginParams), schemas(crate::storage::web::iscsi::NodeParams), - schemas(crate::users::web::RootConfig), - schemas(crate::users::web::RootPatchSettings), + schemas(agama_lib::users::model::RootConfig), + schemas(agama_lib::users::model::RootPatchSettings), schemas(super::http::PingResponse) ) )] diff --git a/rust/agama-server/src/web/event.rs b/rust/agama-server/src/web/event.rs index 5b11eae12b..20075ce00a 100644 --- a/rust/agama-server/src/web/event.rs +++ b/rust/agama-server/src/web/event.rs @@ -1,7 +1,8 @@ -use crate::{l10n::web::LocaleConfig, network::model::NetworkChange}; +use crate::network::model::NetworkChange; use agama_lib::{ - manager::InstallationPhase, product::RegistrationRequirement, progress::Progress, - software::SelectedBy, storage::ISCSINode, users::FirstUser, + localization::model::LocaleConfig, manager::InstallationPhase, + product::RegistrationRequirement, progress::Progress, software::SelectedBy, storage::ISCSINode, + users::FirstUser, }; use serde::Serialize; use std::collections::HashMap; diff --git a/rust/package/agama.changes b/rust/package/agama.changes index 422153939d..6d4419301a 100644 --- a/rust/package/agama.changes +++ b/rust/package/agama.changes @@ -1,3 +1,21 @@ +------------------------------------------------------------------- +Fri Aug 9 08:50:31 UTC 2024 - Martin Vidner + +- For CLI, use HTTP clients instead of D-Bus clients, + for Users and Localization (gh#openSUSE/agama#1438) + - service clients used by CLI: + - added UsersHTTPClient, LocalizationHTTPClient + - removed LocalizationClient + - BaseHTTPClient API reworked: + - return () or deserialized objects + - added PUT and PATCH + - web service: + - PUT /api/users/first: do report backend errors + - PATCH /api/users/root: report the (potential) backend errors + - tests: + - added tests using httpmock + - env_logger added to dev-dependencies + ------------------------------------------------------------------- Mon Jul 22 15:27:44 UTC 2024 - Josef Reidinger diff --git a/setup-services.sh b/setup-services.sh index 0619434a71..3c6534ab29 100755 --- a/setup-services.sh +++ b/setup-services.sh @@ -36,8 +36,10 @@ $SUDO systemctl list-unit-files agama-web-server.service &>/dev/null && $SUDO sy # Ruby services +ZYPPER="zypper --non-interactive -v" + # Packages required for Ruby development (i.e., bundle install). -$SUDO zypper --non-interactive install \ +$SUDO $ZYPPER install \ gcc \ gcc-c++ \ make \ @@ -47,7 +49,7 @@ $SUDO zypper --non-interactive install \ # Packages required by Agama Ruby services (see ./service/package/gem2rpm.yml). # TODO extract list from gem2rpm.yml -$SUDO zypper --non-interactive install \ +$SUDO $ZYPPER install \ dbus-1-common \ suseconnect-ruby-bindings \ autoyast2-installation \ @@ -89,13 +91,13 @@ $SUDO zypper --non-interactive install \ # Install x86_64 packages if [ $(uname -m) == "x86_64" ]; then - $SUDO zypper --non-interactive install \ + $SUDO $ZYPPER install \ fde-tools fi # Install s390 packages if [ $(uname -m) == "s390x" ]; then - $SUDO zypper --non-interactive install \ + $SUDO $ZYPPER install \ yast2-s390 \ yast2-reipl \ yast2-cio @@ -120,10 +122,10 @@ fi # Rust service, CLI and auto-installation. # Only install cargo if it is not available (avoid conflicts with rustup) -which cargo || $SUDO zypper --non-interactive install cargo +which cargo || $SUDO $ZYPPER install cargo # Packages required by Rust code (see ./rust/package/agama.spec) -$SUDO zypper --non-interactive install \ +$SUDO $ZYPPER install \ clang-devel \ gzip \ jsonnet \