diff --git a/rust/Cargo.lock b/rust/Cargo.lock index f8dd9168b3..a61972b45c 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -133,9 +133,11 @@ version = "0.1.0" dependencies = [ "agama-l10n", "agama-software", + "agama-storage", "agama-utils", "async-trait", "merge-struct", + "serde_json", "thiserror 2.0.16", "tokio", "tokio-test", @@ -244,6 +246,20 @@ dependencies = [ "zypp-agama", ] +[[package]] +name = "agama-storage" +version = "0.1.0" +dependencies = [ + "agama-utils", + "async-trait", + "serde", + "serde_json", + "thiserror 2.0.16", + "tokio", + "tokio-stream", + "zbus", +] + [[package]] name = "agama-utils" version = "0.1.0" @@ -263,6 +279,7 @@ dependencies = [ "tokio-test", "tracing", "utoipa", + "uuid", "zbus", "zvariant", ] diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 10c10c025d..72f3c67f58 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -9,6 +9,7 @@ members = [ "agama-network", "agama-server", "agama-software", + "agama-storage", "agama-utils", "xtask", "zypp-agama", diff --git a/rust/agama-l10n/src/message.rs b/rust/agama-l10n/src/message.rs index 31145c86a8..f64d7a2a75 100644 --- a/rust/agama-l10n/src/message.rs +++ b/rust/agama-l10n/src/message.rs @@ -55,7 +55,7 @@ impl Message for GetConfig { } pub struct SetConfig { - pub config: T, + pub config: Option, } impl Message for SetConfig { @@ -63,9 +63,15 @@ impl Message for SetConfig { } impl SetConfig { - pub fn new(config: T) -> Self { + pub fn new(config: Option) -> Self { Self { config } } + + pub fn with(config: T) -> Self { + Self { + config: Some(config), + } + } } pub struct GetProposal; diff --git a/rust/agama-l10n/src/service.rs b/rust/agama-l10n/src/service.rs index beed1fadc2..b6699362c3 100644 --- a/rust/agama-l10n/src/service.rs +++ b/rust/agama-l10n/src/service.rs @@ -123,7 +123,7 @@ impl Service { details: None, source: IssueSource::Config, severity: IssueSeverity::Error, - kind: "unknown_locale".to_string(), + class: "unknown_locale".to_string(), }); } @@ -133,7 +133,7 @@ impl Service { details: None, source: IssueSource::Config, severity: IssueSeverity::Error, - kind: "unknown_keymap".to_string(), + class: "unknown_keymap".to_string(), }); } @@ -143,7 +143,7 @@ impl Service { details: None, source: IssueSource::Config, severity: IssueSeverity::Error, - kind: "unknown_timezone".to_string(), + class: "unknown_timezone".to_string(), }); } @@ -195,16 +195,22 @@ impl MessageHandler> for Service { &mut self, message: message::SetConfig, ) -> Result<(), Error> { - let config = Config::new_from(&self.system); - let merged = config.merge(&message.config)?; - if merged == self.config { + let base_config = Config::new_from(&self.system); + + let config = if let Some(config) = &message.config { + base_config.merge(config)? + } else { + base_config + }; + + if config == self.config { return Ok(()); } - self.config = merged; + self.config = config; let issues = self.find_issues(); self.issues - .cast(issue::message::Update::new(Scope::L10n, issues))?; + .cast(issue::message::Set::new(Scope::L10n, issues))?; self.events .send(Event::ProposalChanged { scope: Scope::L10n })?; Ok(()) diff --git a/rust/agama-l10n/src/start.rs b/rust/agama-l10n/src/start.rs index 46e6b9d6f8..40c78a40dc 100644 --- a/rust/agama-l10n/src/start.rs +++ b/rust/agama-l10n/src/start.rs @@ -71,12 +71,16 @@ mod tests { use crate::model::{KeymapsDatabase, LocalesDatabase, ModelAdapter, TimezonesDatabase}; use crate::service::{self, Service}; use agama_locale_data::{KeymapId, LocaleId}; - use agama_utils::actor::{self, Handler}; - use agama_utils::api; - use agama_utils::api::event::{self, Event}; - use agama_utils::api::l10n::{Keymap, LocaleEntry, TimezoneEntry}; - use agama_utils::api::scope::Scope; - use agama_utils::issue; + use agama_utils::{ + actor::{self, Handler}, + api::{ + self, + event::{self, Event}, + l10n::{Keymap, LocaleEntry, TimezoneEntry}, + scope::Scope, + }, + issue, test, + }; use tokio::sync::broadcast; pub struct TestModel { @@ -145,7 +149,8 @@ mod tests { async fn start_testing_service() -> (event::Receiver, Handler, Handler) { let (events_tx, events_rx) = broadcast::channel::(16); - let issues = issue::start(events_tx.clone(), None).await.unwrap(); + let dbus = test::dbus::connection().await.unwrap(); + let issues = issue::start(events_tx.clone(), dbus).await.unwrap(); let model = build_adapter(); let service = Service::new(model, issues.clone(), events_tx); @@ -167,7 +172,7 @@ mod tests { timezone: Some("Atlantic/Canary".to_string()), }; handler - .call(message::SetConfig::new(input_config.clone())) + .call(message::SetConfig::with(input_config.clone())) .await?; let updated = handler.call(message::GetConfig).await?; @@ -190,7 +195,7 @@ mod tests { // Use system info for missing values. handler - .call(message::SetConfig::new(input_config.clone())) + .call(message::SetConfig::with(input_config.clone())) .await?; let updated = handler.call(message::GetConfig).await?; @@ -206,6 +211,25 @@ mod tests { Ok(()) } + #[tokio::test] + async fn test_reset_config() -> Result<(), Box> { + let (mut _events_rx, handler, _issues) = start_testing_service().await; + + handler.call(message::SetConfig::new(None)).await?; + + let config = handler.call(message::GetConfig).await?; + assert_eq!( + config, + api::l10n::Config { + locale: Some("en_US.UTF-8".to_string()), + keymap: Some("us".to_string()), + timezone: Some("Europe/Berlin".to_string()), + } + ); + + Ok(()) + } + #[tokio::test] async fn test_set_invalid_config() -> Result<(), Box> { let (_events_rx, handler, _issues) = start_testing_service().await; @@ -216,7 +240,7 @@ mod tests { }; let result = handler - .call(message::SetConfig::new(input_config.clone())) + .call(message::SetConfig::with(input_config.clone())) .await; assert!(matches!(result, Err(service::Error::InvalidLocale(_)))); Ok(()) @@ -228,7 +252,7 @@ mod tests { let config = handler.call(message::GetConfig).await?; assert_eq!(config.locale, Some("en_US.UTF-8".to_string())); - let message = message::SetConfig::new(config.clone()); + let message = message::SetConfig::with(config.clone()); handler.call(message).await?; // Wait until the action is dispatched. let _ = handler.call(message::GetConfig).await?; @@ -247,7 +271,7 @@ mod tests { locale: Some("xx_XX.UTF-8".to_string()), timezone: Some("Unknown/Unknown".to_string()), }; - let _ = handler.call(message::SetConfig::new(config)).await?; + let _ = handler.call(message::SetConfig::with(config)).await?; let found_issues = issues.call(issue::message::Get).await?; let l10n_issues = found_issues.get(&Scope::L10n).unwrap(); @@ -277,7 +301,7 @@ mod tests { keymap: Some("es".to_string()), timezone: Some("Atlantic/Canary".to_string()), }; - let message = message::SetConfig::new(input_config.clone()); + let message = message::SetConfig::with(input_config.clone()); handler.call(message).await?; let proposal = handler diff --git a/rust/agama-lib/share/examples/profile_tw.json b/rust/agama-lib/share/examples/profile_tw.json index 2921de1c48..0240823880 100644 --- a/rust/agama-lib/share/examples/profile_tw.json +++ b/rust/agama-lib/share/examples/profile_tw.json @@ -2,7 +2,7 @@ "localization": { "keyboard": "es", "language": "es_ES.UTF-8", - "keymap": "es_ES.UTF-8" + "timezone": "Europe/Berlin" }, "software": { "patterns": ["gnome"], diff --git a/rust/agama-lib/share/profile.schema.json b/rust/agama-lib/share/profile.schema.json index 53fc19912e..14ee57cfb8 100644 --- a/rust/agama-lib/share/profile.schema.json +++ b/rust/agama-lib/share/profile.schema.json @@ -785,9 +785,33 @@ } } }, - "localization": { + "l10n": { "title": "Localization settings", "type": "object", + "additionalProperties": false, + "properties": { + "locale": { + "title": "Locale ID", + "type": "string", + "examples": ["en_US.UTF-8", "en_US"] + }, + "keymap": { + "title": "Keymap ID", + "type": "string", + "examples": ["us", "en", "es"] + }, + "timezone": { + "title": "Time zone ID", + "type": "string", + "examples": ["Europe/Berlin"] + } + } + }, + "localization": { + "deprecated": true, + "title": "Localization settings (old schema)", + "type": "object", + "additionalProperties": false, "properties": { "language": { "title": "System language ID", diff --git a/rust/agama-lib/src/questions/http_client.rs b/rust/agama-lib/src/questions/http_client.rs index 642f58b75e..b4f3227f5f 100644 --- a/rust/agama-lib/src/questions/http_client.rs +++ b/rust/agama-lib/src/questions/http_client.rs @@ -21,7 +21,7 @@ use std::time::Duration; use agama_utils::api::{ - config::Patch, + patch::{self, Patch}, question::{ Answer, AnswerRule, Config as QuestionsConfig, Policy, Question, QuestionSpec, UpdateQuestion, @@ -38,6 +38,8 @@ pub enum QuestionsHTTPClientError { HTTP(#[from] BaseHTTPClientError), #[error("Unknown question with ID {0}")] UnknownQuestion(u32), + #[error(transparent)] + Patch(#[from] patch::Error), } pub struct HTTPClient { @@ -99,9 +101,7 @@ impl HTTPClient { ..Default::default() }; - let patch = Patch { - update: Some(config), - }; + let patch = Patch::with_update(&config)?; _ = self.client.patch_void("/v2/config", &patch).await?; Ok(()) @@ -120,9 +120,7 @@ impl HTTPClient { ..Default::default() }; - let patch = Patch { - update: Some(config), - }; + let patch = Patch::with_update(&config)?; self.client.patch_void("/v2/config", &patch).await?; Ok(()) } diff --git a/rust/agama-manager/Cargo.toml b/rust/agama-manager/Cargo.toml index af87220e5d..7900b723d9 100644 --- a/rust/agama-manager/Cargo.toml +++ b/rust/agama-manager/Cargo.toml @@ -8,11 +8,13 @@ edition.workspace = true agama-utils = { path = "../agama-utils" } agama-l10n = { path = "../agama-l10n" } agama-software = { path = "../agama-software" } +agama-storage = { path = "../agama-storage" } thiserror = "2.0.12" tokio = { version = "1.40.0", features = ["macros", "rt-multi-thread", "sync"] } async-trait = "0.1.83" zbus = { version = "5", default-features = false, features = ["tokio"] } merge-struct = "0.1.0" +serde_json = "1.0.140" [dev-dependencies] tokio-test = "0.4.4" diff --git a/rust/agama-manager/src/lib.rs b/rust/agama-manager/src/lib.rs index 465aa1e5eb..3eb24e4cec 100644 --- a/rust/agama-manager/src/lib.rs +++ b/rust/agama-manager/src/lib.rs @@ -28,3 +28,4 @@ pub mod message; pub use agama_l10n as l10n; pub use agama_software as software; +pub use agama_storage as storage; diff --git a/rust/agama-manager/src/message.rs b/rust/agama-manager/src/message.rs index 47b79216df..83676f56a6 100644 --- a/rust/agama-manager/src/message.rs +++ b/rust/agama-manager/src/message.rs @@ -22,6 +22,7 @@ use agama_utils::{ actor::Message, api::{Action, Config, IssueMap, Proposal, Status, SystemInfo}, }; +use serde_json::Value; /// Gets the installation status. pub struct GetStatus; @@ -118,3 +119,25 @@ impl RunAction { impl Message for RunAction { type Reply = (); } + +// Gets the storage model. +pub struct GetStorageModel; + +impl Message for GetStorageModel { + type Reply = Option; +} + +// Sets the storage model. +pub struct SetStorageModel { + pub model: Value, +} + +impl SetStorageModel { + pub fn new(model: Value) -> Self { + Self { model } + } +} + +impl Message for SetStorageModel { + type Reply = (); +} diff --git a/rust/agama-manager/src/service.rs b/rust/agama-manager/src/service.rs index 23698dbaff..452be1de62 100644 --- a/rust/agama-manager/src/service.rs +++ b/rust/agama-manager/src/service.rs @@ -20,12 +20,11 @@ use std::sync::Arc; -use crate::message; -use crate::{l10n, software}; +use crate::{l10n, message, software, storage}; use agama_utils::{ actor::{self, Actor, Handler, MessageHandler}, api::{ - event, manager, status::State, Action, Config, Event, Issue, IssueMap, IssueSeverity, + self, event, manager, status::State, Action, Config, Event, Issue, IssueMap, IssueSeverity, Proposal, Scope, Status, SystemInfo, }, issue, @@ -35,6 +34,7 @@ use agama_utils::{ }; use async_trait::async_trait; use merge_struct::merge; +use serde_json::Value; use tokio::sync::{broadcast, RwLock}; #[derive(Debug, thiserror::Error)] @@ -46,12 +46,12 @@ pub enum Error { #[error(transparent)] Actor(#[from] actor::Error), #[error(transparent)] - Progress(#[from] progress::service::Error), - #[error(transparent)] L10n(#[from] l10n::service::Error), #[error(transparent)] Software(#[from] software::service::Error), #[error(transparent)] + Storage(#[from] storage::service::Error), + #[error(transparent)] Issues(#[from] issue::service::Error), #[error(transparent)] Questions(#[from] question::service::Error), @@ -59,11 +59,14 @@ pub enum Error { ProductsRegistry(#[from] ProductsRegistryError), #[error(transparent)] License(#[from] LicenseError), + #[error(transparent)] + Progress(#[from] progress::service::Error), } pub struct Service { l10n: Handler, software: Handler, + storage: Handler, issues: Handler, progress: Handler, questions: Handler, @@ -80,6 +83,7 @@ impl Service { pub fn new( l10n: Handler, software: Handler, + storage: Handler, issues: Handler, progress: Handler, questions: Handler, @@ -88,6 +92,7 @@ impl Service { Self { l10n, software, + storage, issues, progress, questions, @@ -128,6 +133,27 @@ impl Service { Ok(()) } + async fn configure_l10n(&self, config: api::l10n::SystemConfig) -> Result<(), Error> { + self.l10n + .call(l10n::message::SetSystem::new(config.clone())) + .await?; + if let Some(locale) = config.locale { + self.storage + .cast(storage::message::SetLocale::new(locale.as_str()))?; + } + Ok(()) + } + + async fn activate_storage(&self) -> Result<(), Error> { + self.storage.call(storage::message::Activate).await?; + Ok(()) + } + + async fn probe_storage(&self) -> Result<(), Error> { + self.storage.call(storage::message::Probe).await?; + Ok(()) + } + async fn install(&mut self) -> Result<(), Error> { self.state = State::Installing; self.events.send(Event::StateChanged)?; @@ -155,7 +181,7 @@ impl Service { ); _ = self .issues - .cast(issue::message::Update::new(Scope::Manager, vec![issue])); + .cast(issue::message::Set::new(Scope::Manager, vec![issue])); } } @@ -181,7 +207,12 @@ impl MessageHandler for Service { async fn handle(&mut self, _message: message::GetSystem) -> Result { let l10n = self.l10n.call(l10n::message::GetSystem).await?; let manager = self.system.clone(); - Ok(SystemInfo { manager, l10n }) + let storage = self.storage.call(storage::message::GetSystem).await?; + Ok(SystemInfo { + l10n, + manager, + storage, + }) } } @@ -194,10 +225,12 @@ impl MessageHandler for Service { let l10n = self.l10n.call(l10n::message::GetConfig).await?; let software = self.software.call(software::message::GetConfig).await?; let questions = self.questions.call(question::message::GetConfig).await?; + let storage = self.storage.call(storage::message::GetConfig).await?; Ok(Config { l10n: Some(l10n), - questions: Some(questions), + questions, software: Some(software), + storage, }) } } @@ -215,6 +248,7 @@ impl MessageHandler for Service { #[async_trait] impl MessageHandler for Service { /// Sets the user configuration with the given values. + /// Sets the config. async fn handle(&mut self, message: message::SetConfig) -> Result<(), Error> { let product_id = message .config @@ -232,41 +266,64 @@ impl MessageHandler for Service { } self.config = message.config.clone(); + let config = message.config; - if let Some(l10n) = &message.config.l10n { - self.l10n - .call(l10n::message::SetConfig::new(l10n.clone())) - .await?; - } - - if let Some(questions) = &message.config.questions { - self.questions - .call(question::message::SetConfig::new(questions.clone())) - .await?; - } + self.questions + .call(question::message::SetConfig::new(config.questions.clone())) + .await?; if let Some(product) = &self.product { self.software .call(software::message::SetConfig::new( Arc::clone(&product), - message.config.software.clone(), + config.software.clone(), )) .await?; } else { self.notify_no_product(); } + + self.l10n + .call(l10n::message::SetConfig::new(config.l10n.clone())) + .await?; + + self.storage + .call(storage::message::SetConfig::new(config.storage.clone())) + .await?; + Ok(()) } } #[async_trait] impl MessageHandler for Service { - /// Patches the user configuration with the given values. + /// Patches the config. /// - /// It merges the current configuration with the given one. + /// It merges the current config with the given one. If some scope is missing in the given + /// config, then it keeps the values from the current config. async fn handle(&mut self, message: message::UpdateConfig) -> Result<(), Error> { let config = merge(&self.config, &message.config).map_err(|_| Error::MergeConfig)?; - self.handle(message::SetConfig::new(config)).await + + if let Some(l10n) = &config.l10n { + self.l10n + .call(l10n::message::SetConfig::with(l10n.clone())) + .await?; + } + + if let Some(questions) = &config.questions { + self.questions + .call(question::message::SetConfig::with(questions.clone())) + .await?; + } + + if let Some(storage) = &config.storage { + self.storage + .call(storage::message::SetConfig::with(storage.clone())) + .await?; + } + + self.config = config; + Ok(()) } } @@ -276,7 +333,12 @@ impl MessageHandler for Service { async fn handle(&mut self, _message: message::GetProposal) -> Result, Error> { let l10n = self.l10n.call(l10n::message::GetProposal).await?; let software = self.software.call(software::message::GetProposal).await?; - Ok(Some(Proposal { l10n, software })) + let storage = self.storage.call(storage::message::GetProposal).await?; + Ok(Some(Proposal { + l10n, + software, + storage, + })) } } @@ -294,8 +356,13 @@ impl MessageHandler for Service { async fn handle(&mut self, message: message::RunAction) -> Result<(), Error> { match message.action { Action::ConfigureL10n(config) => { - let l10n_message = l10n::message::SetSystem::new(config); - self.l10n.call(l10n_message).await?; + self.configure_l10n(config).await?; + } + Action::ActivateStorage => { + self.activate_storage().await?; + } + Action::ProbeStorage => { + self.probe_storage().await?; } Action::Install => { self.install().await?; @@ -304,3 +371,22 @@ impl MessageHandler for Service { Ok(()) } } + +#[async_trait] +impl MessageHandler for Service { + /// It returns the storage model. + async fn handle(&mut self, _message: message::GetStorageModel) -> Result, Error> { + Ok(self.storage.call(storage::message::GetConfigModel).await?) + } +} + +#[async_trait] +impl MessageHandler for Service { + /// It sets the storage model. + async fn handle(&mut self, message: message::SetStorageModel) -> Result<(), Error> { + Ok(self + .storage + .call(storage::message::SetConfigModel::new(message.model)) + .await?) + } +} diff --git a/rust/agama-manager/src/start.rs b/rust/agama-manager/src/start.rs index a2dfbbd025..ae5aeb59f3 100644 --- a/rust/agama-manager/src/start.rs +++ b/rust/agama-manager/src/start.rs @@ -18,11 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::{ - l10n, - service::{self, Service}, - software, -}; +use crate::{l10n, service::Service, software, storage}; use agama_utils::{ actor::{self, Handler}, api::event, @@ -36,37 +32,40 @@ pub enum Error { #[error(transparent)] L10n(#[from] l10n::start::Error), #[error(transparent)] + Manager(#[from] crate::service::Error), + #[error(transparent)] Software(#[from] software::start::Error), #[error(transparent)] - Issues(#[from] issue::start::Error), + Storage(#[from] storage::start::Error), #[error(transparent)] - Service(#[from] service::Error), + Issues(#[from] issue::start::Error), } /// Starts the manager service. /// -/// It starts two Tokio tasks: -/// -/// * The main service, called "Manager", which coordinates the rest of services -/// an entry point for the HTTP API. -/// * An events listener which retransmit the events from all the services. -/// -/// It receives the following argument: -/// /// * `events`: channel to emit the [events](agama_utils::Event). /// * `dbus`: connection to Agama's D-Bus server. If it is not given, those features /// that require to connect to the Agama's D-Bus server won't work. pub async fn start( questions: Handler, events: event::Sender, - dbus: Option, + dbus: zbus::Connection, ) -> Result, Error> { - let issues = issue::start(events.clone(), dbus).await?; + let issues = issue::start(events.clone(), dbus.clone()).await?; let progress = progress::start(events.clone()).await?; let l10n = l10n::start(issues.clone(), events.clone()).await?; let software = software::start(issues.clone(), progress.clone(), events.clone()).await?; - - let mut service = Service::new(l10n, software, issues, progress, questions, events.clone()); + let storage = storage::start(progress.clone(), issues.clone(), events.clone(), dbus).await?; + + let mut service = Service::new( + l10n, + software, + storage, + issues, + progress, + questions, + events.clone(), + ); service.setup().await?; let handler = actor::spawn(service); Ok(handler) @@ -74,18 +73,18 @@ pub async fn start( #[cfg(test)] mod test { - use crate as manager; - use crate::message; - use crate::service::Service; - use agama_utils::actor::Handler; - use agama_utils::api::l10n; - use agama_utils::api::{Config, Event}; - use agama_utils::question; + use crate::{self as manager, message, service::Service}; + use agama_utils::{ + actor::Handler, + api::{l10n, Config, Event}, + question, test, + }; use std::path::PathBuf; use tokio::sync::broadcast; async fn start_service() -> Handler { let (events_sender, mut events_receiver) = broadcast::channel::(16); + let dbus = test::dbus::connection().await.unwrap(); tokio::spawn(async move { while let Ok(event) = events_receiver.recv().await { @@ -94,7 +93,7 @@ mod test { }); let questions = question::start(events_sender.clone()).await.unwrap(); - manager::start(questions, events_sender, None) + manager::start(questions, events_sender, dbus) .await .unwrap() } diff --git a/rust/agama-server/src/server.rs b/rust/agama-server/src/server.rs index a429170043..e06d2546f7 100644 --- a/rust/agama-server/src/server.rs +++ b/rust/agama-server/src/server.rs @@ -20,3 +20,5 @@ pub mod web; pub use web::server_service; + +mod config_schema; diff --git a/rust/agama-server/src/server/config_schema.rs b/rust/agama-server/src/server/config_schema.rs new file mode 100644 index 0000000000..d291176b80 --- /dev/null +++ b/rust/agama-server/src/server/config_schema.rs @@ -0,0 +1,45 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +//! This module provides utilities to check the config schema. + +use agama_lib::{ + error::ProfileError, + profile::{ProfileValidator, ValidationOutcome}, +}; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("The config does not match the schema: {0}")] + Schema(String), + #[error(transparent)] + ProfileValidator(#[from] ProfileError), + #[error(transparent)] + Json(#[from] serde_json::Error), +} + +pub fn check(json: &serde_json::Value) -> Result<(), Error> { + let raw_json = serde_json::to_string(json)?; + let result = ProfileValidator::default_schema()?.validate_str(&raw_json)?; + match result { + ValidationOutcome::Valid => Ok(()), + ValidationOutcome::NotValid(reasons) => Err(Error::Schema(reasons.join(", "))), + } +} diff --git a/rust/agama-server/src/server/web.rs b/rust/agama-server/src/server/web.rs index cb8e166670..389b686cc7 100644 --- a/rust/agama-server/src/server/web.rs +++ b/rust/agama-server/src/server/web.rs @@ -20,14 +20,15 @@ //! This module implements Agama's HTTP API. +use crate::server::config_schema; use agama_lib::error::ServiceError; use agama_manager::{self as manager, message}; use agama_utils::{ actor::Handler, api::{ - config, event, + event, question::{Question, QuestionSpec, UpdateQuestion}, - Action, Config, IssueMap, Status, SystemInfo, + Action, Config, IssueMap, Patch, Status, SystemInfo, }, question, }; @@ -39,7 +40,7 @@ use axum::{ }; use hyper::StatusCode; use serde::Serialize; -use serde_json::json; +use serde_json::{json, Value}; #[derive(thiserror::Error, Debug)] pub enum Error { @@ -47,6 +48,10 @@ pub enum Error { Manager(#[from] manager::service::Error), #[error(transparent)] Questions(#[from] question::service::Error), + #[error(transparent)] + ConfigSchema(#[from] config_schema::Error), + #[error(transparent)] + Json(#[from] serde_json::Error), } impl IntoResponse for Error { @@ -74,7 +79,7 @@ type ServerResult = Result; /// that require to connect to the Agama's D-Bus server won't work. pub async fn server_service( events: event::Sender, - dbus: Option, + dbus: zbus::Connection, ) -> Result { let questions = question::start(events.clone()) .await @@ -100,6 +105,10 @@ pub async fn server_service( "/questions", get(get_questions).post(ask_question).patch(update_question), ) + .route( + "/private/storage_model", + get(get_storage_model).put(set_storage_model), + ) .with_state(state)) } @@ -175,13 +184,12 @@ async fn get_config(State(state): State) -> ServerResult, - Json(config): Json, -) -> ServerResult<()> { +async fn put_config(State(state): State, Json(json): Json) -> ServerResult<()> { + config_schema::check(&json)?; + let config = serde_json::from_value(json)?; state.manager.call(message::SetConfig::new(config)).await?; Ok(()) } @@ -198,14 +206,16 @@ async fn put_config( (status = 400, description = "Not possible to patch the configuration.") ), params( - ("config" = Config, description = "Changes in the configuration.") + ("patch" = Patch, description = "Changes in the configuration.") ) )] async fn patch_config( State(state): State, - Json(patch): Json, + Json(patch): Json, ) -> ServerResult<()> { - if let Some(config) = patch.update { + if let Some(json) = patch.update { + config_schema::check(&json)?; + let config = serde_json::from_value(json)?; state .manager .call(message::UpdateConfig::new(config)) @@ -241,8 +251,7 @@ async fn get_proposal(State(state): State) -> ServerResult) -> ServerResult> { let issues = state.manager.call(message::GetIssues).await?; - let issues_map: IssueMap = issues.into(); - Ok(Json(issues_map)) + Ok(Json(issues)) } /// Returns the issues for each scope. @@ -333,6 +342,42 @@ async fn run_action( Ok(()) } +/// Returns how the target system is configured (proposal). +#[utoipa::path( + get, + path = "/private/storage_model", + context_path = "/api/v2", + responses( + (status = 200, description = "Storage model was successfully retrieved."), + (status = 400, description = "Not possible to retrieve the storage model.") + ) +)] +async fn get_storage_model(State(state): State) -> ServerResult>> { + let model = state.manager.call(message::GetStorageModel).await?; + Ok(Json(model)) +} + +#[utoipa::path( + put, + request_body = String, + path = "/private/storage_model", + context_path = "/api/v2", + responses( + (status = 200, description = "Set the storage model"), + (status = 400, description = "Not possible to set the storage model") + ) +)] +async fn set_storage_model( + State(state): State, + Json(model): Json, +) -> ServerResult<()> { + state + .manager + .call(message::SetStorageModel::new(model)) + .await?; + Ok(()) +} + fn to_option_response(value: Option) -> Response { match value { Some(inner) => Json(inner).into_response(), diff --git a/rust/agama-server/src/web.rs b/rust/agama-server/src/web.rs index 14ba58a4c3..5ee67b90b5 100644 --- a/rust/agama-server/src/web.rs +++ b/rust/agama-server/src/web.rs @@ -87,7 +87,7 @@ where "/manager", manager_service(dbus.clone(), progress.clone()).await?, ) - .add_service("/v2", server_service(events, Some(dbus.clone())).await?) + .add_service("/v2", server_service(events, dbus.clone()).await?) .add_service("/security", security_service(dbus.clone()).await?) .add_service("/storage", storage_service(dbus.clone(), progress).await?) .add_service("/iscsi", iscsi_service(dbus.clone()).await?) diff --git a/rust/agama-server/src/web/docs/config.rs b/rust/agama-server/src/web/docs/config.rs index a36c1c1211..730404c4a1 100644 --- a/rust/agama-server/src/web/docs/config.rs +++ b/rust/agama-server/src/web/docs/config.rs @@ -155,7 +155,7 @@ impl ApiDocBuilder for ConfigApiDocBuilder { .schema_from::() .schema_from::() .schema_from::() - .schema_from::() + .schema_from::() .schema_from::() .schema_from::() .schema_from::() diff --git a/rust/agama-server/tests/common/mod.rs b/rust/agama-server/tests/common/mod.rs index d5af955102..91f34ada8d 100644 --- a/rust/agama-server/tests/common/mod.rs +++ b/rust/agama-server/tests/common/mod.rs @@ -18,113 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use agama_lib::error::ServiceError; use axum::body::{to_bytes, Body}; -use std::{ - future::Future, - process::{Child, Command}, - time::Duration, -}; - -use uuid::Uuid; - -/// D-Bus server to be used on tests. -/// -/// Takes care of starting and stopping a dbus-daemon to be used on integration tests. Each server -/// uses a different socket, so they do not collide. -/// -/// NOTE: this struct implements the [typestate pattern](http://cliffle.com/blog/rust-typestate/). -pub struct DBusServer { - address: String, - extra: S, -} - -pub struct Started { - connection: zbus::Connection, - child: Child, -} - -impl Drop for Started { - fn drop(&mut self) { - self.child.kill().unwrap(); - } -} - -pub struct Stopped; - -pub trait ServerState {} -impl ServerState for Started {} -impl ServerState for Stopped {} - -impl Default for DBusServer { - fn default() -> Self { - Self::new() - } -} - -impl DBusServer { - pub fn new() -> Self { - let uuid = Uuid::new_v4(); - DBusServer { - address: format!("unix:path=/tmp/agama-tests-{uuid}"), - extra: Stopped, - } - } - - pub async fn start(self) -> Result, ServiceError> { - let child = Command::new("/usr/bin/dbus-daemon") - .args([ - "--config-file", - "../share/dbus-test.conf", - "--address", - &self.address, - ]) - .spawn() - .expect("to start the testing D-Bus daemon"); - - let connection = async_retry(|| agama_lib::connection_to(&self.address)).await?; - - Ok(DBusServer { - address: self.address, - extra: Started { child, connection }, - }) - } -} - -impl DBusServer { - pub fn connection(&self) -> zbus::Connection { - self.extra.connection.clone() - } -} - -/// Run and retry an async function. -/// -/// Beware that, if the function is failing for a legit reason, you will -/// introduce a delay in your code. -/// -/// * `func`: async function to run. -pub async fn async_retry(func: F) -> Result -where - F: Fn() -> O, - O: Future>, -{ - const RETRIES: u8 = 10; - const INTERVAL: u64 = 500; - let mut retry = 0; - loop { - match func().await { - Ok(result) => return Ok(result), - Err(error) => { - if retry > RETRIES { - return Err(error); - } - retry += 1; - let wait_time = Duration::from_millis(INTERVAL); - tokio::time::sleep(wait_time).await; - } - } - } -} pub async fn body_to_string(body: Body) -> String { let bytes = to_bytes(body, usize::MAX).await.unwrap(); diff --git a/rust/agama-server/tests/server_service.rs b/rust/agama-server/tests/server_service.rs index e21df0f34f..2b91e84657 100644 --- a/rust/agama-server/tests/server_service.rs +++ b/rust/agama-server/tests/server_service.rs @@ -21,7 +21,7 @@ pub mod common; use agama_lib::error::ServiceError; use agama_server::server::server_service; -use agama_utils::api; +use agama_utils::{api, test}; use axum::{ body::Body, http::{Method, Request, StatusCode}, @@ -38,6 +38,7 @@ async fn build_server_service() -> Result { std::env::set_var("AGAMA_SHARE_DIR", share_dir.display().to_string()); let (tx, mut rx) = channel(16); + let dbus = test::dbus::connection().await.unwrap(); tokio::spawn(async move { while let Ok(event) = rx.recv().await { @@ -45,7 +46,7 @@ async fn build_server_service() -> Result { } }); - server_service(tx, None).await + server_service(tx, dbus).await } #[test] @@ -179,16 +180,15 @@ async fn test_patch_config() -> Result<(), Box> { let response = server_service.clone().oneshot(request).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); - let patch = api::config::Patch { - update: Some(api::Config { - l10n: Some(api::l10n::Config { - locale: None, - keymap: Some("en".to_string()), - timezone: None, - }), - ..Default::default() + let config = api::Config { + l10n: Some(api::l10n::Config { + locale: None, + keymap: Some("en".to_string()), + timezone: None, }), + ..Default::default() }; + let patch = agama_utils::api::Patch::with_update(&config).unwrap(); let request = Request::builder() .uri("/config") diff --git a/rust/agama-software/src/service.rs b/rust/agama-software/src/service.rs index cf1dbdc906..087fac9cf3 100644 --- a/rust/agama-software/src/service.rs +++ b/rust/agama-software/src/service.rs @@ -171,7 +171,7 @@ impl MessageHandler> for Service { } }; proposal.write().await.software = new_proposal; - _ = issues.cast(issue::message::Update::new(Scope::Software, found_issues)); + _ = issues.cast(issue::message::Set::new(Scope::Software, found_issues)); _ = events.send(Event::ProposalChanged { scope: Scope::Software, }); diff --git a/rust/agama-storage/Cargo.toml b/rust/agama-storage/Cargo.toml new file mode 100644 index 0000000000..ed7794abf6 --- /dev/null +++ b/rust/agama-storage/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "agama-storage" +version = "0.1.0" +rust-version.workspace = true +edition.workspace = true + +[dependencies] +agama-utils = { path = "../agama-utils" } +thiserror = "2.0.16" +async-trait = "0.1.89" +zbus = "5.7.1" +tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread", "sync"] } +tokio-stream = "0.1.16" +serde = { version = "1.0.228" } +serde_json = "1.0.140" diff --git a/rust/agama-storage/src/client.rs b/rust/agama-storage/src/client.rs new file mode 100644 index 0000000000..d1f291c4ff --- /dev/null +++ b/rust/agama-storage/src/client.rs @@ -0,0 +1,151 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +//! Implements a client to access Agama's storage service. + +use agama_utils::api::{storage::Config, Issue}; +use serde_json::Value; +use zbus::{names::BusName, zvariant::OwnedObjectPath, Connection, Message}; + +const SERVICE_NAME: &str = "org.opensuse.Agama.Storage1"; +const OBJECT_PATH: &str = "/org/opensuse/Agama/Storage1"; +const INTERFACE: &str = "org.opensuse.Agama.Storage1"; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error(transparent)] + DBus(#[from] zbus::Error), + #[error(transparent)] + DBusName(#[from] zbus::names::Error), + #[error(transparent)] + DBusVariant(#[from] zbus::zvariant::Error), + #[error(transparent)] + Json(#[from] serde_json::Error), +} + +/// D-Bus client for the storage service +#[derive(Clone)] +pub struct Client { + connection: Connection, +} + +impl Client { + pub fn new(connection: Connection) -> Self { + Self { connection } + } + + pub async fn activate(&self) -> Result<(), Error> { + self.call("Activate", &()).await?; + Ok(()) + } + + pub async fn probe(&self) -> Result<(), Error> { + self.call("Probe", &()).await?; + Ok(()) + } + + pub async fn install(&self) -> Result<(), Error> { + self.call("Install", &()).await?; + Ok(()) + } + + pub async fn finish(&self) -> Result<(), Error> { + self.call("Finish", &()).await?; + Ok(()) + } + + pub async fn get_system(&self) -> Result, Error> { + let message = self.call("GetSystem", &()).await?; + try_from_message(message) + } + + pub async fn get_config(&self) -> Result, Error> { + let message = self.call("GetConfig", &()).await?; + try_from_message(message) + } + + pub async fn get_config_model(&self) -> Result, Error> { + let message = self.call("GetConfigModel", &()).await?; + try_from_message(message) + } + + pub async fn get_proposal(&self) -> Result, Error> { + let message = self.call("GetProposal", &()).await?; + try_from_message(message) + } + + pub async fn get_issues(&self) -> Result, Error> { + let message = self.call("GetIssues", &()).await?; + try_from_message(message) + } + + //TODO: send a product config instead of an id. + pub async fn set_product(&self, id: String) -> Result<(), Error> { + self.call("SetProduct", &(id)).await?; + Ok(()) + } + + pub async fn set_config(&self, config: Option) -> Result<(), Error> { + let config = config.filter(|c| c.has_value()); + let json = serde_json::to_string(&config)?; + self.call("SetConfig", &(json)).await?; + Ok(()) + } + + pub async fn set_config_model(&self, model: Value) -> Result<(), Error> { + self.call("SetConfigModel", &(model.to_string())).await?; + Ok(()) + } + + pub async fn solve_config_model(&self, model: Value) -> Result, Error> { + let message = self.call("SolveConfigModel", &(model.to_string())).await?; + try_from_message(message) + } + + pub async fn set_locale(&self, locale: String) -> Result<(), Error> { + self.call("SetLocale", &(locale)).await?; + Ok(()) + } + + async fn call( + &self, + method: &str, + body: &T, + ) -> Result { + let bus = BusName::try_from(SERVICE_NAME.to_string())?; + let path = OwnedObjectPath::try_from(OBJECT_PATH)?; + self.connection + .call_method(Some(&bus), &path, Some(INTERFACE), method, body) + .await + .map_err(|e| e.into()) + } +} + +fn try_from_message( + message: Message, +) -> Result { + let raw_json: String = message.body().deserialize()?; + let json: Value = serde_json::from_str(&raw_json)?; + if json.is_null() { + return Ok(T::default()); + } + let value = serde_json::from_value(json)?; + Ok(value) +} diff --git a/rust/agama-storage/src/lib.rs b/rust/agama-storage/src/lib.rs new file mode 100644 index 0000000000..13c321cd2a --- /dev/null +++ b/rust/agama-storage/src/lib.rs @@ -0,0 +1,29 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +pub mod start; +pub use start::start; + +pub mod service; +pub use service::Service; + +mod client; +pub mod message; +mod monitor; diff --git a/rust/agama-storage/src/message.rs b/rust/agama-storage/src/message.rs new file mode 100644 index 0000000000..6cdc47cb60 --- /dev/null +++ b/rust/agama-storage/src/message.rs @@ -0,0 +1,171 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use agama_utils::{ + actor::Message, + api::{storage::Config, Issue}, +}; +use serde_json::Value; + +#[derive(Clone)] +pub struct Activate; + +impl Message for Activate { + type Reply = (); +} + +#[derive(Clone)] +pub struct Probe; + +impl Message for Probe { + type Reply = (); +} + +#[derive(Clone)] +pub struct Install; + +impl Message for Install { + type Reply = (); +} + +#[derive(Clone)] +pub struct Finish; + +impl Message for Finish { + type Reply = (); +} + +#[derive(Clone)] +pub struct GetSystem; + +impl Message for GetSystem { + type Reply = Option; +} + +#[derive(Clone)] +pub struct GetConfig; + +impl Message for GetConfig { + type Reply = Option; +} + +#[derive(Clone)] +pub struct GetConfigModel; + +impl Message for GetConfigModel { + type Reply = Option; +} + +#[derive(Clone)] +pub struct GetProposal; + +impl Message for GetProposal { + type Reply = Option; +} + +#[derive(Clone)] +pub struct GetIssues; + +impl Message for GetIssues { + type Reply = Vec; +} + +#[derive(Clone)] +pub struct SetProduct { + pub id: String, +} + +impl SetProduct { + pub fn new(id: &str) -> Self { + Self { id: id.to_string() } + } +} + +impl Message for SetProduct { + type Reply = (); +} + +#[derive(Clone)] +pub struct SetConfig { + pub config: Option, +} + +impl SetConfig { + pub fn new(config: Option) -> Self { + Self { config } + } + + pub fn with(config: Config) -> Self { + Self { + config: Some(config), + } + } +} + +impl Message for SetConfig { + type Reply = (); +} + +#[derive(Clone)] +pub struct SetConfigModel { + pub model: Value, +} + +impl SetConfigModel { + pub fn new(model: Value) -> Self { + Self { model } + } +} + +impl Message for SetConfigModel { + type Reply = (); +} + +#[derive(Clone)] +pub struct SolveConfigModel { + pub model: Value, +} + +impl SolveConfigModel { + pub fn new(model: Value) -> Self { + Self { model } + } +} + +impl Message for SolveConfigModel { + type Reply = Option; +} + +#[derive(Clone)] +pub struct SetLocale { + pub locale: String, +} + +impl SetLocale { + pub fn new(locale: &str) -> Self { + Self { + locale: locale.to_string(), + } + } +} + +impl Message for SetLocale { + type Reply = (); +} diff --git a/rust/agama-storage/src/monitor.rs b/rust/agama-storage/src/monitor.rs new file mode 100644 index 0000000000..2eb3f992f9 --- /dev/null +++ b/rust/agama-storage/src/monitor.rs @@ -0,0 +1,253 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use crate::{ + message, + service::{self, Service}, +}; +use agama_utils::{ + actor::Handler, + api::{ + event::{self, Event}, + Progress, Scope, + }, + issue, progress, +}; +use serde::Deserialize; +use serde_json; +use std::pin::Pin; +use tokio::sync::broadcast; +use tokio_stream::{Stream, StreamExt, StreamMap}; +use zbus::{proxy, Connection}; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("Wrong signal arguments")] + ProgressChangedArgs, + #[error("Wrong signal data")] + ProgressChangedData, + #[error(transparent)] + Service(#[from] service::Error), + #[error(transparent)] + Issue(#[from] issue::service::Error), + #[error(transparent)] + Progress(#[from] progress::service::Error), + #[error(transparent)] + DBus(#[from] zbus::Error), + #[error(transparent)] + Event(#[from] broadcast::error::SendError), +} + +#[proxy( + default_service = "org.opensuse.Agama.Storage1", + default_path = "/org/opensuse/Agama/Storage1", + interface = "org.opensuse.Agama.Storage1", + assume_defaults = true +)] +pub trait Storage1 { + #[zbus(signal)] + fn system_changed(&self, system: &str) -> zbus::Result<()>; + + #[zbus(signal)] + fn proposal_changed(&self, proposal: &str) -> zbus::Result<()>; + + #[zbus(signal)] + fn progress_changed(&self, progress: &str) -> zbus::Result<()>; + + #[zbus(signal)] + fn progress_finished(&self) -> zbus::Result<()>; +} + +#[derive(Debug)] +enum Signal { + SystemChanged(SystemChanged), + ProposalChanged(ProposalChanged), + ProgressChanged(ProgressChanged), + ProgressFinished(ProgressFinished), +} + +#[derive(Debug, Deserialize)] +struct ProgressData { + pub size: usize, + pub steps: Vec, + pub step: String, + pub index: usize, +} + +impl From for Progress { + fn from(data: ProgressData) -> Self { + Progress { + scope: Scope::Storage, + size: data.size, + steps: data.steps, + step: data.step, + index: data.index, + } + } +} + +pub struct Monitor { + storage: Handler, + progress: Handler, + issues: Handler, + events: event::Sender, + connection: Connection, +} + +impl Monitor { + pub fn new( + storage: Handler, + progress: Handler, + issues: Handler, + events: event::Sender, + connection: Connection, + ) -> Self { + Self { + storage, + progress, + issues, + events, + connection, + } + } + + async fn run(&self) -> Result<(), Error> { + let mut streams = StreamMap::new(); + streams.insert("SystemChanged", self.system_changed_stream().await?); + streams.insert("ProposalChanged", self.proposal_changed_stream().await?); + streams.insert("ProgressChanged", self.progress_changed_stream().await?); + streams.insert("ProgressFinished", self.progress_finished_stream().await?); + + tokio::pin!(streams); + + while let Some((_, signal)) = streams.next().await { + self.handle_signal(signal).await?; + } + + Ok(()) + } + + async fn handle_signal(&self, signal: Signal) -> Result<(), Error> { + match signal { + Signal::SystemChanged(signal) => self.handle_system_changed(signal)?, + Signal::ProposalChanged(signal) => self.handle_proposal_changed(signal).await?, + Signal::ProgressChanged(signal) => self.handle_progress_changed(signal)?, + Signal::ProgressFinished(signal) => self.handle_progress_finished(signal)?, + } + Ok(()) + } + + // TODO: add system info to the event. + fn handle_system_changed(&self, _signal: SystemChanged) -> Result<(), Error> { + self.events.send(Event::SystemChanged { + scope: Scope::Storage, + })?; + Ok(()) + } + + // TODO: add proposal to the event. + async fn handle_proposal_changed(&self, _signal: ProposalChanged) -> Result<(), Error> { + self.events.send(Event::ProposalChanged { + scope: Scope::Storage, + })?; + + let issues = self.storage.call(message::GetIssues).await?; + self.issues + .call(issue::message::Set::new(Scope::Storage, issues)) + .await?; + + Ok(()) + } + + fn handle_progress_changed(&self, signal: ProgressChanged) -> Result<(), Error> { + let Ok(args) = signal.args() else { + return Err(Error::ProgressChangedArgs); + }; + let Ok(progress_data) = serde_json::from_str::(args.progress) else { + return Err(Error::ProgressChangedData); + }; + self.progress + .cast(progress::message::Set::new(progress_data.into()))?; + + Ok(()) + } + + fn handle_progress_finished(&self, _signal: ProgressFinished) -> Result<(), Error> { + self.progress + .cast(progress::message::Finish::new(Scope::Storage))?; + Ok(()) + } + + async fn system_changed_stream( + &self, + ) -> Result + Send>>, Error> { + let proxy = Storage1Proxy::new(&self.connection).await?; + let stream = proxy + .receive_system_changed() + .await? + .map(|signal| Signal::SystemChanged(signal)); + Ok(Box::pin(stream)) + } + + async fn proposal_changed_stream( + &self, + ) -> Result + Send>>, Error> { + let proxy = Storage1Proxy::new(&self.connection).await?; + let stream = proxy + .receive_proposal_changed() + .await? + .map(|signal| Signal::ProposalChanged(signal)); + Ok(Box::pin(stream)) + } + + async fn progress_changed_stream( + &self, + ) -> Result + Send>>, Error> { + let proxy = Storage1Proxy::new(&self.connection).await?; + let stream = proxy + .receive_progress_changed() + .await? + .map(|signal| Signal::ProgressChanged(signal)); + Ok(Box::pin(stream)) + } + + async fn progress_finished_stream( + &self, + ) -> Result + Send>>, Error> { + let proxy = Storage1Proxy::new(&self.connection).await?; + let stream = proxy + .receive_progress_finished() + .await? + .map(|signal| Signal::ProgressFinished(signal)); + Ok(Box::pin(stream)) + } +} + +/// Spawns a Tokio task for the monitor. +/// +/// * `monitor`: monitor to spawn. +pub fn spawn(monitor: Monitor) -> Result<(), Error> { + tokio::spawn(async move { + if let Err(e) = monitor.run().await { + println!("Error running the storage monitor: {e:?}"); + } + }); + Ok(()) +} diff --git a/rust/agama-storage/src/service.rs b/rust/agama-storage/src/service.rs new file mode 100644 index 0000000000..e012d5dc4d --- /dev/null +++ b/rust/agama-storage/src/service.rs @@ -0,0 +1,176 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use crate::{ + client::{self, Client}, + message, +}; +use agama_utils::{ + actor::{self, Actor, Handler, MessageHandler}, + api::{storage::Config, Issue, Scope}, + issue, +}; +use async_trait::async_trait; +use serde_json::Value; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Actor(#[from] actor::Error), + #[error(transparent)] + Client(#[from] client::Error), + #[error(transparent)] + Issue(#[from] issue::service::Error), +} + +/// Storage service. +pub struct Service { + issues: Handler, + client: Client, +} + +impl Service { + pub fn new(issues: Handler, connection: zbus::Connection) -> Service { + Self { + issues, + client: Client::new(connection), + } + } + + pub async fn start(self) -> Result { + let issues = self.client.get_issues().await?; + self.issues + .call(issue::message::Set::new(Scope::Storage, issues)) + .await?; + Ok(self) + } +} + +impl Actor for Service { + type Error = Error; +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, _message: message::Activate) -> Result<(), Error> { + self.client.activate().await?; + Ok(()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, _message: message::Probe) -> Result<(), Error> { + self.client.probe().await?; + Ok(()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, _message: message::Install) -> Result<(), Error> { + self.client.install().await?; + Ok(()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, _message: message::Finish) -> Result<(), Error> { + self.client.finish().await?; + Ok(()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, _message: message::GetSystem) -> Result, Error> { + self.client.get_system().await.map_err(|e| e.into()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, _message: message::GetConfig) -> Result, Error> { + self.client.get_config().await.map_err(|e| e.into()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, _message: message::GetConfigModel) -> Result, Error> { + self.client.get_config_model().await.map_err(|e| e.into()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, _message: message::GetProposal) -> Result, Error> { + self.client.get_proposal().await.map_err(|e| e.into()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, _message: message::GetIssues) -> Result, Error> { + self.client.get_issues().await.map_err(|e| e.into()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, message: message::SetProduct) -> Result<(), Error> { + self.client.set_product(message.id).await?; + Ok(()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, message: message::SetConfig) -> Result<(), Error> { + self.client.set_config(message.config).await?; + Ok(()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, message: message::SetConfigModel) -> Result<(), Error> { + self.client.set_config_model(message.model).await?; + Ok(()) + } +} +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, message: message::SolveConfigModel) -> Result, Error> { + self.client + .solve_config_model(message.model) + .await + .map_err(|e| e.into()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, message: message::SetLocale) -> Result<(), Error> { + self.client.set_locale(message.locale).await?; + Ok(()) + } +} diff --git a/rust/agama-storage/src/start.rs b/rust/agama-storage/src/start.rs new file mode 100644 index 0000000000..018bb889e0 --- /dev/null +++ b/rust/agama-storage/src/start.rs @@ -0,0 +1,53 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use crate::{ + monitor::{self, Monitor}, + service::{self, Service}, +}; +use agama_utils::{ + actor::{self, Handler}, + api::event, + issue, progress, +}; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Monitor(#[from] monitor::Error), + #[error(transparent)] + Service(#[from] service::Error), +} + +/// Starts the storage service. +pub async fn start( + progress: Handler, + issues: Handler, + events: event::Sender, + dbus: zbus::Connection, +) -> Result, Error> { + let service = Service::new(issues.clone(), dbus.clone()).start().await?; + let handler = actor::spawn(service); + + let monitor = Monitor::new(handler.clone(), progress, issues, events, dbus); + monitor::spawn(monitor)?; + + Ok(handler) +} diff --git a/rust/agama-utils/Cargo.toml b/rust/agama-utils/Cargo.toml index edb53ff26c..6a59ba8aee 100644 --- a/rust/agama-utils/Cargo.toml +++ b/rust/agama-utils/Cargo.toml @@ -5,6 +5,7 @@ rust-version.workspace = true edition.workspace = true [dependencies] +agama-locale-data = { path = "../agama-locale-data" } async-trait = "0.1.89" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.140" @@ -17,10 +18,10 @@ utoipa = "5.3.1" zbus = "5.7.1" zvariant = "5.5.2" gettext-rs = { version = "0.7.2", features = ["gettext-system"] } -agama-locale-data = { path = "../agama-locale-data" } regex = "1.12.2" tracing = "0.1.41" serde_yaml = "0.9.34" +uuid = { version = "1.10.0", features = ["v4"] } [dev-dependencies] tokio-test = "0.4.4" diff --git a/rust/agama-utils/src/api.rs b/rust/agama-utils/src/api.rs index f57d021700..b034d7c15b 100644 --- a/rust/agama-utils/src/api.rs +++ b/rust/agama-utils/src/api.rs @@ -42,6 +42,9 @@ pub use system_info::SystemInfo; pub mod config; pub use config::Config; +pub mod patch; +pub use patch::Patch; + mod proposal; pub use proposal::Proposal; @@ -52,3 +55,4 @@ pub mod l10n; pub mod manager; pub mod question; pub mod software; +pub mod storage; diff --git a/rust/agama-utils/src/api/action.rs b/rust/agama-utils/src/api/action.rs index 858f1aaac8..983b3e46ae 100644 --- a/rust/agama-utils/src/api/action.rs +++ b/rust/agama-utils/src/api/action.rs @@ -23,6 +23,10 @@ use serde::Deserialize; #[derive(Debug, Deserialize, utoipa::ToSchema)] pub enum Action { + #[serde(rename = "activateStorage")] + ActivateStorage, + #[serde(rename = "probeStorage")] + ProbeStorage, #[serde(rename = "configureL10n")] ConfigureL10n(l10n::SystemConfig), #[serde(rename = "install")] diff --git a/rust/agama-utils/src/api/config.rs b/rust/agama-utils/src/api/config.rs index 66a03e1f6e..cc5114dca5 100644 --- a/rust/agama-utils/src/api/config.rs +++ b/rust/agama-utils/src/api/config.rs @@ -18,10 +18,11 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::api::{l10n, question, software}; +use crate::api::{l10n, question, software, storage}; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, Default, Deserialize, Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] pub struct Config { #[serde(skip_serializing_if = "Option::is_none")] #[serde(alias = "localization")] @@ -30,11 +31,7 @@ pub struct Config { pub software: Option, #[serde(skip_serializing_if = "Option::is_none")] pub questions: Option, -} - -/// Patch for the config. -#[derive(Deserialize, Serialize, utoipa::ToSchema)] -pub struct Patch { - /// Update for the current config. - pub update: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(flatten)] + pub storage: Option, } diff --git a/rust/agama-utils/src/api/event.rs b/rust/agama-utils/src/api/event.rs index 5f1f966827..3506038445 100644 --- a/rust/agama-utils/src/api/event.rs +++ b/rust/agama-utils/src/api/event.rs @@ -30,7 +30,6 @@ pub enum Event { StateChanged, /// Progress changed. ProgressChanged { - scope: Scope, progress: Progress, }, /// Progress finished. diff --git a/rust/agama-utils/src/api/issue.rs b/rust/agama-utils/src/api/issue.rs index c8bb3a9e63..a3036d14c4 100644 --- a/rust/agama-utils/src/api/issue.rs +++ b/rust/agama-utils/src/api/issue.rs @@ -44,15 +44,15 @@ pub struct Issue { pub details: Option, pub source: IssueSource, pub severity: IssueSeverity, - pub kind: String, + pub class: String, } impl Issue { /// Creates a new issue. - pub fn new(kind: &str, description: &str, severity: IssueSeverity) -> Self { + pub fn new(class: &str, description: &str, severity: IssueSeverity) -> Self { Self { description: description.to_string(), - kind: kind.to_string(), + class: class.to_string(), source: IssueSource::Config, severity, details: None, @@ -100,14 +100,14 @@ impl TryFrom<&zbus::zvariant::Value<'_>> for Issue { let value = value.downcast_ref::()?; let fields = value.fields(); - let Some([description, kind, details, source, severity]) = fields.get(0..5) else { + let Some([description, class, details, source, severity]) = fields.get(0..5) else { return Err(zbus::zvariant::Error::Message( "Not enough elements for building an Issue.".to_string(), ))?; }; let description: String = description.try_into()?; - let kind: String = kind.try_into()?; + let class: String = class.try_into()?; let details: String = details.try_into()?; let source: u32 = source.try_into()?; let source = source as u8; @@ -120,7 +120,7 @@ impl TryFrom<&zbus::zvariant::Value<'_>> for Issue { Ok(Issue { description, - kind, + class, details: if details.is_empty() { None } else { @@ -150,7 +150,7 @@ mod tests { let issue = Issue::try_from(&Value::Structure(dbus_issue)).unwrap(); assert_eq!(&issue.description, "Product not selected"); - assert_eq!(&issue.kind, "missing_product"); + assert_eq!(&issue.class, "missing_product"); assert_eq!(issue.details, Some("A product is required.".to_string())); assert_eq!(issue.source, IssueSource::System); assert_eq!(issue.severity, IssueSeverity::Warn); diff --git a/rust/agama-utils/src/api/l10n/config.rs b/rust/agama-utils/src/api/l10n/config.rs index 9a06c5c185..19a149eb1a 100644 --- a/rust/agama-utils/src/api/l10n/config.rs +++ b/rust/agama-utils/src/api/l10n/config.rs @@ -22,7 +22,6 @@ use serde::{Deserialize, Serialize}; /// Localization config. #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] -#[schema(as = l10n::UserConfig)] #[serde(rename_all = "camelCase")] pub struct Config { /// Locale (e.g., "en_US.UTF-8"). diff --git a/rust/agama-utils/src/api/patch.rs b/rust/agama-utils/src/api/patch.rs new file mode 100644 index 0000000000..6c401ce7cf --- /dev/null +++ b/rust/agama-utils/src/api/patch.rs @@ -0,0 +1,45 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use crate::api::config::Config; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Json(#[from] serde_json::Error), +} + +/// Patch for the config. +#[derive(Deserialize, Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct Patch { + /// Update for the current config. + pub update: Option, +} + +impl Patch { + pub fn with_update(config: &Config) -> Result { + Ok(Self { + update: Some(serde_json::to_value(config)?), + }) + } +} diff --git a/rust/agama-utils/src/api/progress.rs b/rust/agama-utils/src/api/progress.rs index 69e908696f..a75464bc87 100644 --- a/rust/agama-utils/src/api/progress.rs +++ b/rust/agama-utils/src/api/progress.rs @@ -1,4 +1,4 @@ -// Copyright (c) [2024] SUSE LLC +// Copyright (c) [2025] SUSE LLC // // All Rights Reserved. // @@ -18,7 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -//! This module includes the struct that represent a service progress step. +//! This module define types related to the progress report. use crate::api::scope::Scope; use serde::{Deserialize, Serialize}; diff --git a/rust/agama-utils/src/api/proposal.rs b/rust/agama-utils/src/api/proposal.rs index 20e42aecf7..ef94772a40 100644 --- a/rust/agama-utils/src/api/proposal.rs +++ b/rust/agama-utils/src/api/proposal.rs @@ -20,11 +20,15 @@ use crate::api::{l10n, software}; use serde::Serialize; +use serde_json::Value; #[derive(Clone, Debug, Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] pub struct Proposal { #[serde(skip_serializing_if = "Option::is_none")] pub l10n: Option, #[serde(skip_serializing_if = "Option::is_none")] pub software: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub storage: Option, } diff --git a/rust/agama-utils/src/api/storage.rs b/rust/agama-utils/src/api/storage.rs new file mode 100644 index 0000000000..ee18c8b42b --- /dev/null +++ b/rust/agama-utils/src/api/storage.rs @@ -0,0 +1,22 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +mod config; +pub use config::Config; diff --git a/rust/agama-utils/src/api/storage/config.rs b/rust/agama-utils/src/api/storage/config.rs new file mode 100644 index 0000000000..4fd8cb0196 --- /dev/null +++ b/rust/agama-utils/src/api/storage/config.rs @@ -0,0 +1,37 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct Config { + #[serde(skip_serializing_if = "Option::is_none")] + pub storage: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub legacy_autoyast_storage: Option, +} + +impl Config { + pub fn has_value(&self) -> bool { + self.storage.is_some() || self.legacy_autoyast_storage.is_some() + } +} diff --git a/rust/agama-utils/src/api/system_info.rs b/rust/agama-utils/src/api/system_info.rs index a8ffc25dc4..485708bcc1 100644 --- a/rust/agama-utils/src/api/system_info.rs +++ b/rust/agama-utils/src/api/system_info.rs @@ -20,10 +20,15 @@ use crate::api::{l10n, manager}; use serde::Serialize; +use serde_json::Value; #[derive(Clone, Debug, Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] pub struct SystemInfo { #[serde(flatten)] pub manager: manager::SystemInfo, pub l10n: l10n::SystemInfo, + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub storage: Option, } diff --git a/rust/agama-utils/src/issue.rs b/rust/agama-utils/src/issue.rs index fe04bd83a7..35abeae891 100644 --- a/rust/agama-utils/src/issue.rs +++ b/rust/agama-utils/src/issue.rs @@ -26,23 +26,6 @@ //! //! The service can be started calling the [start] function, which returns an //! [agama_utils::actors::ActorHandler] to interact with it. -//! -//! # Example -//! -//! ```no_run -//! use agama_utils::issue::{self, message}; -//! use agama_utils::api::Scope; -//! use tokio::sync::broadcast; -//! -//! # tokio_test::block_on(async { -//! async fn use_issues_service() { -//! let (events_tx, _events_rx) = broadcast::channel(16); -//! let issues = issue::start(events_tx, None).await.unwrap(); -//! _ = issues.call(message::Update::new(Scope::Manager, vec![])); -//! } -//! # }); -//! -//! ``` pub mod service; pub use service::Service; diff --git a/rust/agama-utils/src/issue/message.rs b/rust/agama-utils/src/issue/message.rs index 7db68ffa01..2d6b2f5b96 100644 --- a/rust/agama-utils/src/issue/message.rs +++ b/rust/agama-utils/src/issue/message.rs @@ -30,13 +30,13 @@ impl Message for Get { // FIXME: consider an alternative approach to avoid pub(crate), // making it only visible to the service. -pub struct Update { +pub struct Set { pub(crate) scope: Scope, pub(crate) issues: Vec, pub(crate) notify: bool, } -impl Update { +impl Set { pub fn new(scope: Scope, issues: Vec) -> Self { Self { scope, @@ -51,7 +51,7 @@ impl Update { } } -impl Message for Update { +impl Message for Set { type Reply = (); } diff --git a/rust/agama-utils/src/issue/monitor.rs b/rust/agama-utils/src/issue/monitor.rs index 85df4ff667..c95a4d4546 100644 --- a/rust/agama-utils/src/issue/monitor.rs +++ b/rust/agama-utils/src/issue/monitor.rs @@ -62,7 +62,6 @@ const STORAGE_SERVICE: &str = "org.opensuse.Agama.Storage1"; const ISCSI_PATH: &str = "/org/opensuse/Agama/Storage1/ISCSI"; const PRODUCT_PATH: &str = "/org/opensuse/Agama/Software1/Product"; const SOFTWARE_PATH: &str = "/org/opensuse/Agama/Software1"; -const STORAGE_PATH: &str = "/org/opensuse/Agama/Storage1"; const USERS_PATH: &str = "/org/opensuse/Agama/Users1"; impl Monitor { @@ -79,8 +78,6 @@ impl Monitor { .await?; self.initialize_issues(SOFTWARE_SERVICE, PRODUCT_PATH) .await?; - self.initialize_issues(STORAGE_SERVICE, STORAGE_PATH) - .await?; self.initialize_issues(STORAGE_SERVICE, ISCSI_PATH).await?; while let Some(Ok(message)) = messages.next().await { @@ -165,7 +162,7 @@ impl Monitor { match Self::scope_from_path(path) { Some(scope) => { self.handler - .cast(message::Update::new(scope, issues).notify(notify))?; + .cast(message::Set::new(scope, issues).notify(notify))?; } None => { eprintln!("Unknown issues object {}", path); @@ -179,7 +176,6 @@ impl Monitor { match path { SOFTWARE_PATH => Some(Scope::Software), PRODUCT_PATH => Some(Scope::Product), - STORAGE_PATH => Some(Scope::Storage), USERS_PATH => Some(Scope::Users), ISCSI_PATH => Some(Scope::Iscsi), _ => None, diff --git a/rust/agama-utils/src/issue/service.rs b/rust/agama-utils/src/issue/service.rs index 8a7e48d457..da4414f790 100644 --- a/rust/agama-utils/src/issue/service.rs +++ b/rust/agama-utils/src/issue/service.rs @@ -64,8 +64,8 @@ impl MessageHandler for Service { } #[async_trait] -impl MessageHandler for Service { - async fn handle(&mut self, message: message::Update) -> Result<(), Error> { +impl MessageHandler for Service { + async fn handle(&mut self, message: message::Set) -> Result<(), Error> { // Compare whether the issues has changed. let old_issues_hash: HashSet<_> = self .issues diff --git a/rust/agama-utils/src/issue/start.rs b/rust/agama-utils/src/issue/start.rs index d557f4410d..9cfcdf4f23 100644 --- a/rust/agama-utils/src/issue/start.rs +++ b/rust/agama-utils/src/issue/start.rs @@ -18,10 +18,14 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::actor::{self, Handler}; -use crate::api::event; -use crate::issue::monitor::{self, Monitor}; -use crate::issue::service::{self, Service}; +use crate::{ + actor::{self, Handler}, + api::event, + issue::{ + monitor::{self, Monitor}, + service::{self, Service}, + }, +}; #[derive(thiserror::Error, Debug)] pub enum Error { @@ -31,32 +35,34 @@ pub enum Error { pub async fn start( events: event::Sender, - dbus: Option, + dbus: zbus::Connection, ) -> Result, Error> { let service = Service::new(events); let handler = actor::spawn(service); - if let Some(conn) = dbus { - let dbus_monitor = Monitor::new(handler.clone(), conn); - monitor::spawn(dbus_monitor); - } + let dbus_monitor = Monitor::new(handler.clone(), dbus); + monitor::spawn(dbus_monitor); Ok(handler) } #[cfg(test)] mod tests { - use crate::api::event::Event; - use crate::api::issue::{Issue, IssueSeverity, IssueSource}; - use crate::api::scope::Scope; - use crate::issue; - use crate::issue::message; + use crate::{ + api::{ + event::Event, + issue::{Issue, IssueSeverity, IssueSource}, + scope::Scope, + }, + issue::{self, message}, + test, + }; use tokio::sync::broadcast::{self, error::TryRecvError}; fn build_issue() -> Issue { Issue { description: "Product not selected".to_string(), - kind: "missing_product".to_string(), + class: "missing_product".to_string(), details: Some("A product is required.".to_string()), source: IssueSource::Config, severity: IssueSeverity::Error, @@ -66,13 +72,14 @@ mod tests { #[tokio::test] async fn test_get_and_update_issues() -> Result<(), Box> { let (events_tx, mut events_rx) = broadcast::channel::(16); - let issues = issue::start(events_tx, None).await.unwrap(); + let dbus = test::dbus::connection().await.unwrap(); + let issues = issue::start(events_tx, dbus).await.unwrap(); let issues_list = issues.call(message::Get).await.unwrap(); assert!(issues_list.is_empty()); let issue = build_issue(); _ = issues - .cast(message::Update::new(Scope::Manager, vec![issue])) + .cast(message::Set::new(Scope::Manager, vec![issue])) .unwrap(); let issues_list = issues.call(message::Get).await.unwrap(); @@ -85,13 +92,14 @@ mod tests { #[tokio::test] async fn test_update_without_event() -> Result<(), Box> { let (events_tx, mut events_rx) = broadcast::channel::(16); - let issues = issue::start(events_tx, None).await.unwrap(); + let dbus = test::dbus::connection().await.unwrap(); + let issues = issue::start(events_tx, dbus).await.unwrap(); let issues_list = issues.call(message::Get).await.unwrap(); assert!(issues_list.is_empty()); let issue = build_issue(); - let update = message::Update::new(Scope::Manager, vec![issue]).notify(false); + let update = message::Set::new(Scope::Manager, vec![issue]).notify(false); _ = issues.cast(update).unwrap(); let issues_list = issues.call(message::Get).await.unwrap(); @@ -104,14 +112,15 @@ mod tests { #[tokio::test] async fn test_update_without_change() -> Result<(), Box> { let (events_tx, mut events_rx) = broadcast::channel::(16); - let issues = issue::start(events_tx, None).await.unwrap(); + let dbus = test::dbus::connection().await.unwrap(); + let issues = issue::start(events_tx, dbus).await.unwrap(); let issue = build_issue(); - let update = message::Update::new(Scope::Manager, vec![issue.clone()]); + let update = message::Set::new(Scope::Manager, vec![issue.clone()]); issues.call(update).await.unwrap(); assert!(events_rx.try_recv().is_ok()); - let update = message::Update::new(Scope::Manager, vec![issue]); + let update = message::Set::new(Scope::Manager, vec![issue]); issues.call(update).await.unwrap(); assert!(matches!(events_rx.try_recv(), Err(TryRecvError::Empty))); Ok(()) diff --git a/rust/agama-utils/src/lib.rs b/rust/agama-utils/src/lib.rs index 980caf95aa..e03d98edea 100644 --- a/rust/agama-utils/src/lib.rs +++ b/rust/agama-utils/src/lib.rs @@ -21,9 +21,6 @@ //! This crate offers a set of utility struct and functions to be used accross //! other Agama's crates. -pub mod service; -pub use service::Service; - pub mod actor; pub mod api; pub mod dbus; @@ -33,3 +30,4 @@ pub mod openapi; pub mod products; pub mod progress; pub mod question; +pub mod test; diff --git a/rust/agama-utils/src/progress/message.rs b/rust/agama-utils/src/progress/message.rs index 1d3c5b6fc5..2ca5b8e5db 100644 --- a/rust/agama-utils/src/progress/message.rs +++ b/rust/agama-utils/src/progress/message.rs @@ -28,6 +28,20 @@ impl Message for Get { type Reply = Vec; } +pub struct Set { + pub progress: Progress, +} + +impl Set { + pub fn new(progress: Progress) -> Self { + Self { progress } + } +} + +impl Message for Set { + type Reply = (); +} + pub struct Start { pub scope: Scope, pub size: usize, diff --git a/rust/agama-utils/src/progress/service.rs b/rust/agama-utils/src/progress/service.rs index 76fed67da7..c43989a885 100644 --- a/rust/agama-utils/src/progress/service.rs +++ b/rust/agama-utils/src/progress/service.rs @@ -64,6 +64,11 @@ impl Service { fn get_progress_index(&self, scope: Scope) -> Option { self.progresses.iter().position(|p| p.scope == scope) } + + fn send_progress_changed(&self, progress: Progress) -> Result<(), Error> { + self.events.send(Event::ProgressChanged { progress })?; + Ok(()) + } } impl Actor for Service { @@ -77,6 +82,20 @@ impl MessageHandler for Service { } } +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, message: message::Set) -> Result<(), Error> { + let progress = message.progress; + if let Some(index) = self.get_progress_index(progress.scope) { + self.progresses[index] = progress.clone(); + } else { + self.progresses.push(progress.clone()); + } + self.send_progress_changed(progress)?; + Ok(()) + } +} + #[async_trait] impl MessageHandler for Service { async fn handle(&mut self, message: message::Start) -> Result<(), Error> { @@ -85,10 +104,7 @@ impl MessageHandler for Service { } let progress = Progress::new(message.scope, message.size, message.step); self.progresses.push(progress.clone()); - self.events.send(Event::ProgressChanged { - scope: message.scope, - progress, - })?; + self.send_progress_changed(progress)?; Ok(()) } } @@ -101,10 +117,7 @@ impl MessageHandler for Service { } let progress = Progress::new_with_steps(message.scope, message.steps); self.progresses.push(progress.clone()); - self.events.send(Event::ProgressChanged { - scope: message.scope, - progress, - })?; + self.send_progress_changed(progress)?; Ok(()) } } @@ -117,10 +130,7 @@ impl MessageHandler for Service { }; progress.next()?; let progress = progress.clone(); - self.events.send(Event::ProgressChanged { - scope: message.scope, - progress, - })?; + self.send_progress_changed(progress)?; Ok(()) } } @@ -133,10 +143,7 @@ impl MessageHandler for Service { }; progress.next_with_step(message.step)?; let progress = progress.clone(); - self.events.send(Event::ProgressChanged { - scope: message.scope, - progress, - })?; + self.send_progress_changed(progress)?; Ok(()) } } diff --git a/rust/agama-utils/src/progress/start.rs b/rust/agama-utils/src/progress/start.rs index 6074a1fd6a..bde71fc274 100644 --- a/rust/agama-utils/src/progress/start.rs +++ b/rust/agama-utils/src/progress/start.rs @@ -39,12 +39,18 @@ pub async fn start(events: event::Sender) -> Result, Error> { #[cfg(test)] mod tests { - use crate::actor::{self, Handler}; - use crate::api::event::{self, Event}; - use crate::api::progress; - use crate::api::scope::Scope; - use crate::progress::message; - use crate::progress::service::{self, Service}; + use crate::{ + actor::{self, Handler}, + api::{ + event::{self, Event}, + progress::{self, Progress}, + scope::Scope, + }, + progress::{ + message, + service::{self, Service}, + }, + }; use tokio::sync::broadcast; fn start_testing_service() -> (event::Receiver, Handler) { @@ -65,16 +71,7 @@ mod tests { .await?; let event = receiver.recv().await.unwrap(); - assert!(matches!( - event, - Event::ProgressChanged { - scope: Scope::L10n, - progress: _ - } - )); - let Event::ProgressChanged { - scope: _, progress: event_progress, } = event else { @@ -99,13 +96,7 @@ mod tests { .await?; let event = receiver.recv().await.unwrap(); - assert!(matches!( - event, - Event::ProgressChanged { - scope: Scope::L10n, - progress: _ - } - )); + assert!(matches!(event, Event::ProgressChanged { progress: _ })); let progresses = handler.call(message::Get).await.unwrap(); let progress = progresses.first().unwrap(); @@ -119,13 +110,7 @@ mod tests { handler.call(message::Next::new(Scope::L10n)).await?; let event = receiver.recv().await.unwrap(); - assert!(matches!( - event, - Event::ProgressChanged { - scope: Scope::L10n, - progress: _ - } - )); + assert!(matches!(event, Event::ProgressChanged { progress: _ })); let progresses = handler.call(message::Get).await.unwrap(); let progress = progresses.first().unwrap(); @@ -150,6 +135,61 @@ mod tests { Ok(()) } + #[tokio::test] + async fn test_set_progress() -> Result<(), Box> { + let (mut receiver, handler) = start_testing_service(); + + // Set first progress. + let progress = Progress::new(Scope::Storage, 3, "first step".to_string()); + handler.call(message::Set::new(progress)).await?; + + let event = receiver.recv().await.unwrap(); + let Event::ProgressChanged { + progress: event_progress, + } = event + else { + panic!("Unexpected event: {:?}", event); + }; + + assert_eq!(event_progress.scope, Scope::Storage); + assert_eq!(event_progress.size, 3); + assert!(event_progress.steps.is_empty()); + assert_eq!(event_progress.step, "first step"); + assert_eq!(event_progress.index, 1); + + let progresses = handler.call(message::Get).await?; + assert_eq!(progresses.len(), 1); + + let progress = progresses.first().unwrap(); + assert_eq!(*progress, event_progress); + + // Set second progress + let progress = Progress::new(Scope::Storage, 3, "second step".to_string()); + handler.call(message::Set::new(progress)).await?; + + let event = receiver.recv().await.unwrap(); + let Event::ProgressChanged { + progress: event_progress, + } = event + else { + panic!("Unexpected event: {:?}", event); + }; + + assert_eq!(event_progress.scope, Scope::Storage); + assert_eq!(event_progress.size, 3); + assert!(event_progress.steps.is_empty()); + assert_eq!(event_progress.step, "second step"); + assert_eq!(event_progress.index, 1); + + let progresses = handler.call(message::Get).await?; + assert_eq!(progresses.len(), 1); + + let progress = progresses.first().unwrap(); + assert_eq!(*progress, event_progress); + + Ok(()) + } + #[tokio::test] async fn test_progress_with_steps() -> Result<(), Box> { let (_receiver, handler) = start_testing_service(); diff --git a/rust/agama-utils/src/question/message.rs b/rust/agama-utils/src/question/message.rs index d976da324b..af4dc6e141 100644 --- a/rust/agama-utils/src/question/message.rs +++ b/rust/agama-utils/src/question/message.rs @@ -20,28 +20,31 @@ use crate::{ actor::Message, - api::{ - self, - question::{self, Config, Question}, - }, + api::question::{self, Config, Question}, }; /// Gets questions configuration (policy, pre-defined answers, etc.). pub struct GetConfig; impl Message for GetConfig { - type Reply = Config; + type Reply = Option; } /// Sets questions configuration (policy, pre-defined answers, etc.). pub struct SetConfig { - pub config: Config, + pub config: Option, } impl SetConfig { - pub fn new(config: Config) -> Self { + pub fn new(config: Option) -> Self { Self { config } } + + pub fn with(config: Config) -> Self { + Self { + config: Some(config), + } + } } impl Message for SetConfig { diff --git a/rust/agama-utils/src/question/service.rs b/rust/agama-utils/src/question/service.rs index dcf2d01d54..2d18bd761f 100644 --- a/rust/agama-utils/src/question/service.rs +++ b/rust/agama-utils/src/question/service.rs @@ -44,7 +44,7 @@ pub enum Error { } pub struct Service { - config: Config, + config: Option, questions: Vec, current_id: u32, events: event::Sender, @@ -61,25 +61,28 @@ impl Service { } pub fn find_answer(&self, spec: &QuestionSpec) -> Option { - let answer = self - .config - .answers - .iter() - .find(|a| a.answers_to(&spec)) - .map(|r| r.answer.clone()); + let answer = self.config.as_ref().and_then(|config| { + config + .answers + .iter() + .find(|a| a.answers_to(&spec)) + .map(|r| r.answer.clone()) + }); if answer.is_some() { return answer; } - if let Some(Policy::Auto) = self.config.policy { - spec.default_action.clone().map(|action| Answer { - action, - value: None, - }) - } else { - None - } + self.config.as_ref().and_then(|config| { + if let Some(Policy::Auto) = config.policy { + spec.default_action.clone().map(|action| Answer { + action, + value: None, + }) + } else { + None + } + }) } } @@ -89,7 +92,7 @@ impl Actor for Service { #[async_trait] impl MessageHandler for Service { - async fn handle(&mut self, _message: message::GetConfig) -> Result { + async fn handle(&mut self, _message: message::GetConfig) -> Result, Error> { Ok(self.config.clone()) } } diff --git a/rust/agama-utils/src/question/start.rs b/rust/agama-utils/src/question/start.rs index 7411d86e74..21283da721 100644 --- a/rust/agama-utils/src/question/start.rs +++ b/rust/agama-utils/src/question/start.rs @@ -99,7 +99,7 @@ mod tests { policy: Some(Policy::Auto), ..Default::default() }; - questions.call(message::SetConfig::new(config)).await?; + questions.call(message::SetConfig::with(config)).await?; // Ask the question let question = questions @@ -134,7 +134,7 @@ mod tests { policy: Some(Policy::User), answers: vec![rule_by_class], }; - questions.call(message::SetConfig::new(config)).await?; + questions.call(message::SetConfig::with(config)).await?; // Ask the question let question = questions diff --git a/rust/agama-utils/src/service.rs b/rust/agama-utils/src/service.rs deleted file mode 100644 index ba1e01bf29..0000000000 --- a/rust/agama-utils/src/service.rs +++ /dev/null @@ -1,88 +0,0 @@ -// Copyright (c) [2025] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -//! Offers a trait to implement an Agama service. -//! -//! An Agama service is composed of, at least, two parts: -//! -//! * The service itself, which holds the configuration and takes care of -//! performing the changes at installation time. It is private to each -//! Agama module (agama-l10n, agama-network, etc.). It should implement -//! the [Service trait]. -//! * The handler, which offers an API to talk to the service. It should -//! implement the [Handler](crate::Handler) trait. - -use core::future::Future; -use std::{any, error}; -use tokio::sync::mpsc; - -/// Implements the basic behavior for an Agama service. -/// -/// It is responsible for: -/// -/// * Holding the configuration. -/// * Making an installation proposal for one aspect of the system -/// (localization, partitioning, etc.). -/// * Performing the changes a installation time. -/// * Optionally, making changes to the system running Agama -/// (e.g., changing the keyboard layout). -/// -/// Usually, a service runs on a separate task and receives the actions to -/// perform through a [mpsc::UnboundedReceiver -/// channel](tokio::sync::mpsc::UnboundedReceiver). -pub trait Service: Send { - type Err: error::Error; - type Message: Send; - - /// Returns the service name used for logging and debugging purposes. - /// - /// An example might be "agama_l10n::l10n::L10n". - fn name() -> &'static str { - any::type_name::() - } - - /// Main loop of the service. - /// - /// It dispatches one message at a time. - fn run(&mut self) -> impl Future + Send { - async { - loop { - let message = self.channel().recv().await; - let Some(message) = message else { - eprintln!("channel closed for {}", Self::name()); - break; - }; - - if let Err(error) = &mut self.dispatch(message).await { - eprintln!("error dispatching command: {error}"); - } - } - } - } - - /// Returns the channel to read the messages from. - fn channel(&mut self) -> &mut mpsc::UnboundedReceiver; - - /// Dispatches a message. - fn dispatch( - &mut self, - command: Self::Message, - ) -> impl Future> + Send; -} diff --git a/rust/agama-utils/src/test.rs b/rust/agama-utils/src/test.rs new file mode 100644 index 0000000000..087c152b51 --- /dev/null +++ b/rust/agama-utils/src/test.rs @@ -0,0 +1,24 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +//! This crate offers a set of utility struct and functions to be used accross +//! other Agama's crates. + +pub mod dbus; diff --git a/rust/agama-utils/src/test/dbus.rs b/rust/agama-utils/src/test/dbus.rs new file mode 100644 index 0000000000..d485927ba7 --- /dev/null +++ b/rust/agama-utils/src/test/dbus.rs @@ -0,0 +1,135 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use std::{ + future::Future, + process::{Child, Command}, + time::Duration, +}; +use uuid::Uuid; +use zbus::conn::Builder; + +pub async fn connection() -> Result { + let server = DBusServer::::new(); + Ok(server.start().await?.connection()) +} + +/// D-Bus server to be used on tests. +/// +/// Takes care of starting and stopping a dbus-daemon to be used on integration tests. Each server +/// uses a different socket, so they do not collide. +/// +/// NOTE: this struct implements the [typestate pattern](http://cliffle.com/blog/rust-typestate/). +struct DBusServer { + address: String, + extra: S, +} + +struct Started { + connection: zbus::Connection, + child: Child, +} + +impl Drop for Started { + fn drop(&mut self) { + self.child.kill().unwrap(); + } +} + +pub struct Stopped; + +pub trait ServerState {} +impl ServerState for Started {} +impl ServerState for Stopped {} + +impl Default for DBusServer { + fn default() -> Self { + Self::new() + } +} + +impl DBusServer { + fn new() -> Self { + let uuid = Uuid::new_v4(); + DBusServer { + address: format!("unix:path=/tmp/agama-tests-{uuid}"), + extra: Stopped, + } + } + + async fn start(self) -> Result, zbus::Error> { + let child = Command::new("/usr/bin/dbus-daemon") + .args([ + "--config-file", + "../share/dbus-test.conf", + "--address", + &self.address, + ]) + .spawn() + .expect("to start the testing D-Bus daemon"); + + let connection = async_retry(|| connection_to(&self.address)).await?; + + Ok(DBusServer { + address: self.address, + extra: Started { child, connection }, + }) + } +} + +impl DBusServer { + fn connection(&self) -> zbus::Connection { + self.extra.connection.clone() + } +} + +/// Run and retry an async function. +/// +/// Beware that, if the function is failing for a legit reason, you will +/// introduce a delay in your code. +/// +/// * `func`: async function to run. +async fn async_retry(func: F) -> Result +where + F: Fn() -> O, + O: Future>, +{ + const RETRIES: u8 = 10; + const INTERVAL: u64 = 500; + let mut retry = 0; + loop { + match func().await { + Ok(result) => return Ok(result), + Err(error) => { + if retry > RETRIES { + return Err(error); + } + retry += 1; + let wait_time = Duration::from_millis(INTERVAL); + tokio::time::sleep(wait_time).await; + } + } + } +} + +async fn connection_to(address: &str) -> Result { + let connection = Builder::address(address)?.build().await?; + Ok(connection) +} diff --git a/rust/package/agama.changes b/rust/package/agama.changes index d896aaf493..7a6ce19574 100644 --- a/rust/package/agama.changes +++ b/rust/package/agama.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Mon Nov 3 14:36:55 UTC 2025 - José Iván López González + +- Add storage service to support the new HTTP API + (gh#agama-project/agama#2816). + ------------------------------------------------------------------- Fri Oct 17 13:14:31 UTC 2025 - Imobach Gonzalez Sosa diff --git a/rust/share/device.storage.schema.json b/rust/share/device.storage.schema.json new file mode 100644 index 0000000000..39ebbfcee6 --- /dev/null +++ b/rust/share/device.storage.schema.json @@ -0,0 +1,206 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "https://github.com/openSUSE/agama/blob/master/rust/share/device.storage.schema.json", + "title": "Storage device", + "description": "Schema to describe a device both in 'system' and 'proposal'.", + "type": "object", + "additionalProperties": false, + "required": ["sid", "name"], + "properties": { + "sid": { "type": "integer" }, + "name": { "type": "string" }, + "description": { "type": "string" }, + "block": { "$ref": "#/$defs/block" }, + "drive": { "$ref": "#/$defs/drive" }, + "filesystem": { "$ref": "#/$defs/filesystem" }, + "md": { "$ref": "#/$defs/md" }, + "multipath": { "$ref": "#/$defs/multipath" }, + "partitionTable": { "$ref": "#/$defs/partitionTable" }, + "partition": { "$ref": "#/$defs/partition" }, + "partitions": { + "type": "array", + "items": { "$ref": "#" } + }, + "volumeGroup": { "$ref": "#/$defs/volumeGroup" }, + "logicalVolumes": { + "type": "array", + "items": { "$ref": "#" } + } + }, + "$defs": { + "block": { + "type": "object", + "additionalProperties": false, + "required": ["start", "size", "shrinking"], + "properties": { + "start": { "type": "integer" }, + "size": { "type": "integer" }, + "active": { "type": "boolean" }, + "encrypted": { "type": "boolean" }, + "udevIds": { + "type": "array", + "items": { "type": "string" } + }, + "udevPaths": { + "type": "array", + "items": { "type": "string" } + }, + "systems": { + "type": "array", + "items": { "type": "string" } + }, + "shrinking": { + "anyOf": [ + { "$ref": "#/$defs/shrinkingSupported" }, + { "$ref": "#/$defs/shrinkingUnsupported" } + ] + } + } + }, + "shrinkingSupported": { + "type": "object", + "additionalProperties": false, + "properties": { + "supported": { "type": "integer" } + } + }, + "shrinkingUnsupported": { + "type": "object", + "additionalProperties": false, + "properties": { + "unsupported": { + "type": "array", + "items": { "type": "string" } + } + } + }, + "drive": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["disk", "raid", "multipath", "dasd"] }, + "vendor": { "type": "string" }, + "model": { "type": "string" }, + "transport": { "type": "string" }, + "bus": { "type": "string" }, + "busId": { "type": "string" }, + "driver": { + "type": "array", + "items": { "type": "string" } + }, + "info": { "$ref": "#/$defs/driveInfo" } + } + }, + "driveInfo": { + "type": "object", + "additionalProperties": false, + "properties": { + "sdCard": { "type": "boolean" }, + "dellBoss": { "type": "boolean" } + } + }, + "filesystem": { + "type": "object", + "additionalProperties": false, + "required": ["sid", "type"], + "properties": { + "sid": { "type": "integer" }, + "type": { "$ref": "#/$defs/filesystemType" }, + "mountPath": { "type": "string" }, + "label": { "type": "string" } + } + }, + "filesystemType": { + "enum": [ + "bcachefs", + "btrfs", + "exfat", + "ext2", + "ext3", + "ext4", + "f2fs", + "jfs", + "nfs", + "nilfs2", + "ntfs", + "reiserfs", + "swap", + "tmpfs", + "vfat", + "xfs" + ] + }, + "md": { + "type": "object", + "additionalProperties": false, + "required": ["level", "devices"], + "properties": { + "level": { "$ref": "#/$defs/mdRaidLevel" }, + "devices": { + "type": "array", + "items": { "type": "integer" } + }, + "uuid": { "type": "string" } + } + }, + "mdRaidLevel": { + "title": "MD level", + "enum": [ + "raid0", + "raid1", + "raid5", + "raid6", + "raid10" + ] + }, + "multipath": { + "type": "object", + "additionalProperties": false, + "required": ["wireNames"], + "properties": { + "wireNames": { + "type": "array", + "items": { "type": "string" } + } + } + }, + "partitionTable": { + "type": "object", + "additionalProperties": false, + "required": ["type", "unusedSlots"], + "properties": { + "type": { "$ref": "#/$defs/ptableType" }, + "unusedSlots": { + "type": "array", + "items": { + "type": "array", + "items": { "type": "integer" } + } + } + } + }, + "ptableType": { + "enum": ["gpt", "msdos", "dasd"] + }, + "partition": { + "type": "object", + "additionalProperties": false, + "required": ["efi"], + "properties": { + "efi": { "type": "boolean" } + } + }, + "volumeGroup": { + "type": "object", + "additionalProperties": false, + "required": ["size", "physicalVolumes"], + "properties": { + "size": { "type": "integer" }, + "physicalVolumes": { + "type": "array", + "items": { "type": "integer" } + } + } + } + } +} diff --git a/rust/share/proposal.storage.schema.json b/rust/share/proposal.storage.schema.json new file mode 100644 index 0000000000..876c9c0ea6 --- /dev/null +++ b/rust/share/proposal.storage.schema.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "https://github.com/openSUSE/agama/blob/master/rust/share/proposal.storage.schema.json", + "title": "Proposal", + "description": "API description of the storage proposal.", + "type": "object", + "additionalProperties": false, + "properties": { + "devices": { + "description": "Expected layout of the system after the commit phase.", + "type": "array", + "items": { "$ref": "device.storage.schema.json" } + }, + "actions": { + "description": "Sorted list of actions to execute during the commit phase.", + "type": "array", + "items": { "$ref": "#/$defs/action" } + } + }, + "$defs": { + "action": { + "type": "object", + "additionalProperties": false, + "required": ["device", "text"], + "properties": { + "device": { "type": "integer" }, + "text": { "type": "string" }, + "subvol": { "type": "boolean" }, + "delete": { "type": "boolean" }, + "resize": { "type": "boolean" } + } + } + } +} diff --git a/rust/share/system.storage.schema.json b/rust/share/system.storage.schema.json new file mode 100644 index 0000000000..7d7e30fe5c --- /dev/null +++ b/rust/share/system.storage.schema.json @@ -0,0 +1,111 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "https://github.com/openSUSE/agama/blob/master/rust/share/system.storage.schema.json", + "title": "System", + "description": "API description of the system", + "type": "object", + "additionalProperties": false, + "properties": { + "devices": { + "description": "All relevant devices on the system", + "type": "array", + "items": { "$ref": "device.storage.schema.json" } + }, + "availableDrives": { + "description": "SIDs of the available drives", + "type": "array", + "items": { "type": "integer" } + }, + "availableMdRaids": { + "description": "SIDs of the available MD RAIDs", + "type": "array", + "items": { "type": "integer" } + }, + "candidateDrives": { + "description": "SIDs of the drives that are candidate for installation", + "type": "array", + "items": { "type": "integer" } + }, + "candidateMdRaids": { + "description": "SIDs of the MD RAIDs that are candidate for installation", + "type": "array", + "items": { "type": "integer" } + }, + "productMountPoints": { + "description": "Meaningful mount points for the current product", + "type": "array", + "items": { "type": "string" } + }, + "encryptionMethods": { + "description": "Possible encryption methods for the current system and product", + "type": "array", + "items": { + "enum": [ + "luks1", "luks2", "pervasiveLuks2", "tmpFde", "protectedSwap", "secureSwap", "randomSwap" + ] + } + }, + "volumeTemplates": { + "description": "Volumes defined by the product as templates", + "type": "array", + "items": { "$ref": "#/$defs/volume" } + }, + "issues": { + "type": "array", + "items": { "$ref": "#/$defs/issue" } + } + }, + "$defs": { + "volume": { + "type": "object", + "additionalProperties": false, + "required": ["mountPath", "minSize", "autoSize"], + "properties": { + "mountPath": { "type": "string" }, + "mountOptions": { + "type": "array", + "items": { "type": "string" } + }, + "fsType": { "$ref": "device.storage.schema.json#/$defs/filesystemType" }, + "autoSize": { "type": "boolean" }, + "minSize": { "type": "integer" }, + "maxSize": { "type": "integer" }, + "snapshots": { "type": "boolean" }, + "transactional": { "type": "boolean" }, + "outline": { "$ref": "#/$defs/volumeOutline" } + } + }, + "volumeOutline": { + "type": "object", + "additionalProperties": false, + "required": ["required", "supportAutoSize"], + "properties": { + "required": { "type": "boolean" }, + "supportAutoSize": { "type": "boolean" }, + "fsTypes": { + "type": "array", + "items": { "$ref": "device.storage.schema.json#/$defs/filesystemType" } + }, + "adjustByRam": { "type": "boolean" }, + "snapshotsConfigurable": { "type": "boolean" }, + "snapshotsAffectSizes": { "type": "boolean" }, + "sizeRelevantVolumes": { + "type": "array", + "items": { "type": "string" } + } + } + }, + "issue": { + "type": "object", + "additionalProperties": false, + "required": ["description"], + "properties": { + "description": { "type": "string" }, + "class": { "type": "string" }, + "details": { "type": "string" }, + "source": { "enum": ["config", "system"] }, + "severity": { "enum": ["warn", "error"] } + } + } + } +} diff --git a/service/lib/agama/dbus/clients/storage.rb b/service/lib/agama/dbus/clients/storage.rb index 66b00ca95d..f5fe91c34b 100644 --- a/service/lib/agama/dbus/clients/storage.rb +++ b/service/lib/agama/dbus/clients/storage.rb @@ -20,10 +20,7 @@ # find current contact information at www.suse.com. require "agama/dbus/clients/base" -require "agama/dbus/clients/with_service_status" require "agama/dbus/clients/with_locale" -require "agama/dbus/clients/with_progress" -require "agama/dbus/clients/with_issues" require "json" module Agama @@ -32,9 +29,6 @@ module Clients # D-Bus client for storage configuration class Storage < Base include WithLocale - include WithServiceStatus - include WithProgress - include WithIssues STORAGE_IFACE = "org.opensuse.Agama.Storage1" private_constant :STORAGE_IFACE @@ -43,22 +37,18 @@ def service_name @service_name ||= "org.opensuse.Agama.Storage1" end + def product=(id) + dbus_object.SetProduct(id) + end + # Starts the probing process # # If a block is given, the method returns immediately and the probing is performed in an # asynchronous way. # - # @param data [Hash] Extra data provided to the D-Bus call. # @param done [Proc] Block to execute once the probing is done - def probe(data = {}, &done) - dbus_object[STORAGE_IFACE].Probe(data, &done) - end - - # Reprobes (keeps the current settings). - # - # @param data [Hash] Extra data provided to the D-Bus call. - def reprobe(data = {}) - dbus_object.Reprobe(data) + def probe(&done) + dbus_object.Probe(&done) end # Performs the packages installation diff --git a/service/lib/agama/dbus/manager.rb b/service/lib/agama/dbus/manager.rb index b6b3c65950..b319e04f21 100644 --- a/service/lib/agama/dbus/manager.rb +++ b/service/lib/agama/dbus/manager.rb @@ -63,8 +63,8 @@ def initialize(backend, logger) FINISH_PHASE = 3 dbus_interface MANAGER_INTERFACE do - dbus_method(:Probe, "in data:a{sv}") { |data| config_phase(data: data) } - dbus_method(:Reprobe, "in data:a{sv}") { |data| config_phase(reprobe: true, data: data) } + dbus_method(:Probe, "in data:a{sv}") { |_| config_phase } + dbus_method(:Reprobe, "in data:a{sv}") { |_| config_phase(reprobe: true) } dbus_method(:Commit, "") { install_phase } dbus_method(:CanInstall, "out result:b") { can_install? } dbus_method(:CollectLogs, "out tarball_filesystem_path:s") { collect_logs } @@ -78,10 +78,9 @@ def initialize(backend, logger) # Runs the config phase # # @param reprobe [Boolean] Whether a reprobe should be done instead of a probe. - # @param data [Hash] Extra data provided to the D-Bus calls. - def config_phase(reprobe: false, data: {}) + def config_phase(reprobe: false) safe_run do - busy_while { backend.config_phase(reprobe: reprobe, data: data) } + busy_while { backend.config_phase(reprobe: reprobe) } end end diff --git a/service/lib/agama/dbus/manager_service.rb b/service/lib/agama/dbus/manager_service.rb index 7e3d8c12ce..5804d9623b 100644 --- a/service/lib/agama/dbus/manager_service.rb +++ b/service/lib/agama/dbus/manager_service.rb @@ -25,7 +25,6 @@ require "agama/dbus/bus" require "agama/dbus/manager" require "agama/dbus/users" -require "agama/dbus/storage/proposal" module Agama module DBus diff --git a/service/lib/agama/dbus/storage.rb b/service/lib/agama/dbus/storage.rb index 8f743fe45c..fcd33d4f6a 100644 --- a/service/lib/agama/dbus/storage.rb +++ b/service/lib/agama/dbus/storage.rb @@ -29,4 +29,3 @@ module Storage require "agama/dbus/storage/iscsi" require "agama/dbus/storage/manager" -require "agama/dbus/storage/proposal" diff --git a/service/lib/agama/dbus/storage/device.rb b/service/lib/agama/dbus/storage/device.rb deleted file mode 100644 index 17ecbe167e..0000000000 --- a/service/lib/agama/dbus/storage/device.rb +++ /dev/null @@ -1,97 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2023-2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require "dbus" -require "agama/dbus/base_object" -require "agama/dbus/storage/interfaces/device" - -module Agama - module DBus - module Storage - # Class for D-Bus objects representing a storage device (e.g., Disk, Partition, VG, etc). - # - # The D-Bus object includes the required interfaces for the storage object that it represents. - class Device < BaseObject - # sid of the Y2Storage device. - # - # @note A Y2Storage device is a wrapper over a libstorage-ng object. If the source - # devicegraph does not exist anymore (e.g., after reprobing), then the Y2Storage device - # object cannot be used (memory error). The device sid is stored to avoid accessing to - # the old Y2Storage device when updating the represented device, see {#storage_device=}. - # - # @return [Integer] - attr_reader :sid - - # Constructor - # - # @param storage_device [Y2Storage::Device] Storage device - # @param path [::DBus::ObjectPath] Path for the D-Bus object - # @param tree [DevicesTree] D-Bus tree in which the device is exported - # @param logger [Logger, nil] - def initialize(storage_device, path, tree, logger: nil) - super(path, logger: logger) - - @storage_device = storage_device - @sid = storage_device.sid - @tree = tree - add_interfaces - end - - # Sets the represented storage device. - # - # @note A properties changed signal is emitted for each interface. - # @raise [RuntimeError] If the given device has a different sid. - # - # @param value [Y2Storage::Device] - def storage_device=(value) - if value.sid != sid - raise "Cannot update the D-Bus object because the given device has a different sid: " \ - "#{value} instead of #{sid}" - end - - @storage_device = value - @sid = value.sid - - interfaces_and_properties.each do |interface, properties| - dbus_properties_changed(interface, properties, []) - end - end - - private - - # @return [DevicesTree] - attr_reader :tree - - # @return [Y2Storage::Device] - attr_reader :storage_device - - # Adds the required interfaces according to the storage object. - def add_interfaces - interfaces = Interfaces::Device.constants - .map { |c| Interfaces::Device.const_get(c) } - .select { |c| c.is_a?(Module) && c.respond_to?(:apply?) && c.apply?(storage_device) } - - interfaces.each { |i| singleton_class.include(i) } - end - end - end - end -end diff --git a/service/lib/agama/dbus/storage/devices_tree.rb b/service/lib/agama/dbus/storage/devices_tree.rb deleted file mode 100644 index 5963971e3d..0000000000 --- a/service/lib/agama/dbus/storage/devices_tree.rb +++ /dev/null @@ -1,107 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2023-2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require "agama/dbus/base_tree" -require "agama/dbus/storage/device" -require "dbus/object_path" - -module Agama - module DBus - module Storage - # Class representing a storage devices tree exported on D-Bus - class DevicesTree < BaseTree - # Object path for the D-Bus object representing the given device - # - # @param device [Y2Storage::Device] - # @return [::DBus::ObjectPath] - def path_for(device) - ::DBus::ObjectPath.new(File.join(root_path, device.sid.to_s)) - end - - # Updates the D-Bus tree according to the given devicegraph. - # - # @note In the devices tree it is important to avoid updating D-Bus nodes. Note that an - # already exported D-Bus object could require to add or remove interfaces (e.g., an - # existing partition needs to add the Filesystem interface after formatting the - # partition). Dynamically adding or removing interfaces is not possible with ruby-dbus - # once the object is exported on D-Bus. - # - # Updating the currently exported D-Bus objects is avoided by calling to {#clean} first. - # - # @param devicegraph [Y2Storage::Devicegraph] - def update(devicegraph) - clean - self.objects = devices(devicegraph) - end - - private - - # @see BaseTree - # @param device [Y2Storage::Device] - def create_dbus_object(device) - Device.new(device, path_for(device), self, logger: logger) - end - - # @see BaseTree - # - # @note D-Bus objects representing devices cannot be safely updated, see {#update}. - def update_dbus_object(_dbus_object, _device) - nil - end - - # @see BaseTree - # @param dbus_object [Device] - # @param device [Y2Storage::Device] - def dbus_object?(dbus_object, device) - dbus_object.sid == device.sid - end - - # Devices to be exported. - # - # Right now, only the required information for calculating a proposal is exported, that is: - # * Potential candidate devices (i.e., disk devices, MDs). - # * Partitions of the candidate devices in order to indicate how to find free space. - # * LVM volume groups and logical volumes. - # - # @param devicegraph [Y2Storage::Devicegraph] - # @return [Array] - def devices(devicegraph) - devices = devicegraph.disk_devices + - devicegraph.stray_blk_devices + - devicegraph.software_raids + - devicegraph.lvm_vgs + - devicegraph.lvm_lvs - - devices + partitions_from(devices) - end - - # All partitions of the given devices. - # - # @param devices [Array] - # @return [Array] - def partitions_from(devices) - devices.select { |d| d.is?(:blk_device) && d.respond_to?(:partitions) } - .flat_map(&:partitions) - end - end - end - end -end diff --git a/service/lib/agama/dbus/storage/interfaces/device.rb b/service/lib/agama/dbus/storage/interfaces/device.rb deleted file mode 100644 index 6376f6a0c4..0000000000 --- a/service/lib/agama/dbus/storage/interfaces/device.rb +++ /dev/null @@ -1,45 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -module Agama - module DBus - module Storage - module Interfaces - # Module for D-Bus interfaces of a device. - module Device - end - end - end - end -end - -require "agama/dbus/storage/interfaces/device/block" -require "agama/dbus/storage/interfaces/device/component" -require "agama/dbus/storage/interfaces/device/device" -require "agama/dbus/storage/interfaces/device/drive" -require "agama/dbus/storage/interfaces/device/filesystem" -require "agama/dbus/storage/interfaces/device/lvm_lv" -require "agama/dbus/storage/interfaces/device/lvm_vg" -require "agama/dbus/storage/interfaces/device/md" -require "agama/dbus/storage/interfaces/device/multipath" -require "agama/dbus/storage/interfaces/device/partition" -require "agama/dbus/storage/interfaces/device/partition_table" -require "agama/dbus/storage/interfaces/device/raid" diff --git a/service/lib/agama/dbus/storage/interfaces/device/block.rb b/service/lib/agama/dbus/storage/interfaces/device/block.rb deleted file mode 100644 index d9f3f5ad8f..0000000000 --- a/service/lib/agama/dbus/storage/interfaces/device/block.rb +++ /dev/null @@ -1,132 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2023-2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require "dbus" -require "agama/storage/device_shrinking" - -module Agama - module DBus - module Storage - module Interfaces - module Device - # Interface for block devices. - # - # @note This interface is intended to be included by {Agama::DBus::Storage::Device} if - # needed. - module Block - # Whether this interface should be implemented for the given device. - # - # @note Block devices implement this interface. - # - # @param storage_device [Y2Storage::Device] - # @return [Boolean] - def self.apply?(storage_device) - storage_device.is?(:blk_device) - end - - BLOCK_INTERFACE = "org.opensuse.Agama.Storage1.Block" - private_constant :BLOCK_INTERFACE - - # Position of the first block of the region. - # - # @return [Integer] - def block_start - storage_device.start - end - - # Whether the block device is currently active - # - # @return [Boolean] - def block_active - storage_device.active? - end - - # Whether the block device is encrypted. - # - # @return [Boolean] - def block_encrypted - storage_device.encrypted? - end - - # Name of the udev by-id links - # - # @return [Array] - def block_udev_ids - storage_device.udev_ids - end - - # Name of the udev by-path links - # - # @return [Array] - def block_udev_paths - storage_device.udev_paths - end - - # Size of the block device in bytes - # - # @return [Integer] - def block_size - storage_device.size.to_i - end - - # Shrinking information. - # - # @return [Hash] - def block_shrinking - shrinking = Agama::Storage::DeviceShrinking.new(storage_device) - - if shrinking.supported? - { "Supported" => shrinking.min_size.to_i } - else - { "Unsupported" => shrinking.unsupported_reasons } - end - end - - # Name of the currently installed systems - # - # @return [Array] - def block_systems - return @systems if @systems - - filesystems = storage_device.descendants.select { |d| d.is?(:filesystem) } - @systems = filesystems.map(&:system_name).compact - end - - def self.included(base) - base.class_eval do - dbus_interface BLOCK_INTERFACE do - dbus_reader :block_start, "t", dbus_name: "Start" - dbus_reader :block_active, "b", dbus_name: "Active" - dbus_reader :block_encrypted, "b", dbus_name: "Encrypted" - dbus_reader :block_udev_ids, "as", dbus_name: "UdevIds" - dbus_reader :block_udev_paths, "as", dbus_name: "UdevPaths" - dbus_reader :block_size, "t", dbus_name: "Size" - dbus_reader :block_shrinking, "a{sv}", dbus_name: "Shrinking" - dbus_reader :block_systems, "as", dbus_name: "Systems" - end - end - end - end - end - end - end - end -end diff --git a/service/lib/agama/dbus/storage/interfaces/device/component.rb b/service/lib/agama/dbus/storage/interfaces/device/component.rb deleted file mode 100644 index a4de9ce3e3..0000000000 --- a/service/lib/agama/dbus/storage/interfaces/device/component.rb +++ /dev/null @@ -1,99 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require "dbus" - -module Agama - module DBus - module Storage - module Interfaces - module Device - # Interface for devices that are used as component of other device (e.g., physical volume, - # MD RAID device, etc). - # - # @note This interface is intended to be included by {Agama::DBus::Storage::Device} if - # needed. - module Component - # Whether this interface should be implemented for the given device. - # - # @note Components of other devices implement this interface. - # - # @param storage_device [Y2Storage::Device] - # @return [Boolean] - def self.apply?(storage_device) - storage_device.is?(:blk_device) && storage_device.component_of.any? - end - - COMPONENT_INTERFACE = "org.opensuse.Agama.Storage1.Component" - private_constant :COMPONENT_INTERFACE - - # Type of component. - # - # @return ["physical_volume", "md_device", "raid_device", "multipath_wire", - # "bcache_device", "bcache_cset_device", "md_btrfs_device", ""] Empty if type is - # unknown. - def component_type - types = { - lvm_vg: "physical_volume", - md: "md_device", - dm_raid: "raid_device", - multipath: "multipath_wire", - bcache: "bcache_device", - bcache_cset: "bcache_cset_device", - btrfs: "md_btrfs_device" - } - - device = storage_device.component_of.first - - types.find { |k, _v| device.is?(k) }&.last || "" - end - - # Name of the devices for which this device is component of. - # - # @return [Array] - def component_device_names - storage_device.component_of.map(&:display_name).compact - end - - # Paths of the D-Bus objects representing the devices. - # - # @return [Array<::DBus::ObjectPath>] - def component_devices - storage_device.component_of.map { |p| tree.path_for(p) } - end - - def self.included(base) - base.class_eval do - dbus_interface COMPONENT_INTERFACE do - dbus_reader :component_type, "s", dbus_name: "Type" - # The names are provided just in case the device is component of a device that - # is not exported yet (e.g., Bcache devices). - dbus_reader :component_device_names, "as", dbus_name: "DeviceNames" - dbus_reader :component_devices, "ao", dbus_name: "Devices" - end - end - end - end - end - end - end - end -end diff --git a/service/lib/agama/dbus/storage/interfaces/device/device.rb b/service/lib/agama/dbus/storage/interfaces/device/device.rb deleted file mode 100644 index 21ff432bb7..0000000000 --- a/service/lib/agama/dbus/storage/interfaces/device/device.rb +++ /dev/null @@ -1,82 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require "dbus" -require "y2storage/device_description" - -module Agama - module DBus - module Storage - module Interfaces - module Device - # Interface for a device. - # - # @note This interface is intended to be included by {Agama::DBus::Storage::Device}. - module Device - # Whether this interface should be implemented for the given device. - # - # @note All devices implement this interface. - # - # @param _storage_device [Y2Storage::Device] - # @return [Boolean] - def self.apply?(_storage_device) - true - end - - DEVICE_INTERFACE = "org.opensuse.Agama.Storage1.Device" - private_constant :DEVICE_INTERFACE - - # sid of the device. - # - # @return [Integer] - def device_sid - storage_device.sid - end - - # Name to represent the device. - # - # @return [String] e.g., "/dev/sda". - def device_name - storage_device.display_name || "" - end - - # Description of the device. - # - # @return [String] e.g., "EXT4 Partition". - def device_description - Y2Storage::DeviceDescription.new(storage_device, include_encryption: true).to_s - end - - def self.included(base) - base.class_eval do - dbus_interface DEVICE_INTERFACE do - dbus_reader :device_sid, "u", dbus_name: "SID" - dbus_reader :device_name, "s", dbus_name: "Name" - dbus_reader :device_description, "s", dbus_name: "Description" - end - end - end - end - end - end - end - end -end diff --git a/service/lib/agama/dbus/storage/interfaces/device/drive.rb b/service/lib/agama/dbus/storage/interfaces/device/drive.rb deleted file mode 100644 index 51c2f0d7c0..0000000000 --- a/service/lib/agama/dbus/storage/interfaces/device/drive.rb +++ /dev/null @@ -1,156 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2023-2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require "dbus" - -module Agama - module DBus - module Storage - module Interfaces - module Device - # Interface for drive devices. - # - # @note This interface is intended to be included by {Agama::DBus::Storage::Device} if - # needed. - module Drive - # Whether this interface should be implemented for the given device. - # - # @note Drive devices implement this interface. - # Drive and disk device are very close concepts, but there are subtle differences. For - # example, a MD RAID is never considered as a drive. - # - # TODO: Revisit the defintion of drive. Maybe some MD devices could implement the drive - # interface if hwinfo provides useful information for them. - # - # @param storage_device [Y2Storage::Device] - # @return [Boolean] - def self.apply?(storage_device) - storage_device.is?(:disk, :dm_raid, :multipath, :dasd) && - storage_device.is?(:disk_device) - end - - DRIVE_INTERFACE = "org.opensuse.Agama.Storage1.Drive" - private_constant :DRIVE_INTERFACE - - # Drive type - # - # @return ["disk", "raid", "multipath", "dasd", ""] Empty if type is unknown. - def drive_type - if storage_device.is?(:disk) - "disk" - elsif storage_device.is?(:dm_raid) - "raid" - elsif storage_device.is?(:multipath) - "multipath" - elsif storage_device.is?(:dasd) - "dasd" - else - "" - end - end - - # Vendor name - # - # @return [String] - def drive_vendor - storage_device.vendor || "" - end - - # Model name - # - # @return [String] - def drive_model - storage_device.model || "" - end - - # Bus name - # - # @return [String] - def drive_bus - # FIXME: not sure whether checking for "none" is robust enough - return "" if storage_device.bus.nil? || storage_device.bus.casecmp?("none") - - storage_device.bus - end - - # Bus Id for DASD - # - # @return [String] - def drive_bus_id - return "" unless storage_device.respond_to?(:bus_id) - - storage_device.bus_id - end - - # Kernel drivers used by the device - # - # @return [Array] - def drive_driver - storage_device.driver - end - - # Data transport layer, if any - # - # @return [String] - def drive_transport - return "" unless storage_device.respond_to?(:transport) - - transport = storage_device.transport - return "" if transport.nil? || transport.is?(:unknown) - - # FIXME: transport does not have proper i18n support at yast2-storage-ng, so we are - # just duplicating some logic from yast2-storage-ng here - return "USB" if transport.is?(:usb) - return "IEEE 1394" if transport.is?(:sbp) - - transport.to_s - end - - # More info about the device - # - # @return [Hash] - def drive_info - { - "SDCard" => storage_device.sd_card?, - "DellBOSS" => storage_device.boss? - } - end - - def self.included(base) - base.class_eval do - dbus_interface DRIVE_INTERFACE do - dbus_reader :drive_type, "s", dbus_name: "Type" - dbus_reader :drive_vendor, "s", dbus_name: "Vendor" - dbus_reader :drive_model, "s", dbus_name: "Model" - dbus_reader :drive_bus, "s", dbus_name: "Bus" - dbus_reader :drive_bus_id, "s", dbus_name: "BusId" - dbus_reader :drive_driver, "as", dbus_name: "Driver" - dbus_reader :drive_transport, "s", dbus_name: "Transport" - dbus_reader :drive_info, "a{sv}", dbus_name: "Info" - end - end - end - end - end - end - end - end -end diff --git a/service/lib/agama/dbus/storage/interfaces/device/filesystem.rb b/service/lib/agama/dbus/storage/interfaces/device/filesystem.rb deleted file mode 100644 index 5f2a4a1b56..0000000000 --- a/service/lib/agama/dbus/storage/interfaces/device/filesystem.rb +++ /dev/null @@ -1,93 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2024-2025] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require "dbus" -require "y2storage/filesystem_label" - -module Agama - module DBus - module Storage - module Interfaces - module Device - # Interface for file systems. - # - # @note This interface is intended to be included by {Agama::DBus::Storage::Device} if - # needed. - module Filesystem - # Whether this interface should be implemented for the given device. - # - # @note Formatted devices implement this interface. - # - # @param storage_device [Y2Storage::Device] - # @return [Boolean] - def self.apply?(storage_device) - storage_device.is?(:blk_device) && !storage_device.filesystem.nil? - end - - FILESYSTEM_INTERFACE = "org.opensuse.Agama.Storage1.Filesystem" - private_constant :FILESYSTEM_INTERFACE - - # SID of the file system. - # - # It is useful to detect whether a file system is new. - # - # @return [Integer] - def filesystem_sid - storage_device.filesystem.sid - end - - # File system type. - # - # @return [String] e.g., "ext4" - def filesystem_type - storage_device.filesystem.type.to_s - end - - # Mount path of the file system. - # - # @return [String] Empty if not mounted. - def filesystem_mount_path - storage_device.filesystem.mount_path || "" - end - - # Label of the file system. - # - # @return [String] Empty if it has no label. - def filesystem_label - Y2Storage::FilesystemLabel.new(storage_device).to_s - end - - def self.included(base) - base.class_eval do - dbus_interface FILESYSTEM_INTERFACE do - dbus_reader :filesystem_sid, "u", dbus_name: "SID" - dbus_reader :filesystem_type, "s", dbus_name: "Type" - dbus_reader :filesystem_mount_path, "s", dbus_name: "MountPath" - dbus_reader :filesystem_label, "s", dbus_name: "Label" - end - end - end - end - end - end - end - end -end diff --git a/service/lib/agama/dbus/storage/interfaces/device/lvm_lv.rb b/service/lib/agama/dbus/storage/interfaces/device/lvm_lv.rb deleted file mode 100644 index 93757ffdfc..0000000000 --- a/service/lib/agama/dbus/storage/interfaces/device/lvm_lv.rb +++ /dev/null @@ -1,66 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require "dbus" - -module Agama - module DBus - module Storage - module Interfaces - module Device - # Interface for LVM logical volume. - # - # @note This interface is intended to be included by {Agama::DBus::Storage::Device} if - # needed. - module LvmLv - # Whether this interface should be implemented for the given device. - # - # @note LVM logical volumes implement this interface. - # - # @param storage_device [Y2Storage::Device] - # @return [Boolean] - def self.apply?(storage_device) - storage_device.is?(:lvm_lv) - end - - LOGICAL_VOLUME_INTERFACE = "org.opensuse.Agama.Storage1.LVM.LogicalVolume" - private_constant :LOGICAL_VOLUME_INTERFACE - - # LVM volume group hosting the this logical volume. - # - # @return [Array<::DBus::ObjectPath>] - def lvm_lv_vg - tree.path_for(storage_device.lvm_vg) - end - - def self.included(base) - base.class_eval do - dbus_interface LOGICAL_VOLUME_INTERFACE do - dbus_reader :lvm_lv_vg, "o", dbus_name: "VolumeGroup" - end - end - end - end - end - end - end - end -end diff --git a/service/lib/agama/dbus/storage/interfaces/device/lvm_vg.rb b/service/lib/agama/dbus/storage/interfaces/device/lvm_vg.rb deleted file mode 100644 index 8d219fc6dd..0000000000 --- a/service/lib/agama/dbus/storage/interfaces/device/lvm_vg.rb +++ /dev/null @@ -1,82 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require "dbus" - -module Agama - module DBus - module Storage - module Interfaces - module Device - # Interface for a LVM Volume Group. - # - # @note This interface is intended to be included by {Agama::DBus::Storage::Device} if - # needed. - module LvmVg - # Whether this interface should be implemented for the given device. - # - # @note LVM Volume Groups implement this interface. - # - # @param storage_device [Y2Storage::Device] - # @return [Boolean] - def self.apply?(storage_device) - storage_device.is?(:lvm_vg) - end - - VOLUME_GROUP_INTERFACE = "org.opensuse.Agama.Storage1.LVM.VolumeGroup" - private_constant :VOLUME_GROUP_INTERFACE - - # Size of the volume group in bytes - # - # @return [Integer] - def lvm_vg_size - storage_device.size.to_i - end - - # D-Bus paths of the objects representing the physical volumes. - # - # @return [Array] - def lvm_vg_pvs - storage_device.lvm_pvs.map { |p| tree.path_for(p.plain_blk_device) } - end - - # D-Bus paths of the objects representing the logical volumes. - # - # @return [Array] - def lvm_vg_lvs - storage_device.lvm_lvs.map { |l| tree.path_for(l) } - end - - def self.included(base) - base.class_eval do - dbus_interface VOLUME_GROUP_INTERFACE do - dbus_reader :lvm_vg_size, "t", dbus_name: "Size" - dbus_reader :lvm_vg_pvs, "ao", dbus_name: "PhysicalVolumes" - dbus_reader :lvm_vg_lvs, "ao", dbus_name: "LogicalVolumes" - end - end - end - end - end - end - end - end -end diff --git a/service/lib/agama/dbus/storage/interfaces/device/md.rb b/service/lib/agama/dbus/storage/interfaces/device/md.rb deleted file mode 100644 index 6cf144489d..0000000000 --- a/service/lib/agama/dbus/storage/interfaces/device/md.rb +++ /dev/null @@ -1,82 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2023-2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require "dbus" - -module Agama - module DBus - module Storage - module Interfaces - module Device - # Interface for MD RAID devices. - # - # @note This interface is intended to be included by {Agama::DBus::Storage::Device} if - # needed. - module Md - # Whether this interface should be implemented for the given device. - # - # @note MD RAIDs implement this interface. - # - # @param storage_device [Y2Storage::Device] - # @return [Boolean] - def self.apply?(storage_device) - storage_device.is?(:md) - end - - MD_INTERFACE = "org.opensuse.Agama.Storage1.MD" - private_constant :MD_INTERFACE - - # UUID of the MD RAID - # - # @return [String] - def md_uuid - storage_device.uuid - end - - # RAID level - # - # @return [String] - def md_level - storage_device.md_level.to_s - end - - # Paths of the D-Bus objects representing the devices of the MD RAID. - # - # @return [Array] - def md_devices - storage_device.plain_devices.map { |p| tree.path_for(p) } - end - - def self.included(base) - base.class_eval do - dbus_interface MD_INTERFACE do - dbus_reader :md_uuid, "s", dbus_name: "UUID" - dbus_reader :md_level, "s", dbus_name: "Level" - dbus_reader :md_devices, "ao", dbus_name: "Devices" - end - end - end - end - end - end - end - end -end diff --git a/service/lib/agama/dbus/storage/interfaces/device/multipath.rb b/service/lib/agama/dbus/storage/interfaces/device/multipath.rb deleted file mode 100644 index 40037fcc75..0000000000 --- a/service/lib/agama/dbus/storage/interfaces/device/multipath.rb +++ /dev/null @@ -1,68 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2023-2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require "dbus" - -module Agama - module DBus - module Storage - module Interfaces - module Device - # Interface for Multipath devices. - # - # @note This interface is intended to be included by {Agama::DBus::Storage::Device} if - # needed. - module Multipath - # Whether this interface should be implemented for the given device. - # - # @note Multipath devices implement this interface. - # - # @param storage_device [Y2Storage::Device] - # @return [Boolean] - def self.apply?(storage_device) - storage_device.is?(:multipath) - end - - MULTIPATH_INTERFACE = "org.opensuse.Agama.Storage1.Multipath" - private_constant :MULTIPATH_INTERFACE - - # Name of the multipath wires. - # - # @note: The multipath wires are not exported in D-Bus yet. - # - # @return [Array] - def multipath_wires - storage_device.parents.map(&:name) - end - - def self.included(base) - base.class_eval do - dbus_interface MULTIPATH_INTERFACE do - dbus_reader :multipath_wires, "as", dbus_name: "Wires" - end - end - end - end - end - end - end - end -end diff --git a/service/lib/agama/dbus/storage/interfaces/device/partition.rb b/service/lib/agama/dbus/storage/interfaces/device/partition.rb deleted file mode 100644 index 050d836663..0000000000 --- a/service/lib/agama/dbus/storage/interfaces/device/partition.rb +++ /dev/null @@ -1,74 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require "dbus" - -module Agama - module DBus - module Storage - module Interfaces - module Device - # Interface for partition. - # - # @note This interface is intended to be included by {Agama::DBus::Storage::Device} if - # needed. - module Partition - # Whether this interface should be implemented for the given device. - # - # @note Partitions implement this interface. - # - # @param storage_device [Y2Storage::Device] - # @return [Boolean] - def self.apply?(storage_device) - storage_device.is?(:partition) - end - - PARTITION_INTERFACE = "org.opensuse.Agama.Storage1.Partition" - private_constant :PARTITION_INTERFACE - - # Device hosting the partition table of this partition. - # - # @return [Array<::DBus::ObjectPath>] - def partition_device - tree.path_for(storage_device.partitionable) - end - - # Whether it is a (valid) EFI System partition - # - # @return [Boolean] - def partition_efi - storage_device.efi_system? - end - - def self.included(base) - base.class_eval do - dbus_interface PARTITION_INTERFACE do - dbus_reader :partition_device, "o", dbus_name: "Device" - dbus_reader :partition_efi, "b", dbus_name: "EFI" - end - end - end - end - end - end - end - end -end diff --git a/service/lib/agama/dbus/storage/interfaces/device/partition_table.rb b/service/lib/agama/dbus/storage/interfaces/device/partition_table.rb deleted file mode 100644 index 97dcb90e25..0000000000 --- a/service/lib/agama/dbus/storage/interfaces/device/partition_table.rb +++ /dev/null @@ -1,87 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2023-2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require "dbus" - -module Agama - module DBus - module Storage - module Interfaces - module Device - # Interface for devices that contain a partition table. - # - # @note This interface is intended to be included by {Agama::DBus::Storage::Device} if - # needed. - module PartitionTable - # Whether this interface should be implemented for the given device. - # - # @note Devices containing a partition table implement this interface. - # - # @param storage_device [Y2Storage::Device] - # @return [Boolean] - def self.apply?(storage_device) - storage_device.is?(:blk_device) && - storage_device.respond_to?(:partition_table?) && - storage_device.partition_table? - end - - PARTITION_TABLE_INTERFACE = "org.opensuse.Agama.Storage1.PartitionTable" - private_constant :PARTITION_TABLE_INTERFACE - - # Type of the partition table - # - # @return [String] - def partition_table_type - storage_device.partition_table.type.to_s - end - - # Paths of the D-Bus objects representing the partitions. - # - # @return [Array<::DBus::ObjectPath>] - def partition_table_partitions - storage_device.partition_table.partitions.map { |p| tree.path_for(p) } - end - - # Available slots within a partition table, that is, the spaces that can be used to - # create a new partition. - # - # @return [Array] The first block and the size of each slot. - def partition_table_unused_slots - storage_device.partition_table.unused_partition_slots.map do |slot| - [slot.region.start, slot.region.size.to_i] - end - end - - def self.included(base) - base.class_eval do - dbus_interface PARTITION_TABLE_INTERFACE do - dbus_reader :partition_table_type, "s", dbus_name: "Type" - dbus_reader :partition_table_partitions, "ao", dbus_name: "Partitions" - dbus_reader :partition_table_unused_slots, "a(tt)", dbus_name: "UnusedSlots" - end - end - end - end - end - end - end - end -end diff --git a/service/lib/agama/dbus/storage/interfaces/device/raid.rb b/service/lib/agama/dbus/storage/interfaces/device/raid.rb deleted file mode 100644 index ecbf3c62a0..0000000000 --- a/service/lib/agama/dbus/storage/interfaces/device/raid.rb +++ /dev/null @@ -1,68 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2023-2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require "dbus" - -module Agama - module DBus - module Storage - module Interfaces - module Device - # Interface for DM RAID devices. - # - # @note This interface is intended to be included by {Agama::DBus::Storage::Device} if - # needed. - module Raid - # Whether this interface should be implemented for the given device. - # - # @note DM RAIDs implement this interface. - # - # @param storage_device [Y2Storage::Device] - # @return [Boolean] - def self.apply?(storage_device) - storage_device.is?(:dm_raid) - end - - RAID_INTERFACE = "org.opensuse.Agama.Storage1.RAID" - private_constant :RAID_INTERFACE - - # Name of the devices used by the DM RAID. - # - # @note: The devices used by a DM RAID are not exported in D-Bus yet. - # - # @return [Array] - def raid_devices - storage_device.parents.map(&:name) - end - - def self.included(base) - base.class_eval do - dbus_interface RAID_INTERFACE do - dbus_reader :raid_devices, "as", dbus_name: "Devices" - end - end - end - end - end - end - end - end -end diff --git a/service/lib/agama/dbus/storage/manager.rb b/service/lib/agama/dbus/storage/manager.rb index 16f41e2660..539c92547f 100644 --- a/service/lib/agama/dbus/storage/manager.rb +++ b/service/lib/agama/dbus/storage/manager.rb @@ -19,26 +19,19 @@ # To contact SUSE LLC about this file by physical or electronic mail, you may # find current contact information at www.suse.com. -require "dbus" -require "json" -require "yast" require "y2storage/storage_manager" require "agama/dbus/base_object" require "agama/dbus/interfaces/issues" -require "agama/dbus/interfaces/locale" -require "agama/dbus/interfaces/progress" -require "agama/dbus/interfaces/service_status" -require "agama/dbus/storage/devices_tree" require "agama/dbus/storage/iscsi_nodes_tree" -require "agama/dbus/storage/proposal" -require "agama/dbus/storage/proposal_settings_conversion" -require "agama/dbus/storage/volume_conversion" -require "agama/dbus/with_progress" -require "agama/dbus/with_service_status" require "agama/storage/config_conversions" require "agama/storage/encryption_settings" -require "agama/storage/proposal_settings" require "agama/storage/volume_templates_builder" +require "agama/storage/devicegraph_conversions" +require "agama/storage/volume_conversions" +require "agama/with_progress" +require "dbus" +require "json" +require "yast" Yast.import "Arch" @@ -47,68 +40,160 @@ module DBus module Storage # D-Bus object to manage storage installation class Manager < BaseObject # rubocop:disable Metrics/ClassLength - include WithProgress - include WithServiceStatus + extend Yast::I18n + include Yast::I18n + include Agama::WithProgress include ::DBus::ObjectManager - include DBus::Interfaces::Issues - include DBus::Interfaces::Locale - include DBus::Interfaces::Progress - include DBus::Interfaces::ServiceStatus PATH = "/org/opensuse/Agama/Storage1" private_constant :PATH - # Constructor - # # @param backend [Agama::Storage::Manager] - # @param service_status [Agama::DBus::ServiceStatus, nil] # @param logger [Logger, nil] - def initialize(backend, service_status: nil, logger: nil) + def initialize(backend, logger: nil) + textdomain "agama" + super(PATH, logger: logger) @backend = backend - @service_status = service_status - @encryption_methods = read_encryption_methods - @actions = read_actions - register_storage_callbacks register_progress_callbacks - register_service_status_callbacks register_iscsi_callbacks - register_software_callbacks add_s390_interfaces if Yast::Arch.s390 end - def locale=(locale) - backend.locale = locale + dbus_interface "org.opensuse.Agama.Storage1" do + dbus_method(:Activate) { activate } + dbus_method(:Probe) { probe } + dbus_method(:SetProduct, "in id:s") { |id| configure_product(id) } + dbus_method(:Install) { install } + dbus_method(:Finish) { finish } + dbus_method(:SetLocale, "in locale:s") { |locale| backend.configure_locale(locale) } + # TODO: receive a product_config instead of an id. + dbus_method(:GetSystem, "out system:s") { recover_system } + dbus_method(:GetConfig, "out config:s") { recover_config } + dbus_method(:SetConfig, "in config:s") { |c| configure(c) } + dbus_method(:GetConfigModel, "out model:s") { recover_config_model } + dbus_method(:SetConfigModel, "in model:s") { |m| configure_with_model(m) } + dbus_method(:SolveConfigModel, "in model:s, out result:s") { |m| solve_config_model(m) } + dbus_method(:GetProposal, "out proposal:s") { recover_proposal } + dbus_method(:GetIssues, "out issues:s") { recover_issues } + dbus_signal(:SystemChanged, "system:s") + dbus_signal(:ProposalChanged, "proposal:s") + dbus_signal(:ProgressChanged, "progress:s") + dbus_signal(:ProgressFinished) end - # List of issues, see {DBus::Interfaces::Issues} - # - # @return [Array] - def issues - backend.issues + # Implementation for the API method #Activate. + def activate + start_progress(3, ACTIVATING_STEP) + backend.reset_activation if backend.activated? + backend.activate + + next_progress_step(PROBING_STEP) + backend.probe + emit_system_changed + + next_progress_step(CONFIGURING_STEP) + configure_with_current + + finish_progress + end + + # Implementation for the API method #Probe. + def probe + start_progress(3, ACTIVATING_STEP) + backend.activate unless backend.activated? + + next_progress_step(PROBING_STEP) + backend.probe + emit_system_changed + + next_progress_step(CONFIGURING_STEP) + configure_with_current + + finish_progress end - STORAGE_INTERFACE = "org.opensuse.Agama.Storage1" - private_constant :STORAGE_INTERFACE + # Implementation for the API method #SetProduct. + def configure_product(id) + backend.product_config.pick_product(id) - # @param keep_config [Boolean] Whether to use the current storage config for calculating - # the proposal. - # @param keep_activation [Boolean] Whether to keep the current activation (e.g., provided - # LUKS passwords). - def probe(keep_config: false, keep_activation: true) - busy_while do - # Clean trees in advance to avoid having old objects exported in D-Bus. - system_devices_tree.clean - staging_devices_tree.clean + start_progress(3, ACTIVATING_STEP) + backend.activate unless backend.activated? - backend.probe(keep_config: keep_config, keep_activation: keep_activation) + next_progress_step(PROBING_STEP) + if !backend.probed? + backend.probe + emit_system_changed end + + next_progress_step(CONFIGURING_STEP) + calculate_proposal + + finish_progress + end + + # Implementation for the API method #Install. + def install + start_progress(4, _("Preparing bootloader proposal")) + backend.bootloader.configure + + next_progress_step(_("Adding storage-related packages")) + backend.add_packages + + next_progress_step(_("Preparing the storage devices")) + backend.install + + next_progress_step(_("Writing bootloader sysconfig")) + backend.bootloader.install + + finish_progress end - # @todo Drop support for the guided settings. + # Implementation for the API method #Finish. + def finish + start_progress(1, _("Finishing installation")) + backend.finish + + finish_progress + end + + # NOTE: memoization of the values? + # @return [String] + def recover_system + return nil.to_json unless backend.probed? + + json = { + devices: json_devices(:probed), + availableDrives: available_drives, + availableMdRaids: available_md_raids, + candidateDrives: candidate_drives, + candidateMdRaids: candidate_md_raids, + issues: system_issues, + productMountPoints: product_mount_points, + encryptionMethods: encryption_methods, + volumeTemplates: volume_templates + } + JSON.pretty_generate(json) + end + + # Gets and serializes the storage config used for calculating the current proposal. # + # @return [String] + def recover_config + json = proposal.storage_json + JSON.pretty_generate(json) + end + + # Gets and serializes the storage config model. + # + # @return [String] + def recover_config_model + json = proposal.model_json + JSON.pretty_generate(json) + end + # Applies the given serialized config according to the JSON schema. # # The JSON schema supports two different variants: @@ -117,19 +202,20 @@ def probe(keep_config: false, keep_activation: true) # @raise If the config is not valid. # # @param serialized_config [String] Serialized storage config. - # @return [Integer] 0 success; 1 error - def apply_config(serialized_config) - logger.info("Setting storage config from D-Bus: #{serialized_config}") + def configure(serialized_config) + start_progress(1, CONFIGURING_STEP) + config_json = JSON.parse(serialized_config, symbolize_names: true) - configure(config_json) + calculate_proposal(config_json) + + finish_progress end # Applies the given serialized config model according to the JSON schema. # # @param serialized_model [String] Serialized storage config model. - # @return [Integer] 0 success; 1 error - def apply_config_model(serialized_model) - logger.info("Setting storage config model from D-Bus: #{serialized_model}") + def configure_with_model(serialized_model) + start_progress(1, CONFIGURING_STEP) model_json = JSON.parse(serialized_model, symbolize_names: true) config = Agama::Storage::ConfigConversions::FromModel.new( @@ -138,104 +224,50 @@ def apply_config_model(serialized_model) storage_system: proposal.storage_system ).convert config_json = { storage: Agama::Storage::ConfigConversions::ToJSON.new(config).convert } + calculate_proposal(config_json) - configure(config_json) - end - - # Resets to the default config. - # - # @return [Integer] 0 success; 1 error - def reset_config - logger.info("Reset storage config from D-Bus") - configure - end - - # Gets and serializes the storage config used for calculating the current proposal. - # - # @return [String] - def recover_config - json = proposal.storage_json - JSON.pretty_generate(json) - end - - # Gets and serializes the storage config model. - # - # @return [String] - def recover_model - json = proposal.model_json - JSON.pretty_generate(json) + finish_progress end # Solves the given serialized config model. # # @param serialized_model [String] Serialized storage config model. # @return [String] Serialized solved model. - def solve_model(serialized_model) - logger.info("Solving storage config model from D-Bus: #{serialized_model}") - + def solve_config_model(serialized_model) model_json = JSON.parse(serialized_model, symbolize_names: true) solved_model_json = proposal.solve_model(model_json) JSON.pretty_generate(solved_model_json) end - def install - busy_while { backend.install } - end + # NOTE: memoization of the values? + # @return [String] + def recover_proposal + return nil.to_json unless backend.proposal.success? - def finish - busy_while { backend.finish } + json = { + devices: json_devices(:staging), + actions: actions + } + JSON.pretty_generate(json) end - # Whether the system is in a deprecated status + # Gets and serializes the list of issues. # - # @return [Boolean] - def deprecated_system - backend.deprecated_system? - end - - # FIXME: Revisit return values. - # * Methods like #SetConfig or #ResetConfig return whether the proposal successes, but - # they should return whether the config was actually applied. - # * Methods like #Probe or #Install return nothing. - dbus_interface STORAGE_INTERFACE do - dbus_signal :Configured, "client_id:s" - dbus_method(:Probe, "in data:a{sv}") do |data| - busy_request(data) { probe } - end - dbus_method(:Reprobe, "in data:a{sv}") do |data| - busy_request(data) { probe(keep_config: true) } - end - dbus_method(:Reactivate, "in data:a{sv}") do |data| - busy_request(data) { probe(keep_config: true, keep_activation: false) } - end - dbus_method( - :SetConfig, - "in serialized_config:s, in data:a{sv}, out result:u" - ) do |serialized_config, data| - busy_request(data) { apply_config(serialized_config) } - end - dbus_method(:ResetConfig, "in data:a{sv}, out result:u") do |data| - busy_request(data) { reset_config } - end - dbus_method( - :SetConfigModel, - "in serialized_model:s, in data:a{sv}, out result:u" - ) do |serialized_model, data| - busy_request(data) { apply_config_model(serialized_model) } + # @return [String] + def recover_issues + json = backend.issues.map { |i| json_issue(i) } + JSON.pretty_generate(json) + end + + dbus_interface "org.opensuse.Agama.Storage1.Bootloader" do + dbus_method(:SetConfig, "in serialized_config:s, out result:u") do |serialized_config| + load_bootloader_config_from_json(serialized_config) end - dbus_method(:GetConfig, "out serialized_config:s") { recover_config } - dbus_method(:GetConfigModel, "out serialized_model:s") { recover_model } - dbus_method(:SolveConfigModel, "in sparse_model:s, out solved_model:s") do |sparse_model| - solve_model(sparse_model) + dbus_method(:GetConfig, "out serialized_config:s") do + bootloader_config_as_json end - dbus_method(:Install) { install } - dbus_method(:Finish) { finish } - dbus_reader(:deprecated_system, "b") end - BOOTLOADER_INTERFACE = "org.opensuse.Agama.Storage1.Bootloader" - private_constant :BOOTLOADER_INTERFACE - # Applies the given serialized config according to the JSON schema. # # @@ -258,150 +290,6 @@ def bootloader_config_as_json backend.bootloader.config.to_json end - dbus_interface BOOTLOADER_INTERFACE do - dbus_method(:SetConfig, "in serialized_config:s, out result:u") do |serialized_config| - load_bootloader_config_from_json(serialized_config) - end - dbus_method(:GetConfig, "out serialized_config:s") do - bootloader_config_as_json - end - end - - # @todo Move device related properties here, for example, the list of system and staging - # devices, dirty, etc. - STORAGE_DEVICES_INTERFACE = "org.opensuse.Agama.Storage1.Devices" - private_constant :STORAGE_DEVICES_INTERFACE - - # List of sorted actions. - # - # @return [Hash] - # * "Device" [Integer] - # * "Text" [String] - # * "Subvol" [Boolean] - # * "Delete" [Boolean] - # * "Resize" [Boolean] - def read_actions - backend.actions.map do |action| - { - "Device" => action.device_sid, - "Text" => action.text, - "Subvol" => action.on_btrfs_subvolume?, - "Delete" => action.delete?, - "Resize" => action.resize? - } - end - end - - # A PropertiesChanged signal is emitted (see ::DBus::Object.dbus_reader_attr_accessor). - def update_actions - self.actions = read_actions - end - - # @see Storage::System#available_drives - # @return [Array<::DBus::ObjectPath>] - def available_drives - proposal.storage_system.available_drives.map { |d| system_device_path(d) } - end - - # @see Storage::System#available_drives - # @return [Array<::DBus::ObjectPath>] - def candidate_drives - proposal.storage_system.candidate_drives.map { |d| system_device_path(d) } - end - - # @see Storage::System#available_drives - # @return [Array<::DBus::ObjectPath>] - def available_md_raids - proposal.storage_system.available_md_raids.map { |d| system_device_path(d) } - end - - # @see Storage::System#available_drives - # @return [Array<::DBus::ObjectPath>] - def candidate_md_raids - proposal.storage_system.candidate_md_raids.map { |d| system_device_path(d) } - end - - # @param device [Y2Storage::Device] - # @return [::DBus::ObjectPath] - def system_device_path(device) - system_devices_tree.path_for(device) - end - - dbus_interface STORAGE_DEVICES_INTERFACE do - # PropertiesChanged signal if storage is configured, see {#register_callbacks}. - dbus_reader_attr_accessor :actions, "aa{sv}" - - dbus_reader :available_drives, "ao" - dbus_reader :candidate_drives, "ao" - dbus_reader :available_md_raids, "ao" - dbus_reader :candidate_md_raids, "ao" - end - - PROPOSAL_CALCULATOR_INTERFACE = "org.opensuse.Agama.Storage1.Proposal.Calculator" - private_constant :PROPOSAL_CALCULATOR_INTERFACE - - # Calculates a guided proposal. - # - # @param settings_dbus [Hash] - # @return [Integer] 0 success; 1 error - def calculate_guided_proposal(settings_dbus) - logger.info("Calculating guided storage proposal from D-Bus: #{settings_dbus}") - - settings = ProposalSettingsConversion.from_dbus(settings_dbus, - config: product_config, logger: logger) - - proposal.calculate_guided(settings) - proposal.success? ? 0 : 1 - end - - # Meaningful mount points for the current product. - # - # @return [Array] - def product_mount_points - volume_templates_builder - .all - .map(&:mount_path) - .reject(&:empty?) - end - - # Reads the list of possible encryption methods for the current system and product. - # - # @return [Array] - def read_encryption_methods - Agama::Storage::EncryptionSettings - .available_methods - .map { |m| m.id.to_s } - end - - # Default volume used as template - # - # @return [Hash] - def default_volume(mount_path) - volume = volume_templates_builder.for(mount_path) - VolumeConversion.to_dbus(volume) - end - - dbus_interface PROPOSAL_CALCULATOR_INTERFACE do - dbus_reader :product_mount_points, "as" - - # PropertiesChanged signal if software is probed, see {#register_software_callbacks}. - dbus_reader_attr_accessor :encryption_methods, "as" - - dbus_method :DefaultVolume, "in mount_path:s, out volume:a{sv}" do |mount_path| - [default_volume(mount_path)] - end - - # @deprecated Use #Storage1.SetConfig - # - # result: 0 success; 1 error - dbus_method(:Calculate, "in settings_dbus:a{sv}, out result:u") do |settings_dbus| - busy_while { calculate_guided_proposal(settings_dbus) } - end - end - - ISCSI_INITIATOR_INTERFACE = "org.opensuse.Agama.Storage1.ISCSI.Initiator" - private_constant :ISCSI_INITIATOR_INTERFACE - # Gets the iSCSI initiator name # # @return [String] @@ -423,6 +311,22 @@ def ibft backend.iscsi.initiator.ibft_name? end + ISCSI_INITIATOR_INTERFACE = "org.opensuse.Agama.Storage1.ISCSI.Initiator" + private_constant :ISCSI_INITIATOR_INTERFACE + + dbus_interface ISCSI_INITIATOR_INTERFACE do + dbus_accessor :initiator_name, "s" + + dbus_reader :ibft, "b", dbus_name: "IBFT" + + dbus_method :Discover, + "in address:s, in port:u, in options:a{sv}, out result:u" do |address, port, options| + iscsi_discover(address, port, options) + end + + dbus_method(:Delete, "in node:o, out result:u") { |n| iscsi_delete(n) } + end + # Performs an iSCSI discovery # # @param address [String] IP address of the iSCSI server @@ -465,135 +369,182 @@ def iscsi_delete(path) 2 # Error code end - dbus_interface ISCSI_INITIATOR_INTERFACE do - dbus_accessor :initiator_name, "s" - - dbus_reader :ibft, "b", dbus_name: "IBFT" + private - dbus_method :Discover, - "in address:s, in port:u, in options:a{sv}, out result:u" do |address, port, options| - busy_while { iscsi_discover(address, port, options) } - end + ACTIVATING_STEP = N_("Activating storage devices") + private_constant :ACTIVATING_STEP - dbus_method(:Delete, "in node:o, out result:u") { |n| iscsi_delete(n) } - end + PROBING_STEP = N_("Probing storage devices") + private_constant :PROBING_STEP - private + CONFIGURING_STEP = N_("Applying storage configuration") + private_constant :CONFIGURING_STEP # @return [Agama::Storage::Manager] attr_reader :backend - # @return [DBus::Storage::Proposal, nil] - attr_reader :dbus_proposal + def register_progress_callbacks + on_progress_change { self.ProgressChanged(progress.to_json) } + on_progress_finish { self.ProgressFinished } + end - # Configures storage. + # Configures storage using the current config. # - # @param config_json [Hash, nil] Storage config according to the JSON schema. If nil, then - # the default config is applied. - # @return [Integer] 0 success; 1 error - def configure(config_json = nil) - success = backend.configure(config_json) - success ? 0 : 1 - end + # @note The proposal is not calculated if there is not a config yet. + def configure_with_current + config_json = proposal.storage_json + return unless config_json - def send_configured_signal - self.Configured(request_data["client_id"].to_s) + configure(config_json) end - def add_s390_interfaces - require "agama/dbus/storage/interfaces/dasd_manager" - require "agama/dbus/storage/interfaces/zfcp_manager" - - singleton_class.include Interfaces::DasdManager - singleton_class.include Interfaces::ZFCPManager + # @see #configure + # @see #configure_with_model + # + # @param config_json [Hash, nil] see Agama::Storage::Manager#configure + def calculate_proposal(config_json = nil) + backend.configure(config_json) + self.ProposalChanged(recover_proposal) + end - register_dasd_callbacks - register_zfcp_callbacks + # JSON representation of the given devicegraph from StorageManager + # + # @param meth [Symbol] method used to get the devicegraph from StorageManager + # @return [Hash] + def json_devices(meth) + devicegraph = Y2Storage::StorageManager.instance.send(meth) + Agama::Storage::DevicegraphConversions::ToJSON.new(devicegraph).convert end - # @return [Agama::Storage::Proposal] - def proposal - backend.proposal + # JSON representation of the given Agama issue + # + # @param issue [Array] + # @return [Hash] + def json_issue(issue) + { + description: issue.description, + class: issue.kind&.to_s, + details: issue.details&.to_s, + source: issue.source&.to_s, + severity: issue.severity&.to_s + }.compact end - def register_storage_callbacks - backend.on_issues_change { issues_properties_changed } - backend.on_deprecated_system_change { storage_properties_changed } - backend.on_probe { refresh_system_devices } - backend.on_configure do - export_proposal - proposal_properties_changed - refresh_staging_devices - update_actions - send_configured_signal + # List of sorted actions. + # + # @return [Hash] + # * :device [Integer] + # * :text [String] + # * :subvol [Boolean] + # * :delete [Boolean] + # * :resize [Boolean] + def actions + backend.actions.map do |action| + { + device: action.device_sid, + text: action.text, + subvol: action.on_btrfs_subvolume?, + delete: action.delete?, + resize: action.resize? + } end end - def register_iscsi_callbacks - backend.iscsi.on_probe do - iscsi_initiator_properties_changed - refresh_iscsi_nodes - end + # @see Storage::System#available_drives + # @return [Array] + def available_drives + proposal.storage_system.available_drives.map(&:sid) + end - backend.iscsi.on_sessions_change do - # Currently, the system is set as deprecated instead of reprobing automatically. This - # is done so to avoid a reprobing after each single session change performed by the UI. - # Clients are expected to request a reprobing if they detect a deprecated system. - # - # If the UI is adapted to use the new iSCSI API (i.e., #SetConfig), then this behaviour - # should be reevaluated. Ideally, the system would be reprobed if the sessions change. - deprecate_system - end + # @see Storage::System#available_drives + # @return [Array] + def candidate_drives + proposal.storage_system.candidate_drives.map(&:sid) end - def register_software_callbacks - backend.software.on_probe_finished do - # A PropertiesChanged signal is emitted (see ::DBus::Object.dbus_reader_attr_accessor). - self.encryption_methods = read_encryption_methods - end + # @see Storage::System#available_drives + # @return [Array] + def available_md_raids + proposal.storage_system.available_md_raids.map(&:sid) end - def storage_properties_changed - properties = interfaces_and_properties[STORAGE_INTERFACE] - dbus_properties_changed(STORAGE_INTERFACE, properties, []) + # @see Storage::System#available_drives + # @return [Array] + def candidate_md_raids + proposal.storage_system.candidate_md_raids.map(&:sid) end - def proposal_properties_changed - properties = interfaces_and_properties[PROPOSAL_CALCULATOR_INTERFACE] - dbus_properties_changed(PROPOSAL_CALCULATOR_INTERFACE, properties, []) + # Problems found during system probing + # + # @see #recover_system + # + # @return [Hash] + def system_issues + backend.system_issues.map { |i| json_issue(i) } end - def iscsi_initiator_properties_changed - properties = interfaces_and_properties[ISCSI_INITIATOR_INTERFACE] - dbus_properties_changed(ISCSI_INITIATOR_INTERFACE, properties, []) + # Meaningful mount points for the current product. + # + # @return [Array] + def product_mount_points + volume_templates_builder + .all + .map(&:mount_path) + .reject(&:empty?) end - def deprecate_system - backend.deprecated_system = true + # Reads the list of possible encryption methods for the current system and product. + # + # @return [Array] + def encryption_methods + Agama::Storage::EncryptionSettings + .available_methods + .map { |m| m.id.to_s } end - # @todo Do not export a separate proposal object. For now, the guided proposal is still - # exported to keep the current UI working. - def export_proposal - if dbus_proposal - @service.unexport(dbus_proposal) - @dbus_proposal = nil + # Default volumes to be used as templates + # + # @return [Array] + def volume_templates + volumes = volume_templates_builder.all + volumes << volume_templates_builder.for("") unless volumes.map(&:mount_path).include?("") + + volumes.map do |vol| + Agama::Storage::VolumeConversions::ToJSON.new(vol).convert end + end - return unless proposal.guided? + # Emits the SystemChanged signal + def emit_system_changed + self.SystemChanged(recover_system) + end - @dbus_proposal = DBus::Storage::Proposal.new(proposal, logger) - @service.export(@dbus_proposal) + def add_s390_interfaces + require "agama/dbus/storage/interfaces/dasd_manager" + require "agama/dbus/storage/interfaces/zfcp_manager" + + singleton_class.include Interfaces::DasdManager + singleton_class.include Interfaces::ZFCPManager + + register_dasd_callbacks + register_zfcp_callbacks end - def refresh_system_devices - devicegraph = Y2Storage::StorageManager.instance.probed - system_devices_tree.update(devicegraph) + # @return [Agama::Storage::Proposal] + def proposal + backend.proposal + end + + def register_iscsi_callbacks + backend.iscsi.on_probe do + iscsi_initiator_properties_changed + refresh_iscsi_nodes + end end - def refresh_staging_devices - devicegraph = Y2Storage::StorageManager.instance.staging - staging_devices_tree.update(devicegraph) + def iscsi_initiator_properties_changed + properties = interfaces_and_properties[ISCSI_INITIATOR_INTERFACE] + dbus_properties_changed(ISCSI_INITIATOR_INTERFACE, properties, []) end def refresh_iscsi_nodes @@ -605,21 +556,6 @@ def iscsi_nodes_tree @iscsi_nodes_tree ||= ISCSINodesTree.new(@service, backend.iscsi, logger: logger) end - # FIXME: D-Bus trees should not be created by the Manager D-Bus object. Note that the - # service (`@service`) is nil until the Manager object is exported. The service should - # have the responsibility of creating the trees and pass them to Manager if needed. - def system_devices_tree - @system_devices_tree ||= DevicesTree.new(@service, tree_path("system"), logger: logger) - end - - def staging_devices_tree - @staging_devices_tree ||= DevicesTree.new(@service, tree_path("staging"), logger: logger) - end - - def tree_path(tree_root) - File.join(PATH, tree_root) - end - # @return [Agama::Config] def product_config backend.product_config diff --git a/service/lib/agama/dbus/storage/proposal.rb b/service/lib/agama/dbus/storage/proposal.rb deleted file mode 100644 index 9050c8a057..0000000000 --- a/service/lib/agama/dbus/storage/proposal.rb +++ /dev/null @@ -1,98 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2022-2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require "agama/dbus/base_object" -require "agama/dbus/storage/proposal_settings_conversion" -require "dbus" - -module Agama - module DBus - module Storage - # D-Bus object to manage the storage proposal. - class Proposal < BaseObject - PATH = "/org/opensuse/Agama/Storage1/Proposal" - private_constant :PATH - - # @param backend [Agama::Storage::Proposal] - # @param logger [Logger] - def initialize(backend, logger) - super(PATH, logger: logger) - @backend = backend - end - - STORAGE_PROPOSAL_INTERFACE = "org.opensuse.Agama.Storage1.Proposal" - private_constant :STORAGE_PROPOSAL_INTERFACE - - dbus_interface STORAGE_PROPOSAL_INTERFACE do - dbus_reader :settings, "a{sv}" - dbus_reader :actions, "aa{sv}" - end - - # Proposal settings. - # - # @see ProposalSettingsConversion::ToDBus - # - # @return [Hash] - def settings - return {} unless backend.guided? - - ProposalSettingsConversion.to_dbus(backend.guided_settings) - end - - # List of sorted actions in D-Bus format. - # - # @see #to_dbus_action - # - # @return [Array] - def actions - backend.actions.map { |a| to_dbus_action(a) } - end - - private - - # @return [Agama::Storage::Proposal] - attr_reader :backend - - # @return [Logger] - attr_reader :logger - - # Converts an action to D-Bus format. - # - # @param action [Y2Storage::CompoundAction] - # @return [Hash] - # * "Device" [Integer] - # * "Text" [String] - # * "Subvol" [Boolean] - # * "Delete" [Boolean] - # * "Resize" [Boolean] - def to_dbus_action(action) - { - "Device" => action.device_sid, - "Text" => action.text, - "Subvol" => action.on_btrfs_subvolume?, - "Delete" => action.delete?, - "Resize" => action.resize? - } - end - end - end - end -end diff --git a/service/lib/agama/dbus/storage/proposal_settings_conversion.rb b/service/lib/agama/dbus/storage/proposal_settings_conversion.rb deleted file mode 100644 index dc8e94ffc7..0000000000 --- a/service/lib/agama/dbus/storage/proposal_settings_conversion.rb +++ /dev/null @@ -1,51 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2023-2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require "agama/dbus/storage/proposal_settings_conversion/from_dbus" -require "agama/dbus/storage/proposal_settings_conversion/to_dbus" - -module Agama - module DBus - module Storage - # Conversions for the proposal settings. - module ProposalSettingsConversion - # Performs conversion from D-Bus format. - # - # @param dbus_settings [Hash] - # @param config [Agama::Config] - # @param logger [Logger, nil] - # - # @return [Agama::Storage::ProposalSettings] - def self.from_dbus(dbus_settings, config:, logger: nil) - FromDBus.new(dbus_settings, config: config, logger: logger).convert - end - - # Performs conversion to D-Bus format. - # - # @param settings [Agama::Storage::ProposalSettings] - # @return [Hash] - def self.to_dbus(settings) - ToDBus.new(settings).convert - end - end - end - end -end diff --git a/service/lib/agama/dbus/storage/proposal_settings_conversion/from_dbus.rb b/service/lib/agama/dbus/storage/proposal_settings_conversion/from_dbus.rb deleted file mode 100644 index c817076e53..0000000000 --- a/service/lib/agama/dbus/storage/proposal_settings_conversion/from_dbus.rb +++ /dev/null @@ -1,279 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2023-2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require "agama/dbus/hash_validator" -require "agama/dbus/storage/volume_conversion" -require "agama/dbus/types" -require "agama/storage/device_settings" -require "agama/storage/proposal_settings" -require "agama/storage/proposal_settings_reader" -require "agama/storage/space_settings" -require "y2storage/encryption_method" -require "y2storage/pbkd_function" - -module Agama - module DBus - module Storage - module ProposalSettingsConversion - # Proposal settings conversion from D-Bus format. - class FromDBus - # @param dbus_settings [Hash] - # @param config [Agama::Config] - # @param logger [Logger, nil] - def initialize(dbus_settings, config:, logger: nil) - @dbus_settings = dbus_settings - @config = config - @logger = logger || Logger.new($stdout) - end - - # Performs the conversion from D-Bus format. - # - # @return [Agama::Storage::ProposalSettings] - def convert - logger.info("D-Bus settings: #{dbus_settings}") - - dbus_settings_issues.each { |i| logger.warn(i) } - - Agama::Storage::ProposalSettingsReader.new(config).read.tap do |target| - valid_dbus_properties.each { |p| conversion(target, p) } - end - end - - private - - # @return [Hash] - attr_reader :dbus_settings - - # @return [Agama::Config] - attr_reader :config - - # @return [Logger] - attr_reader :logger - - DBUS_PROPERTIES = [ - { - name: "Target", - type: String, - conversion: :device_conversion - }, - { - name: "TargetDevice", - type: String - }, - { - name: "TargetPVDevices", - type: Types::Array.new(String) - }, - { - name: "ConfigureBoot", - type: Types::BOOL, - conversion: :configure_boot_conversion - }, - { - name: "BootDevice", - type: String, - conversion: :boot_device_conversion - }, - { - name: "EncryptionPassword", - type: String, - conversion: :encryption_password_conversion - }, - { - name: "EncryptionMethod", - type: String, - conversion: :encryption_method_conversion - }, - { - name: "EncryptionPBKDFunction", - type: String, - conversion: :encryption_pbkd_function_conversion - }, - { - name: "SpacePolicy", - type: String, - conversion: :space_policy_conversion - }, - { - name: "SpaceActions", - type: Types::Array.new(Types::Hash.new(key: String, value: String)), - conversion: :space_actions_conversion - }, - { - name: "Volumes", - type: Types::Array.new(Types::Hash.new(key: String)), - conversion: :volumes_conversion - } - ].freeze - - private_constant :DBUS_PROPERTIES - - # Issues detected in the D-Bus settings, see {HashValidator#issues}. - # - # @return [Array] - def dbus_settings_issues - validator.issues - end - - # D-Bus properties with valid type, see {HashValidator#valid_keys}. - # - # @return [Array] - def valid_dbus_properties - validator.valid_keys - end - - # Validator for D-Bus settings. - # - # @return [HashValidator] - def validator - return @validator if @validator - - scheme = DBUS_PROPERTIES.map { |p| [p[:name], p[:type]] }.to_h - @validator = HashValidator.new(dbus_settings, scheme: scheme) - end - - # @param target [Agama::Storage::ProposalSettings] - # @param dbus_property_name [String] - def conversion(target, dbus_property_name) - dbus_property = DBUS_PROPERTIES.find { |d| d[:name] == dbus_property_name } - conversion_method = dbus_property[:conversion] - - return unless conversion_method - - send(conversion_method, target, dbus_settings[dbus_property_name]) - end - - # @param target [Agama::Storage::ProposalSettings] - # @param value [String] - def device_conversion(target, value) - device_settings = case value - when "disk" - disk_device_conversion - when "newLvmVg" - new_lvm_vg_device_conversion - when "reusedLvmVg" - reused_lvm_vg_device_conversion - end - - target.device = device_settings - end - - # @return [Agama::Storage::DeviceSettings::Disk] - def disk_device_conversion - device = dbus_settings["TargetDevice"] - Agama::Storage::DeviceSettings::Disk.new(device) - end - - # @return [Agama::Storage::DeviceSettings::NewLvmVg] - def new_lvm_vg_device_conversion - candidates = dbus_settings["TargetPVDevices"] || [] - Agama::Storage::DeviceSettings::NewLvmVg.new(candidates) - end - - # @return [Agama::Storage::DeviceSettings::ReusedLvmVg] - def reused_lvm_vg_device_conversion - device = dbus_settings["TargetDevice"] - Agama::Storage::DeviceSettings::ReusedLvmVg.new(device) - end - - # @param target [Agama::Storage::ProposalSettings] - # @param value [Boolean] - def configure_boot_conversion(target, value) - target.boot.configure = value - end - - # @param target [Agama::Storage::ProposalSettings] - # @param value [String] - def boot_device_conversion(target, value) - target.boot.device = value.empty? ? nil : value - end - - # @param target [Agama::Storage::ProposalSettings] - # @param value [String] - def encryption_password_conversion(target, value) - target.encryption.password = value.empty? ? nil : value - end - - # @param target [Agama::Storage::ProposalSettings] - # @param value [String] - def encryption_method_conversion(target, value) - method = Y2Storage::EncryptionMethod.find(value.to_sym) - return unless method - - target.encryption.method = method - end - - # @param target [Agama::Storage::ProposalSettings] - # @param value [String] - def encryption_pbkd_function_conversion(target, value) - function = Y2Storage::PbkdFunction.find(value) - return unless function - - target.encryption.pbkd_function = function - end - - # @param target [Agama::Storage::ProposalSettings] - # @param value [String] - def space_policy_conversion(target, value) - policy = value.to_sym - return unless Agama::Storage::SpaceSettings.policies.include?(policy) - - target.space.policy = policy - end - - # @param target [Agama::Storage::ProposalSettings] - # @param value [Array] - def space_actions_conversion(target, value) - target.space.actions = value.each_with_object({}) do |v, result| - result[v["Device"]] = v["Action"].to_sym - end - end - - # @param target [Agama::Storage::ProposalSettings] - # @param value [Array] - def volumes_conversion(target, value) - # Keep default volumes if no volumes are given - return if value.empty? - - required_volumes = target.volumes.select { |v| v.outline.required? } - volumes = value.map do |dbus_volume| - VolumeConversion.from_dbus(dbus_volume, config: config, logger: logger) - end - - target.volumes = volumes + missing_volumes(required_volumes, volumes) - end - - # Missing required volumes - # - # @param required_volumes [Array] - # @param volumes [Array] - # - # @return [Array] - def missing_volumes(required_volumes, volumes) - mount_paths = volumes.map(&:mount_path) - - required_volumes.reject { |v| mount_paths.include?(v.mount_path) } - end - end - end - end - end -end diff --git a/service/lib/agama/dbus/storage/proposal_settings_conversion/to_dbus.rb b/service/lib/agama/dbus/storage/proposal_settings_conversion/to_dbus.rb deleted file mode 100644 index 1fc3ed3f09..0000000000 --- a/service/lib/agama/dbus/storage/proposal_settings_conversion/to_dbus.rb +++ /dev/null @@ -1,174 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2023-2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require "agama/dbus/storage/volume_conversion" -require "agama/storage/device_settings" - -module Agama - module DBus - module Storage - module ProposalSettingsConversion - # Proposal settings conversion to D-Bus format. - class ToDBus - # @param settings [Agama::Storage::ProposalSettings] - def initialize(settings) - @settings = settings - end - - # Performs the conversion to D-Bus format. - # - # @return [Hash] - # * "Target" [String] - # * "TargetDevice" [String] Optional - # * "TargetPVDevices" [Array] Optional - # * "ConfigureBoot" [Boolean] - # * "BootDevice" [String] - # * "DefaultBootDevice" [String] - # * "EncryptionPassword" [String] - # * "EncryptionMethod" [String] - # * "EncryptionPBKDFunction" [String] - # * "SpacePolicy" [String] - # * "SpaceActions" [Array] see {#space_actions_conversion} - # * "Volumes" [Array] see {#volumes_conversion} - def convert - target = device_conversion - - DBUS_PROPERTIES.each do |dbus_property, conversion| - target[dbus_property] = send(conversion) - end - - target - end - - private - - # @return [Agama::Storage::ProposalSettings] - attr_reader :settings - - DBUS_PROPERTIES = { - "ConfigureBoot" => :configure_boot_conversion, - "BootDevice" => :boot_device_conversion, - "DefaultBootDevice" => :default_boot_device_conversion, - "EncryptionPassword" => :encryption_password_conversion, - "EncryptionMethod" => :encryption_method_conversion, - "EncryptionPBKDFunction" => :encryption_pbkd_function_conversion, - "SpacePolicy" => :space_policy_conversion, - "SpaceActions" => :space_actions_conversion, - "Volumes" => :volumes_conversion - }.freeze - - private_constant :DBUS_PROPERTIES - - # @return [Hash] - def device_conversion - device_settings = settings.device - - case device_settings - when Agama::Storage::DeviceSettings::Disk - disk_device_conversion(device_settings) - when Agama::Storage::DeviceSettings::NewLvmVg - new_lvm_vg_device_conversion(device_settings) - when Agama::Storage::DeviceSettings::ReusedLvmVg - reused_lvm_vg_device_conversion(device_settings) - end - end - - # @param device_settings [Agama::Storage::DeviceSettings::Disk] - # @return [Hash] - def disk_device_conversion(device_settings) - { - "Target" => "disk", - "TargetDevice" => device_settings.name || "" - } - end - - # @param device_settings [Agama::Storage::DeviceSettings::NewLvmVg] - # @return [Hash] - def new_lvm_vg_device_conversion(device_settings) - { - "Target" => "newLvmVg", - "TargetPVDevices" => device_settings.candidate_pv_devices - } - end - - # @param device_settings [Agama::Storage::DeviceSettings::Disk] - # @return [Hash] - def reused_lvm_vg_device_conversion(device_settings) - { - "Target" => "reusedLvmVg", - "TargetDevice" => device_settings.name || "" - } - end - - # @return [Boolean] - def configure_boot_conversion - settings.boot.configure? - end - - # @return [String] - def boot_device_conversion - settings.boot.device || "" - end - - # @return [String] - def default_boot_device_conversion - settings.default_boot_device || "" - end - - # @return [String] - def encryption_password_conversion - settings.encryption.password.to_s - end - - # @return [String] - def encryption_method_conversion - settings.encryption.method.id.to_s - end - - # @return [String] - def encryption_pbkd_function_conversion - settings.encryption.pbkd_function&.value || "" - end - - # @return [String] - def space_policy_conversion - settings.space.policy.to_s - end - - # @return [Array>] - # For each action: - # * "Device" [String] - # * "Action" [String] - def space_actions_conversion - settings.space.actions.each_with_object([]) do |(device, action), actions| - actions << { "Device" => device, "Action" => action.to_s } - end - end - - # @return [Array] see {VolumeConversion::ToDBus}. - def volumes_conversion - settings.volumes.map { |v| VolumeConversion.to_dbus(v) } - end - end - end - end - end -end diff --git a/service/lib/agama/dbus/storage/volume_conversion.rb b/service/lib/agama/dbus/storage/volume_conversion.rb deleted file mode 100644 index 4c62d6b86f..0000000000 --- a/service/lib/agama/dbus/storage/volume_conversion.rb +++ /dev/null @@ -1,51 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2023-2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require "agama/dbus/storage/volume_conversion/from_dbus" -require "agama/dbus/storage/volume_conversion/to_dbus" - -module Agama - module DBus - module Storage - # Conversions for a volume. - module VolumeConversion - # Performs conversion from D-Bus format. - # - # @param dbus_volume [Hash] - # @param config [Agama::Config] - # @param logger [Logger, nil] - # - # @return [Agama::Storage::Volume] - def self.from_dbus(dbus_volume, config:, logger: nil) - FromDBus.new(dbus_volume, config: config, logger: logger).convert - end - - # Performs conversion to D-Bus format. - # - # @param volume [Agama::Storage::Volume] - # @return [Hash] - def self.to_dbus(volume) - ToDBus.new(volume).convert - end - end - end - end -end diff --git a/service/lib/agama/dbus/storage/volume_conversion/from_dbus.rb b/service/lib/agama/dbus/storage/volume_conversion/from_dbus.rb deleted file mode 100644 index 6e6f7ecaef..0000000000 --- a/service/lib/agama/dbus/storage/volume_conversion/from_dbus.rb +++ /dev/null @@ -1,224 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2023-2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require "agama/dbus/hash_validator" -require "agama/dbus/types" -require "agama/storage/volume" -require "agama/storage/volume_location" -require "agama/storage/volume_templates_builder" -require "y2storage/disk_size" -require "y2storage/filesystems/type" - -module Agama - module DBus - module Storage - module VolumeConversion - # Volume conversion from D-Bus format. - class FromDBus - # @param dbus_volume [Hash] - # @param config [Agama::Config] - # @param logger [Logger, nil] - def initialize(dbus_volume, config:, logger: nil) - @dbus_volume = dbus_volume - @config = config - @logger = logger || Logger.new($stdout) - end - - # Performs the conversion from D-Bus format. - # - # @return [Agama::Storage::Volume] - def convert - logger.info("D-Bus volume: #{dbus_volume}") - - dbus_volume_issues.each { |i| logger.warn(i) } - - builder = Agama::Storage::VolumeTemplatesBuilder.new_from_config(config) - builder.for(dbus_volume["MountPath"] || "").tap do |target| - valid_dbus_properties.each { |p| conversion(target, p) } - target.max_size = Y2Storage::DiskSize.unlimited unless dbus_volume.key?("MaxSize") - end - end - - private - - # @return [Hash] - attr_reader :dbus_volume - - # @return [Agama::Config] - attr_reader :config - - # @return [Logger] - attr_reader :logger - - DBUS_PROPERTIES = [ - { - name: "MountPath", - type: String, - conversion: :mount_path_conversion - }, - { - name: "MountOptions", - type: Types::Array.new(String), - conversion: :mount_options_conversion - }, - { - name: "Target", - type: String, - conversion: :target_conversion - }, - { - name: "TargetDevice", - type: String, - conversion: :target_device_conversion - }, - { - name: "FsType", - type: String, - conversion: :fs_type_conversion - }, - { - name: "MinSize", - type: Integer, - conversion: :min_size_conversion - }, - { - name: "MaxSize", - type: Integer, - conversion: :max_size_conversion - }, - { - name: "AutoSize", - type: Types::BOOL, - conversion: :auto_size_conversion - }, - { - name: "Snapshots", - type: Types::BOOL, - conversion: :snapshots_conversion - } - ].freeze - - private_constant :DBUS_PROPERTIES - - # Issues detected in the D-Bus volume, see {HashValidator#issues}. - # - # @return [Array] - def dbus_volume_issues - validator.issues - end - - # D-Bus properties with valid type, see {HashValidator#valid_keys}. - # - # @return [Array] - def valid_dbus_properties - validator.valid_keys - end - - # Validator for D-Bus volume. - # - # @return [HashValidator] - def validator - return @validator if @validator - - scheme = DBUS_PROPERTIES.map { |p| [p[:name], p[:type]] }.to_h - @validator = HashValidator.new(dbus_volume, scheme: scheme) - end - - # @param target [Agama::Storage::Volume] - # @param dbus_property_name [String] - def conversion(target, dbus_property_name) - dbus_property = DBUS_PROPERTIES.find { |d| d[:name] == dbus_property_name } - send(dbus_property[:conversion], target, dbus_volume[dbus_property_name]) - end - - # @param target [Agama::Storage::Volume] - # @param value [String] - def mount_path_conversion(target, value) - target.mount_path = value - end - - # @param target [Agama::Storage::Volume] - # @param value [Array] - def mount_options_conversion(target, value) - target.mount_options = value - end - - # @param target [Agama::Storage::Volume] - # @param value [String] - def target_device_conversion(target, value) - target.location.device = value - end - - # @param target [Agama::Storage::Volume] - # @param value [String] - def target_conversion(target, value) - target_value = value.downcase.to_sym - return unless Agama::Storage::VolumeLocation.targets.include?(target_value) - - target.location.target = target_value - end - - # @param target [Agama::Storage::Volume] - # @param value [String] - def fs_type_conversion(target, value) - downcase_value = value.downcase - - fs_type = target.outline.filesystems.find do |type| - type.to_human_string.downcase == downcase_value - end - - return unless fs_type - - target.fs_type = fs_type - end - - # @param target [Agama::Storage::Volume] - # @param value [Integer] Size in bytes. - def min_size_conversion(target, value) - target.min_size = Y2Storage::DiskSize.new(value) - end - - # @param target [Agama::Storage::Volume] - # @param value [Integer] Size in bytes. - def max_size_conversion(target, value) - target.max_size = Y2Storage::DiskSize.new(value) - end - - # @param target [Agama::Storage::Volume] - # @param value [Boolean] - def auto_size_conversion(target, value) - return unless target.auto_size_supported? - - target.auto_size = value - end - - # @param target [Agama::Storage::Volume] - # @param value [Booelan] - def snapshots_conversion(target, value) - return unless target.outline.snapshots_configurable? - - target.btrfs.snapshots = value - end - end - end - end - end -end diff --git a/service/lib/agama/dbus/storage/volume_conversion/to_dbus.rb b/service/lib/agama/dbus/storage/volume_conversion/to_dbus.rb deleted file mode 100644 index 4133069abc..0000000000 --- a/service/lib/agama/dbus/storage/volume_conversion/to_dbus.rb +++ /dev/null @@ -1,113 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2023-2025] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -module Agama - module DBus - module Storage - module VolumeConversion - # Volume conversion to D-Bus format. - class ToDBus - # @param volume [Agama::Storage::Volume] - def initialize(volume) - @volume = volume - end - - # Performs the conversion to D-Bus format. - # - # @return [Hash] - # * "MountPath" [String] - # * "MountOptions" [Array] - # * "Target" [String] - # * "TargetDevice" [String] - # * "FsType" [String] - # * "MinSize" [Integer] - # * "MaxSize" [Integer] Optional - # * "AutoSize" [Boolean] - # * "Snapshots" [Booelan] - # * "Transactional" [Boolean] - # * "Outline" [Hash] see {#outline_conversion} - def convert - { - "MountPath" => volume.mount_path.to_s, - "MountOptions" => volume.mount_options, - "Target" => volume.location.target.to_s, - "TargetDevice" => volume.location.device.to_s, - "FsType" => volume.fs_type&.to_s || "", - "MinSize" => min_size_conversion, - "AutoSize" => volume.auto_size?, - "Snapshots" => volume.btrfs.snapshots?, - "Transactional" => volume.btrfs.read_only?, - "Outline" => outline_conversion - }.tap do |target| - # Some volumes could not have "MaxSize". - max_size_conversion(target) - end - end - - private - - # @return [Agama::Storage::Volume] - attr_reader :volume - - # @return [Integer] - def min_size_conversion - min_size = volume.min_size - min_size = volume.outline.base_min_size if volume.auto_size? - min_size.to_i - end - - # @param target [Hash] - def max_size_conversion(target) - max_size = volume.max_size - max_size = volume.outline.base_max_size if volume.auto_size? - return if max_size.unlimited? - - target["MaxSize"] = max_size.to_i - end - - # Converts volume outline to D-Bus. - # - # @return [Hash] - # * "Required" [Boolean] - # * "FsTypes" [Array] - # * "SupportAutoSize" [Boolean] - # * "AdjustByRam" [Boolean] - # * "SnapshotsConfigurable" [Boolean] - # * "SnapshotsAffectSizes" [Boolean] - # * "SizeRelevantVolumes" [Array] - def outline_conversion - outline = volume.outline - - { - "Required" => outline.required?, - "FsTypes" => outline.filesystems.map(&:to_s), - "SupportAutoSize" => outline.adaptive_sizes?, - "AdjustByRam" => outline.adjust_by_ram?, - "SnapshotsConfigurable" => outline.snapshots_configurable?, - "SnapshotsAffectSizes" => outline.snapshots_affect_sizes?, - "SizeRelevantVolumes" => outline.size_relevant_volumes - } - end - end - end - end - end -end diff --git a/service/lib/agama/dbus/storage_service.rb b/service/lib/agama/dbus/storage_service.rb index 1d49850031..dd68576ae5 100644 --- a/service/lib/agama/dbus/storage_service.rb +++ b/service/lib/agama/dbus/storage_service.rb @@ -21,11 +21,11 @@ require "dbus" require "agama/dbus/bus" -require "agama/dbus/service_status" require "agama/dbus/storage/iscsi" require "agama/dbus/storage/manager" require "agama/storage" require "y2storage/inhibitors" +require "y2storage/storage_env" module Agama module DBus @@ -56,6 +56,13 @@ def start # Inhibits various storage subsystem (udisk, systemd mounts, raid auto-assembly) that # interfere with the operation of yast-storage-ng and libstorage-ng. Y2Storage::Inhibitors.new.inhibit + + # Underlying yast-storage-ng has own mechanism for proposing boot strategies. + # However, we don't always want to use BLS when it proposes so. Currently + # we want to use BLS only for Tumbleweed / Slowroll + prohibit_bls_boot if !config.boot_strategy&.casecmp("BLS") + + check_multipath export end @@ -84,6 +91,30 @@ def dispatch # @return [Logger] attr_reader :logger + def prohibit_bls_boot + ENV["YAST_NO_BLS_BOOT"] = "1" + # avoiding problems with cached values + Y2Storage::StorageEnv.instance.reset_cache + end + + MULTIPATH_CONFIG = "/etc/multipath.conf" + private_constant :MULTIPATH_CONFIG + + # Checks if all requirement for multipath probing is correct and if not then log it. + def check_multipath + # check if kernel module is loaded + mods = `lsmod`.lines.grep(/dm_multipath/) + logger.warn("dm_multipath modules is not loaded") if mods.empty? + + binary = system("which multipath") + if binary + conf = `multipath -t`.lines.grep(/find_multipaths "smart"/) + logger.warn("multipath: find_multipaths is not set to 'smart'") if conf.empty? + else + logger.warn("multipath is not installed.") + end + end + # @return [::DBus::ObjectServer] def service @service ||= bus.request_service(SERVICE_NAME) @@ -96,32 +127,18 @@ def dbus_objects # @return [Agama::DBus::Storage::Manager] def manager_object - @manager_object ||= Agama::DBus::Storage::Manager.new( - manager, - service_status: service_status, - logger: logger - ) + @manager_object ||= Agama::DBus::Storage::Manager.new(manager, logger: logger) end # @return [Agama::DBus::Storage::ISCSI] def iscsi_object - # Uses the same service status as the manager D-Bus object. - @iscsi_object ||= Agama::DBus::Storage::ISCSI.new( - manager.iscsi, - service_status: service_status, - logger: logger - ) + @iscsi_object ||= Agama::DBus::Storage::ISCSI.new(manager.iscsi, logger: logger) end # @return [Agama::Storage::Manager] def manager @manager ||= Agama::Storage::Manager.new(config, logger: logger) end - - # @return [Agama::DBus::ServiceStatus] - def service_status - @service_status ||= Agama::DBus::ServiceStatus.new - end end end end diff --git a/service/lib/agama/http/clients/questions.rb b/service/lib/agama/http/clients/questions.rb index ff014d0439..d8c087c0c2 100644 --- a/service/lib/agama/http/clients/questions.rb +++ b/service/lib/agama/http/clients/questions.rb @@ -89,7 +89,7 @@ def ask(question) answer = wait_answer(added_question.id) - @logger.info("#{added_question.text} #{answer}") + @logger.info("#{added_question.text} #{answer.inspect}") result = block_given? ? yield(answer) : answer delete(added_question.id) diff --git a/service/lib/agama/manager.rb b/service/lib/agama/manager.rb index dfebdf6bd9..aca1603cfb 100644 --- a/service/lib/agama/manager.rb +++ b/service/lib/agama/manager.rb @@ -26,7 +26,7 @@ require "agama/network" require "agama/proxy_setup" require "agama/with_locale" -require "agama/with_progress" +require "agama/with_progress_manager" require "agama/installation_phase" require "agama/service_status_recorder" require "agama/dbus/service_status" @@ -46,7 +46,7 @@ module Agama # {Agama::Network}, {Agama::Storage::Proposal}, etc.) or asks # other services via HTTP (e.g., `/software`). class Manager - include WithProgress + include WithProgressManager include WithLocale include Helpers include Yast::I18n @@ -92,11 +92,10 @@ def startup_phase # Runs the config phase # # @param reprobe [Boolean] Whether a reprobe should be done instead of a probe. - # @param data [Hash] Extra data provided to the D-Bus calls. - def config_phase(reprobe: false, data: {}) + def config_phase(reprobe: false) installation_phase.config start_progress_with_descriptions(_("Analyze disks"), _("Configure software")) - progress.step { reprobe ? storage.reprobe(data) : storage.probe(data) } + progress.step { configure_storage(reprobe) } progress.step { software.probe } logger.info("Config phase done") @@ -211,11 +210,7 @@ def network # # @return [DBus::Clients::Storage] def storage - @storage ||= DBus::Clients::Storage.new.tap do |client| - client.on_service_status_change do |status| - service_status_recorder.save(client.service.name, status) - end - end + @storage ||= DBus::Clients::Storage.new end # Name of busy services @@ -238,7 +233,7 @@ def on_services_status_change(&block) # # @return [Boolean] def valid? - users.issues.empty? && !software.errors? && !storage.errors? + users.issues.empty? && !software.errors? end # Collects the logs and stores them into an archive @@ -296,6 +291,22 @@ def iguana? # Finish shutdown option for each finish method SHUTDOWN_OPT = { REBOOT => "-r", HALT => "-H", POWEROFF => "-P" }.freeze + # Configures storage. + # + # Storage is configured as part of the config phase. The config phase is executed after + # selecting or registering a product. + # + # @param reprobe [Boolean] is used to keep the current storage config after registering a + # product, see https://github.com/agama-project/agama/pull/2532. + def configure_storage(reprobe) + # Note that probing storage is not needed after the product registration, but let's keep the + # current behavior. + return storage.probe if reprobe + + # Select the product + storage.product = software.selected_product + end + # @param method [String, nil] # @return [String] the cmd to be run for finishing the installation def finish_cmd(method) diff --git a/service/lib/agama/old_progress.rb b/service/lib/agama/old_progress.rb new file mode 100644 index 0000000000..d918940e75 --- /dev/null +++ b/service/lib/agama/old_progress.rb @@ -0,0 +1,198 @@ +# frozen_string_literal: true + +# Copyright (c) [2022-2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +module Agama + # Class to manage progress + # + # It allows to configure callbacks to be called on each step and also when the progress finishes. + # + # In most cases all steps are known in advance (e.g., "probing software", "probing storage", etc.) + # but, in some situations, only the number of steps is known (e.g., "Installing package X"). + # + # Use the Progress.with_descriptions to initialize a progress with known step descriptions and + # Progress.with_size when only the number of steps is known + # + # @example + # + # progress = Progress.with_size(3) # 3 steps + # progress.on_change { puts progress.message } # configures callbacks + # progress.on_finish { puts "finished" } # configures callbacks + # + # progress.step("Doing step1") { step1 } # calls on_change callbacks and executes step1 + # progress.step("Doing step2") { step2 } # calls on_change callbacks and executes step2 + # + # progress.current_step #=> + # progress.current_step.id #=> 2 + # progress.current_step.description #=> "Doing step2" + # + # progress.finished? #=> false + + # progress.step("Doing step3") do # calls on_change callbacks, executes the given + # progress.current_step.description # block and calls on_finish callbacks + # end #=> "Doing step3" + # + # progress.finished? #=> true + # progress.current_step #=> nil + # + # @example Progress with known step descriptions + # + # progress = Progress.with_descriptions(["Partitioning", "Installing", "Finishing"]) + # progress.step { partitioning } # next step + # progress.current_step.description #=> "Partitioning" + # progress.step("Installing packages") { installing } # overwrite the description + # progress.current_step.description # "Installing packages" + class OldProgress + # Step of the progress + class Step + # Id of the step + # + # @return [Integer] + attr_reader :id + + # Description of the step + # + # @return [String] + attr_reader :description + + # Constructor + # + # @param id [Integer] + # @param description [String] + def initialize(id, description) + @id = id + @description = description + end + end + + # Total number of steps + # + # @return [Integer] + attr_reader :total_steps + + # Step descriptions in case they are known + # + # @return [Array] + attr_reader :descriptions + + class << self + def with_size(size) + new(size: size) + end + + def with_descriptions(descriptions) + new(descriptions: descriptions) + end + end + + # Constructor + # + # @param descriptions [Array] Steps of the progress sequence. This argument + # has precedence over the `size` + # @param size [Integer] total number of steps of the progress sequence + def initialize(descriptions: [], size: nil) + @descriptions = descriptions || [] + @total_steps = descriptions.size unless descriptions.empty? + @total_steps ||= size + @current_step = nil + @counter = 0 + @finished = false + @on_change_callbacks = [] + @on_finish_callbacks = [] + end + + # Current progress step, if any + # + # @return [Step, nil] nil if the progress is already finished or not stated yet. + def current_step + return nil if finished? + + @current_step + end + + # Runs a progress step + # + # It calls the `on_change` callbacks and then runs the given block, if any. It also calls + # `on_finish` callbacks after the last step. + # + # @param description [String, nil] description of the step + # @param block [Proc] + # + # @return [Object, nil] result of the given block or nil if no block is given + def step(description = nil, &block) + return if finished? + + @counter += 1 + step_description = description || description_for(@counter) + @current_step = Step.new(@counter, step_description) + @on_change_callbacks.each(&:call) + + result = block_given? ? block.call : nil + + finish if @counter == total_steps + + result + end + + # Whether the last step was already done + # + # @return [Boolean] + def finished? + total_steps == 0 || @finished + end + + # Finishes the progress and runs the callbacks + # + # This method can be called to force the progress to finish before of running all the steps. + def finish + @finished = true + @on_finish_callbacks.each(&:call) + end + + # Adds a callback to be called when progress changes + # + # @param block [Proc] + def on_change(&block) + @on_change_callbacks << block + end + + # Adds a callback to be called when progress finishes + # + # @param block [Proc] + def on_finish(&block) + @on_finish_callbacks << block + end + + # Returns a string-based representation of the progress + # + # @return [String] + def to_s + return "Finished" if finished? + + "#{current_step.description} (#{@counter}/#{total_steps})" + end + + private + + def description_for(step) + @descriptions[step - 1] || "" + end + end +end diff --git a/service/lib/agama/progress.rb b/service/lib/agama/progress.rb index 797ecda71b..0b67f1177b 100644 --- a/service/lib/agama/progress.rb +++ b/service/lib/agama/progress.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2022] SUSE LLC +# Copyright (c) [2025] SUSE LLC # # All Rights Reserved. # @@ -19,180 +19,64 @@ # To contact SUSE LLC about this file by physical or electronic mail, you may # find current contact information at www.suse.com. -module Agama - # Class to manage progress - # - # It allows to configure callbacks to be called on each step and also when the progress finishes. - # - # In most cases all steps are known in advance (e.g., "probing software", "probing storage", etc.) - # but, in some situations, only the number of steps is known (e.g., "Installing package X"). - # - # Use the Progress.with_descriptions to initialize a progress with known step descriptions and - # Progress.with_size when only the number of steps is known - # - # @example - # - # progress = Progress.with_size(3) # 3 steps - # progress.on_change { puts progress.message } # configures callbacks - # progress.on_finish { puts "finished" } # configures callbacks - # - # progress.step("Doing step1") { step1 } # calls on_change callbacks and executes step1 - # progress.step("Doing step2") { step2 } # calls on_change callbacks and executes step2 - # - # progress.current_step #=> - # progress.current_step.id #=> 2 - # progress.current_step.description #=> "Doing step2" - # - # progress.finished? #=> false +require "json" - # progress.step("Doing step3") do # calls on_change callbacks, executes the given - # progress.current_step.description # block and calls on_finish callbacks - # end #=> "Doing step3" - # - # progress.finished? #=> true - # progress.current_step #=> nil - # - # @example Progress with known step descriptions - # - # progress = Progress.with_descriptions(["Partitioning", "Installing", "Finishing"]) - # progress.step { partitioning } # next step - # progress.current_step.description #=> "Partitioning" - # progress.step("Installing packages") { installing } # overwrite the description - # progress.current_step.description # "Installing packages" +module Agama + # Class to keep track of a process divided in a set of steps. class Progress - # Step of the progress - class Step - # Id of the step - # - # @return [Integer] - attr_reader :id - - # Description of the step - # - # @return [String] - attr_reader :description + class MissingStep < StandardError; end - # Constructor - # - # @param id [Integer] - # @param description [String] - def initialize(id, description) - @id = id - @description = description - end - end - - # Total number of steps - # # @return [Integer] - attr_reader :total_steps + attr_reader :size - # Step descriptions in case they are known - # # @return [Array] - attr_reader :descriptions - - class << self - def with_size(size) - new(size: size) - end - - def with_descriptions(descriptions) - new(descriptions: descriptions) - end - end - - # Constructor - # - # @param descriptions [Array] Steps of the progress sequence. This argument - # has precedence over the `size` - # @param size [Integer] total number of steps of the progress sequence - def initialize(descriptions: [], size: nil) - @descriptions = descriptions || [] - @total_steps = descriptions.size unless descriptions.empty? - @total_steps ||= size - @current_step = nil - @counter = 0 - @finished = false - @on_change_callbacks = [] - @on_finish_callbacks = [] - end - - # Current progress step, if any - # - # @return [Step, nil] nil if the progress is already finished or not stated yet. - def current_step - return nil if finished? + attr_reader :steps - @current_step - end - - # Runs a progress step - # - # It calls the `on_change` callbacks and then runs the given block, if any. It also calls - # `on_finish` callbacks after the last step. - # - # @param description [String, nil] description of the step - # @param block [Proc] - # - # @return [Object, nil] result of the given block or nil if no block is given - def step(description = nil, &block) - return if finished? - - @counter += 1 - step_description = description || description_for(@counter) - @current_step = Step.new(@counter, step_description) - @on_change_callbacks.each(&:call) + # @return [String, nil] + attr_reader :step - result = block_given? ? block.call : nil - - finish if @counter == total_steps - - result - end - - # Whether the last step was already done - # - # @return [Boolean] - def finished? - total_steps == 0 || @finished + # @return [Integer] + attr_reader :index + + # @param steps [Array] + # @return [Progress] + def self.new_with_steps(steps) + @size = steps.size + @steps = steps + @step = steps.first + @index = 1 end - # Finishes the progress and runs the callbacks - # - # This method can be called to force the progress to finish before of running all the steps. - def finish - @finished = true - @on_finish_callbacks.each(&:call) + # @param size [Integer] + # @param step [String] + # @return [Progress] + def initialize(size, step) + @size = size + @steps = [] + @step = step + @index = 1 end - # Adds a callback to be called when progress changes - # - # @param block [Proc] - def on_change(&block) - @on_change_callbacks << block - end + def next + raise MissingStep if index == steps.size - # Adds a callback to be called when progress finishes - # - # @param block [Proc] - def on_finish(&block) - @on_finish_callbacks << block + @step = steps.at(@index) + @index += 1 end - # Returns a string-based representation of the progress - # - # @return [String] - def to_s - return "Finished" if finished? - - "#{current_step.description} (#{@counter}/#{total_steps})" + # @param step [String] + def next_with_step(step) + self.next + @step = step end - private - - def description_for(step) - @descriptions[step - 1] || "" + def to_json(*args) + { + "size" => @size, + "steps" => @steps, + "step" => @step || "", + "index" => @index + }.to_json(*args) end end end diff --git a/service/lib/agama/progress_manager.rb b/service/lib/agama/progress_manager.rb index 85d5b2f401..4500bae1a9 100644 --- a/service/lib/agama/progress_manager.rb +++ b/service/lib/agama/progress_manager.rb @@ -19,7 +19,7 @@ # To contact SUSE LLC about this file by physical or electronic mail, you may # find current contact information at www.suse.com. -require "agama/progress" +require "agama/old_progress" module Agama # There is an unfinished progress. @@ -86,7 +86,7 @@ def on_finish(&block) def start_progress(args) raise NotFinishedProgress if progress && !progress.finished? - @progress = Progress.new(**args).tap do |progress| + @progress = OldProgress.new(**args).tap do |progress| progress.on_change { on_change_callbacks.each(&:call) } progress.on_finish { on_finish_callbacks.each(&:call) } end diff --git a/service/lib/agama/software/manager.rb b/service/lib/agama/software/manager.rb index 9ff399e93e..f75903dfe6 100644 --- a/service/lib/agama/software/manager.rb +++ b/service/lib/agama/software/manager.rb @@ -37,7 +37,7 @@ require "agama/software/proposal" require "agama/software/repositories_manager" require "agama/with_locale" -require "agama/with_progress" +require "agama/with_progress_manager" require "agama/with_issues" Yast.import "Installation" @@ -64,7 +64,7 @@ class Manager # rubocop:disable Metrics/ClassLength include Helpers include WithLocale include WithIssues - include WithProgress + include WithProgressManager include Yast::I18n GPG_KEYS_GLOB = "/usr/lib/rpm/gnupg/keys/gpg-*" diff --git a/service/lib/agama/storage/bootloader.rb b/service/lib/agama/storage/bootloader.rb index 0ac8f364b3..c3beb603f8 100644 --- a/service/lib/agama/storage/bootloader.rb +++ b/service/lib/agama/storage/bootloader.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -22,6 +22,7 @@ require "yast" require "json" require "bootloader/bootloader_factory" +require "bootloader/proposal_client" module Agama module Storage @@ -94,6 +95,22 @@ def initialize(logger) @logger = logger end + # Calculates proposal. + def configure + # first make bootloader proposal to be sure that required packages are installed + proposal = ::Bootloader::ProposalClient.new.make_proposal({}) + # then also apply changes to that proposal + write_config + @logger.debug "Bootloader proposal #{proposal.inspect}" + end + + # Installs bootloader. + def install + Yast::WFM.CallFunction("inst_bootloader", []) + end + + private + def write_config bootloader = ::Bootloader::BootloaderFactory.current write_stop_on_boot(bootloader) if @config.keys_to_export.include?(:stop_on_boot_menu) @@ -105,8 +122,6 @@ def write_config bootloader end - private - def write_extra_kernel_params(bootloader) # no systemd boot support for now return unless bootloader.respond_to?(:grub_default) diff --git a/service/lib/agama/storage/devicegraph_conversions.rb b/service/lib/agama/storage/devicegraph_conversions.rb new file mode 100644 index 0000000000..7f203adf9f --- /dev/null +++ b/service/lib/agama/storage/devicegraph_conversions.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/storage/devicegraph_conversions/to_json" + +module Agama + module Storage + # Conversions for the Y2Storage devicegraph + module DevicegraphConversion + end + end +end diff --git a/service/lib/agama/storage/devicegraph_conversions/to_json.rb b/service/lib/agama/storage/devicegraph_conversions/to_json.rb new file mode 100644 index 0000000000..78422049a4 --- /dev/null +++ b/service/lib/agama/storage/devicegraph_conversions/to_json.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/storage/devicegraph_conversions/to_json_conversions/device" + +module Agama + module Storage + module DevicegraphConversions + # Devicegraph conversion to JSON array according to schema. + class ToJSON + # @param devicegraph [Y2Storage::Devicegraph] + def initialize(devicegraph) + @devicegraph = devicegraph + end + + # Performs the conversion to array according to the JSON schema. + # + # @return [Hash] + def convert + original_devices.map { |d| ToJSONConversions::Device.new(d).convert } + end + + private + + # @return [Y2Storage::Devicegraph] + attr_reader :devicegraph + + # First-level devices to be included in the JSON representation. + # + # @return [Array] + def original_devices + devicegraph.disk_devices + + devicegraph.stray_blk_devices + + devicegraph.software_raids + + devicegraph.lvm_vgs + end + end + end + end +end diff --git a/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/block.rb b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/block.rb new file mode 100644 index 0000000000..7fd8225e36 --- /dev/null +++ b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/block.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/storage/devicegraph_conversions/to_json_conversions/section" +require "agama/storage/device_shrinking" + +module Agama + module Storage + module DevicegraphConversions + module ToJSONConversions + # Section with properties for block devices. + class Block < Section + # @see Section.apply? + def self.apply?(storage_device) + storage_device.is?(:blk_device) + end + + private + + # @see Section#conversions + def conversions + { + start: block_start, + active: block_active, + encrypted: block_encrypted, + udevIds: block_udev_ids, + udevPaths: block_udev_paths, + size: block_size, + shrinking: block_shrinking, + systems: block_systems + } + end + + # Position of the first block of the region. + # + # @return [Integer] + def block_start + storage_device.start + end + + # Whether the block device is currently active + # + # @return [Boolean] + def block_active + storage_device.active? + end + + # Whether the block device is encrypted. + # + # @return [Boolean] + def block_encrypted + storage_device.encrypted? + end + + # Name of the udev by-id links + # + # @return [Array] + def block_udev_ids + storage_device.udev_ids + end + + # Name of the udev by-path links + # + # @return [Array] + def block_udev_paths + storage_device.udev_paths + end + + # Size of the block device in bytes + # + # @return [Integer] + def block_size + storage_device.size.to_i + end + + # Shrinking information. + # + # @return [Hash] + def block_shrinking + shrinking = Agama::Storage::DeviceShrinking.new(storage_device) + + if shrinking.supported? + { supported: shrinking.min_size.to_i } + else + { unsupported: shrinking.unsupported_reasons } + end + end + + # Name of the currently installed systems + # + # @return [Array] + def block_systems + return @systems if @systems + + filesystems = storage_device.descendants.select { |d| d.is?(:filesystem) } + @systems = filesystems.map(&:system_name).compact + end + end + end + end + end +end diff --git a/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/device.rb b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/device.rb new file mode 100644 index 0000000000..f0254f2a32 --- /dev/null +++ b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/device.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/storage/devicegraph_conversions/to_json_conversions/sections" +require "y2storage/device_description" + +module Agama + module Storage + module DevicegraphConversions + module ToJSONConversions + # Device conversion to JSON hash according to schema. + class Device + # @param storage_device [Y2Storage::Device] + def initialize(storage_device) + @storage_device = storage_device + end + + # Hash representing the Y2Storage device + # + # @return [Hash] + def convert + result = { + sid: device_sid, + name: device_name, + description: device_description + } + add_sections(result) + add_nested_devices(result) + result + end + + private + + # Device to convert + # @return [Y2Storage::Device] + attr_reader :storage_device + + # sid of the device. + # + # @return [Integer] + def device_sid + storage_device.sid + end + + # Name to represent the device. + # + # @return [String] e.g., "/dev/sda". + def device_name + storage_device.display_name || "" + end + + # Description of the device. + # + # @return [String] e.g., "EXT4 Partition". + def device_description + Y2Storage::DeviceDescription.new(storage_device, include_encryption: true).to_s + end + + # Adds the required sub-sections according to the storage object. + # + # @param hash [Hash] the argument gets modified + def add_sections(hash) + conversions = Section.subclasses.select { |c| c.apply?(storage_device) } + + conversions.each do |conversion| + hash.merge!(conversion.new(storage_device).convert) + end + end + + # Add nested devices like partitions or LVM logical volumes + # + # @param hash [Hash] the argument gets modified + def add_nested_devices(hash) + add_partitions(hash) + add_logical_volumes(hash) + end + + # @see #add_nested_devices + def add_partitions(hash) + return unless PartitionTable.apply?(storage_device) + + hash[:partitions] = storage_device.partition_table.partitions.map do |part| + self.class.new(part).convert + end + end + + # @see #add_nested_devices + def add_logical_volumes(hash) + return unless VolumeGroup.apply?(storage_device) + + hash[:logicalVolumes] = storage_device.lvm_lvs.map do |lv| + self.class.new(lv).convert + end + end + end + end + end + end +end diff --git a/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/drive.rb b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/drive.rb new file mode 100644 index 0000000000..a7db039974 --- /dev/null +++ b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/drive.rb @@ -0,0 +1,146 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/storage/devicegraph_conversions/to_json_conversions/section" + +module Agama + module Storage + module DevicegraphConversions + module ToJSONConversions + # Section with properties for drive devices. + class Drive < Section + # Whether this section should be exported for the given device. + # + # Drive and disk device are very close concepts, but there are subtle differences. For + # example, a MD RAID is never considered as a drive. + # + # TODO: Revisit the defintion of drive. Maybe some MD devices could implement the drive + # interface if hwinfo provides useful information for them. + # + # @param storage_device [Y2Storage::Device] + # @return [Boolean] + def self.apply?(storage_device) + storage_device.is?(:disk, :dm_raid, :multipath, :dasd) && + storage_device.is?(:disk_device) + end + + private + + # @see Section#conversions + def conversions + { + type: drive_type, + vendor: drive_vendor, + model: drive_model, + bus: drive_bus, + busId: drive_bus_id, + driver: drive_driver, + transport: drive_transport, + info: drive_info + } + end + + # Drive type + # + # @return ["disk", "raid", "multipath", "dasd", nil] Nil if type is unknown. + def drive_type + if storage_device.is?(:disk) + "disk" + elsif storage_device.is?(:dm_raid) + "raid" + elsif storage_device.is?(:multipath) + "multipath" + elsif storage_device.is?(:dasd) + "dasd" + end + end + + # Vendor name + # + # @return [String, nil] + def drive_vendor + storage_device.vendor + end + + # Model name + # + # @return [String, nil] + def drive_model + storage_device.model + end + + # Bus name + # + # @return [String, nil] + def drive_bus + # FIXME: not sure whether checking for "none" is robust enough + return if storage_device.bus.nil? || storage_device.bus.casecmp?("none") + + storage_device.bus + end + + # Bus Id for DASD + # + # @return [String, nil] + def drive_bus_id + return unless storage_device.respond_to?(:bus_id) + + storage_device.bus_id + end + + # Kernel drivers used by the device + # + # @return [Array] + def drive_driver + storage_device.driver + end + + # Data transport layer, if any + # + # @return [String, nil] + def drive_transport + return unless storage_device.respond_to?(:transport) + + transport = storage_device.transport + return if transport.nil? || transport.is?(:unknown) + + # FIXME: transport does not have proper i18n support at yast2-storage-ng, so we are + # just duplicating some logic from yast2-storage-ng here + return "USB" if transport.is?(:usb) + return "IEEE 1394" if transport.is?(:sbp) + + transport.to_s + end + + # More info about the device + # + # @return [Hash] + def drive_info + { + sdCard: storage_device.sd_card?, + dellBoss: storage_device.boss? + } + end + end + end + end + end +end diff --git a/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/filesystem.rb b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/filesystem.rb new file mode 100644 index 0000000000..a1718c5fcf --- /dev/null +++ b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/filesystem.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/storage/devicegraph_conversions/to_json_conversions/section" +require "y2storage/filesystem_label" + +module Agama + module Storage + module DevicegraphConversions + module ToJSONConversions + # Section with properties for formatted devices. + class Filesystem < Section + # @see Section.apply? + def self.apply?(storage_device) + storage_device.is?(:blk_device) && !storage_device.filesystem.nil? + end + + private + + # @see Section#conversions + def conversions + { + sid: filesystem_sid, + type: filesystem_type, + mountPath: filesystem_mount_path, + label: filesystem_label + } + end + + # SID of the file system. + # + # It is useful to detect whether a file system is new. + # + # @return [Integer] + def filesystem_sid + storage_device.filesystem.sid + end + + # File system type. + # + # @return [String] e.g., "ext4" + def filesystem_type + storage_device.filesystem.type.to_s + end + + # Mount path of the file system. + # + # @return [String, nil] Nil if not mounted. + def filesystem_mount_path + storage_device.filesystem.mount_path + end + + # Label of the file system. + # + # @return [String, nil] Nil if it has no label. + def filesystem_label + label = Y2Storage::FilesystemLabel.new(storage_device).to_s + return if label.empty? + + label + end + end + end + end + end +end diff --git a/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/md.rb b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/md.rb new file mode 100644 index 0000000000..805b15fa13 --- /dev/null +++ b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/md.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/storage/devicegraph_conversions/to_json_conversions/section" + +module Agama + module Storage + module DevicegraphConversions + module ToJSONConversions + # Section with the properties of MD RAID devices. + class Md < Section + # @see Section.apply? + def self.apply?(storage_device) + storage_device.is?(:md) + end + + private + + # @see Section#conversions + def conversions + { + uuid: md_uuid, + level: md_level, + devices: md_devices + } + end + + # UUID of the MD RAID + # + # @return [String, nil] + def md_uuid + storage_device.uuid + end + + # RAID level + # + # @return [String] + def md_level + storage_device.md_level.to_s + end + + # SIDs of the objects representing the devices of the MD RAID. + # + # @return [Array] + def md_devices + storage_device.plain_devices.map(&:sid) + end + end + end + end + end +end diff --git a/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/multipath.rb b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/multipath.rb new file mode 100644 index 0000000000..8a57f2e964 --- /dev/null +++ b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/multipath.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/storage/devicegraph_conversions/to_json_conversions/section" + +module Agama + module Storage + module DevicegraphConversions + module ToJSONConversions + # Section with properties for multipath devices. + class Multipath < Section + # @see Section.apply? + def self.apply?(storage_device) + storage_device.is?(:multipath) + end + + private + + # @see Section#conversions + def conversions + { wireNames: multipath_wire_names } + end + + # Name of the multipath wires. + # + # @note: The multipath wires are not exported yet. + # + # @return [Array] + def multipath_wire_names + storage_device.parents.map(&:name) + end + end + end + end + end +end diff --git a/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/partition.rb b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/partition.rb new file mode 100644 index 0000000000..7280cf25c4 --- /dev/null +++ b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/partition.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/storage/devicegraph_conversions/to_json_conversions/section" + +module Agama + module Storage + module DevicegraphConversions + module ToJSONConversions + # Section with properties for partitions. + class Partition < Section + # @see Section.apply? + def self.apply?(storage_device) + storage_device.is?(:partition) + end + + private + + # @see Section#conversions + def conversions + { efi: partition_efi } + end + + # Whether it is a (valid) EFI System partition + # + # @return [Boolean] + def partition_efi + storage_device.efi_system? + end + end + end + end + end +end diff --git a/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/partition_table.rb b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/partition_table.rb new file mode 100644 index 0000000000..a97a70a9db --- /dev/null +++ b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/partition_table.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/storage/devicegraph_conversions/to_json_conversions/section" + +module Agama + module Storage + module DevicegraphConversions + module ToJSONConversions + # Section for devices that contain a partition table. + class PartitionTable < Section + # @see Section.apply? + def self.apply?(storage_device) + storage_device.is?(:blk_device) && + storage_device.respond_to?(:partition_table?) && + storage_device.partition_table? + end + + private + + # @see Section.conversions + def conversions + { + type: partition_table_type, + unusedSlots: partition_table_unused_slots + } + end + + # Type of the partition table + # + # @return [String] + def partition_table_type + storage_device.partition_table.type.to_s + end + + # Available slots within a partition table, that is, the spaces that can be used to + # create a new partition. + # + # @return [Array] The first block and the size of each slot. + def partition_table_unused_slots + storage_device.partition_table.unused_partition_slots.map do |slot| + [slot.region.start, slot.region.size.to_i] + end + end + end + end + end + end +end diff --git a/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/section.rb b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/section.rb new file mode 100644 index 0000000000..1f9758531d --- /dev/null +++ b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/section.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +module Agama + module Storage + module DevicegraphConversions + module ToJSONConversions + # Base class for all the sub-sections that are only included for certain types of devices. + class Section + # Whether it makes sense to export this section as part of the hash. + # + # To be redefined by every subclass. + # + # @param _storage_device [Y2Storage::Device] device to describe + # @return [Boolean] + def self.apply?(_storage_device) + false + end + + # @param storage_device [Y2Storage::Device] + def initialize(storage_device) + @storage_device = storage_device + end + + # Hash representing the section with information about the Y2Storage device. + # + # @return [Hash] + def convert + { section_name => conversions.compact } + end + + private + + # Device to convert + # @return [Y2Storage::Device] + attr_reader :storage_device + + # Name of the section + # + # @return [Symbol] + def section_name + name = class_basename + (name[0].downcase + name[1..-1]).to_sym + end + + # Properties included in the section + # + # To be defined by every subclass + # + # @return [Hash] + def conversions + {} + end + + # @see #section_name + def class_basename + self.class.name.split("::").last + end + end + end + end + end +end diff --git a/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/sections.rb b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/sections.rb new file mode 100644 index 0000000000..90b779d42c --- /dev/null +++ b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/sections.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/storage/devicegraph_conversions/to_json_conversions/section" +require "agama/storage/devicegraph_conversions/to_json_conversions/block" +require "agama/storage/devicegraph_conversions/to_json_conversions/drive" +require "agama/storage/devicegraph_conversions/to_json_conversions/filesystem" +require "agama/storage/devicegraph_conversions/to_json_conversions/md" +require "agama/storage/devicegraph_conversions/to_json_conversions/multipath" +require "agama/storage/devicegraph_conversions/to_json_conversions/partition" +require "agama/storage/devicegraph_conversions/to_json_conversions/partition_table" +require "agama/storage/devicegraph_conversions/to_json_conversions/volume_group" diff --git a/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/volume_group.rb b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/volume_group.rb new file mode 100644 index 0000000000..0bc8b47f3e --- /dev/null +++ b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/volume_group.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/storage/devicegraph_conversions/to_json_conversions/section" + +module Agama + module Storage + module DevicegraphConversions + module ToJSONConversions + # Section with the properties of an LVM Volume Group. + class VolumeGroup < Section + # @see Section.apply? + def self.apply?(storage_device) + storage_device.is?(:lvm_vg) + end + + private + + # @see Section#conversions + def conversions + { + size: lvm_vg_size, + physicalVolumes: lvm_vg_pvs + } + end + + # Size of the volume group in bytes + # + # @return [Integer] + def lvm_vg_size + storage_device.size.to_i + end + + # D-Bus paths of the objects representing the physical volumes. + # + # @return [Array] + def lvm_vg_pvs + storage_device.lvm_pvs.map(&:sid) + end + end + end + end + end +end diff --git a/service/lib/agama/storage/finisher.rb b/service/lib/agama/storage/finisher.rb index 7e6c12f31a..31ecf18bd1 100644 --- a/service/lib/agama/storage/finisher.rb +++ b/service/lib/agama/storage/finisher.rb @@ -26,7 +26,7 @@ require "yast2/fs_snapshot" require "bootloader/finish_client" require "y2storage/storage_manager" -require "agama/with_progress" +require "agama/with_progress_manager" require "agama/helpers" require "agama/http" require "agama/network" @@ -40,7 +40,7 @@ module Agama module Storage # Auxiliary class to handle the last storage-related steps of the installation class Finisher - include WithProgress + include WithProgressManager include Helpers # Constructor diff --git a/service/lib/agama/storage/iscsi/manager.rb b/service/lib/agama/storage/iscsi/manager.rb index 7987a1d593..616127def3 100644 --- a/service/lib/agama/storage/iscsi/manager.rb +++ b/service/lib/agama/storage/iscsi/manager.rb @@ -24,7 +24,7 @@ require "agama/storage/iscsi/config_importer" require "agama/storage/iscsi/node" require "agama/with_issues" -require "agama/with_progress" +require "agama/with_progress_manager" require "yast/i18n" module Agama @@ -33,7 +33,7 @@ module ISCSI # Manager for iSCSI. class Manager include WithIssues - include WithProgress + include WithProgressManager include Yast::I18n STARTUP_OPTIONS = ["onboot", "manual", "automatic"].freeze diff --git a/service/lib/agama/storage/manager.rb b/service/lib/agama/storage/manager.rb index 2f6a9a5675..92c8e427eb 100644 --- a/service/lib/agama/storage/manager.rb +++ b/service/lib/agama/storage/manager.rb @@ -32,12 +32,10 @@ require "agama/storage/proposal_settings" require "agama/with_issues" require "agama/with_locale" -require "agama/with_progress" +require "agama/with_progress_manager" require "yast" -require "bootloader/proposal_client" require "y2storage/clients/inst_prepdisk" require "y2storage/luks" -require "y2storage/storage_env" require "y2storage/storage_manager" Yast.import "PackagesProposal" @@ -48,8 +46,7 @@ module Storage class Manager include WithLocale include WithIssues - include WithProgress - include Yast::I18n + include WithProgressManager # @return [Agama::Config] attr_reader :product_config @@ -57,139 +54,81 @@ class Manager # @return [Bootloader] attr_reader :bootloader - # Constructor - # # @param product_config [Agama::Config] # @param logger [Logger, nil] def initialize(product_config, logger: nil) - textdomain "agama" - @product_config = product_config @logger = logger || Logger.new($stdout) @bootloader = Bootloader.new(logger) - - register_progress_callbacks end - # Whether the system is in a deprecated status - # - # The system is usually set as deprecated as effect of managing some kind of devices, for - # example, when iSCSI sessions are created. - # - # A deprecated system means that the probed system could not match with the current system. - # - # @return [Boolean] - def deprecated_system? - !!@deprecated_system + def activated? + !!@activated end - # Sets whether the system is deprecated + # Resets any information regarding activation of devices that may be cached by Y2Storage. # - # If the deprecated status changes, then callbacks are executed and the issues are - # recalculated, see {#on_deprecated_system_change}. - # - # @param value [Boolean] - def deprecated_system=(value) - return if deprecated_system? == value + # Note this does NOT deactivate any device. There is not way to revert a previous activation. + def reset_activation + Y2Storage::Luks.reset_activation_infos + end - @deprecated_system = value - @on_deprecated_system_change_callbacks&.each(&:call) - update_issues + # Activates the devices. + def activate + iscsi.activate + callbacks = Callbacks::Activate.new(questions_client, logger) + Y2Storage::StorageManager.instance.activate(callbacks) + @activated = true end - # Registers a callback to be called when the system is set as deprecated - # - # @param block [Proc] - def on_deprecated_system_change(&block) - @on_deprecated_system_change_callbacks ||= [] - @on_deprecated_system_change_callbacks << block + def probed? + Y2Storage::StorageManager.instance.probed? end - # Registers a callback to be called when the system is probed - # - # @param block [Proc] - def on_probe(&block) - @on_probe_callbacks ||= [] - @on_probe_callbacks << block + # Probes the devices. + def probe + iscsi.probe + callbacks = Y2Storage::Callbacks::UserProbe.new + Y2Storage::StorageManager.instance.probe(callbacks) end - # Registers a callback to be called when storage is configured. + # Configures storage. # - # @param block [Proc] - def on_configure(&block) - @on_configure_callbacks ||= [] - @on_configure_callbacks << block + # @param config_json [Hash, nil] Storage config according to the JSON schema. If nil, then + # the default config is applied. + # @return [Boolean] Whether storage was successfully configured. + def configure(config_json = nil) + logger.info("Configuring storage: #{config_json}") + result = Configurator.new(proposal).configure(config_json) + update_issues + result end - # Probes storage devices and performs an initial proposal + # Commits the storage changes. # - # @param keep_config [Boolean] Whether to use the current storage config for calculating the - # proposal. - # @param keep_activation [Boolean] Whether to keep the current activation (e.g., provided LUKS - # passwords). - def probe(keep_config: false, keep_activation: true) - start_progress_with_descriptions( - _("Activating storage devices"), - _("Probing storage devices"), - _("Calculating the storage proposal") - ) - - product_config.pick_product(software.config["product"]) - # Underlying yast-storage-ng has own mechanism for proposing boot strategies. - # However, we don't always want to use BLS when it proposes so. Currently - # we want to use BLS only for Tumbleweed / Slowroll - prohibit_bls_boot if !product_config.boot_strategy&.casecmp("BLS") - check_multipath - - progress.step { activate_devices(keep_activation: keep_activation) } - progress.step { probe_devices } - progress.step do - config_json = proposal.storage_json if keep_config - configure(config_json) - end + # @return [Boolean] true if the all actions were successful. + def install + callbacks = Callbacks::Commit.new(questions_client, logger: logger) - # The system is not deprecated anymore - self.deprecated_system = false - update_issues - @on_probe_callbacks&.each(&:call) + client = Y2Storage::Clients::InstPrepdisk.new(commit_callbacks: callbacks) + client.run == :next end - # Prepares the partitioning to install the system - def install - start_progress_with_size(4) - progress.step(_("Preparing bootloader proposal")) do - # first make bootloader proposal to be sure that required packages are installed - proposal = ::Bootloader::ProposalClient.new.make_proposal({}) - # then also apply changes to that proposal - bootloader.write_config - logger.debug "Bootloader proposal #{proposal.inspect}" - end - progress.step(_("Adding storage-related packages")) { add_packages } - progress.step(_("Preparing the storage devices")) { perform_storage_actions } - progress.step(_("Writing bootloader sysconfig")) do - # call inst bootloader to get properly initialized bootloader - # sysconfig before package installation - Yast::WFM.CallFunction("inst_bootloader", []) - end + # Adds the required packages to the list of resolvables to install. + def add_packages + packages = devicegraph.used_features.pkg_list + packages += ISCSI::Manager::PACKAGES if need_iscsi? + return if packages.empty? + + logger.info "Selecting these packages for installation: #{packages}" + Yast::PackagesProposal.SetResolvables(PROPOSAL_ID, :package, packages) end - # Performs the final steps on the target file system(s) + # Performs the final steps on the target file system(s). def finish Finisher.new(logger, product_config, security).run end - # Configures storage. - # - # @param config_json [Hash, nil] Storage config according to the JSON schema. If nil, then - # the default config is applied. - # @return [Boolean] Whether storage was successfully configured. - def configure(config_json = nil) - result = Configurator.new(proposal).configure(config_json) - update_issues - @on_configure_callbacks&.each(&:call) - result - end - # Storage proposal manager # # @return [Storage::Proposal] @@ -206,13 +145,6 @@ def iscsi @iscsi ||= ISCSI::Manager.new(progress_manager: progress_manager, logger: logger) end - # Returns the client to ask the software service - # - # @return [Agama::DBus::Clients::Software] - def software - @software ||= HTTP::Clients::Software.new(logger) - end - # Storage actions. # # @return [Array] @@ -227,7 +159,7 @@ def actions # Changes the service's locale # # @param locale [String] new locale - def locale=(locale) + def configure_locale(locale) change_process_locale(locale) update_issues end @@ -239,6 +171,13 @@ def security @security ||= Security.new(logger, product_config) end + # Issues from the system + # + # @return [Array] + def system_issues + probing_issues + [candidate_devices_issue].compact + end + private PROPOSAL_ID = "storage_proposal" @@ -247,46 +186,6 @@ def security # @return [Logger] attr_reader :logger - def prohibit_bls_boot - ENV["YAST_NO_BLS_BOOT"] = "1" - # avoiding problems with cached values - Y2Storage::StorageEnv.instance.reset_cache - end - - def register_progress_callbacks - on_progress_change { logger.info(progress.to_s) } - end - - # Activates the devices, calling activation callbacks if needed - # - # @param keep_activation [Boolean] Whether to keep the current activation (e.g., provided LUKS - # passwords). - def activate_devices(keep_activation: true) - Y2Storage::Luks.reset_activation_infos unless keep_activation - - callbacks = Callbacks::Activate.new(questions_client, logger) - iscsi.activate - Y2Storage::StorageManager.instance.activate(callbacks) - end - - # Probes the devices - def probe_devices - callbacks = Y2Storage::Callbacks::UserProbe.new - - iscsi.probe - Y2Storage::StorageManager.instance.probe(callbacks) - end - - # Adds the required packages to the list of resolvables to install - def add_packages - packages = devicegraph.used_features.pkg_list - packages += ISCSI::Manager::PACKAGES if need_iscsi? - return if packages.empty? - - logger.info "Selecting these packages for installation: #{packages}" - Yast::PackagesProposal.SetResolvables(PROPOSAL_ID, :package, packages) - end - # Whether iSCSI is needed in the target system. # # @return [Boolean] @@ -301,31 +200,9 @@ def devicegraph Y2Storage::StorageManager.instance.staging end - # Prepares the storage devices for installation - # - # @return [Boolean] true if the all actions were successful - def perform_storage_actions - callbacks = Callbacks::Commit.new(questions_client, logger: logger) - - client = Y2Storage::Clients::InstPrepdisk.new(commit_callbacks: callbacks) - client.run == :next - end - # Recalculates the list of issues def update_issues - self.issues = system_issues + proposal.issues - end - - # Issues from the system - # - # @return [Array] - def system_issues - issues = probing_issues + [ - deprecated_system_issue, - candidate_devices_issue - ] - - issues.compact + self.issues = proposal.issues end # Issues from the probing phase @@ -343,17 +220,6 @@ def probing_issues end end - # Returns an issue if the system is deprecated - # - # @return [Issue, nil] - def deprecated_system_issue - return unless deprecated_system? - - Issue.new("The system devices have changed", - source: Issue::Source::SYSTEM, - severity: Issue::Severity::ERROR) - end - # Returns an issue if there is no candidate device for installation # # @return [Issue, nil] @@ -371,23 +237,6 @@ def candidate_devices_issue def questions_client @questions_client ||= Agama::HTTP::Clients::Questions.new(logger) end - - MULTIPATH_CONFIG = "/etc/multipath.conf" - # Checks if all requirement for multipath probing is correct and if not - # then log it - def check_multipath - # check if kernel module is loaded - mods = `lsmod`.lines.grep(/dm_multipath/) - logger.warn("dm_multipath modules is not loaded") if mods.empty? - - binary = system("which multipath") - if binary - conf = `multipath -t`.lines.grep(/find_multipaths "smart"/) - logger.warn("multipath: find_multipaths is not set to 'smart'") if conf.empty? - else - logger.warn("multipath is not installed.") - end - end end end end diff --git a/service/lib/agama/storage/proposal.rb b/service/lib/agama/storage/proposal.rb index 236537a72c..d21a2b199d 100644 --- a/service/lib/agama/storage/proposal.rb +++ b/service/lib/agama/storage/proposal.rb @@ -26,7 +26,6 @@ require "agama/storage/config_json_generator" require "agama/storage/config_solver" require "agama/storage/model_support_checker" -require "agama/storage/proposal_settings" require "agama/storage/proposal_strategies" require "agama/storage/system" require "json" @@ -79,12 +78,6 @@ def default_storage_json(device = nil) # @return [Hash, nil] nil if there is no proposal yet. def storage_json case strategy - when ProposalStrategies::Guided - { - storage: { - guided: strategy.settings.to_json_settings - } - } when ProposalStrategies::Agama source_json || { storage: ConfigConversions::ToJSON.new(config).convert } when ProposalStrategies::Autoyast @@ -130,13 +123,10 @@ def solve_model(model_json) def calculate_from_json(source_json) # @todo Validate source_json with JSON schema. - guided_json = source_json.dig(:storage, :guided) storage_json = source_json[:storage] autoyast_json = source_json[:legacyAutoyastStorage] - if guided_json - calculate_guided_from_json(guided_json) - elsif storage_json + if storage_json calculate_agama_from_json(storage_json) elsif autoyast_json calculate_autoyast(autoyast_json) @@ -148,17 +138,6 @@ def calculate_from_json(source_json) success? end - # Calculates a new proposal using the guided strategy. - # - # @param settings [Agama::Storage::ProposalSettings] - # @return [Boolean] Whether the proposal successes. - def calculate_guided(settings) - logger.info("Calculating proposal with guided strategy: #{settings.inspect}") - reset - @strategy = ProposalStrategies::Guided.new(product_config, storage_system, settings, logger) - calculate - end - # Calculates a new proposal using the agama strategy. # # @param config [Agama::Storage::Config] @@ -219,20 +198,9 @@ def issues # Whether the guided strategy was used for calculating the current proposal. # - # @return [Boolean] + # @return [Boolean] Always false because the guided strategy does not longer exists def guided? - return false unless calculated? - - strategy.is_a?(ProposalStrategies::Guided) - end - - # Settings used for calculating the guided proposal, if any. - # - # @return [ProposalSettings, nil] - def guided_settings - return unless guided? - - strategy.settings + false end # @return [Storage::System] @@ -286,15 +254,6 @@ def model_supported?(config) ModelSupportChecker.new(config).supported? end - # Calculates a proposal from guided JSON settings. - # - # @param guided_json [Hash] e.g., { "target": { "disk": "/dev/vda" } }. - # @return [Boolean] Whether the proposal successes. - def calculate_guided_from_json(guided_json) - settings = ProposalSettings.new_from_json(guided_json, config: product_config) - calculate_guided(settings) - end - # Calculates a proposal from storage JSON settings. # # @param config_json [Hash] e.g., { "drives": [] }. diff --git a/service/lib/agama/storage/proposal_settings_conversions.rb b/service/lib/agama/storage/proposal_settings_conversions.rb index 11517c4b58..602d4bce56 100644 --- a/service/lib/agama/storage/proposal_settings_conversions.rb +++ b/service/lib/agama/storage/proposal_settings_conversions.rb @@ -19,9 +19,6 @@ # To contact SUSE LLC about this file by physical or electronic mail, you may # find current contact information at www.suse.com. -require "agama/storage/proposal_settings_conversions/from_json" -require "agama/storage/proposal_settings_conversions/from_y2storage" -require "agama/storage/proposal_settings_conversions/to_json" require "agama/storage/proposal_settings_conversions/to_y2storage" module Agama diff --git a/service/lib/agama/storage/proposal_settings_conversions/from_json.rb b/service/lib/agama/storage/proposal_settings_conversions/from_json.rb deleted file mode 100644 index e709c98aa7..0000000000 --- a/service/lib/agama/storage/proposal_settings_conversions/from_json.rb +++ /dev/null @@ -1,161 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require "agama/storage/configs/boot" -require "agama/storage/device_settings" -require "agama/storage/encryption_settings" -require "agama/storage/proposal_settings_reader" -require "agama/storage/space_settings" -require "agama/storage/volume" -require "y2storage/encryption_method" -require "y2storage/pbkd_function" - -module Agama - module Storage - module ProposalSettingsConversions - # Proposal settings conversion from JSON hash according to schema. - class FromJSON - # @param settings_json [Hash] - # @param config [Config] - def initialize(settings_json, config:) - @settings_json = settings_json - @config = config - end - - # Performs the conversion from Hash according to the JSON schema. - # - # @return [ProposalSettings] - def convert - # @todo Raise error if settings_json does not match the JSON schema. - device_settings = target_conversion - boot_settings = boot_conversion - encryption_settings = encryption_conversion - space_settings = space_conversion - volumes = volumes_conversion - - Agama::Storage::ProposalSettingsReader.new(config).read.tap do |settings| - settings.device = device_settings if device_settings - settings.boot = boot_settings if boot_settings - settings.encryption = encryption_settings if encryption_settings - settings.space = space_settings if space_settings - settings.volumes = add_required_volumes(volumes, settings.volumes) if volumes.any? - end - end - - private - - # @return [Hash] - attr_reader :settings_json - - # @return [Config] - attr_reader :config - - def target_conversion - target_json = settings_json[:target] - return unless target_json - - if target_json == "disk" - Agama::Storage::DeviceSettings::Disk.new - elsif target_json == "newLvmVg" - Agama::Storage::DeviceSettings::NewLvmVg.new - elsif (device = target_json[:disk]) - Agama::Storage::DeviceSettings::Disk.new(device) - elsif (devices = target_json[:newLvmVg]) - Agama::Storage::DeviceSettings::NewLvmVg.new(devices) - end - end - - def boot_conversion - boot_json = settings_json[:boot] - return unless boot_json - - Agama::Storage::Configs::Boot.new.tap do |boot_settings| - boot_settings.configure = boot_json[:configure] - boot_settings.device = boot_json[:device] - end - end - - def encryption_conversion - encryption_json = settings_json[:encryption] - return unless encryption_json - - Agama::Storage::EncryptionSettings.new.tap do |encryption_settings| - encryption_settings.password = encryption_json[:password] - - if (method_value = encryption_json[:method]) - method = Y2Storage::EncryptionMethod.find(method_value.to_sym) - encryption_settings.method = method - end - - if (function_value = encryption_json[:pbkdFunction]) - function = Y2Storage::PbkdFunction.find(function_value) - encryption_settings.pbkd_function = function - end - end - end - - def space_conversion - space_json = settings_json[:space] - return unless space_json - - Agama::Storage::SpaceSettings.new.tap do |space_settings| - space_settings.policy = space_json[:policy].to_sym - - actions_value = space_json[:actions] || [] - space_settings.actions = actions_value.map { |a| action_conversion(a) }.inject(:merge) - end - end - - # @param action [Hash] - def action_conversion(action) - return action.invert unless action[:forceDelete] - - { action[:forceDelete] => :force_delete } - end - - def volumes_conversion - volumes_json = settings_json[:volumes] - return [] unless volumes_json - - volumes_json.map do |volume_json| - Volume.new_from_json(volume_json, config: config) - end - end - - # Adds the missing required volumes to the list of volumes. - # - # @param volumes [Array] - # @param default_volumes [Array] Default volumes including the required ones. - # - # @return [Array] - def add_required_volumes(volumes, default_volumes) - mount_paths = volumes.map(&:mount_path) - - missing_required_volumes = default_volumes - .select { |v| v.outline.required? } - .reject { |v| mount_paths.include?(v.mount_path) } - - missing_required_volumes + volumes - end - end - end - end -end diff --git a/service/lib/agama/storage/proposal_settings_conversions/from_y2storage.rb b/service/lib/agama/storage/proposal_settings_conversions/from_y2storage.rb deleted file mode 100644 index a9712086e7..0000000000 --- a/service/lib/agama/storage/proposal_settings_conversions/from_y2storage.rb +++ /dev/null @@ -1,94 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2023-2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require "agama/storage/volume_conversions/from_y2storage" - -module Agama - module Storage - module ProposalSettingsConversions - # Proposal settings conversion from Y2Storage. - # - # @note This class does not perform a real conversion from Y2Storage settings. Instead of - # that, it copies the given settings and recovers some values from Y2Storage. - # A real conversion is not needed because the original settings are always available. - # Moreover, Agama introduces some concepts that do not exist in the Y2Storage settings - # (e.g., target, boot device or space policy), which could be impossible to infer. - class FromY2Storage - # @param y2storage_settings [Y2Storage::ProposalSettings] - # @param settings [Agama::Storage::ProposalSettings] Settings to be copied and modified. - def initialize(y2storage_settings, settings) - @y2storage_settings = y2storage_settings - @settings = settings - end - - # Performs the conversion from Y2Storage. - # - # @return [Agama::Storage::ProposalSettings] - def convert - settings.dup.tap do |target| - space_actions_conversion(target) - volumes_conversion(target) - end - end - - private - - # @return [Y2Storage::ProposalSettings] - attr_reader :y2storage_settings - - # @return [Agama::Storage::ProposalSettings] - attr_reader :settings - - # Recovers space actions. - # - # @note Space actions are generated in the conversion of the settings to Y2Storage format, - # see {ProposalSettingsConversions::ToY2Storage}. - # - # @param target [Agama::Storage::ProposalSettings] - def space_actions_conversion(target) - target.space.actions = y2storage_settings.space_settings.actions.map do |action| - [action.device, action_to_symbol(action)] - end.to_h - end - - # @see #space_action_conversion - def action_to_symbol(action) - return :resize if action.is?(:resize) - - action.mandatory ? :force_delete : :delete - end - - # Some values of the volumes have to be recovered from Y2Storage proposal. - # - # @param target [Agama::Storage::ProposalSettings] - def volumes_conversion(target) - target.volumes = target.volumes.map { |v| volume_conversion(v) } - end - - # @param volume [Agama::Storage::Volume] - # @return [Agama::Storage::Volume] - def volume_conversion(volume) - VolumeConversions::FromY2Storage.new(volume).convert - end - end - end - end -end diff --git a/service/lib/agama/storage/proposal_settings_conversions/to_json.rb b/service/lib/agama/storage/proposal_settings_conversions/to_json.rb deleted file mode 100644 index a137412b51..0000000000 --- a/service/lib/agama/storage/proposal_settings_conversions/to_json.rb +++ /dev/null @@ -1,113 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require "agama/storage/device_settings" - -module Agama - module Storage - module ProposalSettingsConversions - # Proposal settings conversion to JSON hash according to schema. - class ToJSON - # @param settings [ProposalSettings] - def initialize(settings) - @settings = settings - end - - # Performs the conversion to JSON. - # - # @return [Hash] - def convert - { - target: target_conversion, - boot: boot_conversion, - space: space_conversion, - volumes: volumes_conversion - }.tap do |settings_json| - encryption_json = encryption_conversion - settings_json[:encryption] = encryption_json if encryption_json - end - end - - private - - # @return [ProposalSettings] - attr_reader :settings - - def target_conversion - device_settings = settings.device - - case device_settings - when Agama::Storage::DeviceSettings::Disk - device = device_settings.name - device ? { disk: device } : "disk" - when Agama::Storage::DeviceSettings::NewLvmVg - candidates = device_settings.candidate_pv_devices - candidates.any? ? { newLvmVg: candidates } : "newLvmVg" - end - end - - def boot_conversion - { - configure: settings.boot.configure? - }.tap do |boot_json| - device = settings.boot.device - boot_json[:device] = device if device - end - end - - def encryption_conversion - return unless settings.encryption.encrypt? - - { - password: settings.encryption.password, - method: settings.encryption.method.id.to_s - }.tap do |encryption_json| - function = settings.encryption.pbkd_function - encryption_json[:pbkdFunction] = function.value if function - end - end - - def space_conversion - if settings.space.policy == :custom - { - policy: "custom", - actions: settings.space.actions.map { |d, a| { action_key(a) => d } } - } - else - { - policy: settings.space.policy.to_s - } - end - end - - def action_key(action) - return action.to_sym if action.to_s != "force_delete" - - :forceDelete - end - - def volumes_conversion - settings.volumes.map(&:to_json_settings) - end - end - end - end -end diff --git a/service/lib/agama/storage/proposal_strategies.rb b/service/lib/agama/storage/proposal_strategies.rb index 8bfe8ade59..65717e5c95 100644 --- a/service/lib/agama/storage/proposal_strategies.rb +++ b/service/lib/agama/storage/proposal_strategies.rb @@ -29,4 +29,3 @@ module ProposalStrategies require "agama/storage/proposal_strategies/agama" require "agama/storage/proposal_strategies/autoyast" -require "agama/storage/proposal_strategies/guided" diff --git a/service/lib/agama/storage/proposal_strategies/guided.rb b/service/lib/agama/storage/proposal_strategies/guided.rb deleted file mode 100644 index 926b8ea90c..0000000000 --- a/service/lib/agama/storage/proposal_strategies/guided.rb +++ /dev/null @@ -1,165 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2024-2025] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require "agama/storage/proposal_strategies/base" -require "agama/storage/device_settings" -require "agama/storage/proposal_settings_conversions/from_y2storage" - -module Agama - module Storage - module ProposalStrategies - # Main strategy for the Agama proposal. - class Guided < Base - include Yast::I18n - - # @param product_config [Config] Product config - # @param storage_system [Storage::System] - # @param input_settings [ProposalSettings] - # @param logger [Logger] - def initialize(product_config, storage_system, input_settings, logger) - textdomain "agama" - - super(product_config, storage_system, logger) - @input_settings = input_settings - end - - # Settings used for calculating the proposal. - # - # @note Some values are recoverd from Y2Storage, see - # {ProposalSettingsConversions::FromY2Storage} - # - # @return [ProposalSettings] - attr_reader :settings - - # @see Base#calculate - def calculate - select_target_device(input_settings) if missing_target_device?(input_settings) - proposal = guided_proposal(input_settings) - proposal.propose - ensure - storage_manager.proposal = proposal - @settings = ProposalSettingsConversions::FromY2Storage - .new(proposal.settings, input_settings) - .convert - end - - # @see Base#issues - def issues - # Returning [] in case of a missing proposal is a workaround (the scenario should - # not happen). But this class is not expected to live long. - return [] unless storage_manager.proposal - return [] unless storage_manager.proposal.failed? - - [target_device_issue, missing_devices_issue].compact - end - - private - - # Initial set of proposal settings - # @return [ProposalSettings] - attr_reader :input_settings - - # Available devices for installation. - # - # @return [Array] - def available_devices - storage_system.analyzer&.candidate_disks || [] - end - - # Selects the first available device as target device for installation. - # - # @param settings [ProposalSettings] - def select_target_device(settings) - device = available_devices.first&.name - return unless device - - case settings.device - when DeviceSettings::Disk - settings.device.name = device - when DeviceSettings::NewLvmVg - settings.device.candidate_pv_devices = [device] - when DeviceSettings::ReusedLvmVg - # TODO: select an existing VG? - end - end - - # Whether the given settings has no target device for the installation. - # - # @param settings [ProposalSettings] - # @return [Boolean] - def missing_target_device?(settings) - case settings.device - when DeviceSettings::Disk, DeviceSettings::ReusedLvmVg - settings.device.name.nil? - when DeviceSettings::NewLvmVg - settings.device.candidate_pv_devices.empty? - end - end - - # Instance of the Y2Storage proposal to be used to run the calculation. - # - # @param settings [Y2Storage::ProposalSettings] - # @return [Y2Storage::GuidedProposal] - def guided_proposal(settings) - Y2Storage::MinGuidedProposal.new( - settings: settings.to_y2storage(config: product_config), - devicegraph: storage_system.devicegraph, - disk_analyzer: storage_system.analyzer - ) - end - - # Returns an issue if there is no target device. - # - # @return [Issue, nil] - def target_device_issue - return unless missing_target_device?(settings) - - Issue.new(_("No device selected for installation"), - source: Issue::Source::CONFIG, - severity: Issue::Severity::ERROR) - end - - # Returns an issue if any of the devices required for the proposal is not found - # - # @return [Issue, nil] - def missing_devices_issue - available = available_devices.map(&:name) - missing = settings.installation_devices.reject { |d| available.include?(d) } - - return if missing.none? - - Issue.new( - format( - n_( - "The following selected device is not found in the system: %{devices}", - "The following selected devices are not found in the system: %{devices}", - missing.size - ), - devices: missing.join(", ") - ), - source: Issue::Source::CONFIG, - severity: Issue::Severity::ERROR - ) - end - end - end - end -end diff --git a/service/lib/agama/storage/volume_conversions.rb b/service/lib/agama/storage/volume_conversions.rb index e8a2518a78..a83787bb93 100644 --- a/service/lib/agama/storage/volume_conversions.rb +++ b/service/lib/agama/storage/volume_conversions.rb @@ -19,8 +19,6 @@ # To contact SUSE LLC about this file by physical or electronic mail, you may # find current contact information at www.suse.com. -require "agama/storage/volume_conversions/from_json" -require "agama/storage/volume_conversions/from_y2storage" require "agama/storage/volume_conversions/to_json" require "agama/storage/volume_conversions/to_y2storage" diff --git a/service/lib/agama/storage/volume_conversions/from_json.rb b/service/lib/agama/storage/volume_conversions/from_json.rb deleted file mode 100644 index a400d250a2..0000000000 --- a/service/lib/agama/storage/volume_conversions/from_json.rb +++ /dev/null @@ -1,155 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require "agama/storage/volume" -require "agama/storage/volume_location" -require "agama/storage/volume_templates_builder" -require "y2storage" - -module Agama - module Storage - module VolumeConversions - # Volume conversion from JSON hash according schema. - class FromJSON - # @param volume_json [Hash] - # @param config [Config] - def initialize(volume_json, config:) - @volume_json = volume_json - @config = config - end - - # Performs the conversion from JSON Hash according to schema. - # - # @return [Volume] - def convert - # @todo Raise error if volume_json does not match the JSON schema. - - default_volume.tap do |volume| - mount_conversion(volume) - filesystem_conversion(volume) - size_conversion(volume) - target_conversion(volume) - end - end - - private - - # @return [Hash] - attr_reader :volume_json - - # @return [Agama::Config] - attr_reader :config - - # @param volume [Volume] - def mount_conversion(volume) - path_value = volume_json.dig(:mount, :path) - options_value = volume_json.dig(:mount, :options) - - volume.mount_path = path_value - volume.mount_options = options_value if options_value - end - - # @param volume [Volume] - def filesystem_conversion(volume) - filesystem_json = volume_json[:filesystem] - return unless filesystem_json - - if filesystem_json.is_a?(String) - filesystem_string_conversion(volume, filesystem_json) - else - filesystem_hash_conversion(volume, filesystem_json) - end - end - - # @param volume [Volume] - # @param filesystem_json [String] - def filesystem_string_conversion(volume, filesystem_json) - filesystems = volume.outline.filesystems - - fs_type = filesystems.find { |t| t.to_s == filesystem_json } - volume.fs_type = fs_type if fs_type - end - - # @param volume [Volume] - # @param filesystem_json [Hash] - def filesystem_hash_conversion(volume, filesystem_json) - filesystem_string_conversion(volume, "btrfs") - - snapshots_value = filesystem_json.dig(:btrfs, :snapshots) - return if !volume.outline.snapshots_configurable? || snapshots_value.nil? - - volume.btrfs.snapshots = snapshots_value - end - - # @todo Support array format ([min, max]) and string format ("2 GiB") - # @param volume [Volume] - def size_conversion(volume) - size_json = volume_json[:size] - return unless size_json - - if size_json == "auto" - volume.auto_size = true if volume.auto_size_supported? - else - volume.auto_size = false - - min_value = size_json[:min] - max_value = size_json[:max] - - volume.min_size = Y2Storage::DiskSize.new(min_value) - volume.max_size = if max_value - Y2Storage::DiskSize.new(max_value) - else - Y2Storage::DiskSize.unlimited - end - end - end - - def target_conversion(volume) - target_json = volume_json[:target] - return unless target_json - - if target_json == "default" - volume.location.target = :default - volume.location.device = nil - elsif (device = target_json[:newPartition]) - volume.location.target = :new_partition - volume.location.device = device - elsif (device = target_json[:newVg]) - volume.location.target = :new_vg - volume.location.device = device - elsif (device = target_json[:device]) - volume.location.target = :device - volume.location.device = device - elsif (device = target_json[:filesystem]) - volume.location.target = :filesystem - volume.location.device = device - end - end - - def default_volume - Agama::Storage::VolumeTemplatesBuilder - .new_from_config(config) - .for(volume_json.dig(:mount, :path)) - end - end - end - end -end diff --git a/service/lib/agama/storage/volume_conversions/from_y2storage.rb b/service/lib/agama/storage/volume_conversions/from_y2storage.rb deleted file mode 100644 index 0ad025eb1b..0000000000 --- a/service/lib/agama/storage/volume_conversions/from_y2storage.rb +++ /dev/null @@ -1,92 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2023-2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require "y2storage/storage_manager" - -module Agama - module Storage - module VolumeConversions - # Volume conversion from Y2Storage. - # - # @note This class does not perform a real conversion from Y2Storage. Instead of that, it - # copies the given volume and recovers some values from Y2Storage. - class FromY2Storage - # @param volume [Agama::Storage::ProposalSettings] - def initialize(volume) - @volume = volume - end - - # Performs the conversion from Y2Storage. - # - # @return [Agama::Storage::Volume] - def convert - volume.dup.tap do |target| - sizes_conversion(target) - end - end - - private - - # @return [Agama::Storage::ProposalSettings] - attr_reader :volume - - # Recovers the range of sizes used by the Y2Storage proposal, if needed. - # - # If the volume is configured to use auto sizes, then the final range of sizes used by the - # Y2Storage proposal depends on the fallback sizes (if this volume is fallback for other - # volume) and the size for snapshots (if snapshots is active). The planned device contains - # the real range of sizes used by the Y2Storage proposal. - # - # FIXME: Recovering the sizes from the planned device is done to know the range of sizes - # assigned to the volume and to present that information in the UI. But such information - # should be provided in a different way, for example as part of the proposal result - # reported on D-Bus: { success:, settings:, strategy:, computed_sizes: }. - # - # @param target [Agama::Storage::Volume] - def sizes_conversion(target) - return unless target.auto_size? - - planned = planned_device_for(target.mount_path) - return unless planned - - target.min_size = planned.min if planned.respond_to?(:min) - target.max_size = planned.max if planned.respond_to?(:max) - end - - # Planned device for the given mount path. - # - # @param mount_path [String] - # @return [Y2Storage::Planned::Device, nil] - def planned_device_for(mount_path) - planned_devices = proposal&.planned_devices || [] - planned_devices.find { |d| d.respond_to?(:mount_point) && d.mount_point == mount_path } - end - - # Current proposal. - # - # @return [Y2Storage::Proposal, nil] - def proposal - Y2Storage::StorageManager.instance.proposal - end - end - end - end -end diff --git a/service/lib/agama/storage/volume_conversions/to_json.rb b/service/lib/agama/storage/volume_conversions/to_json.rb index 4915ae68c0..09b270d627 100644 --- a/service/lib/agama/storage/volume_conversions/to_json.rb +++ b/service/lib/agama/storage/volume_conversions/to_json.rb @@ -27,6 +27,15 @@ module Agama module Storage module VolumeConversions # Volume conversion to JSON hash according to schema. + # + # This class was in the past meant to convert the 'volumes' section of the ProposalSettings, + # when we used to have a Guided strategy. So each conversion represented a volume that was + # meant to be part of the proposal (as a new partition, LV, etc.). That Guided strategy does + # not exist anymore. + # + # Now the volumes are only used to describe the templates used by the product to represent + # the suggested/acceptable settings for each mount point, since the class Volume is still + # (ab)used for that purpose. Thus, this conversion now serves that purpose. class ToJSON # @param volume [Volume] def initialize(volume) @@ -38,12 +47,17 @@ def initialize(volume) # @return [Hash] def convert { - mount: mount_conversion, - size: size_conversion, - target: target_conversion + mountPath: volume.mount_path.to_s, + mountOptions: volume.mount_options, + fsType: volume.fs_type&.to_s || "", + minSize: min_size_conversion, + autoSize: volume.auto_size?, + snapshots: volume.btrfs.snapshots?, + transactional: volume.btrfs.read_only?, + outline: outline_conversion }.tap do |volume_json| - filesystem_json = filesystem_conversion - volume_json[:filesystem] = filesystem_json if filesystem_json + # Some volumes could not have "MaxSize". + max_size_conversion(volume_json) end end @@ -52,47 +66,44 @@ def convert # @return [Volume] attr_reader :volume - def mount_conversion - { - path: volume.mount_path.to_s, - options: volume.mount_options - } + # @return [Integer] + def min_size_conversion + min_size = volume.min_size + min_size = volume.outline.base_min_size if volume.auto_size? + min_size.to_i end - def filesystem_conversion - return unless volume.fs_type - return volume.fs_type.to_s if volume.fs_type != Y2Storage::Filesystems::Type::BTRFS + # @param json [Hash] + def max_size_conversion(json) + max_size = volume.max_size + max_size = volume.outline.base_max_size if volume.auto_size? + return if max_size.unlimited? - { - btrfs: { - snapshots: volume.btrfs.snapshots? - } - } + json[:maxSize] = max_size.to_i end - def size_conversion - return "auto" if volume.auto_size? - - size = { min: volume.min_size.to_i } - size[:max] = volume.max_size.to_i if volume.max_size != Y2Storage::DiskSize.unlimited - size - end - - def target_conversion - location = volume.location + # Converts volume outline to D-Bus. + # + # @return [Hash] + # * required [Boolean] + # * fsTypes [Array] + # * supportAutoSize [Boolean] + # * adjustByRam [Boolean] + # * snapshotsConfigurable [Boolean] + # * snapshotsAffectSizes [Boolean] + # * sizeRelevantVolumes [Array] + def outline_conversion + outline = volume.outline - case location.target - when :default - "default" - when :new_partition - { newPartition: location.device } - when :new_vg - { newVg: location.device } - when :device - { device: location.device } - when :filesystem - { filesystem: location.device } - end + { + required: outline.required?, + fsTypes: outline.filesystems.map(&:to_s), + supportAutoSize: outline.adaptive_sizes?, + adjustByRam: outline.adjust_by_ram?, + snapshotsConfigurable: outline.snapshots_configurable?, + snapshotsAffectSizes: outline.snapshots_affect_sizes?, + sizeRelevantVolumes: outline.size_relevant_volumes + } end end end diff --git a/service/lib/agama/with_progress.rb b/service/lib/agama/with_progress.rb index cdf7a478a7..dd130f9913 100644 --- a/service/lib/agama/with_progress.rb +++ b/service/lib/agama/with_progress.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2022-2025] SUSE LLC +# Copyright (c) [2025] SUSE LLC # # All Rights Reserved. # @@ -19,52 +19,55 @@ # To contact SUSE LLC about this file by physical or electronic mail, you may # find current contact information at www.suse.com. -require "agama/progress_manager" +require "agama/progress" module Agama - # Mixin that allows to start a progress and configure callbacks + # Mixin to use Agama::Progress to track the status of an object. module WithProgress - # @return [ProgressManager] - def progress_manager - @progress_manager ||= Agama::ProgressManager.new - end + attr_reader :progress - # @return [Progress, nil] - def progress - progress_manager.progress + def start_progress(size, step) + @progress = Progress.new(size, step) + progress_change end - # Creates a new progress with a given number of steps - # - # @param size [Integer] Number of steps - def start_progress_with_size(size) - progress_manager.start_with_size(size) + def start_progress_with_steps(steps) + @progress = Progress.new_with_steps(steps) + progress_change end - # Creates a new progress with a given set of steps - # - # @param descriptions [Array] Steps descriptions - def start_progress_with_descriptions(*descriptions) - progress_manager.start_with_descriptions(*descriptions) + def next_progress_step(step = nil) + return unless @progress + + step ? @progress.next_with_step(step) : @progress.step + progress_change end - # Finishes the current progress def finish_progress - progress_manager.finish + return unless @progress + + @progress = nil + progress_finish + end + + def progress_change + @on_progress_change_callbacks.each(&:call) + end + + def progress_finish + @on_progress_finish_callbacks.each(&:call) end - # Registers an on_change callback to be added to the progress - # # @param block [Proc] def on_progress_change(&block) - progress_manager.on_change(&block) + @on_progress_change_callbacks ||= [] + @on_progress_change_callbacks << block end - # Registers an on_finish callback to be added to the progress - # # @param block [Proc] def on_progress_finish(&block) - progress_manager.on_finish(&block) + @on_progress_finish_callbacks ||= [] + @on_progress_finish_callbacks << block end end end diff --git a/service/lib/agama/with_progress_manager.rb b/service/lib/agama/with_progress_manager.rb new file mode 100644 index 0000000000..9c096524ce --- /dev/null +++ b/service/lib/agama/with_progress_manager.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +# Copyright (c) [2022-2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/progress_manager" + +module Agama + # Mixin that allows to start a progress and configure callbacks + module WithProgressManager + # @return [ProgressManager] + def progress_manager + @progress_manager ||= Agama::ProgressManager.new + end + + # @return [Progress, nil] + def progress + progress_manager.progress + end + + # Creates a new progress with a given number of steps + # + # @param size [Integer] Number of steps + def start_progress_with_size(size) + progress_manager.start_with_size(size) + end + + # Creates a new progress with a given set of steps + # + # @param descriptions [Array] Steps descriptions + def start_progress_with_descriptions(*descriptions) + progress_manager.start_with_descriptions(*descriptions) + end + + # Finishes the current progress + def finish_progress + progress_manager.finish + end + + # Registers an on_change callback to be added to the progress + # + # @param block [Proc] + def on_progress_change(&block) + progress_manager.on_change(&block) + end + + # Registers an on_finish callback to be added to the progress + # + # @param block [Proc] + def on_progress_finish(&block) + progress_manager.on_finish(&block) + end + end +end diff --git a/service/package/rubygem-agama-yast.changes b/service/package/rubygem-agama-yast.changes index 728a7735ba..39ab434ca8 100644 --- a/service/package/rubygem-agama-yast.changes +++ b/service/package/rubygem-agama-yast.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Mon Nov 3 14:34:26 UTC 2025 - José Iván López González + +- Adapt the storage D-Bus API to support the new HTTP API + (gh#agama-project/agama#2816). + ------------------------------------------------------------------- Fri Oct 17 13:15:00 UTC 2025 - Imobach Gonzalez Sosa diff --git a/service/test/agama/dbus/clients/storage_test.rb b/service/test/agama/dbus/clients/storage_test.rb index 10fa7aad33..856f51f6e2 100644 --- a/service/test/agama/dbus/clients/storage_test.rb +++ b/service/test/agama/dbus/clients/storage_test.rb @@ -20,8 +20,6 @@ # find current contact information at www.suse.com. require_relative "../../../test_helper" -require_relative "with_issues_examples" -require_relative "with_progress_examples" require "agama/dbus/clients/storage" require "dbus" @@ -31,22 +29,19 @@ allow(bus).to receive(:service).with("org.opensuse.Agama.Storage1").and_return(service) allow(service).to receive(:[]).with("/org/opensuse/Agama/Storage1").and_return(dbus_object) allow(dbus_object).to receive(:introspect) - allow(dbus_object).to receive(:[]).with("org.opensuse.Agama.Storage1").and_return(storage_iface) end let(:bus) { instance_double(Agama::DBus::Bus) } let(:service) { instance_double(::DBus::ProxyService) } - let(:dbus_object) { instance_double(::DBus::ProxyObject) } - let(:storage_iface) { instance_double(::DBus::ProxyObjectInterface) } subject { described_class.new } describe "#probe" do - let(:storage_iface) { double(::DBus::ProxyObjectInterface, Probe: nil) } + let(:dbus_object) { double(::DBus::ProxyObject, Probe: nil) } it "calls the D-Bus Probe method" do - expect(storage_iface).to receive(:Probe) + expect(dbus_object).to receive(:Probe) subject.probe end @@ -54,7 +49,7 @@ context "when a block is given" do it "passes the block to the Probe method (async)" do callback = proc {} - expect(storage_iface).to receive(:Probe) do |&block| + expect(dbus_object).to receive(:Probe) do |&block| expect(block).to be(callback) end @@ -82,7 +77,4 @@ subject.finish end end - - include_examples "issues" - include_examples "progress" end diff --git a/service/test/agama/dbus/interfaces/progress_test.rb b/service/test/agama/dbus/interfaces/progress_test.rb index e6dd6b1bd1..1c8047ece8 100644 --- a/service/test/agama/dbus/interfaces/progress_test.rb +++ b/service/test/agama/dbus/interfaces/progress_test.rb @@ -23,8 +23,7 @@ require "agama/dbus/base_object" require "agama/dbus/interfaces/progress" require "agama/dbus/with_progress" -require "agama/with_progress" -require "agama/progress" +require "agama/with_progress_manager" class DBusObjectWithProgressInterface < Agama::DBus::BaseObject include Agama::DBus::WithProgress @@ -39,7 +38,7 @@ def backend end class Backend - include Agama::WithProgress + include Agama::WithProgressManager end end diff --git a/service/test/agama/dbus/storage/device_test.rb b/service/test/agama/dbus/storage/device_test.rb deleted file mode 100644 index efb5ffe720..0000000000 --- a/service/test/agama/dbus/storage/device_test.rb +++ /dev/null @@ -1,282 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2023-2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require_relative "../../../test_helper" -require_relative "../../storage/storage_helpers" -require_relative "./interfaces/device/block_examples" -require_relative "./interfaces/device/component_examples" -require_relative "./interfaces/device/device_examples" -require_relative "./interfaces/device/drive_examples" -require_relative "./interfaces/device/filesystem_examples" -require_relative "./interfaces/device/lvm_lv_examples" -require_relative "./interfaces/device/lvm_vg_examples" -require_relative "./interfaces/device/md_examples" -require_relative "./interfaces/device/multipath_examples" -require_relative "./interfaces/device/partition_examples" -require_relative "./interfaces/device/partition_table_examples" -require_relative "./interfaces/device/raid_examples" -require "agama/dbus/storage/device" -require "agama/dbus/storage/devices_tree" -require "dbus" - -describe Agama::DBus::Storage::Device do - include Agama::RSpec::StorageHelpers - - RSpec::Matchers.define(:include_dbus_interface) do |interface| - match do |dbus_object| - !dbus_object.interfaces_and_properties[interface].nil? - end - - failure_message do |dbus_object| - "D-Bus interface #{interface} is not included.\n" \ - "Interfaces: #{dbus_object.interfaces_and_properties.keys.join(", ")}" - end - end - - subject { described_class.new(device, "/test", tree) } - - let(:tree) { Agama::DBus::Storage::DevicesTree.new(service, "/agama/devices") } - - let(:service) { instance_double(::DBus::ObjectServer) } - - before do - mock_storage(devicegraph: scenario) - end - - let(:devicegraph) { Y2Storage::StorageManager.instance.probed } - - describe ".new" do - context "when the given device is a disk" do - let(:scenario) { "partitioned_md.yml" } - - let(:device) { devicegraph.find_by_name("/dev/sda") } - - it "defines the Device interface" do - expect(subject).to include_dbus_interface("org.opensuse.Agama.Storage1.Device") - end - - it "defines the Drive interface" do - expect(subject).to include_dbus_interface("org.opensuse.Agama.Storage1.Drive") - end - - it "defines the Block interface" do - expect(subject).to include_dbus_interface("org.opensuse.Agama.Storage1.Block") - end - end - - context "when the device is a DM RAID" do - let(:scenario) { "empty-dm_raids.xml" } - - let(:device) { devicegraph.dm_raids.first } - - it "defines the Device interface" do - expect(subject).to include_dbus_interface("org.opensuse.Agama.Storage1.Device") - end - - it "defines the Drive interface" do - expect(subject).to include_dbus_interface("org.opensuse.Agama.Storage1.Drive") - end - - it "defines the RAID interface" do - expect(subject).to include_dbus_interface("org.opensuse.Agama.Storage1.RAID") - end - - it "defines the Block interface" do - expect(subject).to include_dbus_interface("org.opensuse.Agama.Storage1.Block") - end - end - - context "when the device is a MD RAID" do - let(:scenario) { "partitioned_md.yml" } - - let(:device) { devicegraph.md_raids.first } - - it "defines the Device interface" do - expect(subject).to include_dbus_interface("org.opensuse.Agama.Storage1.Device") - end - - it "does not define the Drive interface" do - expect(subject).to_not include_dbus_interface("org.opensuse.Agama.Storage1.Drive") - end - - it "defines the MD interface" do - expect(subject).to include_dbus_interface("org.opensuse.Agama.Storage1.MD") - end - - it "defines the Block interface" do - expect(subject).to include_dbus_interface("org.opensuse.Agama.Storage1.Block") - end - end - - context "when the given device is a LVM volume group" do - let(:scenario) { "trivial_lvm.yml" } - - let(:device) { devicegraph.find_by_name("/dev/vg0") } - - it "defines the Device interface" do - expect(subject).to include_dbus_interface("org.opensuse.Agama.Storage1.Device") - end - - it "does not define the Drive interface" do - expect(subject).to_not include_dbus_interface("org.opensuse.Agama.Storage1.Drive") - end - - it "defines the LVM.VolumeGroup interface" do - expect(subject).to include_dbus_interface("org.opensuse.Agama.Storage1.LVM.VolumeGroup") - end - end - - context "when the given device is a LVM logical volume" do - let(:scenario) { "trivial_lvm.yml" } - - let(:device) { devicegraph.find_by_name("/dev/vg0/lv1") } - - it "defines the Device interface" do - expect(subject).to include_dbus_interface("org.opensuse.Agama.Storage1.Device") - end - - it "does not define the Drive interface" do - expect(subject).to_not include_dbus_interface("org.opensuse.Agama.Storage1.Drive") - end - - it "defines the LVM.LogicalVolume interface" do - expect(subject).to include_dbus_interface("org.opensuse.Agama.Storage1.LVM.LogicalVolume") - end - end - - context "when the given device is a partition" do - let(:scenario) { "partitioned_md.yml" } - - let(:device) { devicegraph.find_by_name("/dev/sda1") } - - it "defines the Device interface" do - expect(subject).to include_dbus_interface("org.opensuse.Agama.Storage1.Device") - end - - it "defines the Block interface" do - expect(subject).to include_dbus_interface("org.opensuse.Agama.Storage1.Block") - end - - it "does not define the Drive interface" do - expect(subject).to_not include_dbus_interface("org.opensuse.Agama.Storage1.Drive") - end - - it "defines the Partition interface" do - expect(subject).to include_dbus_interface("org.opensuse.Agama.Storage1.Partition") - end - end - - context "when the given device has a partition table" do - let(:scenario) { "partitioned_md.yml" } - - let(:device) { devicegraph.find_by_name("/dev/sda") } - - it "defines the PartitionTable interface" do - expect(subject).to include_dbus_interface("org.opensuse.Agama.Storage1.PartitionTable") - end - end - - context "when the given device has no partition table" do - let(:scenario) { "partitioned_md.yml" } - - let(:device) { devicegraph.find_by_name("/dev/sdb") } - - it "does not define the PartitionTable interface" do - expect(subject) - .to_not include_dbus_interface("org.opensuse.Agama.Storage1.PartitionTable") - end - end - - context "when the device is formatted" do - let(:scenario) { "multipath-formatted.xml" } - - let(:device) { devicegraph.find_by_name("/dev/mapper/0QEMU_QEMU_HARDDISK_mpath1") } - - it "defines the Filesystem interface" do - expect(subject).to include_dbus_interface("org.opensuse.Agama.Storage1.Filesystem") - end - end - - context "when the device is no formatted" do - let(:scenario) { "partitioned_md.yml" } - - let(:device) { devicegraph.find_by_name("/dev/sda") } - - it "does not define the Filesystem interface" do - expect(subject).to_not include_dbus_interface("org.opensuse.Agama.Storage1.Filesystem") - end - end - end - - include_examples "Device interface" - - include_examples "Drive interface" - - include_examples "RAID interface" - - include_examples "Multipath interface" - - include_examples "MD interface" - - include_examples "Block interface" - - include_examples "LVM.VolumeGroup interface" - - include_examples "LVM.LogicalVolume interface" - - include_examples "Partition interface" - - include_examples "PartitionTable interface" - - include_examples "Filesystem interface" - - include_examples "Component interface" - - describe "#storage_device=" do - before do - allow(subject).to receive(:dbus_properties_changed) - end - - let(:scenario) { "partitioned_md.yml" } - let(:device) { devicegraph.find_by_name("/dev/sda") } - - context "if the given device has a different sid" do - let(:new_device) { devicegraph.find_by_name("/dev/sdb") } - - it "raises an error" do - expect { subject.storage_device = new_device } - .to raise_error(RuntimeError, /Cannot update the D-Bus object/) - end - end - - context "if the given device has the same sid" do - let(:new_device) { devicegraph.find_by_name("/dev/sda") } - - it "emits a properties changed signal for each interface" do - subject.interfaces_and_properties.each_key do |interface| - expect(subject).to receive(:dbus_properties_changed).with(interface, anything, anything) - end - - subject.storage_device = new_device - end - end - end -end diff --git a/service/test/agama/dbus/storage/devices_tree_test.rb b/service/test/agama/dbus/storage/devices_tree_test.rb deleted file mode 100644 index a876c2f77d..0000000000 --- a/service/test/agama/dbus/storage/devices_tree_test.rb +++ /dev/null @@ -1,153 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2023-2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require_relative "../../../test_helper" -require_relative "../../storage/storage_helpers" -require "agama/dbus/storage/devices_tree" -require "dbus" -require "y2storage" - -describe Agama::DBus::Storage::DevicesTree do - include Agama::RSpec::StorageHelpers - - RSpec::Matchers.define(:export_object) do |object_path| - match do |service| - expect(service).to receive(:export) do |dbus_object| - expect(dbus_object.path).to eq(object_path) - end - end - - failure_message do |_| - "The object #{object_path} is not exported." - end - - match_when_negated do |service| - expect(service).to receive(:export) do |dbus_object| - expect(dbus_object.path).to_not eq(object_path) - end - end - - failure_message_when_negated do |_| - "The object #{object_path} is exported." - end - end - - RSpec::Matchers.define(:unexport_object) do |object_path| - match do |service| - expect(service).to receive(:unexport) do |dbus_object| - expect(dbus_object.path).to eq(object_path) - end - end - - failure_message do |_| - "The object #{object_path} is not unexported." - end - end - - subject { described_class.new(service, root_path, logger: logger) } - - let(:service) { instance_double(::DBus::ObjectServer) } - - let(:root_path) { "/test/system" } - - let(:logger) { Logger.new($stdout, level: :warn) } - - describe "#path_for" do - let(:device) { instance_double(Y2Storage::Device, sid: 50) } - - it "returns a D-Bus object path" do - expect(subject.path_for(device)).to be_a(::DBus::ObjectPath) - end - - it "uses the device sid as basename" do - expect(subject.path_for(device)).to eq("#{root_path}/50") - end - end - - describe "#update" do - before do - mock_storage(devicegraph: scenario) - - allow(service).to receive(:get_node).with(root_path, anything).and_return(root_node) - # Returning an empty list for the second call to mock the effect of calling to #clear. - allow(root_node).to receive(:descendant_objects).and_return(dbus_objects, []) - - allow(service).to receive(:export) - allow(service).to receive(:unexport) - - allow_any_instance_of(::DBus::Object).to receive(:interfaces_and_properties).and_return({}) - allow_any_instance_of(::DBus::Object).to receive(:dbus_properties_changed) - end - - let(:scenario) { "partitioned_md.yml" } - - let(:root_node) { instance_double(::DBus::Node) } - - let(:devicegraph) { Y2Storage::StorageManager.instance.probed } - - let(:dbus_objects) { [dbus_object1, dbus_object2] } - let(:dbus_object1) { Agama::DBus::Storage::Device.new(sda, subject.path_for(sda), subject) } - let(:dbus_object2) { Agama::DBus::Storage::Device.new(sdb, subject.path_for(sdb), subject) } - let(:sda) { devicegraph.find_by_name("/dev/sda") } - let(:sdb) { devicegraph.find_by_name("/dev/sdb") } - - it "unexports the current D-Bus objects" do - expect(service).to unexport_object("#{root_path}/#{sda.sid}") - expect(service).to unexport_object("#{root_path}/#{sdb.sid}") - - subject.update(devicegraph) - end - - it "exports disk devices and partitions" do - md0 = devicegraph.find_by_name("/dev/md0") - sda1 = devicegraph.find_by_name("/dev/sda1") - sda2 = devicegraph.find_by_name("/dev/sda2") - md0p1 = devicegraph.find_by_name("/dev/md0p1") - - expect(service).to export_object("#{root_path}/#{sda.sid}") - expect(service).to export_object("#{root_path}/#{sdb.sid}") - expect(service).to export_object("#{root_path}/#{md0.sid}") - expect(service).to export_object("#{root_path}/#{sda1.sid}") - expect(service).to export_object("#{root_path}/#{sda2.sid}") - expect(service).to export_object("#{root_path}/#{md0p1.sid}") - expect(service).to_not receive(:export) - - subject.update(devicegraph) - end - - context "if there are LVM volume groups" do - let(:scenario) { "trivial_lvm.yml" } - - let(:dbus_objects) { [] } - - it "exports the LVM volume groups and the logical volumes" do - vg0 = devicegraph.find_by_name("/dev/vg0") - lv1 = devicegraph.find_by_name("/dev/vg0/lv1") - - expect(service).to receive(:export) - expect(service).to export_object("#{root_path}/#{vg0.sid}") - expect(service).to export_object("#{root_path}/#{lv1.sid}") - - subject.update(devicegraph) - end - end - end -end diff --git a/service/test/agama/dbus/storage/manager_test.rb b/service/test/agama/dbus/storage/manager_test.rb index 19d4a119ab..4e46f03755 100644 --- a/service/test/agama/dbus/storage/manager_test.rb +++ b/service/test/agama/dbus/storage/manager_test.rb @@ -22,7 +22,6 @@ require_relative "../../../test_helper" require_relative "../../storage/storage_helpers" require "agama/dbus/storage/manager" -require "agama/dbus/storage/proposal" require "agama/storage/config" require "agama/storage/device_settings" require "agama/storage/manager" @@ -40,6 +39,10 @@ def serialize(value) JSON.pretty_generate(value) end +def parse(string) + JSON.parse(string, symbolize_names: true) +end + describe Agama::DBus::Storage::Manager do include Agama::RSpec::StorageHelpers @@ -67,8 +70,11 @@ def serialize(value) end before do - # Speed up tests by avoding real check of TPM presence. + # Speed up tests by avoiding real check of TPM presence. allow(Y2Storage::EncryptionMethod::TPM_FDE).to receive(:possible?).and_return(true) + # Speed up tests by avoiding looking up by name in the system + allow(Y2Storage::BlkDevice).to receive(:find_by_any_name) + allow(Yast::Arch).to receive(:s390).and_return false allow(backend).to receive(:on_configure) allow(backend).to receive(:on_issues_change) @@ -79,519 +85,377 @@ def serialize(value) mock_storage(devicegraph: "empty-hd-50GiB.yaml") end - describe "#deprecated_system" do - before do - allow(backend).to receive(:deprecated_system?).and_return(deprecated) - end - - context "if the system is set as deprecated" do - let(:deprecated) { true } - - it "returns true" do - expect(subject.deprecated_system).to eq(true) + describe "#recover_proposal" do + context "if no proposal has been successfully calculated" do + before do + allow(proposal).to receive(:success?).and_return false end - end - - context "if the system is not set as deprecated" do - let(:deprecated) { false } - it "returns false" do - expect(subject.deprecated_system).to eq(false) + it "returns 'null'" do + expect(subject.recover_proposal).to eq("null") end end - end - - describe "#read_actions" do - before do - allow(backend).to receive(:actions).and_return(actions) - end - context "if there are no actions" do - let(:actions) { [] } - - it "returns an empty list" do - expect(subject.actions).to eq([]) + context "if a proposal was successfully calculated" do + before do + allow(proposal).to receive(:success?).and_return true end - end - - context "if there are actions" do - let(:actions) { [action1, action2, action3, action4] } - - let(:action1) do - instance_double(Agama::Storage::Action, - text: "test1", - device_sid: 1, - on_btrfs_subvolume?: false, - delete?: false, - resize?: false) - end - - let(:action2) do - instance_double(Agama::Storage::Action, - text: "test2", - device_sid: 2, - on_btrfs_subvolume?: false, - delete?: true, - resize?: false) - end - - let(:action3) do - instance_double(Agama::Storage::Action, - text: "test3", - device_sid: 3, - on_btrfs_subvolume?: false, - delete?: false, - resize?: true) - end - - let(:action4) do - instance_double(Agama::Storage::Action, - text: "test4", - device_sid: 4, - on_btrfs_subvolume?: true, - delete?: false, - resize?: false) - end - - it "returns a list with a hash for each action" do - expect(subject.actions.size).to eq(4) - expect(subject.actions).to all(be_a(Hash)) - - action1, action2, action3, action4 = subject.actions - - expect(action1).to eq({ - "Device" => 1, - "Text" => "test1", - "Subvol" => false, - "Delete" => false, - "Resize" => false - }) - expect(action2).to eq({ - "Device" => 2, - "Text" => "test2", - "Subvol" => false, - "Delete" => true, - "Resize" => false - }) + describe "recover_proposal[:actions]" do + before do + allow(backend).to receive(:actions).and_return(actions) + end - expect(action3).to eq({ - "Device" => 3, - "Text" => "test3", - "Subvol" => false, - "Delete" => false, - "Resize" => true - }) - expect(action4).to eq({ - "Device" => 4, - "Text" => "test4", - "Subvol" => true, - "Delete" => false, - "Resize" => false - }) - end - end - end + context "if there are no actions" do + let(:actions) { [] } - describe "#available_drives" do - before do - allow(proposal.storage_system).to receive(:available_drives).and_return(drives) - end + it "returns an empty list" do + expect(parse(subject.recover_proposal)[:actions]).to eq([]) + end + end - context "if there is no available drives" do - let(:drives) { [] } + context "if there are actions" do + let(:actions) { [action1, action2, action3, action4] } - it "returns an empty list" do - expect(subject.available_drives).to eq([]) - end - end + let(:action1) do + instance_double(Agama::Storage::Action, + text: "test1", + device_sid: 1, + on_btrfs_subvolume?: false, + delete?: false, + resize?: false) + end - context "if there are available drives" do - let(:drives) { [drive1, drive2, drive3] } + let(:action2) do + instance_double(Agama::Storage::Action, + text: "test2", + device_sid: 2, + on_btrfs_subvolume?: false, + delete?: true, + resize?: false) + end - let(:drive1) { instance_double(Y2Storage::Disk, name: "/dev/vda", sid: 95) } - let(:drive2) { instance_double(Y2Storage::Disk, name: "/dev/vdb", sid: 96) } - let(:drive3) { instance_double(Y2Storage::Disk, name: "/dev/vdb", sid: 97) } + let(:action3) do + instance_double(Agama::Storage::Action, + text: "test3", + device_sid: 3, + on_btrfs_subvolume?: false, + delete?: false, + resize?: true) + end - it "retuns the path of each drive" do - result = subject.available_drives + let(:action4) do + instance_double(Agama::Storage::Action, + text: "test4", + device_sid: 4, + on_btrfs_subvolume?: true, + delete?: false, + resize?: false) + end - expect(result).to contain_exactly( - /system\/95/, - /system\/96/, - /system\/97/ - ) + it "returns a list with a hash for each action" do + all_actions = parse(subject.recover_proposal)[:actions] + expect(all_actions.size).to eq(4) + expect(all_actions).to all(be_a(Hash)) + + action1, action2, action3, action4 = all_actions + + expect(action1).to eq({ + device: 1, + text: "test1", + subvol: false, + delete: false, + resize: false + }) + + expect(action2).to eq({ + device: 2, + text: "test2", + subvol: false, + delete: true, + resize: false + }) + + expect(action3).to eq({ + device: 3, + text: "test3", + subvol: false, + delete: false, + resize: true + }) + expect(action4).to eq({ + device: 4, + text: "test4", + subvol: true, + delete: false, + resize: false + }) + end + end end end end - describe "#candidate_drives" do - before do - allow(proposal.storage_system).to receive(:candidate_drives).and_return(drives) - end - - context "if there is no candidate drives" do - let(:drives) { [] } - - it "returns an empty list" do - expect(subject.candidate_drives).to eq([]) + describe "#recover_system" do + context "if the system has not been probed yet" do + before do + allow(Y2Storage::StorageManager.instance).to receive(:probed?).and_return(false) end - end - - context "if there are candidate drives" do - let(:drives) { [drive1, drive2] } - let(:drive1) { instance_double(Y2Storage::Disk, name: "/dev/vda", sid: 95) } - let(:drive2) { instance_double(Y2Storage::Disk, name: "/dev/vdb", sid: 96) } - - it "retuns the path of each drive" do - result = subject.candidate_drives - - expect(result).to contain_exactly( - /system\/95/, - /system\/96/ - ) + it "returns 'null'" do + expect(subject.recover_system).to eq("null") end end - end - describe "#available_md_raids" do before do - allow(proposal.storage_system).to receive(:available_md_raids).and_return(md_raids) + allow(Y2Storage::StorageManager.instance).to receive(:probed?).and_return(true) + allow(proposal.storage_system).to receive(:available_drives).and_return(available_drives) + allow(proposal.storage_system).to receive(:candidate_drives).and_return(candidate_drives) + allow(proposal.storage_system).to receive(:available_md_raids).and_return(available_raids) + allow(proposal.storage_system).to receive(:candidate_md_raids).and_return(candidate_raids) + allow(proposal.storage_system).to receive(:candidate_devices) + .and_return(candidate_drives + candidate_raids) end - context "if there is no available MD RAIDs" do - let(:md_raids) { [] } + let(:available_drives) { [] } + let(:candidate_drives) { [] } + let(:available_raids) { [] } + let(:candidate_raids) { [] } - it "returns an empty list" do - expect(subject.available_md_raids).to eq([]) - end - end + describe "recover_system[:availableDrives]" do + context "if there is no available drives" do + let(:available_drives) { [] } - context "if there are available MD RAIDs" do - let(:md_raids) { [md_raid1, md_raid2, md_raid3] } + it "returns an empty list" do + expect(parse(subject.recover_system)[:availableDrives]).to eq([]) + end + end - let(:md_raid1) { instance_double(Y2Storage::Md, name: "/dev/md0", sid: 100) } - let(:md_raid2) { instance_double(Y2Storage::Md, name: "/dev/md1", sid: 101) } - let(:md_raid3) { instance_double(Y2Storage::Md, name: "/dev/md2", sid: 102) } + context "if there are available drives" do + let(:available_drives) { [drive1, drive2, drive3] } - it "retuns the path of each MD RAID" do - result = subject.available_md_raids + let(:drive1) { instance_double(Y2Storage::Disk, name: "/dev/vda", sid: 95) } + let(:drive2) { instance_double(Y2Storage::Disk, name: "/dev/vdb", sid: 96) } + let(:drive3) { instance_double(Y2Storage::Disk, name: "/dev/vdb", sid: 97) } - expect(result).to contain_exactly( - /system\/100/, - /system\/101/, - /system\/102/ - ) + it "retuns the id of each drive" do + result = parse(subject.recover_system)[:availableDrives] + expect(result).to contain_exactly(95, 96, 97) + end end end - end - - describe "#candidate_md_raids" do - before do - allow(proposal.storage_system).to receive(:candidate_md_raids).and_return(md_raids) - end - context "if there is no candidate MD RAIDs" do - let(:md_raids) { [] } + describe "recover_system[:candidateDrives]" do + context "if there is no candidate drives" do + let(:candidate_drives) { [] } - it "returns an empty list" do - expect(subject.candidate_md_raids).to eq([]) + it "returns an empty list" do + expect(parse(subject.recover_system)[:candidateDrives]).to eq([]) + end end - end - - context "if there are candidate MD RAIDs" do - let(:md_raids) { [md_raid1, md_raid2] } - let(:md_raid1) { instance_double(Y2Storage::Md, name: "/dev/md0", sid: 100) } - let(:md_raid2) { instance_double(Y2Storage::Md, name: "/dev/md1", sid: 101) } + context "if there are candidate drives" do + let(:candidate_drives) { [drive1, drive2] } - it "retuns the path of each MD RAID" do - result = subject.candidate_md_raids + let(:drive1) { instance_double(Y2Storage::Disk, name: "/dev/vda", sid: 95) } + let(:drive2) { instance_double(Y2Storage::Disk, name: "/dev/vdb", sid: 96) } - expect(result).to contain_exactly( - /system\/100/, - /system\/101/ - ) + it "retuns the id of each drive" do + result = parse(subject.recover_system)[:candidateDrives] + expect(result).to contain_exactly(95, 96) + end end end - end - - describe "#product_mount_points" do - let(:config_data) do - { "storage" => { "volumes" => [], "volume_templates" => cfg_templates } } - end - context "with no storage section in the configuration" do - let(:cfg_templates) { [] } + describe "recover_system[:availableMdRaids]" do + context "if there is no available MD RAIDs" do + let(:available_raids) { [] } - it "returns an empty list" do - expect(subject.product_mount_points).to eq([]) + it "returns an empty list" do + expect(parse(subject.recover_system)[:availableMdRaids]).to eq([]) + end end - end - context "with a set of volume templates in the configuration" do - let(:cfg_templates) do - [ - { "mount_path" => "/" }, - { "mount_path" => "swap" }, - { "mount_path" => "/home" }, - { "filesystem" => "ext4" } - ] - end + context "if there are available MD RAIDs" do + let(:available_raids) { [md_raid1, md_raid2, md_raid3] } - it "returns the mount points of each volume template" do - expect(subject.product_mount_points).to contain_exactly("/", "swap", "/home") - end - end - end + let(:md_raid1) { instance_double(Y2Storage::Md, name: "/dev/md0", sid: 100) } + let(:md_raid2) { instance_double(Y2Storage::Md, name: "/dev/md1", sid: 101) } + let(:md_raid3) { instance_double(Y2Storage::Md, name: "/dev/md2", sid: 102) } - describe "#default_volume" do - let(:config_data) do - { "storage" => { "volumes" => [], "volume_templates" => cfg_templates } } + it "returns the id of each MD RAID" do + result = parse(subject.recover_system)[:availableMdRaids] + expect(result).to contain_exactly(100, 101, 102) + end + end end - context "with no storage section in the configuration" do - let(:cfg_templates) { [] } + describe "recover_system[:candidateMdRaids]" do + context "if there is no candidate MD RAIDs" do + let(:candidate_raids) { [] } - it "returns the same generic default volume for any path" do - generic = { - "FsType" => "ext4", "MountOptions" => [], - "MinSize" => 0, "AutoSize" => false - } - generic_outline = { "Required" => false, "FsTypes" => [], "SupportAutoSize" => false } + it "returns an empty list" do + expect(parse(subject.recover_system)[:candidateMdRaids]).to eq([]) + end + end - expect(subject.default_volume("/")).to include(generic) - expect(subject.default_volume("/")["Outline"]).to include(generic_outline) + context "if there are candidate MD RAIDs" do + let(:candidate_raids) { [md_raid1, md_raid2] } - expect(subject.default_volume("swap")).to include(generic) - expect(subject.default_volume("swap")["Outline"]).to include(generic_outline) + let(:md_raid1) { instance_double(Y2Storage::Md, name: "/dev/md0", sid: 100) } + let(:md_raid2) { instance_double(Y2Storage::Md, name: "/dev/md1", sid: 101) } - expect(subject.default_volume("/foo")).to include(generic) - expect(subject.default_volume("/foo")["Outline"]).to include(generic_outline) + it "retuns the path of each MD RAID" do + result = parse(subject.recover_system)[:candidateMdRaids] + expect(result).to contain_exactly(100, 101) + end end end - context "with a set of volume templates in the configuration" do - let(:cfg_templates) do - [ - { - "mount_path" => "/", "filesystem" => "btrfs", "size" => { "auto" => true }, - "outline" => { - "required" => true, - "filesystems" => ["btrfs"], - "auto_size" => { - "base_min" => "5 GiB", "base_max" => "20 GiB", "min_fallback_for" => "/home" - } - } - }, - { - "mount_path" => "swap", "filesystem" => "swap", - "size" => { "auto" => false, "min" => "1 GiB", "max" => "2 GiB" }, - "outline" => { "required" => false, "filesystems" => ["swap"] } - }, - { - "mount_path" => "/home", "filesystem" => "xfs", - "size" => { "auto" => false, "min" => "10 GiB" }, - "outline" => { "required" => false, "filesystems" => ["xfs", "ext2"] } - }, - { - "filesystem" => "ext4", "size" => { "auto" => false, "min" => "10 GiB" }, - "outline" => { "filesystems" => ["ext3", "ext4", "xfs"] } - } - ] - end + describe "recover_system[:issues]" do + context "if there is no candidate drives" do + let(:candidate_drives) { [] } - it "returns the appropriate volume if there is a corresponding template" do - expect(subject.default_volume("/")).to include("FsType" => "btrfs", "AutoSize" => true) - expect(subject.default_volume("/")["Outline"]).to include( - "Required" => true, "FsTypes" => ["btrfs"], - "SupportAutoSize" => true, "SizeRelevantVolumes" => ["/home"] - ) - - expect(subject.default_volume("swap")).to include( - "FsType" => "swap", "AutoSize" => false, "MinSize" => 1024**3, "MaxSize" => 2 * (1024**3) - ) - expect(subject.default_volume("swap")["Outline"]).to include( - "Required" => false, "FsTypes" => ["swap"], "SupportAutoSize" => false - ) + it "contains a issue about the absence of disks" do + result = parse(subject.recover_system)[:issues] + expect(result).to contain_exactly( + a_hash_including(description: /no suitable device for installation/i) + ) + end end - it "returns the default volume for any path without a template" do - default = { "FsType" => "ext4", "AutoSize" => false, "MinSize" => 10 * (1024**3) } - default_outline = { "FsTypes" => ["ext3", "ext4", "xfs"], "SupportAutoSize" => false } + context "if there are candidate drives" do + let(:candidate_drives) { [drive] } - expect(subject.default_volume("/foo")).to include(default) - expect(subject.default_volume("/foo")["Outline"]).to include(default_outline) + let(:drive) { instance_double(Y2Storage::Disk, name: "/dev/vda", sid: 95) } + + it "retuns an empty array" do + result = parse(subject.recover_system)[:issues] + expect(result).to eq [] + end end end - end - - describe "#apply_config" do - let(:serialized_config) { config_json.to_json } - context "if the serialized config contains guided proposal settings" do - let(:config_json) do - { - storage: { - guided: { - target: { - disk: "/dev/vda" - }, - boot: { - device: "/dev/vdb" - }, - encryption: { - password: "notsecret" - }, - volumes: volumes_settings - } - } - } + describe "recover_system[:productMountPoints]" do + let(:config_data) do + { "storage" => { "volumes" => [], "volume_templates" => cfg_templates } } end - let(:volumes_settings) do - [ - { - mount: { - path: "/" - } - }, - { - mount: { - path: "swap" - } - } - ] - end + context "with no storage section in the configuration" do + let(:cfg_templates) { [] } - it "calculates a guided proposal with the given settings" do - expect(proposal).to receive(:calculate_guided) do |settings| - expect(settings).to be_a(Agama::Storage::ProposalSettings) - expect(settings.device).to be_a(Agama::Storage::DeviceSettings::Disk) - expect(settings.device.name).to eq "/dev/vda" - expect(settings.boot.device).to eq "/dev/vdb" - expect(settings.encryption).to be_a(Agama::Storage::EncryptionSettings) - expect(settings.encryption.password).to eq("notsecret") - expect(settings.volumes).to contain_exactly( - an_object_having_attributes(mount_path: "/"), - an_object_having_attributes(mount_path: "swap") - ) + it "contains an empty list" do + expect(parse(subject.recover_system)[:productMountPoints]).to eq([]) end - - subject.apply_config(serialized_config) end - context "when the serialized config omits some settings" do - let(:config_json) do - { - storage: { - guided: {} - } - } + context "with a set of volume templates in the configuration" do + let(:cfg_templates) do + [ + { "mount_path" => "/" }, + { "mount_path" => "swap" }, + { "mount_path" => "/home" }, + { "filesystem" => "ext4" } + ] end - it "calculates a proposal with default values for the missing settings" do - expect(proposal).to receive(:calculate_guided) do |settings| - expect(settings).to be_a(Agama::Storage::ProposalSettings) - expect(settings.device).to be_a(Agama::Storage::DeviceSettings::Disk) - expect(settings.device.name).to be_nil - expect(settings.boot.device).to be_nil - expect(settings.encryption).to be_a(Agama::Storage::EncryptionSettings) - expect(settings.encryption.password).to be_nil - expect(settings.volumes).to eq([]) - end - - subject.apply_config(serialized_config) + it "contains the mount points of each volume template" do + result = parse(subject.recover_system) + expect(result[:productMountPoints]).to contain_exactly("/", "swap", "/home") end end + end - context "when the serialized config includes a volume" do - let(:volumes_settings) { [volume1_settings] } + describe "recover_system[:volumeTemplates]" do + let(:config_data) do + { "storage" => { "volumes" => [], "volume_templates" => cfg_templates } } + end - let(:volume1_settings) do - { - mount: { - path: "/" - }, - size: { - min: 1024, - max: 2048 - }, - filesystem: { - btrfs: { - snapshots: true - } - } - } - end + context "with no storage section in the configuration" do + let(:cfg_templates) { [] } + + it "contains only a generic default template with empty path" do + generic = { fsType: "ext4", mountOptions: [], minSize: 0, autoSize: false } + generic_outline = { required: false, fsTypes: [], supportAutoSize: false } - let(:config_data) do - { "storage" => { "volumes" => [], "volume_templates" => cfg_templates } } + templates = parse(subject.recover_system)[:volumeTemplates] + expect(templates.size).to eq 1 + + expect(templates.first).to include(generic) + expect(templates.first[:outline]).to include(generic_outline) end + end + context "with a set of volume templates in the configuration" do let(:cfg_templates) do [ { - "mount_path" => "/", - "outline" => { - "snapshots_configurable" => true + "mount_path" => "/", "filesystem" => "btrfs", "size" => { "auto" => true }, + "outline" => { + "required" => true, + "filesystems" => ["btrfs"], + "auto_size" => { + "base_min" => "5 GiB", "base_max" => "20 GiB", "min_fallback_for" => "/home" + } } + }, + { + "mount_path" => "swap", "filesystem" => "swap", + "size" => { "auto" => false, "min" => "1 GiB", "max" => "2 GiB" }, + "outline" => { "required" => false, "filesystems" => ["swap"] } + }, + { + "mount_path" => "/home", "filesystem" => "xfs", + "size" => { "auto" => false, "min" => "10 GiB" }, + "outline" => { "required" => false, "filesystems" => ["xfs", "ext2"] } + }, + { + "filesystem" => "ext4", "size" => { "auto" => false, "min" => "10 GiB" }, + "outline" => { "filesystems" => ["ext3", "ext4", "xfs"] } } ] end - it "calculates a proposal with the given volume settings" do - expect(proposal).to receive(:calculate_guided) do |settings| - volume = settings.volumes.first + it "contains a template for every relevant mount path" do + templates = parse(subject.recover_system)[:volumeTemplates] - expect(volume.mount_path).to eq("/") - expect(volume.auto_size).to eq(false) - expect(volume.min_size.to_i).to eq(1024) - expect(volume.max_size.to_i).to eq(2048) - expect(volume.btrfs.snapshots).to eq(true) - end + root = templates.find { |v| v[:mountPath] == "/" } + expect(root).to include(fsType: "btrfs", autoSize: true) + expect(root[:outline]).to include( + required: true, fsTypes: ["btrfs"], + supportAutoSize: true, sizeRelevantVolumes: ["/home"] + ) - subject.apply_config(serialized_config) + swap = templates.find { |v| v[:mountPath] == "swap" } + expect(swap).to include( + fsType: "swap", autoSize: false, minSize: 1024**3, maxSize: 2 * (1024**3) + ) + expect(swap[:outline]).to include( + required: false, fsTypes: ["swap"], supportAutoSize: false + ) end - context "and the volume settings omits some values" do - let(:volume1_settings) do - { - mount: { - path: "/" - } - } - end - - let(:cfg_templates) do - [ - { - "mount_path" => "/", "filesystem" => "btrfs", - "size" => { "auto" => false, "min" => "5 GiB", "max" => "20 GiB" }, - "outline" => { - "filesystems" => ["btrfs"] - } - } - ] - end - - it "calculates a proposal with default settings for the missing volume settings" do - expect(proposal).to receive(:calculate_guided) do |settings| - volume = settings.volumes.first + it "constains the expected default template" do + default = { fsType: "ext4", autoSize: false, minSize: 10 * (1024**3) } + default_outline = { fsTypes: ["ext3", "ext4", "xfs"], supportAutoSize: false } - expect(volume.mount_path).to eq("/") - expect(volume.auto_size).to eq(false) - expect(volume.min_size.to_i).to eq(5 * (1024**3)) - expect(volume.max_size.to_i).to eq(20 * (1024**3)) - expect(volume.btrfs.snapshots).to eq(false) - end - - subject.apply_config(serialized_config) - end + templates = parse(subject.recover_system)[:volumeTemplates] + template = templates.find { |v| v[:mountPath] == "" } + expect(template).to include(default) + expect(template[:outline]).to include(default_outline) end end end + end + + describe "#configure" do + before do + allow(subject).to receive(:ProposalChanged) + allow(subject).to receive(:ProgressChanged) + allow(subject).to receive(:ProgressFinished) + end + + let(:serialized_config) { config_json.to_json } context "if the serialized config contains storage settings" do let(:config_json) do @@ -626,7 +490,17 @@ def serialize(value) expect(partition.filesystem.path).to eq("/") end - subject.apply_config(serialized_config) + subject.configure(serialized_config) + end + + it "emits signals for ProposalChanged, ProgressChanged and ProgressFinished" do + allow(proposal).to receive(:calculate_agama) + + expect(subject).to receive(:ProposalChanged) + expect(subject).to receive(:ProgressChanged).with(/storage configuration/i) + expect(subject).to receive(:ProgressFinished) + + subject.configure(serialized_config) end end @@ -644,12 +518,28 @@ def serialize(value) expect(settings).to eq(config_json[:legacyAutoyastStorage]) end - subject.apply_config(serialized_config) + subject.configure(serialized_config) + end + + it "emits signals for ProposalChanged, ProgressChanged and ProgressFinished" do + allow(proposal).to receive(:calculate_autoyast) + + expect(subject).to receive(:ProposalChanged) + expect(subject).to receive(:ProgressChanged).with(/storage configuration/i) + expect(subject).to receive(:ProgressFinished) + + subject.configure(serialized_config) end end end - describe "#apply_config_model" do + describe "#configure_with_model" do + before do + allow(subject).to receive(:ProposalChanged) + allow(subject).to receive(:ProgressChanged) + allow(subject).to receive(:ProgressFinished) + end + let(:serialized_model) { model_json.to_json } let(:model_json) do @@ -676,7 +566,17 @@ def serialize(value) expect(partition.filesystem.path).to eq("/") end - subject.apply_config_model(serialized_model) + subject.configure_with_model(serialized_model) + end + + it "emits signals for ProposalChanged, ProgressChanged and ProgressFinished" do + allow(proposal).to receive(:calculate_agama) + + expect(subject).to receive(:ProposalChanged) + expect(subject).to receive(:ProgressChanged).with(/storage configuration/i) + expect(subject).to receive(:ProgressFinished) + + subject.configure_with_model(serialized_model) end end @@ -687,43 +587,6 @@ def serialize(value) end end - context "if a guided proposal has been calculated" do - before do - proposal.calculate_from_json(settings_json) - end - - let(:settings_json) do - { - storage: { - guided: { - target: { disk: "/dev/vda" } - } - } - } - end - - it "returns serialized solved guided storage config" do - expect(subject.recover_config).to eq( - serialize({ - storage: { - guided: { - target: { - disk: "/dev/vda" - }, - boot: { - configure: true - }, - space: { - policy: "keep" - }, - volumes: [] - } - } - }) - ) - end - end - context "if an agama proposal has been calculated" do before do proposal.calculate_from_json(config_json) @@ -769,30 +632,10 @@ def serialize(value) end end - describe "#recover_model" do + describe "#recover_config_model" do context "if a proposal has not been calculated" do it "returns 'null'" do - expect(subject.recover_model).to eq("null") - end - end - - context "if a guided proposal has been calculated" do - before do - proposal.calculate_from_json(settings_json) - end - - let(:settings_json) do - { - storage: { - guided: { - target: { disk: "/dev/vda" } - } - } - } - end - - it "returns 'null'" do - expect(subject.recover_model).to eq("null") + expect(subject.recover_config_model).to eq("null") end end @@ -819,7 +662,7 @@ def serialize(value) end it "returns the serialized config model" do - expect(subject.recover_model).to eq( + expect(subject.recover_config_model).to eq( serialize({ boot: { configure: true, @@ -873,12 +716,12 @@ def serialize(value) end it "returns 'null'" do - expect(subject.recover_model).to eq("null") + expect(subject.recover_config_model).to eq("null") end end end - describe "#solve_model" do + describe "#solve_config_model" do let(:model) do { drives: [ @@ -893,7 +736,7 @@ def serialize(value) end it "returns the serialized solved model" do - result = subject.solve_model(model.to_json) + result = subject.solve_config_model(model.to_json) expect(result).to eq( serialize({ @@ -940,12 +783,180 @@ def serialize(value) end it "returns 'null'" do - result = subject.solve_model(model.to_json) + result = subject.solve_config_model(model.to_json) expect(result).to eq("null") end end end + describe "#probe" do + before do + allow(subject).to receive(:SystemChanged) + allow(subject).to receive(:ProgressChanged) + allow(subject).to receive(:ProgressFinished) + + allow(backend).to receive(:activated?).and_return activated + allow(backend).to receive(:probe) + end + + let(:activated) { true } + + it "triggers a new probing" do + expect(backend).to receive(:probe) + subject.probe + end + + context "when storage devices are already activated" do + it "does not activate devices" do + expect(backend).to_not receive(:activate) + subject.probe + end + end + + context "when storage devices are not yet activated" do + let(:activated) { false } + + it "activates the devices" do + expect(backend).to receive(:activate) + subject.probe + end + end + + context "when no storage configuration has been set" do + it "does not calculate a new proposal" do + expect(backend).to_not receive(:configure) + subject.probe + end + + it "does not emit a ProposalChanged signal" do + expect(subject).to_not receive(:ProposalChanged) + subject.probe + end + + it "emits signals for SystemChanged, ProgressChanged and ProgressFinished" do + expect(subject).to receive(:SystemChanged) do |system_str| + system = parse(system_str) + device = system[:devices].first + expect(device[:name]).to eq "/dev/sda" + expect(system[:availableDrives]).to eq [device[:sid]] + end + expect(subject).to receive(:ProgressChanged).with(/storage configuration/i) + expect(subject).to receive(:ProgressFinished) + + subject.probe + end + end + + context "when a storage configuration was previously set" do + before do + allow(proposal).to receive(:storage_json).and_return config_json.to_json + allow(subject).to receive(:ProposalChanged) + end + + let(:config_json) do + { + storage: { + drives: [ + { + partitions: [ + { search: "*", delete: true }, + { filesystem: { path: "/" }, size: { min: "5 GiB" } } + ] + } + ] + } + } + end + + it "re-calculates the proposal" do + expect(backend).to receive(:configure).with(config_json) + subject.probe + end + + it "emits signals for ProposalChanged, SystemChanged, ProgressChanged and ProgressFinished" do + expect(subject).to receive(:SystemChanged) do |system_str| + system = parse(system_str) + device = system[:devices].first + expect(device[:name]).to eq "/dev/sda" + expect(system[:availableDrives]).to eq [device[:sid]] + end + expect(subject).to receive(:ProposalChanged) do |proposal_str| + proposal = parse(proposal_str) + expect(proposal[:devices]).to be_a Array + expect(proposal[:actions]).to be_a Array + end + expect(subject).to receive(:ProgressChanged).with(/storage configuration/i) + expect(subject).to receive(:ProgressFinished) + + subject.probe + end + end + end + + describe "#recover_issues" do + context "if no proposal has been calculated" do + it "returns an empty array" do + expect(subject.recover_issues).to eq "[]" + end + end + + context "if an agama proposal has been succesfully calculated" do + before do + backend.configure(config_json) + end + + let(:config_json) do + { + storage: { + drives: [ + { + partitions: [ + { size: "10 GiB", filesystem: { path: "/" } } + ] + } + ] + } + } + end + + it "returns an empty array" do + expect(subject.recover_issues).to eq "[]" + end + end + + context "if an agama proposal failed to be calculated" do + before do + backend.configure(config_json) + end + + let(:config_json) do + { + storage: { + drives: [ + { + partitions: [ + { size: "60 TiB", filesystem: { path: "/home" } } + ] + } + ] + } + } + end + + it "returns the list of proposal issues" do + result = parse(subject.recover_issues) + expect(result).to include( + a_hash_including( + description: /cannot calculate a valid storage setup/i, severity: "error" + ), + a_hash_including( + description: /boot device cannot be automatically/i, severity: "error" + ) + ) + end + end + end + describe "#iscsi_discover" do it "performs an iSCSI discovery" do expect(iscsi).to receive(:discover).with("192.168.100.90", 3260, anything) diff --git a/service/test/agama/dbus/storage/proposal_settings_conversion/from_dbus_test.rb b/service/test/agama/dbus/storage/proposal_settings_conversion/from_dbus_test.rb deleted file mode 100644 index b65221e7c7..0000000000 --- a/service/test/agama/dbus/storage/proposal_settings_conversion/from_dbus_test.rb +++ /dev/null @@ -1,280 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2023-2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require_relative "../../../../test_helper" -require "agama/dbus/storage/proposal_settings_conversion/from_dbus" -require "agama/config" -require "agama/storage/device_settings" -require "agama/storage/proposal_settings" -require "y2storage/encryption_method" -require "y2storage/pbkd_function" - -describe Agama::DBus::Storage::ProposalSettingsConversion::FromDBus do - subject { described_class.new(dbus_settings, config: config, logger: logger) } - - let(:config) { Agama::Config.new(config_data) } - - let(:config_data) do - { - "storage" => { - "lvm" => false, - "space_policy" => "delete", - "encryption" => { - "method" => "luks2", - "pbkd_function" => "argon2id" - }, - "volumes" => ["/", "swap"], - "volume_templates" => [ - { - "mount_path" => "/", - "outline" => { "required" => true } - }, - { - "mount_path" => "/home", - "outline" => { "required" => false } - }, - { - "mount_path" => "swap", - "outline" => { "required" => false } - } - ] - } - } - end - - let(:logger) { Logger.new($stdout, level: :warn) } - - before do - allow(Agama::Storage::EncryptionSettings) - .to receive(:available_methods).and_return( - [ - Y2Storage::EncryptionMethod::LUKS1, - Y2Storage::EncryptionMethod::LUKS2 - ] - ) - end - - describe "#convert" do - let(:dbus_settings) do - { - "Target" => "disk", - "TargetDevice" => "/dev/sda", - "ConfigureBoot" => true, - "BootDevice" => "/dev/sdb", - "EncryptionPassword" => "notsecret", - "EncryptionMethod" => "luks1", - "EncryptionPBKDFunction" => "pbkdf2", - "SpacePolicy" => "custom", - "SpaceActions" => [ - { - "Device" => "/dev/sda", - "Action" => "force_delete" - }, - { - "Device" => "/dev/sdb1", - "Action" => "resize" - } - ], - "Volumes" => [ - { "MountPath" => "/" }, - { "MountPath" => "/test" } - ] - } - end - - it "generates proposal settings with the values provided from D-Bus" do - settings = subject.convert - - expect(settings).to be_a(Agama::Storage::ProposalSettings) - expect(settings.device).to be_a(Agama::Storage::DeviceSettings::Disk) - expect(settings.device.name).to eq("/dev/sda") - expect(settings.boot.configure?).to eq(true) - expect(settings.boot.device).to eq("/dev/sdb") - expect(settings.encryption.method).to eq(Y2Storage::EncryptionMethod::LUKS1) - expect(settings.encryption.pbkd_function).to eq(Y2Storage::PbkdFunction::PBKDF2) - expect(settings.space.policy).to eq(:custom) - expect(settings.space.actions).to eq({ - "/dev/sda" => :force_delete, "/dev/sdb1" => :resize - }) - expect(settings.volumes).to contain_exactly( - an_object_having_attributes(mount_path: "/"), - an_object_having_attributes(mount_path: "/test") - ) - end - - context "when some values are not provided from D-Bus" do - let(:dbus_settings) { {} } - - it "completes missing values with default values from config" do - settings = subject.convert - - expect(settings).to be_a(Agama::Storage::ProposalSettings) - expect(settings.device).to be_a(Agama::Storage::DeviceSettings::Disk) - expect(settings.device.name).to be_nil - expect(settings.boot.configure?).to eq(true) - expect(settings.boot.device).to be_nil - expect(settings.encryption.method).to eq(Y2Storage::EncryptionMethod::LUKS2) - expect(settings.encryption.pbkd_function).to eq(Y2Storage::PbkdFunction::ARGON2ID) - expect(settings.space.policy).to eq(:delete) - expect(settings.volumes).to contain_exactly( - an_object_having_attributes(mount_path: "/"), - an_object_having_attributes(mount_path: "swap") - ) - end - end - - context "when 'Target' is not provided from D-Bus" do - let(:dbus_settings) { {} } - - it "sets device settings to create partitions" do - settings = subject.convert - - expect(settings.device).to be_a(Agama::Storage::DeviceSettings::Disk) - expect(settings.device.name).to be_nil - end - end - - context "when 'Target' has 'disk' value" do - let(:dbus_settings) do - { - "Target" => "disk", - "TargetDevice" => "/dev/vda" - } - end - - it "sets device settings to create partitions in the indicated device" do - settings = subject.convert - - expect(settings.device).to be_a(Agama::Storage::DeviceSettings::Disk) - expect(settings.device.name).to eq("/dev/vda") - end - end - - context "when 'Target' has 'newLvmVg' value" do - let(:dbus_settings) do - { - "Target" => "newLvmVg", - "TargetPVDevices" => ["/dev/vda", "/dev/vdb"] - } - end - - it "sets device settings to create a new LVM volume group in the indicated devices" do - settings = subject.convert - - expect(settings.device).to be_a(Agama::Storage::DeviceSettings::NewLvmVg) - expect(settings.device.candidate_pv_devices).to contain_exactly("/dev/vda", "/dev/vdb") - end - end - - context "when 'Target' has 'reusedLvmVg' value" do - let(:dbus_settings) do - { - "Target" => "reusedLvmVg", - "TargetDevice" => "/dev/vg0" - } - end - - it "sets device settings to reuse the indicated LVM volume group" do - settings = subject.convert - - expect(settings.device).to be_a(Agama::Storage::DeviceSettings::ReusedLvmVg) - expect(settings.device.name).to eq("/dev/vg0") - end - end - - context "when some value provided from D-Bus has unexpected type" do - let(:dbus_settings) { { "BootDevice" => 1 } } - - it "ignores the value" do - settings = subject.convert - - expect(settings.boot.device).to be_nil - end - end - - context "when some unexpected setting is provided from D-Bus" do - let(:dbus_settings) { { "Foo" => 1 } } - - it "does not fail" do - settings = subject.convert - - expect(settings).to be_a(Agama::Storage::ProposalSettings) - end - end - - context "when volumes are not provided from D-Bus" do - let(:dbus_settings) { { "Volumes" => [] } } - - it "completes the volumes with the default volumes from config" do - settings = subject.convert - expect(settings.volumes).to contain_exactly( - an_object_having_attributes(mount_path: "/"), - an_object_having_attributes(mount_path: "swap") - ) - end - - it "ignores templates of non-default volumes" do - settings = subject.convert - expect(settings.volumes).to_not include( - an_object_having_attributes(mount_path: "/home") - ) - end - end - - context "when a mandatory volume is not provided from D-Bus" do - let(:dbus_settings) do - { - "Volumes" => [ - { "MountPath" => "/test" } - ] - } - end - - it "completes the volumes with the mandatory volumes" do - settings = subject.convert - expect(settings.volumes).to include( - an_object_having_attributes(mount_path: "/") - ) - end - - it "includes the volumes provided from D-Bus" do - settings = subject.convert - expect(settings.volumes).to include( - an_object_having_attributes(mount_path: "/test") - ) - end - - it "ignores default volumes that are not mandatory" do - settings = subject.convert - expect(settings.volumes).to_not include( - an_object_having_attributes(mount_path: "swap") - ) - end - - it "ignores templates for excluded volumes" do - settings = subject.convert - expect(settings.volumes).to_not include( - an_object_having_attributes(mount_path: "/home") - ) - end - end - end -end diff --git a/service/test/agama/dbus/storage/proposal_settings_conversion/to_dbus_test.rb b/service/test/agama/dbus/storage/proposal_settings_conversion/to_dbus_test.rb deleted file mode 100644 index 76700c39c5..0000000000 --- a/service/test/agama/dbus/storage/proposal_settings_conversion/to_dbus_test.rb +++ /dev/null @@ -1,164 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require_relative "../../../../test_helper" -require "agama/dbus/storage/proposal_settings_conversion/to_dbus" -require "agama/storage/device_settings" -require "agama/storage/proposal_settings" -require "agama/storage/volume" -require "y2storage/encryption_method" -require "y2storage/pbkd_function" - -describe Agama::DBus::Storage::ProposalSettingsConversion::ToDBus do - let(:default_settings) { Agama::Storage::ProposalSettings.new } - - let(:custom_settings) do - Agama::Storage::ProposalSettings.new.tap do |settings| - settings.device.name = "/dev/sda" - settings.boot.device = "/dev/sdb" - settings.encryption.password = "notsecret" - settings.encryption.method = Y2Storage::EncryptionMethod::LUKS2 - settings.encryption.pbkd_function = Y2Storage::PbkdFunction::ARGON2ID - settings.space.policy = :custom - settings.space.actions = { "/dev/sda" => :force_delete, "/dev/sdb1" => "resize" } - settings.volumes = [Agama::Storage::Volume.new("/test")] - end - end - - describe "#convert" do - it "converts the settings to a D-Bus hash" do - expect(described_class.new(default_settings).convert).to eq( - "Target" => "disk", - "TargetDevice" => "", - "ConfigureBoot" => true, - "BootDevice" => "", - "DefaultBootDevice" => "", - "EncryptionPassword" => "", - "EncryptionMethod" => "luks2", - "EncryptionPBKDFunction" => "pbkdf2", - "SpacePolicy" => "keep", - "SpaceActions" => [], - "Volumes" => [] - ) - - expect(described_class.new(custom_settings).convert).to eq( - "Target" => "disk", - "TargetDevice" => "/dev/sda", - "ConfigureBoot" => true, - "BootDevice" => "/dev/sdb", - "DefaultBootDevice" => "/dev/sda", - "EncryptionPassword" => "notsecret", - "EncryptionMethod" => "luks2", - "EncryptionPBKDFunction" => "argon2id", - "SpacePolicy" => "custom", - "SpaceActions" => [ - { - "Device" => "/dev/sda", - "Action" => "force_delete" - }, - { - "Device" => "/dev/sdb1", - "Action" => "resize" - } - ], - "Volumes" => [ - { - "MountPath" => "/test", - "MountOptions" => [], - "TargetDevice" => "", - "Target" => "default", - "FsType" => "", - "MinSize" => 0, - "AutoSize" => false, - "Snapshots" => false, - "Transactional" => false, - "Outline" => { - "Required" => false, - "FsTypes" => [], - "SupportAutoSize" => false, - "SnapshotsConfigurable" => false, - "SnapshotsAffectSizes" => false, - "AdjustByRam" => false, - "SizeRelevantVolumes" => [] - } - } - ] - ) - end - - context "when the device is set to create partitions" do - let(:settings) do - Agama::Storage::ProposalSettings.new.tap do |settings| - settings.device = Agama::Storage::DeviceSettings::Disk.new("/dev/vda") - end - end - - it "generates settings to use a disk as target device" do - dbus_settings = described_class.new(settings).convert - - expect(dbus_settings).to include( - "Target" => "disk", - "TargetDevice" => "/dev/vda" - ) - - expect(dbus_settings).to_not include("TargetPVDevices") - end - end - - context "when the device is set to create a new LVM volume group" do - let(:settings) do - Agama::Storage::ProposalSettings.new.tap do |settings| - settings.device = Agama::Storage::DeviceSettings::NewLvmVg.new(["/dev/vda"]) - end - end - - it "generates settings to create a LVM volume group as target device" do - dbus_settings = described_class.new(settings).convert - - expect(dbus_settings).to include( - "Target" => "newLvmVg", - "TargetPVDevices" => ["/dev/vda"] - ) - - expect(dbus_settings).to_not include("TargetDevice") - end - end - - context "when the device is set to reuse a LVM volume group" do - let(:settings) do - Agama::Storage::ProposalSettings.new.tap do |settings| - settings.device = Agama::Storage::DeviceSettings::ReusedLvmVg.new("/dev/vg0") - end - end - - it "generates settings to reuse a LVM volume group as target device" do - dbus_settings = described_class.new(settings).convert - - expect(dbus_settings).to include( - "Target" => "reusedLvmVg", - "TargetDevice" => "/dev/vg0" - ) - - expect(dbus_settings).to_not include("TargetPVDevices") - end - end - end -end diff --git a/service/test/agama/dbus/storage/proposal_settings_conversion_test.rb b/service/test/agama/dbus/storage/proposal_settings_conversion_test.rb deleted file mode 100644 index 236e98db35..0000000000 --- a/service/test/agama/dbus/storage/proposal_settings_conversion_test.rb +++ /dev/null @@ -1,49 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2023-2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require_relative "../../../test_helper" -require "agama/config" -require "agama/storage/proposal_settings" -require "agama/dbus/storage/proposal_settings_conversion" - -describe Agama::DBus::Storage::ProposalSettingsConversion do - describe "#from_dbus" do - let(:config) { Agama::Config.new } - - let(:dbus_settings) { {} } - - let(:logger) { Logger.new($stdout, level: :warn) } - - it "generates proposal settings from D-Bus settings" do - result = described_class.from_dbus(dbus_settings, config: config, logger: logger) - expect(result).to be_a(Agama::Storage::ProposalSettings) - end - end - - describe "#to_dbus" do - let(:proposal_settings) { Agama::Storage::ProposalSettings.new } - - it "generates D-Bus settings from proposal settings" do - result = described_class.to_dbus(proposal_settings) - expect(result).to be_a(Hash) - end - end -end diff --git a/service/test/agama/dbus/storage/proposal_test.rb b/service/test/agama/dbus/storage/proposal_test.rb deleted file mode 100644 index 49237c240c..0000000000 --- a/service/test/agama/dbus/storage/proposal_test.rb +++ /dev/null @@ -1,193 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2022-2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require_relative "../../../test_helper" -require "agama/dbus/storage/proposal" -require "agama/storage/action" -require "agama/storage/device_settings" -require "agama/storage/proposal" -require "agama/storage/proposal_settings" -require "agama/storage/volume" -require "y2storage" - -describe Agama::DBus::Storage::Proposal do - subject { described_class.new(backend, logger) } - - let(:backend) do - instance_double(Agama::Storage::Proposal, guided_settings: settings, guided?: guided) - end - - let(:logger) { Logger.new($stdout, level: :warn) } - - let(:settings) { nil } - - let(:guided) { false } - - describe "#settings" do - context "if a guided proposal has not been calculated yet" do - let(:guided) { false } - - it "returns an empty hash" do - expect(subject.settings).to eq({}) - end - end - - context "if a guided proposal has been calculated" do - let(:guided) { true } - - let(:settings) do - Agama::Storage::ProposalSettings.new.tap do |settings| - settings.device = Agama::Storage::DeviceSettings::Disk.new("/dev/vda") - settings.boot.device = "/dev/vdb" - settings.encryption.password = "n0ts3cr3t" - settings.encryption.method = luks2 - settings.encryption.pbkd_function = argon2id - settings.space.policy = :custom - settings.space.actions = { - "/dev/vda1" => :force_delete, - "/dev/vda2" => :resize - } - settings.volumes = [ - Agama::Storage::Volume.new("/test1"), - Agama::Storage::Volume.new("/test2") - ] - end - end - - let(:luks2) { Y2Storage::EncryptionMethod::LUKS2 } - - let(:argon2id) { Y2Storage::PbkdFunction::ARGON2ID } - - it "returns the proposal settings in D-Bus format" do - expect(subject.settings).to include( - "Target" => "disk", - "TargetDevice" => "/dev/vda", - "ConfigureBoot" => true, - "BootDevice" => "/dev/vdb", - "EncryptionPassword" => "n0ts3cr3t", - "EncryptionMethod" => luks2.id.to_s, - "EncryptionPBKDFunction" => argon2id.value, - "SpacePolicy" => "custom", - "SpaceActions" => [ - { "Device" => "/dev/vda1", "Action" => "force_delete" }, - { "Device" => "/dev/vda2", "Action" => "resize" } - ], - "Volumes" => [ - include("MountPath" => "/test1"), - include("MountPath" => "/test2") - ] - ) - end - end - end - - describe "#actions" do - before do - allow(backend).to receive(:actions).and_return(actions) - end - - context "if there are no actions" do - let(:actions) { [] } - - it "returns an empty list" do - expect(subject.actions).to eq([]) - end - end - - context "if there are actions" do - let(:actions) { [action1, action2, action3, action4] } - - let(:action1) do - instance_double(Agama::Storage::Action, - text: "test1", - device_sid: 1, - on_btrfs_subvolume?: false, - delete?: false, - resize?: false) - end - - let(:action2) do - instance_double(Agama::Storage::Action, - text: "test2", - device_sid: 2, - on_btrfs_subvolume?: false, - delete?: true, - resize?: false) - end - - let(:action3) do - instance_double(Agama::Storage::Action, - text: "test3", - device_sid: 3, - on_btrfs_subvolume?: false, - delete?: false, - resize?: true) - end - - let(:action4) do - instance_double(Agama::Storage::Action, - text: "test4", - device_sid: 4, - on_btrfs_subvolume?: true, - delete?: false, - resize?: false) - end - - it "returns a list with a hash for each action" do - expect(subject.actions.size).to eq(4) - expect(subject.actions).to all(be_a(Hash)) - - action1, action2, action3, action4 = subject.actions - - expect(action1).to eq({ - "Device" => 1, - "Text" => "test1", - "Subvol" => false, - "Delete" => false, - "Resize" => false - }) - - expect(action2).to eq({ - "Device" => 2, - "Text" => "test2", - "Subvol" => false, - "Delete" => true, - "Resize" => false - }) - - expect(action3).to eq({ - "Device" => 3, - "Text" => "test3", - "Subvol" => false, - "Delete" => false, - "Resize" => true - }) - expect(action4).to eq({ - "Device" => 4, - "Text" => "test4", - "Subvol" => true, - "Delete" => false, - "Resize" => false - }) - end - end - end -end diff --git a/service/test/agama/dbus/storage/volume_conversion/from_dbus_test.rb b/service/test/agama/dbus/storage/volume_conversion/from_dbus_test.rb deleted file mode 100644 index a5e584a1ef..0000000000 --- a/service/test/agama/dbus/storage/volume_conversion/from_dbus_test.rb +++ /dev/null @@ -1,264 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2023-2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require_relative "../../../../test_helper" -require_relative "../../../rspec/matchers/storage" -require "agama/config" -require "agama/storage/volume" -require "agama/storage/volume_templates_builder" -require "agama/dbus/storage/volume_conversion/from_dbus" - -describe Agama::DBus::Storage::VolumeConversion::FromDBus do - subject { described_class.new(dbus_volume, config: config) } - - let(:config) { Agama::Config.new(config_data) } - - let(:config_data) do - { - "storage" => { - "volume_templates" => [ - { - "mount_path" => "/test", - "mount_options" => ["data=ordered"], - "filesystem" => "btrfs", - "size" => { - "auto" => false, - "min" => "5 GiB", - "max" => "10 GiB" - }, - "btrfs" => { - "snapshots" => false - }, - "outline" => outline - } - ] - } - } - end - - let(:outline) do - { - "filesystems" => ["xfs", "ext3", "ext4"], - "snapshots_configurable" => true - } - end - - let(:logger) { Logger.new($stdout, level: :warn) } - - describe "#convert" do - let(:dbus_volume) do - { - "MountPath" => "/test", - "MountOptions" => ["rw", "default"], - "TargetDevice" => "/dev/sda", - "Target" => "new_vg", - "FsType" => "Ext4", - "MinSize" => 1024, - "MaxSize" => 2048, - "AutoSize" => false, - "Snapshots" => true - } - end - - it "generates a volume with the expected outline from the config" do - volume = subject.convert - default_volume = Agama::Storage::VolumeTemplatesBuilder.new_from_config(config).for("/test") - - expect(volume.outline).to eq_outline(default_volume.outline) - end - - it "generates a volume with the values provided from D-Bus" do - volume = subject.convert - - expect(volume).to be_a(Agama::Storage::Volume) - expect(volume.mount_path).to eq("/test") - expect(volume.mount_options).to contain_exactly("rw", "default") - expect(volume.location.device).to eq("/dev/sda") - expect(volume.location.target).to eq(:new_vg) - expect(volume.fs_type).to eq(Y2Storage::Filesystems::Type::EXT4) - expect(volume.auto_size?).to eq(false) - expect(volume.min_size.to_i).to eq(1024) - expect(volume.max_size.to_i).to eq(2048) - expect(volume.btrfs.snapshots).to eq(true) - end - - context "when some values are not provided from D-Bus" do - let(:dbus_volume) { { "MountPath" => "/test" } } - - it "completes missing values with default values from the config" do - volume = subject.convert - - expect(volume).to be_a(Agama::Storage::Volume) - expect(volume.mount_path).to eq("/test") - expect(volume.mount_options).to contain_exactly("data=ordered") - expect(volume.location.target).to eq :default - expect(volume.fs_type).to eq(Y2Storage::Filesystems::Type::BTRFS) - expect(volume.auto_size?).to eq(false) - expect(volume.min_size.to_i).to eq(5 * (1024**3)) - # missing maximum value means unlimited size - expect(volume.max_size.to_i).to eq(-1) - expect(volume.btrfs.snapshots?).to eq(false) - end - end - - context "when the D-Bus settings include changes in the volume outline" do - let(:outline) { { "required" => true } } - - let(:dbus_volume) do - { - "MountPath" => "/test", - "Outline" => { - "Required" => false - } - } - end - - it "ignores the outline values provided from D-Bus" do - volume = subject.convert - - expect(volume.outline.required?).to eq(true) - end - end - - context "when the D-Bus settings provide AutoSize value for a supported volume" do - let(:outline) do - { - "auto_size" => { - "min_fallback_for" => ["/"] - } - } - end - - let(:dbus_volume) do - { - "MountPath" => "/test", - "AutoSize" => true - } - end - - it "sets the AutoSize value provided from D-Bus" do - volume = subject.convert - - expect(volume.auto_size?).to eq(true) - end - end - - context "when the D-Bus settings provide AutoSize for an unsupported volume" do - let(:outline) { {} } - - let(:dbus_volume) do - { - "MountPath" => "/test", - "AutoSize" => true - } - end - - it "ignores the AutoSize value provided from D-Bus" do - volume = subject.convert - - expect(volume.auto_size?).to eq(false) - end - end - - context "when the D-Bus settings provide a FsType listed in the outline" do - let(:outline) { { "filesystems" => ["btrfs", "ext4"] } } - - let(:dbus_volume) do - { - "MountPath" => "/test", - "FsType" => "ext4" - } - end - - it "sets the FsType value provided from D-Bus" do - volume = subject.convert - - expect(volume.fs_type).to eq(Y2Storage::Filesystems::Type::EXT4) - end - end - - context "when the D-Bus settings provide a FsType not listed in the outline" do - let(:outline) { { "filesystems" => ["btrfs"] } } - - let(:dbus_volume) do - { - "MountPath" => "/test", - "FsType" => "ext4" - } - end - - it "ignores the FsType value provided from D-Bus" do - volume = subject.convert - - expect(volume.fs_type).to eq(Y2Storage::Filesystems::Type::BTRFS) - end - end - - context "when the D-Bus settings provide Snapshots for a supported volume" do - let(:outline) { { "snapshots_configurable" => true } } - - let(:dbus_volume) do - { - "MountPath" => "/test", - "Snapshots" => true - } - end - - it "sets the Snapshots value provided from D-Bus" do - volume = subject.convert - - expect(volume.btrfs.snapshots?).to eq(true) - end - end - - context "when the D-Bus settings provide Snapshots for an unsupported volume" do - let(:outline) { { "snapshots_configurable" => false } } - - let(:dbus_volume) do - { - "MountPath" => "/test", - "Snapshots" => true - } - end - - it "ignores the Snapshots value provided from D-Bus" do - volume = subject.convert - - expect(volume.btrfs.snapshots?).to eq(false) - end - end - - context "when the D-Bus settings provide a Target that makes no sense" do - let(:dbus_volume) do - { - "MountPath" => "/test", - "Target" => "new_disk" - } - end - - it "ignores the Target value provided from D-Bus and uses :default" do - volume = subject.convert - - expect(volume.location.target).to eq :default - end - end - end -end diff --git a/service/test/agama/dbus/storage/volume_conversion/to_dbus_test.rb b/service/test/agama/dbus/storage/volume_conversion/to_dbus_test.rb deleted file mode 100644 index 321cf0ed47..0000000000 --- a/service/test/agama/dbus/storage/volume_conversion/to_dbus_test.rb +++ /dev/null @@ -1,138 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2023-2025] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require_relative "../../../../test_helper" -require "agama/dbus/storage/volume_conversion/to_dbus" -require "agama/storage/volume" -require "y2storage/filesystems/type" -require "y2storage/disk_size" - -describe Agama::DBus::Storage::VolumeConversion::ToDBus do - let(:volume1) { Agama::Storage::Volume.new("/test1") } - - let(:volume2) do - Agama::Storage::Volume.new("/test2").tap do |volume| - volume.min_size = nil - volume.max_size = nil - volume.auto_size = true - volume.outline.base_min_size = Y2Storage::DiskSize.new(1024) - volume.outline.base_max_size = Y2Storage::DiskSize.new(4096) - end - end - - let(:volume3) do - volume_outline = Agama::Storage::VolumeOutline.new.tap do |outline| - outline.required = true - outline.filesystems = [Y2Storage::Filesystems::Type::EXT3, Y2Storage::Filesystems::Type::EXT4] - outline.adjust_by_ram = true - outline.min_size_fallback_for = ["/", "/home"] - outline.max_size_fallback_for = ["swap"] - outline.snapshots_configurable = true - outline.snapshots_size = Y2Storage::DiskSize.new(1000) - outline.snapshots_percentage = 10 - outline.adjust_by_ram = true - outline.base_min_size = Y2Storage::DiskSize.new(2048) - outline.base_max_size = Y2Storage::DiskSize.new(4096) - end - - Agama::Storage::Volume.new("/test3").tap do |volume| - volume.outline = volume_outline - volume.fs_type = Y2Storage::Filesystems::Type::EXT4 - volume.btrfs.snapshots = true - volume.btrfs.read_only = true - volume.mount_options = ["rw", "default"] - volume.location.device = "/dev/sda" - volume.location.target = :new_partition - volume.min_size = Y2Storage::DiskSize.new(1024) - volume.max_size = Y2Storage::DiskSize.new(2048) - volume.auto_size = true - end - end - - describe "#convert" do - it "converts the volume to a D-Bus hash" do - expect(described_class.new(volume1).convert).to eq( - "MountPath" => "/test1", - "MountOptions" => [], - "TargetDevice" => "", - "Target" => "default", - "FsType" => "", - "MinSize" => 0, - "AutoSize" => false, - "Snapshots" => false, - "Transactional" => false, - "Outline" => { - "Required" => false, - "FsTypes" => [], - "SupportAutoSize" => false, - "AdjustByRam" => false, - "SnapshotsConfigurable" => false, - "SnapshotsAffectSizes" => false, - "SizeRelevantVolumes" => [] - } - ) - - expect(described_class.new(volume2).convert).to eq( - "MountPath" => "/test2", - "MountOptions" => [], - "TargetDevice" => "", - "Target" => "default", - "FsType" => "", - "MinSize" => 1024, - "MaxSize" => 4096, - "AutoSize" => true, - "Snapshots" => false, - "Transactional" => false, - "Outline" => { - "Required" => false, - "FsTypes" => [], - "SupportAutoSize" => false, - "AdjustByRam" => false, - "SnapshotsConfigurable" => false, - "SnapshotsAffectSizes" => false, - "SizeRelevantVolumes" => [] - } - ) - - expect(described_class.new(volume3).convert).to eq( - "MountPath" => "/test3", - "MountOptions" => ["rw", "default"], - "TargetDevice" => "/dev/sda", - "Target" => "new_partition", - "FsType" => "ext4", - "MinSize" => 2048, - "MaxSize" => 4096, - "AutoSize" => true, - "Snapshots" => true, - "Transactional" => true, - "Outline" => { - "Required" => true, - "FsTypes" => ["ext3", "ext4"], - "AdjustByRam" => true, - "SupportAutoSize" => true, - "SnapshotsConfigurable" => true, - "SnapshotsAffectSizes" => true, - "SizeRelevantVolumes" => ["/", "/home", "swap"] - } - ) - end - end -end diff --git a/service/test/agama/dbus/storage/volume_conversion_test.rb b/service/test/agama/dbus/storage/volume_conversion_test.rb deleted file mode 100644 index 5dd15d7a98..0000000000 --- a/service/test/agama/dbus/storage/volume_conversion_test.rb +++ /dev/null @@ -1,49 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2023-2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require_relative "../../../test_helper" -require "agama/config" -require "agama/storage/volume" -require "agama/dbus/storage/volume_conversion" - -describe Agama::DBus::Storage::VolumeConversion do - describe "#from_dbus" do - let(:config) { Agama::Config.new } - - let(:dbus_volume) { {} } - - let(:logger) { Logger.new($stdout, level: :warn) } - - it "generates a volume from D-Bus settings" do - result = described_class.from_dbus(dbus_volume, config: config, logger: logger) - expect(result).to be_a(Agama::Storage::Volume) - end - end - - describe "#to_dbus" do - let(:volume) { Agama::Storage::Volume.new("/test") } - - it "generates D-Bus settings from a volume" do - result = described_class.to_dbus(volume) - expect(result).to be_a(Hash) - end - end -end diff --git a/service/test/agama/dbus/storage_service_test.rb b/service/test/agama/dbus/storage_service_test.rb new file mode 100644 index 0000000000..f27c44a628 --- /dev/null +++ b/service/test/agama/dbus/storage_service_test.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../../test_helper" +require "agama/dbus/storage_service" + +describe Agama::DBus::StorageService do + subject(:service) { described_class.new(config, logger) } + + let(:config) { Agama::Config.new } + let(:logger) { Logger.new($stdout, level: :warn) } + let(:manager) { Agama::Storage::Manager.new(config, logger: logger) } + let(:inhibitors) { instance_double(Y2Storage::Inhibitors, inhibit: nil, uninhibit: nil) } + + let(:object_server) { instance_double(DBus::ObjectServer, export: nil) } + let(:bus) { instance_double(Agama::DBus::Bus, request_name: nil) } + + let(:manager_obj) do + instance_double(Agama::DBus::Storage::Manager, path: "/org/opensuse/Agama/Storage1") + end + + before do + allow(Agama::DBus::Bus).to receive(:current).and_return(bus) + allow(bus).to receive(:request_service).with("org.opensuse.Agama.Storage1") + .and_return(object_server) + allow(Y2Storage::Inhibitors).to receive(:new).and_return inhibitors + allow(Agama::Storage::Manager).to receive(:new).with(config, logger: logger) + .and_return(manager) + allow(Agama::DBus::Storage::Manager).to receive(:new).with(manager, logger: logger) + .and_return(manager_obj) + end + + describe "#start" do + before { allow(ENV).to receive(:[]=) } + + it "sets env YAST_NO_BLS_BOOT to yes if product doesn't requires bls boot explicitly" do + expect(config).to receive(:boot_strategy).and_return(nil) + expect(ENV).to receive(:[]=).with("YAST_NO_BLS_BOOT", "1") + + service.start + end + + it "activates the Y2Storage inhibitors" do + expect(inhibitors).to receive(:inhibit) + + service.start + end + end + + describe "#export" do + it "exports the storage manager" do + expect(object_server).to receive(:export).with(manager_obj) + service.export + end + end + + describe "#dispatch" do + it "dispatches the messages from the bus" do + expect(bus).to receive(:dispatch_message_queue) + service.dispatch + end + end +end diff --git a/service/test/agama/manager_test.rb b/service/test/agama/manager_test.rb index ac428b639a..376c56a2a5 100644 --- a/service/test/agama/manager_test.rb +++ b/service/test/agama/manager_test.rb @@ -57,8 +57,8 @@ let(:network) { instance_double(Agama::Network, install: nil, startup: nil) } let(:storage) do instance_double( - Agama::DBus::Clients::Storage, probe: nil, reprobe: nil, install: nil, finish: nil, - on_service_status_change: nil, errors?: false + Agama::DBus::Clients::Storage, probe: nil, install: nil, finish: nil, + :product= => nil ) end let(:scripts) do @@ -115,27 +115,28 @@ end describe "#config_phase" do + let(:product) { "Geecko" } + it "sets the installation phase to config" do subject.config_phase expect(subject.installation_phase.config?).to eq(true) end - it "calls #probe method of each module" do - expect(storage).to receive(:probe) - expect(software).to receive(:probe) + it "sets the product for the storage module" do + expect(storage).to receive(:product=).with product subject.config_phase end - context "if reprobe is requested" do - it "calls #reprobe method of the storage module" do - expect(storage).to receive(:reprobe) - subject.config_phase(reprobe: true) - end + it "calls #probe method for both software and storage modules if reprobe is requested" do + expect(storage).to receive(:probe) + expect(software).to receive(:probe) + subject.config_phase(reprobe: true) + end - it "calls #probe method of the software module" do - expect(software).to receive(:probe) - subject.config_phase(reprobe: true) - end + it "calls #probe method only for the software module if reprobe is not requested" do + expect(software).to receive(:probe) + expect(storage).to_not receive(:probe) + subject.config_phase(reprobe: false) end end @@ -219,16 +220,6 @@ end end - context "when there are storage errors" do - before do - allow(storage).to receive(:errors?).and_return(true) - end - - it "returns false" do - expect(subject.valid?).to eq(false) - end - end - context "when the software configuration is not valid" do before do allow(software).to receive(:errors?).and_return(true) diff --git a/service/test/agama/network_test.rb b/service/test/agama/network_test.rb index 89df015473..20d944cbbf 100644 --- a/service/test/agama/network_test.rb +++ b/service/test/agama/network_test.rb @@ -22,7 +22,6 @@ require_relative "../test_helper" require "tmpdir" require "agama/network" -require "agama/progress" describe Agama::Network do subject(:network) { described_class.new(logger) } diff --git a/service/test/agama/progress_test.rb b/service/test/agama/old_progress_test.rb similarity index 98% rename from service/test/agama/progress_test.rb rename to service/test/agama/old_progress_test.rb index 4688cbd86f..592f37556a 100644 --- a/service/test/agama/progress_test.rb +++ b/service/test/agama/old_progress_test.rb @@ -20,9 +20,9 @@ # find current contact information at www.suse.com. require_relative "../test_helper" -require "agama/progress" +require "agama/old_progress" -describe Agama::Progress do +describe Agama::OldProgress do subject { described_class.with_size(steps) } describe "when the steps are known in advance" do @@ -63,7 +63,7 @@ it "returns an step with the id and description of the current step" do step = subject.current_step - expect(step).to be_a(Agama::Progress::Step) + expect(step).to be_a(Agama::OldProgress::Step) expect(step.id).to eq(2) expect(step.description).to match(/step 2/) end diff --git a/service/test/agama/progress_manager_test.rb b/service/test/agama/progress_manager_test.rb index 008b3c75f4..c44a6848c1 100644 --- a/service/test/agama/progress_manager_test.rb +++ b/service/test/agama/progress_manager_test.rb @@ -20,7 +20,7 @@ # find current contact information at www.suse.com. require_relative "../test_helper" -require "agama/progress" +require "agama/old_progress" require "agama/progress_manager" shared_examples "unfinished progress" do |action| @@ -72,7 +72,7 @@ end it "returns the progress object" do - expect(subject.progress).to be_a(Agama::Progress) + expect(subject.progress).to be_a(Agama::OldProgress) end end end diff --git a/service/test/agama/storage/devicegraph_conversions/to_json_test.rb b/service/test/agama/storage/devicegraph_conversions/to_json_test.rb new file mode 100644 index 0000000000..b8ec92c336 --- /dev/null +++ b/service/test/agama/storage/devicegraph_conversions/to_json_test.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../storage_helpers" +require "agama/storage/devicegraph_conversions" +require "y2storage/refinements" + +using Y2Storage::Refinements::SizeCasts + +describe Agama::Storage::DevicegraphConversions::ToJSON do + include Agama::RSpec::StorageHelpers + + before do + mock_storage(devicegraph: scenario) + allow_any_instance_of(Y2Storage::Partition).to receive(:resize_info).and_return(resize_info) + end + + subject { described_class.new(devicegraph) } + + let(:devicegraph) { Y2Storage::StorageManager.instance.probed } + + let(:resize_info) do + instance_double( + Y2Storage::ResizeInfo, resize_ok?: true, reasons: [], + min_size: Y2Storage::DiskSize.GiB(20), max_size: Y2Storage::DiskSize.GiB(40) + ) + end + + describe "#convert" do + describe "for a devicegraph with several disks" do + let(:scenario) { "disks.yaml" } + + it "generates an entry for each disk" do + json = subject.convert + expect(json.map { |e| e[:name] }).to contain_exactly("/dev/vda", "/dev/vdb", "/dev/vdc") + end + + it "exports the block device sizes in bytes" do + json = subject.convert + expect(json.map { |e| e[:block][:size] }).to all eq(50 * (1024**3)) + end + + it "generates the :partitions and :partitionTable entries only for partitioned disks" do + json = subject.convert + + vda = json.find { |d| d[:name] == "/dev/vda" } + expect(vda[:partitions].size).to eq 3 + expect(vda[:partitionTable][:type]).to eq "gpt" + + vdb = json.find { |d| d[:name] == "/dev/vdb" } + expect(vdb.keys).to_not include :partitions + expect(vdb.keys).to_not include :partitionTable + + vdc = json.find { |d| d[:name] == "/dev/vdc" } + expect(vdc.keys).to_not include :partitions + expect(vdc.keys).to_not include :partitionTable + end + + it "generates the :filesystem entry only for formatted disks" do + json = subject.convert + + vda = json.find { |d| d[:name] == "/dev/vda" } + expect(vda.keys).to_not include :filesystem + + vdb = json.find { |d| d[:name] == "/dev/vdb" } + expect(vdb.keys).to_not include :filesystem + + vdc = json.find { |d| d[:name] == "/dev/vdc" } + expect(vdc.keys).to include :filesystem + expect(vdc[:filesystem][:type]).to eq "ext4" + end + end + + describe "for a devicegraph with LVM" do + let(:scenario) { "trivial_lvm.yml" } + + it "generates an entry for each disk and volume group" do + json = subject.convert + expect(json.map { |e| e[:name] }).to contain_exactly("/dev/sda", "/dev/vg0") + end + + it "exports the size and physical volumes of the LVM volume group" do + json = subject.convert + vg0 = json.find { |d| d[:name] == "/dev/vg0" } + expect(vg0[:volumeGroup][:size]).to eq (100 * (1024**3)) - (4 * (1024**2)) + pvs = vg0[:volumeGroup][:physicalVolumes] + expect(pvs).to be_a Array + expect(pvs.size).to eq 1 + end + + it "generates the :logicalVolumes entries only for LVM volume groups" do + json = subject.convert + + sda = json.find { |d| d[:name] == "/dev/sda" } + expect(sda.keys).to_not include :logicalVolumes + + vg0 = json.find { |d| d[:name] == "/dev/vg0" } + lvs = vg0[:logicalVolumes] + expect(lvs.map { |lv| lv[:name] }).to eq ["/dev/vg0/lv1"] + expect(lvs.first[:block].keys).to include :size + end + + it "generates the :filesystem entry for formatted logical volumes" do + json = subject.convert + vg0 = json.find { |d| d[:name] == "/dev/vg0" } + lv = vg0[:logicalVolumes].first + + expect(lv.keys).to include :filesystem + expect(lv[:filesystem][:type]).to eq "btrfs" + end + end + + describe "for a devicegraph with MD RAIDs" do + let(:scenario) { "md_disks.yaml" } + + it "generates an entry for each disk and MD RAID" do + json = subject.convert + expect(json.map { |e| e[:name] }).to contain_exactly("/dev/vda", "/dev/vdb", "/dev/md0") + end + + it "exports the level and members of the MD RAIDs" do + json = subject.convert + md0 = json.find { |d| d[:name] == "/dev/md0" } + expect(md0[:md][:level]).to eq "raid0" + members = md0[:md][:devices] + expect(members).to be_a Array + expect(members.size).to eq 2 + end + end + + describe "for a devicegraph with multipath devices" do + let(:scenario) { "multipath-formatted.xml" } + + it "generates an entry for each multipath device" do + json = subject.convert + expect(json.map { |e| e[:name] }).to eq ["/dev/mapper/0QEMU_QEMU_HARDDISK_mpath1"] + end + + it "exports the name of the multipath wires" do + json = subject.convert + wires = json.first[:multipath][:wireNames] + expect(wires).to contain_exactly("/dev/sda", "/dev/sdb") + end + end + end +end diff --git a/service/test/agama/storage/finisher_test.rb b/service/test/agama/storage/finisher_test.rb index 5d2c2baa81..4c2e3ed3a5 100644 --- a/service/test/agama/storage/finisher_test.rb +++ b/service/test/agama/storage/finisher_test.rb @@ -41,7 +41,7 @@ let(:config) { Agama::Config.from_file(config_path) } let(:security) { instance_double(Agama::Security, write: nil) } let(:copy_files) { Agama::Storage::Finisher::CopyFilesStep.new(logger) } - let(:progress) { instance_double(Agama::Progress, step: nil) } + let(:progress) { instance_double(Agama::OldProgress, step: nil) } describe "#run" do before do diff --git a/service/test/agama/storage/manager_test.rb b/service/test/agama/storage/manager_test.rb index d6c3fdce1e..c497800c71 100644 --- a/service/test/agama/storage/manager_test.rb +++ b/service/test/agama/storage/manager_test.rb @@ -33,6 +33,7 @@ require "agama/storage/proposal" require "agama/storage/proposal_settings" require "agama/storage/volume" +require "agama/dbus" require "y2storage/issue" require "y2storage/luks" require "yast2/fs_snapshot" @@ -84,120 +85,65 @@ let(:network) { instance_double(Agama::Network, link_resolv: nil, unlink_resolv: nil) } let(:bootloader_finish) { instance_double(Bootloader::FinishClient, write: nil) } let(:security) { instance_double(Agama::Security, write: nil) } - let(:scenario) { "empty-hd-50GiB.yaml" } - describe "#deprecated_system=" do - let(:callback) { proc {} } - - context "if the current value is changed" do - before do - storage.deprecated_system = true - end - - it "executes the on_deprecated_system_change callbacks" do - storage.on_deprecated_system_change(&callback) - - expect(callback).to receive(:call) - - storage.deprecated_system = false - end + describe "#activate" do + before do + allow(Agama::Storage::ISCSI::Manager).to receive(:new).and_return(iscsi) + allow(iscsi).to receive(:activate) + allow(y2storage_manager).to receive(:activate) end - context "if the current value is not changed" do - before do - storage.deprecated_system = true - end - - it "does not execute the on_deprecated_system_change callbacks" do - storage.on_deprecated_system_change(&callback) - - expect(callback).to_not receive(:call) + let(:iscsi) { Agama::Storage::ISCSI::Manager.new } - storage.deprecated_system = true + it "activates iSCSI and devices managed by Y2Storage" do + expect(iscsi).to receive(:activate) + expect(y2storage_manager).to receive(:activate) do |callbacks| + expect(callbacks).to be_a(Agama::Storage::Callbacks::Activate) end + storage.activate end - context "when the system is set as deprecated" do - it "marks the system as deprecated" do - storage.deprecated_system = true - - expect(storage.deprecated_system?).to eq(true) - end - - it "adds a deprecated system issue" do - expect(storage.issues).to be_empty - - storage.deprecated_system = true - - expect(storage.issues).to include( - an_object_having_attributes(description: /system devices have changed/) - ) - end + it "does not reset information from previous activation" do + expect(Y2Storage::Luks).to_not receive(:reset_activation_infos) + storage.activate end + end - context "when the system is set as not deprecated" do - it "marks the system as not deprecated" do - storage.deprecated_system = false - - expect(storage.deprecated_system?).to eq(false) - end - - it "does not add a deprecated system issue" do - storage.deprecated_system = false - - expect(storage.issues).to_not include( - an_object_having_attributes(description: /system devices have changed/) - ) - end + describe "#reset_activation" do + it "resets information from previous activation" do + expect(Y2Storage::Luks).to receive(:reset_activation_infos) + storage.reset_activation end end describe "#probe" do before do allow(Agama::Storage::ISCSI::Manager).to receive(:new).and_return(iscsi) - allow(y2storage_manager).to receive(:raw_probed).and_return(raw_devicegraph) - allow(proposal).to receive(:issues).and_return(proposal_issues) - allow(proposal.storage_system).to receive(:candidate_devices).and_return(devices) allow(proposal).to receive(:calculate_from_json).and_return(true) allow(proposal).to receive(:success?).and_return(true) - allow(proposal).to receive(:storage_json).and_return(current_config) - allow_any_instance_of(Agama::Storage::Configurator) - .to receive(:generate_configs).and_return([default_config]) - allow(config).to receive(:pick_product) - allow(iscsi).to receive(:activate) - allow(y2storage_manager).to receive(:activate) - allow(iscsi).to receive(:probe) - allow(y2storage_manager).to receive(:probe) end - let(:raw_devicegraph) do - instance_double(Y2Storage::Devicegraph, probing_issues: probing_issues) - end - - let(:proposal) { Agama::Storage::Proposal.new(config, logger: logger) } + let(:iscsi) { Agama::Storage::ISCSI::Manager.new } - let(:default_config) do - { - storage: { - drives: [ - search: "/dev/vda1" - ] - } - } + it "probes the storage devices" do + expect(iscsi).to receive(:probe) + expect(y2storage_manager).to receive(:probe) do |callbacks| + expect(callbacks).to be_a(Y2Storage::Callbacks::UserProbe) + end + storage.probe end + end - let(:current_config) do - { - storage: { - drives: [ - search: "/dev/vda2" - ] - } - } + describe "#system_issues" do + before do + allow(y2storage_manager).to receive(:raw_probed).and_return(raw_devicegraph) + allow(proposal.storage_system).to receive(:candidate_devices).and_return(devices) end - let(:iscsi) { Agama::Storage::ISCSI::Manager.new } + let(:raw_devicegraph) do + instance_double(Y2Storage::Devicegraph, probing_issues: probing_issues) + end let(:devices) { [disk1, disk2] } @@ -206,104 +152,17 @@ let(:probing_issues) { [Y2Storage::Issue.new("probing issue")] } - let(:proposal_issues) { [Agama::Issue.new("proposal issue")] } - - let(:callback) { proc {} } - - it "sets env YAST_NO_BLS_BOOT to yes if product doesn't requires bls boot explicitly" do - expect(config).to receive(:pick_product) - expect(config).to receive(:boot_strategy).and_return(nil) - expect(ENV).to receive(:[]=).with("YAST_NO_BLS_BOOT", "1") - - storage.probe - end - - it "probes the storage devices and calculates a proposal" do - expect(config).to receive(:pick_product).with("ALP") - expect(iscsi).to receive(:activate) - expect(y2storage_manager).to receive(:activate) do |callbacks| - expect(callbacks).to be_a(Agama::Storage::Callbacks::Activate) - end - expect(iscsi).to receive(:probe) - expect(y2storage_manager).to receive(:probe) - expect(proposal).to receive(:calculate_from_json) - storage.probe - end - - it "sets the system as non deprecated" do - storage.deprecated_system = true - storage.probe - - expect(storage.deprecated_system?).to eq(false) - end - - it "adds the probing issues" do - storage.probe - - expect(storage.issues).to include( + it "includes the probing issues" do + expect(storage.system_issues).to include( an_object_having_attributes(description: /probing issue/) ) end - it "adds the proposal issues" do - storage.probe - - expect(storage.issues).to include( - an_object_having_attributes(description: /proposal issue/) - ) - end - - it "executes the on_probe callbacks" do - storage.on_probe(&callback) - - expect(callback).to receive(:call) - - storage.probe - end - - context "if :keep_config is false" do - let(:keep_config) { false } - - it "calculates a proposal using the default product config" do - expect(proposal).to receive(:calculate_from_json).with(default_config) - storage.probe(keep_config: keep_config) - end - end - - context "if :keep_config is true" do - let(:keep_config) { true } - - it "calculates a proposal using the current config" do - expect(proposal).to receive(:calculate_from_json).with(current_config) - storage.probe(keep_config: keep_config) - end - end - - context "if :keep_activation is false" do - let(:keep_activation) { false } - - it "resets information from previous activation" do - expect(Y2Storage::Luks).to receive(:reset_activation_infos) - storage.probe(keep_activation: keep_activation) - end - end - - context "if :keep_activation is true" do - let(:keep_activation) { true } - - it "does not reset information from previous activation" do - expect(Y2Storage::Luks).to_not receive(:reset_activation_infos) - storage.probe(keep_activation: keep_activation) - end - end - context "if there are available devices" do let(:devices) { [disk1] } - it "does not add an issue for available devices" do - storage.probe - - expect(storage.issues).to_not include( + it "does not include an issue for available devices" do + expect(storage.system_issues).to_not include( an_object_having_attributes(description: /no suitable device/) ) end @@ -312,10 +171,8 @@ context "if there are not available devices" do let(:devices) { [] } - it "adds an issue for available devices" do - storage.probe - - expect(storage.issues).to include( + it "includes an issue for available devices" do + expect(storage.system_issues).to include( an_object_having_attributes(description: /no suitable device/) ) end @@ -355,8 +212,6 @@ let(:proposal_issues) { [Agama::Issue.new("proposal issue")] } - let(:callback) { proc {} } - it "calculates a proposal using the default config if no config is given" do expect(proposal).to receive(:calculate_from_json).with(default_config) storage.configure @@ -375,12 +230,6 @@ ) end - it "executes the on_configure callbacks" do - storage.on_configure(&callback) - expect(callback).to receive(:call) - storage.configure - end - context "if the proposal was correctly calculated" do before do allow(proposal).to receive(:success?).and_return(true) @@ -404,16 +253,33 @@ describe "#install" do before do - allow(y2storage_manager).to receive(:staging).and_return(proposed_devicegraph) - allow(Yast::WFM).to receive(:CallFunction).with("inst_prepdisk", []) allow(Yast::WFM).to receive(:CallFunction).with("inst_bootloader", []) - allow(Yast::PackagesProposal).to receive(:SetResolvables) allow(Bootloader::ProposalClient).to receive(:new) .and_return(bootloader_proposal) allow(Y2Storage::Clients::InstPrepdisk).to receive(:new).and_return(client) end + let(:bootloader_proposal) { instance_double(Bootloader::ProposalClient, make_proposal: nil) } + let(:client) { instance_double(Y2Storage::Clients::InstPrepdisk, run: nil) } + + it "runs the inst_prepdisk client" do + expect(Y2Storage::Clients::InstPrepdisk).to receive(:new) do |params| + expect(params[:commit_callbacks]).to be_a(Agama::Storage::Callbacks::Commit) + end.and_return(client) + + expect(client).to receive(:run) + + storage.install + end + end + + describe "#add_packages" do + before do + allow(y2storage_manager).to receive(:staging).and_return(proposed_devicegraph) + allow(Yast::PackagesProposal).to receive(:SetResolvables) + end + let(:proposed_devicegraph) do instance_double(Y2Storage::Devicegraph, used_features: used_features) end @@ -426,26 +292,12 @@ ) end - let(:bootloader_proposal) { instance_double(Bootloader::ProposalClient, make_proposal: nil) } - - let(:client) { instance_double(Y2Storage::Clients::InstPrepdisk, run: nil) } - it "adds storage software to install" do expect(Yast::PackagesProposal).to receive(:SetResolvables) do |_, _, packages| expect(packages).to contain_exactly("btrfsprogs", "snapper") end - storage.install - end - - it "runs the inst_prepdisk client" do - expect(Y2Storage::Clients::InstPrepdisk).to receive(:new) do |params| - expect(params[:commit_callbacks]).to be_a(Agama::Storage::Callbacks::Commit) - end.and_return(client) - - expect(client).to receive(:run) - - storage.install + storage.add_packages end context "if iSCSI was configured" do @@ -459,7 +311,7 @@ expect(packages).to include("open-iscsi", "iscsiuio") end - storage.install + storage.add_packages end end @@ -478,7 +330,7 @@ expect(packages).to include("open-iscsi", "iscsiuio") end - storage.install + storage.add_packages end end end @@ -549,14 +401,23 @@ before do mock_storage(devicegraph: "partitioned_md.yml") - subject.proposal.calculate_guided(settings) + subject.proposal.calculate_from_json(config_json) end - let(:settings) do - Agama::Storage::ProposalSettings.new.tap do |settings| - settings.device.name = "/dev/sdb" - settings.volumes = [Agama::Storage::Volume.new("/")] - end + let(:config_json) do + { + storage: { + drives: [ + { + search: "/dev/sdb", + partitions: [ + { search: "*", delete: true }, + { filesystem: { path: "/" } } + ] + } + ] + } + } end it "returns the list of actions" do diff --git a/service/test/agama/storage/proposal_settings_conversions/from_json_test.rb b/service/test/agama/storage/proposal_settings_conversions/from_json_test.rb deleted file mode 100644 index 3426264077..0000000000 --- a/service/test/agama/storage/proposal_settings_conversions/from_json_test.rb +++ /dev/null @@ -1,293 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require_relative "../../../test_helper" -require "agama/storage/proposal_settings_conversions/from_json" -require "agama/config" -require "agama/storage/device_settings" -require "agama/storage/proposal_settings" -require "y2storage/encryption_method" -require "y2storage/pbkd_function" - -describe Agama::Storage::ProposalSettingsConversions::FromJSON do - subject { described_class.new(settings_json, config: config) } - - let(:config) { Agama::Config.new(config_data) } - - let(:config_data) do - { - "storage" => { - "lvm" => false, - "space_policy" => "delete", - "encryption" => { - "method" => "luks2", - "pbkd_function" => "argon2id" - }, - "volumes" => ["/", "swap"], - "volume_templates" => [ - { - "mount_path" => "/", - "outline" => { "required" => true } - }, - { - "mount_path" => "/home", - "outline" => { "required" => false } - }, - { - "mount_path" => "swap", - "outline" => { "required" => false } - } - ] - } - } - end - - before do - allow(Agama::Storage::EncryptionSettings) - .to receive(:available_methods).and_return( - [ - Y2Storage::EncryptionMethod::LUKS1, - Y2Storage::EncryptionMethod::LUKS2 - ] - ) - end - - describe "#convert" do - let(:settings_json) do - { - target: { - disk: "/dev/sda" - }, - boot: { - configure: true, - device: "/dev/sdb" - }, - encryption: { - password: "notsecret", - method: "luks1", - pbkdFunction: "pbkdf2" - }, - space: { - policy: "custom", - actions: [ - { forceDelete: "/dev/sda" }, - { resize: "/dev/sdb1" } - ] - }, - volumes: [ - { - mount: { - path: "/" - } - }, - { - mount: { - path: "/test" - } - } - ] - } - end - - it "generates settings with the values provided from JSON" do - settings = subject.convert - - expect(settings).to be_a(Agama::Storage::ProposalSettings) - expect(settings.device).to be_a(Agama::Storage::DeviceSettings::Disk) - expect(settings.device.name).to eq("/dev/sda") - expect(settings.boot.configure?).to eq(true) - expect(settings.boot.device).to eq("/dev/sdb") - expect(settings.encryption.method).to eq(Y2Storage::EncryptionMethod::LUKS1) - expect(settings.encryption.pbkd_function).to eq(Y2Storage::PbkdFunction::PBKDF2) - expect(settings.space.policy).to eq(:custom) - expect(settings.space.actions).to eq({ - "/dev/sda" => :force_delete, "/dev/sdb1" => :resize - }) - expect(settings.volumes).to contain_exactly( - an_object_having_attributes(mount_path: "/"), - an_object_having_attributes(mount_path: "/test") - ) - end - - context "when the JSON is missing some values" do - let(:settings_json) { {} } - - it "completes the missing values with default values from the config" do - settings = subject.convert - - expect(settings).to be_a(Agama::Storage::ProposalSettings) - expect(settings.device).to be_a(Agama::Storage::DeviceSettings::Disk) - expect(settings.device.name).to be_nil - expect(settings.boot.configure?).to eq(true) - expect(settings.boot.device).to be_nil - expect(settings.encryption.method).to eq(Y2Storage::EncryptionMethod::LUKS2) - expect(settings.encryption.pbkd_function).to eq(Y2Storage::PbkdFunction::ARGON2ID) - expect(settings.space.policy).to eq(:delete) - expect(settings.volumes).to contain_exactly( - an_object_having_attributes(mount_path: "/"), - an_object_having_attributes(mount_path: "swap") - ) - end - end - - context "when the JSON does not indicate the target" do - let(:settings_json) { {} } - - it "generates settings with disk target and without specific device" do - settings = subject.convert - - expect(settings.device).to be_a(Agama::Storage::DeviceSettings::Disk) - expect(settings.device.name).to be_nil - end - end - - context "when the JSON indicates disk target without device" do - let(:settings_json) do - { - target: "disk" - } - end - - it "generates settings with disk target and without specific device" do - settings = subject.convert - - expect(settings.device).to be_a(Agama::Storage::DeviceSettings::Disk) - expect(settings.device.name).to be_nil - end - end - - context "when the JSON indicates disk target with a device" do - let(:settings_json) do - { - target: { - disk: "/dev/vda" - } - } - end - - it "generates settings with disk target and with specific device" do - settings = subject.convert - - expect(settings.device).to be_a(Agama::Storage::DeviceSettings::Disk) - expect(settings.device.name).to eq("/dev/vda") - end - end - - context "when the JSON indicates newLvmVg target without devices" do - let(:settings_json) do - { - target: "newLvmVg" - } - end - - it "generates settings with newLvmVg target and without specific devices" do - settings = subject.convert - - expect(settings.device).to be_a(Agama::Storage::DeviceSettings::NewLvmVg) - expect(settings.device.candidate_pv_devices).to eq([]) - end - end - - context "when the JSON indicates newLvmVg target with devices" do - let(:settings_json) do - { - target: { - newLvmVg: ["/dev/vda", "/dev/vdb"] - } - } - end - - it "generates settings with newLvmVg target and with specific devices" do - settings = subject.convert - - expect(settings.device).to be_a(Agama::Storage::DeviceSettings::NewLvmVg) - expect(settings.device.candidate_pv_devices).to contain_exactly("/dev/vda", "/dev/vdb") - end - end - - context "when the JSON does not indicate volumes" do - let(:settings_json) { { volumes: [] } } - - it "generates settings with the default volumes from config" do - settings = subject.convert - - expect(settings.volumes).to contain_exactly( - an_object_having_attributes(mount_path: "/"), - an_object_having_attributes(mount_path: "swap") - ) - end - - it "ignores templates of non-default volumes" do - settings = subject.convert - - expect(settings.volumes).to_not include( - an_object_having_attributes(mount_path: "/home") - ) - end - end - - context "when the JSON does not contain a required volume" do - let(:settings_json) do - { - volumes: [ - { - mount: { - path: "/test" - } - } - ] - } - end - - it "generates settings including the required volumes" do - settings = subject.convert - - expect(settings.volumes).to include( - an_object_having_attributes(mount_path: "/") - ) - end - - it "generates settings including the given volumes" do - settings = subject.convert - - expect(settings.volumes).to include( - an_object_having_attributes(mount_path: "/test") - ) - end - - it "ignores default volumes that are not required" do - settings = subject.convert - - expect(settings.volumes).to_not include( - an_object_having_attributes(mount_path: "swap") - ) - end - - it "ignores templates for excluded volumes" do - settings = subject.convert - - expect(settings.volumes).to_not include( - an_object_having_attributes(mount_path: "/home") - ) - end - end - end -end diff --git a/service/test/agama/storage/proposal_settings_conversions/from_y2storage_test.rb b/service/test/agama/storage/proposal_settings_conversions/from_y2storage_test.rb deleted file mode 100644 index b33b737bf0..0000000000 --- a/service/test/agama/storage/proposal_settings_conversions/from_y2storage_test.rb +++ /dev/null @@ -1,81 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2023-2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require_relative "../../../test_helper" -require "agama/config" -require "agama/storage/proposal_settings" -require "agama/storage/proposal_settings_conversions/from_y2storage" -require "y2storage" - -describe Agama::Storage::ProposalSettingsConversions::FromY2Storage do - subject { described_class.new(y2storage_settings, original_settings) } - - let(:y2storage_settings) do - Y2Storage::ProposalSettings.new.tap do |settings| - settings.space_settings.actions = [ - Y2Storage::SpaceActions::Delete.new("/dev/sda", mandatory: true), - Y2Storage::SpaceActions::Resize.new("/dev/sdb1"), - Y2Storage::SpaceActions::Delete.new("/dev/sdb2") - ] - end - end - - let(:original_settings) do - Agama::Storage::ProposalSettings.new.tap do |settings| - settings.device.name = "/dev/sda" - settings.boot.device = "/dev/sdb" - settings.encryption.password = "notsecret" - settings.encryption.method = Y2Storage::EncryptionMethod::LUKS2 - settings.encryption.pbkd_function = Y2Storage::PbkdFunction::ARGON2ID - settings.space.policy = :delete - settings.space.actions = {} - settings.volumes = [Agama::Storage::Volume.new("/test")] - end - end - - describe "#convert" do - it "generates settings with the same values as the given settings" do - settings = subject.convert - - expect(settings).to be_a(Agama::Storage::ProposalSettings) - expect(settings.device).to be_a(Agama::Storage::DeviceSettings::Disk) - expect(settings.device.name).to eq("/dev/sda") - expect(settings.boot.device).to eq("/dev/sdb") - expect(settings.encryption.password).to eq("notsecret") - expect(settings.encryption.method).to eq(Y2Storage::EncryptionMethod::LUKS2) - expect(settings.encryption.pbkd_function).to eq(Y2Storage::PbkdFunction::ARGON2ID) - expect(settings.space.policy).to eq(:delete) - expect(settings.volumes).to contain_exactly( - an_object_having_attributes(mount_path: "/test") - ) - end - - it "restores the space actions from Y2Storage" do - settings = subject.convert - - expect(settings.space.actions).to eq( - "/dev/sda" => :force_delete, - "/dev/sdb1" => :resize, - "/dev/sdb2" => :delete - ) - end - end -end diff --git a/service/test/agama/storage/proposal_settings_conversions/to_json_test.rb b/service/test/agama/storage/proposal_settings_conversions/to_json_test.rb deleted file mode 100644 index 79f6080eb6..0000000000 --- a/service/test/agama/storage/proposal_settings_conversions/to_json_test.rb +++ /dev/null @@ -1,119 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require_relative "../../../test_helper" -require "agama/storage/proposal_settings_conversions/to_json" -require "agama/storage/device_settings" -require "agama/storage/proposal_settings" -require "agama/storage/volume" -require "y2storage/encryption_method" -require "y2storage/pbkd_function" - -describe Agama::Storage::ProposalSettingsConversions::ToJSON do - let(:default_settings) { Agama::Storage::ProposalSettings.new } - - let(:custom_settings) do - Agama::Storage::ProposalSettings.new.tap do |settings| - settings.device.name = "/dev/sda" - settings.boot.device = "/dev/sdb" - settings.encryption.password = "notsecret" - settings.encryption.method = Y2Storage::EncryptionMethod::LUKS2 - settings.encryption.pbkd_function = Y2Storage::PbkdFunction::ARGON2ID - settings.space.policy = :custom - settings.space.actions = { "/dev/sda" => :force_delete, "/dev/sdb1" => "resize" } - settings.volumes = [Agama::Storage::Volume.new("/test")] - end - end - - describe "#convert" do - it "converts the settings to a JSON hash according to schema" do - # @todo Check whether the result matches the JSON schema. - - expect(described_class.new(default_settings).convert).to eq( - target: "disk", - boot: { - configure: true - }, - space: { - policy: "keep" - }, - volumes: [] - ) - - expect(described_class.new(custom_settings).convert).to eq( - target: { - disk: "/dev/sda" - }, - boot: { - configure: true, - device: "/dev/sdb" - }, - encryption: { - password: "notsecret", - method: "luks2", - pbkdFunction: "argon2id" - }, - space: { - policy: "custom", - actions: [ - { forceDelete: "/dev/sda" }, - { resize: "/dev/sdb1" } - ] - }, - volumes: [ - { - mount: { - path: "/test", - options: [] - }, - size: { - min: 0 - }, - target: "default" - } - ] - ) - end - - context "when the target is a new LVM volume group" do - let(:settings) do - Agama::Storage::ProposalSettings.new.tap do |settings| - settings.device = Agama::Storage::DeviceSettings::NewLvmVg.new(["/dev/vda"]) - end - end - - it "converts the settings to a JSON hash according to schema" do - expect(described_class.new(settings).convert).to eq( - target: { - newLvmVg: ["/dev/vda"] - }, - boot: { - configure: true - }, - space: { - policy: "keep" - }, - volumes: [] - ) - end - end - end -end diff --git a/service/test/agama/storage/proposal_settings_test.rb b/service/test/agama/storage/proposal_settings_test.rb index 2d509f4fd4..53d4e6ae7a 100644 --- a/service/test/agama/storage/proposal_settings_test.rb +++ b/service/test/agama/storage/proposal_settings_test.rb @@ -173,32 +173,6 @@ end end - describe ".new_from_json" do - let(:config) { Agama::Config.new } - - let(:settings_json) do - { - target: { - disk: "/dev/vda" - } - } - end - - it "generates a proposal settings from JSON according to schema" do - result = described_class.new_from_json(settings_json, config: config) - expect(result).to be_a(Agama::Storage::ProposalSettings) - end - end - - describe "#to_json_settings" do - let(:proposal_settings) { Agama::Storage::ProposalSettings.new } - - it "generates a JSON hash according to schema" do - result = proposal_settings.to_json_settings - expect(result).to be_a(Hash) - end - end - describe "#to_y2storage" do let(:config) { Agama::Config.new } diff --git a/service/test/agama/storage/proposal_test.rb b/service/test/agama/storage/proposal_test.rb index 0d1a3f5f34..e45bf8d8f6 100644 --- a/service/test/agama/storage/proposal_test.rb +++ b/service/test/agama/storage/proposal_test.rb @@ -166,33 +166,6 @@ def drive(partitions) end end - context "if a proposal was calculated with the guided strategy" do - before do - subject.calculate_guided(Agama::Storage::ProposalSettings.new) - end - - it "returns the solved guided JSON config" do - expected_json = { - storage: { - guided: { - boot: { - configure: true - }, - space: { - policy: "keep" - }, - target: { - disk: "/dev/sda" - }, - volumes: [] - } - } - } - - expect(subject.storage_json).to eq(expected_json) - end - end - context "if a proposal was calculated with the agama strategy" do before do subject.calculate_agama(achivable_config) @@ -261,45 +234,6 @@ def drive(partitions) end end - context "if a proposal was calculated from guided JSON config" do - before do - subject.calculate_from_json(config_json) - end - - let(:config_json) do - { - storage: { - guided: { - target: { - disk: "/dev/vda" - } - } - } - } - end - - it "returns the solved guided JSON config" do - expected_json = { - storage: { - guided: { - boot: { - configure: true - }, - space: { - policy: "keep" - }, - target: { - disk: "/dev/vda" - }, - volumes: [] - } - } - } - - expect(subject.storage_json).to eq(expected_json) - end - end - context "if a proposal was calculated from storage JSON config" do before do subject.calculate_from_json(config_json) @@ -358,26 +292,6 @@ def drive(partitions) end end - context "if a guided proposal has been calculated" do - before do - subject.calculate_from_json(settings_json) - end - - let(:settings_json) do - { - storage: { - guided: { - target: { disk: "/dev/vda" } - } - } - } - end - - it "returns nil" do - expect(subject.model_json).to be_nil - end - end - context "if an AutoYaST proposal has been calculated" do before do subject.calculate_from_json(autoyast_json) @@ -620,88 +534,6 @@ def drive(partitions) end end - describe "#calculate_guided" do - before do - mock_storage(devicegraph: "partitioned_md.yml") - end - - let(:achivable_settings) do - Agama::Storage::ProposalSettings.new.tap do |settings| - settings.device.name = "/dev/sdb" - settings.boot.device = "/dev/sda" - settings.volumes = [Agama::Storage::Volume.new("/")] - end - end - - let(:impossible_settings) do - Agama::Storage::ProposalSettings.new.tap do |settings| - settings.device.name = "/dev/sdb" - settings.volumes = [ - # The boot disk size is 500 GiB, so it cannot accomodate a 1 TiB volume. - Agama::Storage::Volume.new("/").tap { |v| v.min_size = Y2Storage::DiskSize.TiB(1) } - ] - end - end - - it "calculates a proposal with the guided strategy and with the given settings" do - expect(Y2Storage::StorageManager.instance.proposal).to be_nil - - subject.calculate_guided(achivable_settings) - - expect(Y2Storage::StorageManager.instance.proposal).to_not be_nil - y2storage_settings = Y2Storage::StorageManager.instance.proposal.settings - expect(y2storage_settings.root_device).to eq("/dev/sda") - expect(y2storage_settings.volumes).to contain_exactly( - an_object_having_attributes(mount_point: "/", device: "/dev/sdb") - ) - end - - include_examples "check proposal return", - :calculate_guided, :achivable_settings, :impossible_settings - - include_examples "check early proposal", :calculate_guided, :achivable_settings - - context "if the given device settings sets a disk as target" do - before do - achivable_settings.device = Agama::Storage::DeviceSettings::Disk.new - end - - context "and the target disk is not indicated" do - before do - achivable_settings.device.name = nil - end - - it "sets the first available device as target device for volumes" do - subject.calculate_guided(achivable_settings) - y2storage_settings = Y2Storage::StorageManager.instance.proposal.settings - - expect(y2storage_settings.volumes).to contain_exactly( - an_object_having_attributes(mount_point: "/", device: "/dev/sda") - ) - end - end - end - - context "if the given device settings sets a new LVM volume group as target" do - before do - achivable_settings.device = Agama::Storage::DeviceSettings::NewLvmVg.new - end - - context "and the target disks for physical volumes are not indicated" do - before do - achivable_settings.device.candidate_pv_devices = [] - end - - it "sets the first available device as candidate device" do - subject.calculate_guided(achivable_settings) - y2storage_settings = Y2Storage::StorageManager.instance.proposal.settings - - expect(y2storage_settings.candidate_devices).to contain_exactly("/dev/sda") - end - end - end - end - describe "#calculate_agama" do it "calculates a proposal with the agama strategy and with the given config" do expect(Y2Storage::StorageManager.instance.proposal).to be_nil @@ -759,29 +591,6 @@ def drive(partitions) end describe "#calculate_from_json" do - context "if the JSON contains storage guided settings" do - let(:config_json) do - { - storage: { - guided: { - target: { - disk: "/dev/vda" - } - } - } - } - end - - it "calculates a proposal with the guided strategy and with the expected settings" do - expect(subject).to receive(:calculate_guided) do |settings| - expect(settings).to be_a(Agama::Storage::ProposalSettings) - expect(settings.device.name).to eq("/dev/vda") - end - - subject.calculate_from_json(config_json) - end - end - context "if the JSON contains storage settings" do let(:config_json) do { @@ -894,60 +703,6 @@ def drive(partitions) ) end end - - context "if the proposal was calculated with the guided strategy" do - before do - mock_storage(devicegraph: "partitioned_md.yml") - end - - let(:impossible_settings) do - Agama::Storage::ProposalSettings.new.tap do |settings| - settings.device.name = "/dev/sdb" - settings.volumes = [ - # The boot disk size is 500 GiB, so it cannot accomodate a 1 TiB volume. - Agama::Storage::Volume.new("/").tap { |v| v.min_size = Y2Storage::DiskSize.TiB(1) } - ] - end - end - - context "and the settings does not indicate a target device" do - before do - # Avoid to automatically set the first device - allow(Y2Storage::StorageManager.instance.probed_disk_analyzer) - .to receive(:candidate_disks).and_return([]) - end - - let(:settings) { impossible_settings.tap { |s| s.device.name = nil } } - - it "includes an error because a device is not selected" do - subject.calculate_guided(settings) - - expect(subject.issues).to include( - an_object_having_attributes(description: /No device selected/) - ) - - expect(subject.issues).to_not include( - an_object_having_attributes(description: /is not found/) - ) - - expect(subject.issues).to_not include( - an_object_having_attributes(description: /are not found/) - ) - end - end - - context "and some installation device is missing in the system" do - let(:settings) { impossible_settings.tap { |s| s.device.name = "/dev/vdz" } } - - it "includes an error because the device is not found" do - subject.calculate_guided(settings) - - expect(subject.issues).to include( - an_object_having_attributes(description: /is not found/) - ) - end - end - end end describe "#guided?" do @@ -958,17 +713,6 @@ def drive(partitions) end end - context "if the proposal was calculated with the guided strategy" do - before do - settings = Agama::Storage::ProposalSettings.new - subject.calculate_guided(settings) - end - - it "returns true" do - expect(subject.guided?).to eq(true) - end - end - context "if the proposal was calculated with any other strategy" do before do subject.calculate_agama(achivable_config) @@ -979,34 +723,4 @@ def drive(partitions) end end end - - describe "#guided_settings" do - context "if no proposal has been calculated yet" do - it "returns nil" do - expect(subject.calculated?).to eq(false) - expect(subject.guided_settings).to be_nil - end - end - - context "if the proposal was calculated with the guided strategy" do - before do - settings = Agama::Storage::ProposalSettings.new - subject.calculate_guided(settings) - end - - it "returns the guided settings" do - expect(subject.guided_settings).to be_a(Agama::Storage::ProposalSettings) - end - end - - context "if the proposal was calculated with any other strategy" do - before do - subject.calculate_agama(achivable_config) - end - - it "returns nil" do - expect(subject.guided_settings).to be_nil - end - end - end end diff --git a/service/test/agama/storage/proposal_volumes_test.rb b/service/test/agama/storage/proposal_volumes_test.rb deleted file mode 100644 index 1638d997d1..0000000000 --- a/service/test/agama/storage/proposal_volumes_test.rb +++ /dev/null @@ -1,351 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2022-2023] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require_relative "../../test_helper" -require_relative "storage_helpers" -require "agama/storage/proposal" -require "agama/storage/proposal_settings" -require "agama/storage/volume_templates_builder" -require "agama/config" -require "y2storage" - -describe Agama::Storage::Proposal do - include Agama::RSpec::StorageHelpers - before do - allow(Yast::SCR).to receive(:Read).and_call_original - allow(Yast::SCR).to receive(:Read).with(path(".proc.meminfo")) - .and_return("memtotal" => 8388608) - - mock_storage - end - - subject(:proposal) { described_class.new(config, logger: logger) } - - let(:logger) { Logger.new($stdout, level: :warn) } - let(:config) { Agama::Config.new(config_data) } - - let(:config_data) do - { "storage" => { "volumes" => cfg_volumes, "volume_templates" => cfg_templates } } - end - - let(:cfg_volumes) { ["/", "swap"] } - - let(:cfg_templates) { [root_template, swap_template, other_template] } - let(:root_template) do - { - "mount_path" => "/", "filesystem" => "btrfs", "size" => { "auto" => true }, - "outline" => { - "snapshots_configurable" => true, - "auto_size" => { - "base_min" => "10 GiB", "base_max" => "20 GiB", - "min_fallback_for" => ["/two"], "snapshots_increment" => "300%" - } - } - } - end - let(:swap_template) do - { - "mount_path" => "swap", "filesystem" => "swap", "size" => { "auto" => true }, - "outline" => { - "auto_size" => { "base_min" => "1 GiB", "base_max" => "2 GiB", "adjust_by_ram" => true } - } - } - end - let(:other_template) do - { - "mount_path" => "/two", "filesystem" => "xfs", - "size" => { "auto" => false, "min" => "5 GiB" } - } - end - - let(:settings) do - settings = Agama::Storage::ProposalSettings.new - settings.volumes = volumes - settings - end - - let(:y2storage_proposal) do - instance_double(Y2Storage::MinGuidedProposal, - propose: true, failed?: false, settings: y2storage_settings, planned_devices: []) - end - - let(:vol_builder) { Agama::Storage::VolumeTemplatesBuilder.new_from_config(config) } - - let(:y2storage_settings) { Y2Storage::ProposalSettings.new } - - # Constructs a Agama volume with the given set of attributes - # - # @param attrs [Hash] set of attributes and their values (sizes can be provided as strings) - # @return [Agama::Storage::Volume] - def test_vol(path, attrs = {}) - vol = vol_builder.for(path) - attrs.each do |attr, value| - if [:min_size, :max_size].include?(attr.to_sym) - # DiskSize.new can take a DiskSize, a string or a number - value = Y2Storage::DiskSize.new(value) - end - if attr.to_sym == :snapshots - vol.btrfs.snapshots = value - else - vol.public_send(:"#{attr}=", value) - end - end - vol - end - - # Sets the expectation for a Y2Storage::MinGuidedProposal to be created with the - # given set of Y2Storage::VolumeSpecification objects and returns proposal mocked as - # 'y2storage_proposal' - # - # @param specs [Hash] arguments to check on each VolumeSpecification object - def expect_proposal_with_specs(*specs) - expect(Y2Storage::MinGuidedProposal).to receive(:new) do |**args| - expect(args[:settings]).to be_a(Y2Storage::ProposalSettings) - expect(args[:settings].volumes).to all(be_a(Y2Storage::VolumeSpecification)) - expect(args[:settings].volumes).to contain_exactly( - *specs.map { |spec| an_object_having_attributes(spec) } - ) - - y2storage_proposal - end - end - - context "when auto size is used and the size is affected by other volumes" do - let(:volumes) { [test_vol("/", snapshots: false, auto_size: true, min_size: "4 GiB")] } - - describe "#calculate" do - before do - allow(Y2Storage::StorageManager.instance) - .to receive(:proposal).and_return(y2storage_proposal) - - allow(Y2Storage::StorageManager.instance).to receive(:proposal=) - end - - it "runs the Y2Storage proposal with the correct set of VolumeSpecification" do - expect_proposal_with_specs( - { - mount_point: "/", proposed: true, snapshots: false, - ignore_fallback_sizes: false, ignore_snapshots_sizes: false, - min_size: Y2Storage::DiskSize.GiB(10) - }, - { mount_point: "swap", proposed: false }, - { mount_point: "/two", proposed: false, fallback_for_min_size: "/" } - ) - proposal.calculate_guided(settings) - end - end - - describe "#settings" do - it "returns settings with a set of volumes with adjusted sizes" do - proposal.calculate_guided(settings) - - expect(proposal.guided_settings.volumes).to contain_exactly( - an_object_having_attributes( - mount_path: "/", - auto_size: true, - min_size: Y2Storage::DiskSize.GiB(15), - btrfs: an_object_having_attributes(snapshots?: false) - ) - ) - end - end - end - - context "when auto size is used and it is affected by snapshots" do - let(:volumes) { [test_vol("/", snapshots: true), test_vol("/two")] } - - describe "#calculate" do - before do - allow(Y2Storage::StorageManager.instance) - .to receive(:proposal).and_return(y2storage_proposal) - - allow(Y2Storage::StorageManager.instance).to receive(:proposal=) - end - - it "runs the Y2Storage proposal with the correct set of VolumeSpecification" do - expect_proposal_with_specs( - { - mount_point: "/", proposed: true, snapshots: true, - ignore_fallback_sizes: false, ignore_snapshots_sizes: false, - min_size: Y2Storage::DiskSize.GiB(10) - }, - { mount_point: "swap", proposed: false }, - { mount_point: "/two", proposed: true, fallback_for_min_size: "/" } - ) - proposal.calculate_guided(settings) - end - end - - describe "#settings" do - it "returns settings with a set of volumes with adjusted sizes" do - proposal.calculate_guided(settings) - - expect(proposal.guided_settings.volumes).to contain_exactly( - an_object_having_attributes( - mount_path: "/", - auto_size: true, - min_size: Y2Storage::DiskSize.GiB(40), - btrfs: an_object_having_attributes(snapshots?: true) - ), - an_object_having_attributes(mount_path: "/two") - ) - end - end - end - - context "when auto size is used and it is affected by snapshots and other volumes" do - let(:volumes) { [test_vol("/", auto_size: true, snapshots: true, min_size: "6 GiB")] } - - describe "#calculate" do - before do - allow(Y2Storage::StorageManager.instance) - .to receive(:proposal).and_return(y2storage_proposal) - - allow(Y2Storage::StorageManager.instance).to receive(:proposal=) - end - - it "runs the Y2Storage proposal with the correct set of VolumeSpecification" do - expect_proposal_with_specs( - { - mount_point: "/", proposed: true, snapshots: true, - ignore_fallback_sizes: false, ignore_snapshots_sizes: false, - min_size: Y2Storage::DiskSize.GiB(10) - }, - { mount_point: "swap", proposed: false }, - { mount_point: "/two", proposed: false, fallback_for_min_size: "/" } - ) - proposal.calculate_guided(settings) - end - end - - describe "#settings" do - it "returns settings with a set of volumes with fixed limits and adjusted sizes" do - proposal.calculate_guided(settings) - - expect(proposal.guided_settings.volumes).to contain_exactly( - an_object_having_attributes( - mount_path: "/", - btrfs: an_object_having_attributes(snapshots?: true), - auto_size?: true, - min_size: Y2Storage::DiskSize.GiB(60), - outline: an_object_having_attributes(min_size_fallback_for: ["/two"]) - ) - ) - end - end - end - - context "when auto size is used and it is affected by RAM size" do - let(:volumes) { [test_vol("/"), test_vol("swap")] } - - describe "#calculate" do - before do - allow(Y2Storage::StorageManager.instance) - .to receive(:proposal).and_return(y2storage_proposal) - - allow(Y2Storage::StorageManager.instance).to receive(:proposal=) - end - - it "runs the Y2Storage proposal with the correct set of VolumeSpecification" do - expect_proposal_with_specs( - { mount_point: "/", proposed: true }, - { mount_point: "/two", proposed: false }, - { - mount_point: "swap", proposed: true, ignore_adjust_by_ram: false, - min_size: Y2Storage::DiskSize.GiB(1) - } - ) - proposal.calculate_guided(settings) - end - end - - describe "#settings" do - it "returns settings with a set of volumes with adjusted sizes" do - proposal.calculate_guided(settings) - - expect(proposal.guided_settings.volumes).to contain_exactly( - an_object_having_attributes(mount_path: "/", auto_size: true), - an_object_having_attributes( - mount_path: "swap", - auto_size?: true, - min_size: Y2Storage::DiskSize.GiB(8) - ) - ) - end - end - end - - context "when fixed sizes are enforced" do - let(:volumes) do - [ - test_vol("/", auto_size: false, min_size: "6 GiB"), - test_vol("swap", auto_size: false, min_size: "1 GiB") - ] - end - - describe "#calculate" do - before do - allow(Y2Storage::StorageManager.instance) - .to receive(:proposal).and_return(y2storage_proposal) - - allow(Y2Storage::StorageManager.instance).to receive(:proposal=) - end - - it "runs the Y2Storage proposal with the correct set of VolumeSpecification" do - expect_proposal_with_specs( - { - mount_point: "/", proposed: true, snapshots: false, - ignore_fallback_sizes: true, ignore_snapshots_sizes: true, - min_size: Y2Storage::DiskSize.GiB(6) - }, - { - mount_point: "swap", proposed: true, ignore_adjust_by_ram: true, - min_size: Y2Storage::DiskSize.GiB(1) - }, - { mount_point: "/two", proposed: false, fallback_for_min_size: "/" } - ) - proposal.calculate_guided(settings) - end - end - - describe "#settings" do - it "returns settings with a set of volumes with fixed limits and adjusted sizes" do - proposal.calculate_guided(settings) - - expect(proposal.guided_settings.volumes).to contain_exactly( - an_object_having_attributes( - mount_path: "/", - btrfs: an_object_having_attributes(snapshots?: false), - auto_size?: false, - min_size: Y2Storage::DiskSize.GiB(6), - outline: an_object_having_attributes(min_size_fallback_for: ["/two"]) - ), - an_object_having_attributes( - mount_path: "swap", - auto_size?: false, - min_size: Y2Storage::DiskSize.GiB(1), - outline: an_object_having_attributes(adjust_by_ram: true) - ) - ) - end - end - end -end diff --git a/service/test/agama/storage/volume_conversions/from_json_test.rb b/service/test/agama/storage/volume_conversions/from_json_test.rb deleted file mode 100644 index 579da8505b..0000000000 --- a/service/test/agama/storage/volume_conversions/from_json_test.rb +++ /dev/null @@ -1,279 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require_relative "../../../test_helper" -require_relative "../../rspec/matchers/storage" -require "agama/config" -require "agama/storage/volume" -require "agama/storage/volume_templates_builder" -require "agama/storage/volume_conversions/from_json" -require "y2storage/disk_size" - -def default_volume(mount_path) - Agama::Storage::VolumeTemplatesBuilder.new_from_config(config).for(mount_path) -end - -describe Agama::Storage::VolumeConversions::FromJSON do - subject { described_class.new(volume_json, config: config) } - - let(:config) { Agama::Config.new(config_data) } - - let(:config_data) do - { - "storage" => { - "volume_templates" => [ - { - "mount_path" => "/test", - "mount_options" => ["data=ordered"], - "filesystem" => "btrfs", - "size" => { - "auto" => false, - "min" => "5 GiB", - "max" => "10 GiB" - }, - "btrfs" => { - "snapshots" => false - }, - "outline" => outline - } - ] - } - } - end - - let(:outline) do - { - "filesystems" => ["xfs", "ext3", "ext4"], - "snapshots_configurable" => true - } - end - - describe "#convert" do - let(:volume_json) do - { - mount: { - path: "/test", - options: ["rw", "default"] - }, - target: { - newVg: "/dev/sda" - }, - filesystem: "ext4", - size: { - min: 1024, - max: 2048 - } - } - end - - it "generates a volume with the expected outline from JSON" do - volume = subject.convert - - expect(volume.outline).to eq_outline(default_volume("/test").outline) - end - - it "generates a volume with the values provided from JSON" do - volume = subject.convert - - expect(volume).to be_a(Agama::Storage::Volume) - expect(volume.mount_path).to eq("/test") - expect(volume.mount_options).to contain_exactly("rw", "default") - expect(volume.location.device).to eq("/dev/sda") - expect(volume.location.target).to eq(:new_vg) - expect(volume.fs_type).to eq(Y2Storage::Filesystems::Type::EXT4) - expect(volume.auto_size?).to eq(false) - expect(volume.min_size.to_i).to eq(1024) - expect(volume.max_size.to_i).to eq(2048) - expect(volume.btrfs.snapshots).to eq(false) - end - - context "when the JSON is missing some values" do - let(:volume_json) do - { - mount: { - path: "/test" - } - } - end - - it "completes the missing values with default values from the config" do - volume = subject.convert - - expect(volume).to be_a(Agama::Storage::Volume) - expect(volume.mount_path).to eq("/test") - expect(volume.mount_options).to contain_exactly("data=ordered") - expect(volume.location.target).to eq :default - expect(volume.fs_type).to eq(Y2Storage::Filesystems::Type::BTRFS) - expect(volume.auto_size?).to eq(false) - expect(volume.min_size.to_i).to eq(5 * (1024**3)) - expect(volume.max_size.to_i).to eq(10 * (1024**3)) - expect(volume.btrfs.snapshots?).to eq(false) - end - end - - context "when the JSON does not indicate max size" do - let(:volume_json) do - { - mount: { - path: "/test" - }, - size: { - min: 1024 - } - } - end - - it "generates a volume with unlimited max size" do - volume = subject.convert - - expect(volume.max_size).to eq(Y2Storage::DiskSize.unlimited) - end - end - - context "when the JSON indicates auto size for a supported volume" do - let(:outline) do - { - "auto_size" => { - "min_fallback_for" => ["/"] - } - } - end - - let(:volume_json) do - { - mount: { - path: "/test" - }, - size: "auto" - } - end - - it "generates a volume with auto size" do - volume = subject.convert - - expect(volume.auto_size?).to eq(true) - end - end - - context "when the JSON indicates auto size for an unsupported volume" do - let(:outline) { {} } - - let(:volume_json) do - { - mount: { - path: "/test" - }, - size: "auto" - } - end - - it "ignores the auto size setting" do - volume = subject.convert - - expect(volume.auto_size?).to eq(false) - end - end - - context "when the JSON indicates a filesystem included in the outline" do - let(:outline) { { "filesystems" => ["btrfs", "ext4"] } } - - let(:volume_json) do - { - mount: { - path: "/test" - }, - filesystem: "ext4" - } - end - - it "generates a volume with the indicated filesystem" do - volume = subject.convert - - expect(volume.fs_type).to eq(Y2Storage::Filesystems::Type::EXT4) - end - end - - context "when the JSON indicates a filesystem not included in the outline" do - let(:outline) { { "filesystems" => ["btrfs"] } } - - let(:volume_json) do - { - mount: { - path: "/test" - }, - filesystem: "ext4" - } - end - - it "ignores the filesystem setting" do - volume = subject.convert - - expect(volume.fs_type).to eq(Y2Storage::Filesystems::Type::BTRFS) - end - end - - context "when the JSON indicates snapshots for a supported volume" do - let(:outline) { { "snapshots_configurable" => true } } - - let(:volume_json) do - { - mount: { - path: "/test" - }, - filesystem: { - btrfs: { - snapshots: true - } - } - } - end - - it "generates a volume with snapshots" do - volume = subject.convert - - expect(volume.btrfs.snapshots?).to eq(true) - end - end - - context "when the JSON indicates snapshots for an unsupported volume" do - let(:outline) { { "snapshots_configurable" => false } } - - let(:volume_json) do - { - mount: { - path: "/test" - }, - filesystem: { - btrfs: { - snapshots: true - } - } - } - end - - it "ignores the snapshots setting" do - volume = subject.convert - - expect(volume.btrfs.snapshots?).to eq(false) - end - end - end -end diff --git a/service/test/agama/storage/volume_conversions/from_y2storage_test.rb b/service/test/agama/storage/volume_conversions/from_y2storage_test.rb deleted file mode 100644 index 7fa66d558b..0000000000 --- a/service/test/agama/storage/volume_conversions/from_y2storage_test.rb +++ /dev/null @@ -1,153 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2023-2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require_relative "../../../test_helper" -require_relative "../storage_helpers" -require_relative "../../rspec/matchers/storage" -require "agama/storage/volume" -require "agama/storage/volume_conversions/from_y2storage" -require "y2storage" - -describe Agama::Storage::VolumeConversions::FromY2Storage do - include Agama::RSpec::StorageHelpers - - before { mock_storage } - - subject { described_class.new(volume) } - - let(:btrfs) { Y2Storage::Filesystems::Type::BTRFS } - let(:ext4) { Y2Storage::Filesystems::Type::EXT4 } - let(:xfs) { Y2Storage::Filesystems::Type::XFS } - - let(:volume) do - Agama::Storage::Volume.new("/").tap do |volume| - volume.location.target = :new_vg - volume.location.device = "/dev/sda" - volume.mount_options = ["defaults"] - volume.fs_type = btrfs - volume.auto_size = false - volume.min_size = Y2Storage::DiskSize.GiB(5) - volume.max_size = Y2Storage::DiskSize.GiB(20) - volume.btrfs.snapshots = true - volume.btrfs.subvolumes = ["@/home", "@/var"] - volume.btrfs.default_subvolume = "@" - volume.btrfs.read_only = true - volume.outline.required = true - volume.outline.filesystems = [btrfs, ext4, xfs] - volume.outline.adjust_by_ram = false - volume.outline.snapshots_configurable = true - volume.outline.snapshots_size = Y2Storage::DiskSize.GiB(10) - volume.outline.snapshots_percentage = 20 - end - end - - describe "#convert" do - it "generates a volume with the same values as the given volume" do - result = subject.convert - - expect(result).to be_a(Agama::Storage::Volume) - expect(result).to_not equal(volume) - expect(result.location.target).to eq(:new_vg) - expect(result.location.device).to eq("/dev/sda") - expect(result.mount_path).to eq("/") - expect(result.mount_options).to contain_exactly("defaults") - expect(result.fs_type).to eq(btrfs) - expect(result.auto_size).to eq(false) - expect(result.min_size).to eq(Y2Storage::DiskSize.GiB(5)) - expect(result.max_size).to eq(Y2Storage::DiskSize.GiB(20)) - expect(result.btrfs.snapshots).to eq(true) - expect(result.btrfs.subvolumes).to contain_exactly("@/home", "@/var") - expect(result.btrfs.default_subvolume).to eq("@") - expect(result.btrfs.read_only).to eq(true) - expect(result.outline).to eq_outline(volume.outline) - end - - context "sizes conversion" do - before do - allow(Y2Storage::StorageManager.instance).to receive(:proposal).and_return(proposal) - end - - let(:proposal) do - instance_double(Y2Storage::MinGuidedProposal, planned_devices: planned_devices) - end - - let(:planned_devices) { [planned_volume] } - - context "if the volume is configured with auto size" do - before do - volume.auto_size = true - end - - context "if there is a planned device for the volume" do - let(:planned_volume) do - Y2Storage::Planned::LvmLv.new("/").tap do |planned| - planned.min = Y2Storage::DiskSize.GiB(10) - planned.max = Y2Storage::DiskSize.GiB(40) - end - end - - it "sets the min and max sizes according to the planned device" do - result = subject.convert - - expect(result.min_size).to eq(Y2Storage::DiskSize.GiB(10)) - expect(result.max_size).to eq(Y2Storage::DiskSize.GiB(40)) - end - end - - context "if there is no planned device for the volume" do - let(:planned_volume) do - Y2Storage::Planned::LvmLv.new("/home").tap do |planned| - planned.min = Y2Storage::DiskSize.GiB(10) - planned.max = Y2Storage::DiskSize.GiB(40) - end - end - - it "keeps the sizes of the given volume" do - result = subject.convert - - expect(result.min_size).to eq(Y2Storage::DiskSize.GiB(5)) - expect(result.max_size).to eq(Y2Storage::DiskSize.GiB(20)) - end - end - end - - context "if the volume is not configured with auto size" do - before do - volume.auto_size = false - end - - let(:planned_volume) do - Y2Storage::Planned::LvmLv.new("/").tap do |planned| - planned.min = Y2Storage::DiskSize.GiB(10) - planned.max = Y2Storage::DiskSize.GiB(40) - end - end - - it "keeps the sizes of the given volume" do - result = subject.convert - - expect(result.min_size).to eq(Y2Storage::DiskSize.GiB(5)) - expect(result.max_size).to eq(Y2Storage::DiskSize.GiB(20)) - end - end - end - end -end diff --git a/service/test/agama/storage/volume_conversions/to_json_test.rb b/service/test/agama/storage/volume_conversions/to_json_test.rb index 11cb600328..81b52c904f 100644 --- a/service/test/agama/storage/volume_conversions/to_json_test.rb +++ b/service/test/agama/storage/volume_conversions/to_json_test.rb @@ -52,42 +52,60 @@ # @todo Check whether the result matches the JSON schema. expect(described_class.new(default_volume).convert).to eq( - mount: { - path: "/test", - options: [] - }, - size: { - min: 0 - }, - target: "default" + mountPath: "/test", + mountOptions: [], + fsType: "", + minSize: 0, + autoSize: false, + snapshots: false, + transactional: false, + outline: { + required: false, + fsTypes: [], + supportAutoSize: false, + adjustByRam: false, + snapshotsConfigurable: false, + snapshotsAffectSizes: false, + sizeRelevantVolumes: [] + } ) expect(described_class.new(custom_volume1).convert).to eq( - mount: { - path: "/test", - options: [] - }, - filesystem: "xfs", - size: "auto", - target: "default" + mountPath: "/test", + mountOptions: [], + fsType: "xfs", + minSize: 0, + autoSize: true, + snapshots: false, + transactional: false, + outline: { + required: false, + fsTypes: [], + supportAutoSize: false, + adjustByRam: false, + snapshotsConfigurable: false, + snapshotsAffectSizes: false, + sizeRelevantVolumes: [] + } ) expect(described_class.new(custom_volume2).convert).to eq( - mount: { - path: "/test", - options: ["rw", "default"] - }, - size: { - min: 1024, - max: 2048 - }, - target: { - newPartition: "/dev/sda" - }, - filesystem: { - btrfs: { - snapshots: true - } + mountPath: "/test", + mountOptions: ["rw", "default"], + fsType: "btrfs", + minSize: 1024, + maxSize: 2048, + autoSize: false, + snapshots: true, + transactional: false, + outline: { + required: false, + fsTypes: [], + supportAutoSize: false, + adjustByRam: false, + snapshotsConfigurable: false, + snapshotsAffectSizes: false, + sizeRelevantVolumes: [] } ) end diff --git a/service/test/agama/storage/volume_test.rb b/service/test/agama/storage/volume_test.rb index 2c46c68acc..0dc8dc7c38 100644 --- a/service/test/agama/storage/volume_test.rb +++ b/service/test/agama/storage/volume_test.rb @@ -25,24 +25,6 @@ require "y2storage/volume_specification" describe Agama::Storage::Volume do - describe ".new_from_json" do - let(:config) { Agama::Config.new } - - let(:volume_json) do - { - mount: { - path: "/test" - } - } - end - - it "generates a volume from JSON according to schema" do - result = described_class.new_from_json(volume_json, config: config) - expect(result).to be_a(Agama::Storage::Volume) - expect(result.mount_path).to eq("/test") - end - end - describe "#to_json_settngs" do let(:volume) { Agama::Storage::Volume.new("/test") } diff --git a/service/test/agama/with_progress_examples.rb b/service/test/agama/with_progress_examples.rb index 9e8876548c..3e7b9e1763 100644 --- a/service/test/agama/with_progress_examples.rb +++ b/service/test/agama/with_progress_examples.rb @@ -35,7 +35,7 @@ end it "returns the progress object" do - expect(subject.progress).to be_a(Agama::Progress) + expect(subject.progress).to be_a(Agama::OldProgress) end end end diff --git a/web/src/api/storage/proposal.ts b/web/src/api/storage/proposal.ts new file mode 100644 index 0000000000..0973d1931d --- /dev/null +++ b/web/src/api/storage/proposal.ts @@ -0,0 +1,118 @@ +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +export type MDLevel = "raid0" | "raid1" | "raid5" | "raid6" | "raid10"; + +/** + * API description of the storage proposal. + */ +export interface Proposal { + /** + * Expected layout of the system after the commit phase. + */ + devices?: StorageDevice[]; + /** + * Sorted list of actions to execute during the commit phase. + */ + actions?: Action[]; +} +/** + * Schema to describe a device both in 'system' and 'proposal'. + */ +export interface StorageDevice { + sid: number; + name: string; + description?: string; + block?: Block; + drive?: Drive; + filesystem?: Filesystem; + md?: Md; + multipath?: Multipath; + partitionTable?: PartitionTable; + partition?: Partition; + partitions?: StorageDevice[]; + volumeGroup?: VolumeGroup; + logicalVolumes?: StorageDevice[]; +} +export interface Block { + start: number; + size: number; + active?: boolean; + encrypted?: boolean; + udevIds?: string[]; + udevPaths?: string[]; + systems?: string[]; + shrinking: ShrinkingSupported | ShrinkingUnsupported; +} +export interface ShrinkingSupported { + supported?: number; +} +export interface ShrinkingUnsupported { + unsupported?: string[]; +} +export interface Drive { + type?: "disk" | "raid" | "multipath" | "dasd"; + vendor?: string; + model?: string; + transport?: string; + bus?: string; + busId?: string; + driver?: string[]; + info?: DriveInfo; +} +export interface DriveInfo { + sdCard?: boolean; + dellBoss?: boolean; +} +export interface Filesystem { + sid: number; + type: + | "bcachefs" + | "btrfs" + | "exfat" + | "ext2" + | "ext3" + | "ext4" + | "f2fs" + | "jfs" + | "nfs" + | "nilfs2" + | "ntfs" + | "reiserfs" + | "swap" + | "tmpfs" + | "vfat" + | "xfs"; + mountPath?: string; + label?: string; +} +export interface Md { + level: MDLevel; + devices: number[]; + uuid?: string; +} +export interface Multipath { + wireNames: string[]; +} +export interface PartitionTable { + type: "gpt" | "msdos" | "dasd"; + unusedSlots: number[][]; +} +export interface Partition { + efi: boolean; +} +export interface VolumeGroup { + size: number; + physicalVolumes: number[]; +} +export interface Action { + device: number; + text: string; + subvol?: boolean; + delete?: boolean; + resize?: boolean; +} diff --git a/web/src/api/storage/system.ts b/web/src/api/storage/system.ts new file mode 100644 index 0000000000..88078a978e --- /dev/null +++ b/web/src/api/storage/system.ts @@ -0,0 +1,204 @@ +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +export type MDLevel = "raid0" | "raid1" | "raid5" | "raid6" | "raid10"; + +/** + * API description of the system + */ +export interface System { + /** + * All relevant devices on the system + */ + devices?: StorageDevice[]; + /** + * SIDs of the available drives + */ + availableDrives?: number[]; + /** + * SIDs of the available MD RAIDs + */ + availableMdRaids?: number[]; + /** + * SIDs of the drives that are candidate for installation + */ + candidateDrives?: number[]; + /** + * SIDs of the MD RAIDs that are candidate for installation + */ + candidateMdRaids?: number[]; + /** + * Meaningful mount points for the current product + */ + productMountPoints?: string[]; + /** + * Possible encryption methods for the current system and product + */ + encryptionMethods?: ( + | "luks1" + | "luks2" + | "pervasiveLuks2" + | "tmpFde" + | "protectedSwap" + | "secureSwap" + | "randomSwap" + )[]; + /** + * Volumes defined by the product as templates + */ + volumeTemplates?: Volume[]; + issues?: Issue[]; +} +/** + * Schema to describe a device both in 'system' and 'proposal'. + */ +export interface StorageDevice { + sid: number; + name: string; + description?: string; + block?: Block; + drive?: Drive; + filesystem?: Filesystem; + md?: Md; + multipath?: Multipath; + partitionTable?: PartitionTable; + partition?: Partition; + partitions?: StorageDevice[]; + volumeGroup?: VolumeGroup; + logicalVolumes?: StorageDevice[]; +} +export interface Block { + start: number; + size: number; + active?: boolean; + encrypted?: boolean; + udevIds?: string[]; + udevPaths?: string[]; + systems?: string[]; + shrinking: ShrinkingSupported | ShrinkingUnsupported; +} +export interface ShrinkingSupported { + supported?: number; +} +export interface ShrinkingUnsupported { + unsupported?: string[]; +} +export interface Drive { + type?: "disk" | "raid" | "multipath" | "dasd"; + vendor?: string; + model?: string; + transport?: string; + bus?: string; + busId?: string; + driver?: string[]; + info?: DriveInfo; +} +export interface DriveInfo { + sdCard?: boolean; + dellBoss?: boolean; +} +export interface Filesystem { + sid: number; + type: + | "bcachefs" + | "btrfs" + | "exfat" + | "ext2" + | "ext3" + | "ext4" + | "f2fs" + | "jfs" + | "nfs" + | "nilfs2" + | "ntfs" + | "reiserfs" + | "swap" + | "tmpfs" + | "vfat" + | "xfs"; + mountPath?: string; + label?: string; +} +export interface Md { + level: MDLevel; + devices: number[]; + uuid?: string; +} +export interface Multipath { + wireNames: string[]; +} +export interface PartitionTable { + type: "gpt" | "msdos" | "dasd"; + unusedSlots: number[][]; +} +export interface Partition { + efi: boolean; +} +export interface VolumeGroup { + size: number; + physicalVolumes: number[]; +} +export interface Volume { + mountPath: string; + mountOptions?: string[]; + fsType?: + | "bcachefs" + | "btrfs" + | "exfat" + | "ext2" + | "ext3" + | "ext4" + | "f2fs" + | "jfs" + | "nfs" + | "nilfs2" + | "ntfs" + | "reiserfs" + | "swap" + | "tmpfs" + | "vfat" + | "xfs"; + autoSize: boolean; + minSize: number; + maxSize?: number; + snapshots?: boolean; + transactional?: boolean; + outline?: VolumeOutline; +} +export interface VolumeOutline { + required: boolean; + supportAutoSize: boolean; + fsTypes?: ( + | "bcachefs" + | "btrfs" + | "exfat" + | "ext2" + | "ext3" + | "ext4" + | "f2fs" + | "jfs" + | "nfs" + | "nilfs2" + | "ntfs" + | "reiserfs" + | "swap" + | "tmpfs" + | "vfat" + | "xfs" + )[]; + adjustByRam?: boolean; + snapshotsConfigurable?: boolean; + snapshotsAffectSizes?: boolean; + sizeRelevantVolumes?: string[]; +} +export interface Issue { + description: string; + class?: string; + details?: string; + source?: "config" | "system"; + severity?: "warn" | "error"; +}