diff --git a/doc/answers_example.yaml b/doc/answers_example.yaml new file mode 100644 index 0000000000..581f39ff1d --- /dev/null +++ b/doc/answers_example.yaml @@ -0,0 +1,3 @@ +answers: + - class: storage.luks_activation + answer: "skip" diff --git a/doc/dbus/bus/org.opensuse.Agama.Questions1.bus.xml b/doc/dbus/bus/org.opensuse.Agama.Questions1.bus.xml index c9d9c8a126..12ed3ad5c5 100644 --- a/doc/dbus/bus/org.opensuse.Agama.Questions1.bus.xml +++ b/doc/dbus/bus/org.opensuse.Agama.Questions1.bus.xml @@ -87,10 +87,13 @@ + + + - - + diff --git a/doc/dbus/org.opensuse.Agama.Questions1.doc.xml b/doc/dbus/org.opensuse.Agama.Questions1.doc.xml index eddea73a4a..e32cdbb56f 100644 --- a/doc/dbus/org.opensuse.Agama.Questions1.doc.xml +++ b/doc/dbus/org.opensuse.Agama.Questions1.doc.xml @@ -76,18 +76,30 @@ when the question is answered and the answer is successfully read. + - + - - + + + + diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 745095d4c7..56c4fa26c9 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -36,6 +36,8 @@ dependencies = [ "async-std", "log", "parking_lot", + "serde", + "serde_yaml", "simplelog", "systemd-journal-logger", "thiserror", @@ -665,6 +667,12 @@ dependencies = [ "syn 2.0.16", ] +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + [[package]] name = "errno" version = "0.3.1" @@ -883,6 +891,12 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c6201b9ff9fd90a5a3bac2e56a830d0caa509576f0e503818ee82c181b3437a" + [[package]] name = "heck" version = "0.4.1" @@ -927,7 +941,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" dependencies = [ "autocfg", - "hashbrown", + "hashbrown 0.12.3", +] + +[[package]] +name = "indexmap" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5477fe2230a79769d8dc68e0eabf5437907c0457a5614a9e8dddb67f65eb65d" +dependencies = [ + "equivalent", + "hashbrown 0.14.0", ] [[package]] @@ -1605,11 +1629,11 @@ dependencies = [ [[package]] name = "serde_yaml" -version = "0.9.21" +version = "0.9.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9d684e3ec7de3bf5466b32bd75303ac16f0736426e5a4e0d6e489559ce1249c" +checksum = "bd5f51e3fdb5b9cdd1577e1cb7a733474191b1aca6a72c2e50913241632c1180" dependencies = [ - "indexmap", + "indexmap 2.0.0", "itoa", "ryu", "serde", @@ -1847,7 +1871,7 @@ version = "0.19.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "239410c8609e8125456927e6707163a3b1fdb40561e4b803bc041f466ccfdc13" dependencies = [ - "indexmap", + "indexmap 1.9.3", "toml_datetime", "winnow", ] diff --git a/rust/agama-cli/src/questions.rs b/rust/agama-cli/src/questions.rs index d0a22c1c00..8bce0cb928 100644 --- a/rust/agama-cli/src/questions.rs +++ b/rust/agama-cli/src/questions.rs @@ -1,12 +1,15 @@ use agama_lib::connection; use agama_lib::proxies::Questions1Proxy; -use anyhow::{Context, Ok}; +use anyhow::Context; use clap::{Args, Subcommand, ValueEnum}; #[derive(Subcommand, Debug)] pub enum QuestionsCommands { /// Set mode for answering questions. Mode(ModesArgs), + Answers { + path: String, + }, } #[derive(Args, Debug)] @@ -20,29 +23,31 @@ pub enum Modes { Interactive, NonInteractive, } -// TODO when more commands is added, refactor and add it to agama-lib and share a bit of functionality -async fn set_mode(value: Modes) -> anyhow::Result<()> { - match value { - Modes::NonInteractive => { - let connection = connection().await?; - let proxy = Questions1Proxy::new(&connection) - .await - .context("Failed to connect to Questions service")?; - // TODO: how to print dbus error in that anyhow? - proxy - .use_default_answer() - .await - .context("Failed to set default answer")?; - } - Modes::Interactive => log::info!("not implemented"), //TODO do it - } +async fn set_mode(proxy: Questions1Proxy<'_>, value: Modes) -> anyhow::Result<()> { + // TODO: how to print dbus error in that anyhow? + proxy + .set_interactive(value == Modes::Interactive) + .await + .context("Failed to set mode for answering questions.") +} - Ok(()) +async fn set_answers(proxy: Questions1Proxy<'_>, path: String) -> anyhow::Result<()> { + // TODO: how to print dbus error in that anyhow? + proxy + .add_answer_file(path.as_str()) + .await + .context("Failed to set answers from answers file") } pub async fn run(subcommand: QuestionsCommands) -> anyhow::Result<()> { + let connection = connection().await?; + let proxy = Questions1Proxy::new(&connection) + .await + .context("Failed to connect to Questions service")?; + match subcommand { - QuestionsCommands::Mode(value) => set_mode(value.value).await, + QuestionsCommands::Mode(value) => set_mode(proxy, value.value).await, + QuestionsCommands::Answers { path } => set_answers(proxy, path).await, } } diff --git a/rust/agama-dbus-server/Cargo.toml b/rust/agama-dbus-server/Cargo.toml index dff9b917f1..f74f49d0d2 100644 --- a/rust/agama-dbus-server/Cargo.toml +++ b/rust/agama-dbus-server/Cargo.toml @@ -18,3 +18,5 @@ async-std = { version = "1.12.0", features = ["attributes"]} uuid = { version = "1.3.4", features = ["v4"] } parking_lot = "0.12.1" thiserror = "1.0.40" +serde = { version = "1.0.152", features = ["derive"] } +serde_yaml = "0.9.24" \ No newline at end of file diff --git a/rust/agama-dbus-server/src/network/action.rs b/rust/agama-dbus-server/src/network/action.rs index e0592e7fa4..9f935f70f1 100644 --- a/rust/agama-dbus-server/src/network/action.rs +++ b/rust/agama-dbus-server/src/network/action.rs @@ -3,7 +3,7 @@ use agama_lib::network::types::DeviceType; /// Networking actions, like adding, updating or removing connections. /// -/// These actions are meant to be processed by [crate::system::NetworkSystem], updating the model +/// These actions are meant to be processed by [crate::network::system::NetworkSystem], updating the model /// and the D-Bus tree as needed. #[derive(Debug)] pub enum Action { diff --git a/rust/agama-dbus-server/src/network/dbus/interfaces.rs b/rust/agama-dbus-server/src/network/dbus/interfaces.rs index e85f1d188e..db5e2ad118 100644 --- a/rust/agama-dbus-server/src/network/dbus/interfaces.rs +++ b/rust/agama-dbus-server/src/network/dbus/interfaces.rs @@ -82,7 +82,7 @@ impl Device { /// /// Possible values: 0 = loopback, 1 = ethernet, 2 = wireless. /// - /// See [crate::model::DeviceType]. + /// See [agama_lib::network::types::DeviceType]. #[dbus_interface(property, name = "Type")] pub fn device_type(&self) -> u8 { self.device.type_ as u8 @@ -124,7 +124,7 @@ impl Connections { /// Adds a new network connection. /// /// * `id`: connection name. - /// * `ty`: connection type (see [crate::model::DeviceType]). + /// * `ty`: connection type (see [agama_lib::network::types::DeviceType]). pub async fn add_connection(&mut self, id: String, ty: u8) -> zbus::fdo::Result<()> { let actions = self.actions.lock(); actions @@ -274,7 +274,7 @@ impl Ipv4 { /// /// Possible values: "disabled", "auto", "manual" or "link-local". /// - /// See [crate::model::IpMethod]. + /// See [crate::network::model::IpMethod]. #[dbus_interface(property)] pub fn method(&self) -> String { let connection = self.get_connection(); @@ -401,7 +401,7 @@ impl Wireless { /// /// Possible values: "unknown", "adhoc", "infrastructure", "ap" or "mesh". /// - /// See [crate::model::WirelessMode]. + /// See [crate::network::model::WirelessMode]. #[dbus_interface(property)] pub fn mode(&self) -> String { let connection = self.get_wireless(); @@ -442,7 +442,7 @@ impl Wireless { /// Possible values: "none", "owe", "ieee8021x", "wpa-psk", "sae", "wpa-eap", /// "wpa-eap-suite-b192". /// - /// See [crate::model::SecurityProtocol]. + /// See [crate::network::model::SecurityProtocol]. #[dbus_interface(property)] pub fn security(&self) -> String { let connection = self.get_wireless(); diff --git a/rust/agama-dbus-server/src/questions.rs b/rust/agama-dbus-server/src/questions.rs index b84a3a834d..21afb6606c 100644 --- a/rust/agama-dbus-server/src/questions.rs +++ b/rust/agama-dbus-server/src/questions.rs @@ -9,6 +9,8 @@ use anyhow::Context; use log; use zbus::{dbus_interface, fdo::ObjectManager, zvariant::ObjectPath, Connection}; +mod answers; + #[derive(Clone, Debug)] struct GenericQuestionObject(questions::GenericQuestion); @@ -81,7 +83,11 @@ enum QuestionType { } /// Trait for objects that can provide answers to all kind of Question. +/// +/// If no strategy is selected or the answer is unknown, then ask to the user. trait AnswerStrategy { + /// Id for quick runtime inspection of strategy type + fn id(&self) -> u8; /// Provides answer for generic question /// /// I gets as argument the question to answer. Returned value is `answer` @@ -103,7 +109,17 @@ trait AnswerStrategy { /// AnswerStrategy that provides as answer the default option. struct DefaultAnswers; +impl DefaultAnswers { + pub fn id() -> u8 { + 1 + } +} + impl AnswerStrategy for DefaultAnswers { + fn id(&self) -> u8 { + DefaultAnswers::id() + } + fn answer(&self, question: &GenericQuestion) -> Option { Some(question.default_option.clone()) } @@ -227,11 +243,43 @@ impl Questions { Ok(()) } - /// sets questions to be answered by default answer instead of asking user - async fn use_default_answer(&mut self) -> Result<(), Error> { - log::info!("Answer questions with default option"); - self.answer_strategies.push(Box::new(DefaultAnswers {})); - Ok(()) + /// property that defines if questions is interactive or automatically answered with + /// default answer + #[dbus_interface(property)] + fn interactive(&self) -> bool { + let last = self.answer_strategies.last(); + if let Some(real_strategy) = last { + real_strategy.id() != DefaultAnswers::id() + } else { + true + } + } + + #[dbus_interface(property)] + fn set_interactive(&mut self, value: bool) { + if value != self.interactive() { + log::info!("interactive value unchanged - {}", value); + return; + } + + log::info!("set interactive to {}", value); + if value { + self.answer_strategies.pop(); + } else { + self.answer_strategies.push(Box::new(DefaultAnswers {})); + } + } + + fn add_answer_file(&mut self, path: String) -> Result<(), Error> { + log::info!("Adding answer file {}", path); + let answers = answers::Answers::new_from_file(path.as_str()); + match answers { + Ok(answers) => { + self.answer_strategies.push(Box::new(answers)); + Ok(()) + } + Err(e) => Err(e.into()), + } } } diff --git a/rust/agama-dbus-server/src/questions/answers.rs b/rust/agama-dbus-server/src/questions/answers.rs new file mode 100644 index 0000000000..a955093fdd --- /dev/null +++ b/rust/agama-dbus-server/src/questions/answers.rs @@ -0,0 +1,291 @@ +use std::collections::HashMap; + +use anyhow::Context; +use serde::{Deserialize, Serialize}; + +/// Data structure for single yaml answer. For variables specification see +/// corresponding [agama_lib::questions::GenericQuestion] fields. +/// The *matcher* part is: `class`, `text`, `data`. +/// The *answer* part is: `answer`, `password`. +#[derive(Serialize, Deserialize, PartialEq, Debug)] +struct Answer { + pub class: Option, + pub text: Option, + /// A matching GenericQuestion can have other data fields too + pub data: Option>, + /// The answer text is the only mandatory part of an Answer + pub answer: String, + /// All possible mixins have to be here, so they can be specified in an Answer + pub password: Option, +} + +/// Data structure holding list of Answer. +/// The first matching Answer is used, even if there is +/// a better (more specific) match later in the list. +#[derive(Serialize, Deserialize, PartialEq, Debug)] +pub struct Answers { + answers: Vec, +} + +impl Answers { + pub fn new_from_file(path: &str) -> anyhow::Result { + let f = std::fs::File::open(path).context(format!("Failed to open {}", path))?; + let result: Self = + serde_yaml::from_reader(f).context(format!("Failed to parse values at {}", path))?; + + Ok(result) + } + + pub fn id() -> u8 { + 2 + } + + fn find_answer(&self, question: &agama_lib::questions::GenericQuestion) -> Option<&Answer> { + 'main: for answerd in self.answers.iter() { + if let Some(v) = &answerd.class { + if !question.class.eq(v) { + continue; + } + } + if let Some(v) = &answerd.text { + if !question.text.eq(v) { + continue; + } + } + if let Some(v) = &answerd.data { + for (key, value) in v { + // all keys defined in answer has to match + let entry = question.data.get(key); + if let Some(e_val) = entry { + if !e_val.eq(value) { + continue 'main; + } + } else { + continue 'main; + } + } + } + + return Some(answerd); + } + + None + } +} + +impl crate::questions::AnswerStrategy for Answers { + fn id(&self) -> u8 { + Answers::id() + } + + fn answer(&self, question: &agama_lib::questions::GenericQuestion) -> Option { + let answer = self.find_answer(question); + answer.map(|answer| answer.answer.clone()) + } + + fn answer_with_password( + &self, + question: &agama_lib::questions::WithPassword, + ) -> (Option, Option) { + // use here fact that with password share same matchers as generic one + let answer = self.find_answer(&question.base); + if let Some(answer) = answer { + (Some(answer.answer.clone()), answer.password.clone()) + } else { + (None, None) + } + } +} + +#[cfg(test)] +mod tests { + use agama_lib::questions::{GenericQuestion, WithPassword}; + + use crate::questions::AnswerStrategy; + + use super::*; + + // set of fixtures for test + fn get_answers() -> Answers { + Answers { + answers: vec![ + Answer { + class: Some("without_data".to_string()), + data: None, + text: None, + answer: "Ok".to_string(), + password: Some("testing pwd".to_string()), // ignored for generic question + }, + Answer { + class: Some("with_data".to_string()), + data: Some(HashMap::from([ + ("data1".to_string(), "value1".to_string()), + ("data2".to_string(), "value2".to_string()), + ])), + text: None, + answer: "Maybe".to_string(), + password: None, + }, + Answer { + class: Some("with_data".to_string()), + data: Some(HashMap::from([( + "data1".to_string(), + "another_value1".to_string(), + )])), + text: None, + answer: "Ok2".to_string(), + password: None, + }, + ], + } + } + + #[test] + fn test_class_match() { + let answers = get_answers(); + let question = GenericQuestion { + id: 1, + class: "without_data".to_string(), + text: "JFYI we will kill all bugs during installation.".to_string(), + options: vec!["Ok".to_string(), "Cancel".to_string()], + default_option: "Cancel".to_string(), + data: HashMap::new(), + answer: "".to_string(), + }; + assert_eq!(Some("Ok".to_string()), answers.answer(&question)); + } + + #[test] + fn test_no_match() { + let answers = get_answers(); + let question = GenericQuestion { + id: 1, + class: "non-existing".to_string(), + text: "Hard question?".to_string(), + options: vec!["Ok".to_string(), "Cancel".to_string()], + default_option: "Cancel".to_string(), + data: HashMap::new(), + answer: "".to_string(), + }; + assert_eq!(None, answers.answer(&question)); + } + + #[test] + fn test_with_password() { + let answers = get_answers(); + let question = GenericQuestion { + id: 1, + class: "without_data".to_string(), + text: "Please provide password for dooms day.".to_string(), + options: vec!["Ok".to_string(), "Cancel".to_string()], + default_option: "Cancel".to_string(), + data: HashMap::new(), + answer: "".to_string(), + }; + let with_password = WithPassword { + password: "".to_string(), + base: question, + }; + let expected = (Some("Ok".to_string()), Some("testing pwd".to_string())); + assert_eq!(expected, answers.answer_with_password(&with_password)); + } + + /// An Answer matches on *data* if all its keys and values are in the GenericQuestion *data*. + /// The GenericQuestion can have other *data* keys. + #[test] + fn test_partial_data_match() { + let answers = get_answers(); + let question = GenericQuestion { + id: 1, + class: "with_data".to_string(), + text: "Hard question?".to_string(), + options: vec!["Ok2".to_string(), "Maybe".to_string(), "Cancel".to_string()], + default_option: "Cancel".to_string(), + data: HashMap::from([ + ("data1".to_string(), "value1".to_string()), + ("data2".to_string(), "value2".to_string()), + ("data3".to_string(), "value3".to_string()), + ]), + answer: "".to_string(), + }; + assert_eq!(Some("Maybe".to_string()), answers.answer(&question)); + } + + #[test] + fn test_full_data_match() { + let answers = get_answers(); + let question = GenericQuestion { + id: 1, + class: "with_data".to_string(), + text: "Hard question?".to_string(), + options: vec!["Ok2".to_string(), "Maybe".to_string(), "Cancel".to_string()], + default_option: "Cancel".to_string(), + data: HashMap::from([ + ("data1".to_string(), "another_value1".to_string()), + ("data2".to_string(), "value2".to_string()), + ("data3".to_string(), "value3".to_string()), + ]), + answer: "".to_string(), + }; + assert_eq!(Some("Ok2".to_string()), answers.answer(&question)); + } + + #[test] + fn test_no_data_match() { + let answers = get_answers(); + let question = GenericQuestion { + id: 1, + class: "with_data".to_string(), + text: "Hard question?".to_string(), + options: vec!["Ok2".to_string(), "Maybe".to_string(), "Cancel".to_string()], + default_option: "Cancel".to_string(), + data: HashMap::from([ + ("data1".to_string(), "different value".to_string()), + ("data2".to_string(), "value2".to_string()), + ("data3".to_string(), "value3".to_string()), + ]), + answer: "".to_string(), + }; + assert_eq!(None, answers.answer(&question)); + } + + // A "universal answer" with unspecified class+text+data is possible + #[test] + fn test_universal_match() { + let answers = Answers { + answers: vec![Answer { + class: None, + text: None, + data: None, + answer: "Yes".into(), + password: None, + }], + }; + let question = GenericQuestion { + id: 1, + class: "without_data".to_string(), + text: "JFYI we will kill all bugs during installation.".to_string(), + options: vec!["Ok".to_string(), "Cancel".to_string()], + default_option: "Cancel".to_string(), + data: HashMap::new(), + answer: "".to_string(), + }; + assert_eq!(Some("Yes".to_string()), answers.answer(&question)); + } + + #[test] + fn test_loading_yaml() { + let file = r#" + answers: + - class: "without_data" + answer: "OK" + - class: "with_data" + data: + testk: testv + testk2: testv2 + answer: "Cancel" + "#; + let result: Answers = serde_yaml::from_str(file).expect("failed to load yaml string"); + assert_eq!(result.answers.len(), 2); + } +} diff --git a/rust/agama-lib/src/proxies.rs b/rust/agama-lib/src/proxies.rs index d6678c0701..9d6371eac0 100644 --- a/rust/agama-lib/src/proxies.rs +++ b/rust/agama-lib/src/proxies.rs @@ -111,14 +111,21 @@ trait Locale1 { fn set_vconsole_keyboard(&self, value: &str) -> zbus::Result<()>; } -#[dbus_proxy(interface = "org.opensuse.Agama.Questions1", assume_defaults = true)] +#[dbus_proxy( + interface = "org.opensuse.Agama.Questions1", + default_service = "org.opensuse.Agama.Questions1", + default_path = "/org/opensuse/Agama/Questions1" +)] trait Questions1 { + /// AddAnswerFile method + fn add_answer_file(&self, path: &str) -> zbus::Result<()>; + /// Delete method fn delete(&self, question: &zbus::zvariant::ObjectPath<'_>) -> zbus::Result<()>; /// New method #[dbus_proxy(name = "New")] - fn new_generic( + fn new_quetion( &self, class: &str, text: &str, @@ -137,6 +144,8 @@ trait Questions1 { data: std::collections::HashMap<&str, &str>, ) -> zbus::Result; - /// UseDefaultAnswer method - fn use_default_answer(&self) -> zbus::Result<()>; + /// Interactive property + #[dbus_proxy(property)] + fn interactive(&self) -> zbus::Result; + fn set_interactive(&self, value: bool) -> zbus::Result<()>; } diff --git a/rust/agama-lib/src/questions.rs b/rust/agama-lib/src/questions.rs index b5e6b75970..34094b4371 100644 --- a/rust/agama-lib/src/questions.rs +++ b/rust/agama-lib/src/questions.rs @@ -1,11 +1,11 @@ -use std::collections::HashMap; +//! Data model for Agama questions -/// module holdings data model for agama questions +use std::collections::HashMap; /// Basic generic question that fits question without special needs #[derive(Clone, Debug)] pub struct GenericQuestion { - /// numeric id used to indetify question on dbus + /// numeric id used to identify question on D-Bus pub id: u32, /// class of questions. Similar kinds of questions share same class. /// It is dot separated list of elements. Examples are @@ -74,11 +74,13 @@ impl GenericQuestion { /// mixins arise to convert it to Question Struct that have optional mixins /// inside like /// +/// ```no_compile /// struct Question { /// base: GenericQuestion, /// with_password: Option, /// another_mixin: Option /// } +/// ``` /// /// This way all handling code can check if given mixin is used and /// act appropriate. diff --git a/rust/package/agama-cli.changes b/rust/package/agama-cli.changes index 20902ea8d0..15f54b5eb1 100644 --- a/rust/package/agama-cli.changes +++ b/rust/package/agama-cli.changes @@ -1,3 +1,11 @@ +------------------------------------------------------------------- +Wed Jul 26 11:08:09 UTC 2023 - Josef Reidinger + +- CLI: add to "questions" command "answers" subcommand to set + file with predefined answers +- dbus-server: add "AddAnswersFile" method to Questions service + (gh#openSUSE/agama#669) + ------------------------------------------------------------------- Tue Jul 18 13:32:04 UTC 2023 - Josef Reidinger