diff --git a/rust/agama-derive/src/lib.rs b/rust/agama-derive/src/lib.rs index 020d4d461f..ec72af3637 100644 --- a/rust/agama-derive/src/lib.rs +++ b/rust/agama-derive/src/lib.rs @@ -41,6 +41,7 @@ enum SettingKind { Collection, /// The value is another FooSettings, use `#[settings(nested)]`. Nested, + Ignored, } /// Represents a setting and its configuration @@ -176,7 +177,7 @@ fn expand_merge_fn(settings: &SettingFieldsList) -> TokenStream2 { let arms = settings.all().iter().map(|s| { let field_name = &s.ident; match s.kind { - SettingKind::Scalar => quote! { + SettingKind::Scalar | SettingKind::Ignored => quote! { if let Some(value) = &other.#field_name { self.#field_name = Some(value.clone()) } @@ -274,6 +275,10 @@ fn parse_setting_fields(fields: Vec<&syn::Field>) -> SettingFieldsList { setting.kind = SettingKind::Nested; } + if meta.path.is_ident("ignored") { + setting.kind = SettingKind::Ignored; + } + if meta.path.is_ident("flatten") { setting.flatten = true; } diff --git a/rust/agama-lib/Cargo.toml b/rust/agama-lib/Cargo.toml index 1d67eb918a..4dabd10eca 100644 --- a/rust/agama-lib/Cargo.toml +++ b/rust/agama-lib/Cargo.toml @@ -15,7 +15,7 @@ jsonschema = { version = "0.16.1", default-features = false } log = "0.4" reqwest = { version = "0.12.4", features = ["json", "cookies"] } serde = { version = "1.0.152", features = ["derive"] } -serde_json = "1.0.94" +serde_json = { version = "1.0.94", features = ["raw_value"] } serde_repr = "0.1.18" tempfile = "3.4.0" thiserror = "1.0.39" diff --git a/rust/agama-lib/src/install_settings.rs b/rust/agama-lib/src/install_settings.rs index d292db79df..de4ddff75b 100644 --- a/rust/agama-lib/src/install_settings.rs +++ b/rust/agama-lib/src/install_settings.rs @@ -7,6 +7,7 @@ use crate::{ }; use agama_settings::Settings; use serde::{Deserialize, Serialize}; +use serde_json::value::RawValue; use std::default::Default; use std::str::FromStr; @@ -22,6 +23,8 @@ pub enum Scope { Software, /// Storage settings Storage, + /// Storage AutoYaST settings (for backward compatibility with AutoYaST profiles) + StorageAutoyast, /// Network settings Network, /// Product settings @@ -34,13 +37,14 @@ impl Scope { /// Returns known scopes /// // TODO: we can rely on strum so we do not forget to add them - pub fn all() -> [Scope; 6] { + pub fn all() -> [Scope; 7] { [ Scope::Localization, Scope::Network, Scope::Product, Scope::Software, Scope::Storage, + Scope::StorageAutoyast, Scope::Users, ] } @@ -49,6 +53,9 @@ impl Scope { impl FromStr for Scope { type Err = &'static str; + // Do not generate the StorageAutoyast scope. Note that storage AutoYaST settings will only be + // temporary available for importing an AutoYaST profile. But CLI should not allow modifying the + // storate AutoYaST settings. fn from_str(s: &str) -> Result { match s { "users" => Ok(Self::Users), @@ -81,6 +88,9 @@ pub struct InstallSettings { #[serde(default)] #[settings(nested)] pub storage: Option, + #[serde(default, rename = "legacyAutoyastStorage")] + #[settings(ignored)] + pub storage_autoyast: Option>, #[serde(default)] #[settings(nested)] pub network: Option, @@ -95,11 +105,12 @@ impl InstallSettings { if self.user.is_some() { scopes.push(Scope::Users); } - if self.storage.is_some() { scopes.push(Scope::Storage); } - + if self.storage_autoyast.is_some() { + scopes.push(Scope::StorageAutoyast); + } if self.software.is_some() { scopes.push(Scope::Software); } diff --git a/rust/agama-lib/src/software/store.rs b/rust/agama-lib/src/software/store.rs index 5bb5ee21c4..3c72d952c2 100644 --- a/rust/agama-lib/src/software/store.rs +++ b/rust/agama-lib/src/software/store.rs @@ -1,4 +1,4 @@ -//! Implements the store for the storage settings. +//! Implements the store for the software settings. use std::collections::HashMap; diff --git a/rust/agama-lib/src/storage.rs b/rust/agama-lib/src/storage.rs index 66940a3599..2df50cbae1 100644 --- a/rust/agama-lib/src/storage.rs +++ b/rust/agama-lib/src/storage.rs @@ -1,11 +1,13 @@ //! Implements support for handling the storage settings +mod autoyast; pub mod client; pub mod model; pub mod proxies; mod settings; mod store; +pub use autoyast::store::StorageAutoyastStore; pub use client::{ iscsi::{ISCSIAuth, ISCSIClient, ISCSIInitiator, ISCSINode}, StorageClient, diff --git a/rust/agama-lib/src/storage/autoyast.rs b/rust/agama-lib/src/storage/autoyast.rs new file mode 100644 index 0000000000..4aefc745c8 --- /dev/null +++ b/rust/agama-lib/src/storage/autoyast.rs @@ -0,0 +1,2 @@ +//! Implements support for handling the storage AutoYaST settings +pub mod store; diff --git a/rust/agama-lib/src/storage/autoyast/store.rs b/rust/agama-lib/src/storage/autoyast/store.rs new file mode 100644 index 0000000000..576f744648 --- /dev/null +++ b/rust/agama-lib/src/storage/autoyast/store.rs @@ -0,0 +1,26 @@ +//! Implements the store for the storage AutoYaST settings. + +use crate::error::ServiceError; +use crate::storage::StorageClient; +use zbus::Connection; + +/// Stores the storage AutoYaST settings to the D-Bus service. +/// +/// NOTE: The AutoYaST settings are not loaded from D-Bus because they cannot be modified. The only +/// way of using the storage AutoYaST settings is by loading a JSON config file. +pub struct StorageAutoyastStore<'a> { + storage_client: StorageClient<'a>, +} + +impl<'a> StorageAutoyastStore<'a> { + pub async fn new(connection: Connection) -> Result, ServiceError> { + Ok(Self { + storage_client: StorageClient::new(connection).await?, + }) + } + + pub async fn store(&self, settings: &str) -> Result<(), ServiceError> { + self.storage_client.calculate_autoyast(settings).await?; + Ok(()) + } +} diff --git a/rust/agama-lib/src/storage/client.rs b/rust/agama-lib/src/storage/client.rs index 3f7c95d451..00ee681442 100644 --- a/rust/agama-lib/src/storage/client.rs +++ b/rust/agama-lib/src/storage/client.rs @@ -148,6 +148,10 @@ impl<'a> StorageClient<'a> { Ok(self.calculator_proxy.calculate(settings.into()).await?) } + pub async fn calculate_autoyast(&self, settings: &str) -> Result { + Ok(self.calculator_proxy.calculate_autoyast(settings).await?) + } + /// Calculates a new proposal with the given settings. pub async fn calculate(&self, settings: &StorageSettings) -> Result { let mut dbus_settings: HashMap<&str, zbus::zvariant::Value<'_>> = HashMap::new(); diff --git a/rust/agama-lib/src/storage/proxies.rs b/rust/agama-lib/src/storage/proxies.rs index f028528583..c64f87d02a 100644 --- a/rust/agama-lib/src/storage/proxies.rs +++ b/rust/agama-lib/src/storage/proxies.rs @@ -29,12 +29,15 @@ trait Storage1 { default_path = "/org/opensuse/Agama/Storage1" )] trait ProposalCalculator { - /// Calculate method + /// Calculate guided proposal fn calculate( &self, settings: std::collections::HashMap<&str, zbus::zvariant::Value<'_>>, ) -> zbus::Result; + /// Calculate AutoYaST proposal + fn calculate_autoyast(&self, settings: &str) -> zbus::Result; + /// DefaultVolume method fn default_volume( &self, @@ -53,9 +56,9 @@ trait ProposalCalculator { #[dbus_proxy(property)] fn product_mount_points(&self) -> zbus::Result>; - /// Result property - #[dbus_proxy(property)] - fn result(&self) -> zbus::Result; + /// Proposal result + fn result(&self) + -> zbus::Result>; } #[dbus_proxy( diff --git a/rust/agama-lib/src/store.rs b/rust/agama-lib/src/store.rs index 9ff30b8ea6..89ac4b2f44 100644 --- a/rust/agama-lib/src/store.rs +++ b/rust/agama-lib/src/store.rs @@ -5,7 +5,8 @@ use crate::error::ServiceError; use crate::install_settings::{InstallSettings, Scope}; use crate::{ localization::LocalizationStore, network::NetworkStore, product::ProductStore, - software::SoftwareStore, storage::StorageStore, users::UsersStore, + software::SoftwareStore, storage::StorageAutoyastStore, storage::StorageStore, + users::UsersStore, }; use zbus::Connection; @@ -21,6 +22,7 @@ pub struct Store<'a> { product: ProductStore<'a>, software: SoftwareStore<'a>, storage: StorageStore<'a>, + storage_autoyast: StorageAutoyastStore<'a>, localization: LocalizationStore<'a>, } @@ -35,11 +37,16 @@ impl<'a> Store<'a> { network: NetworkStore::new(http_client).await?, product: ProductStore::new(connection.clone()).await?, software: SoftwareStore::new(connection.clone()).await?, - storage: StorageStore::new(connection).await?, + storage: StorageStore::new(connection.clone()).await?, + storage_autoyast: StorageAutoyastStore::new(connection).await?, }) } - /// Loads the installation settings from the D-Bus service + /// Loads the installation settings from the D-Bus service. + /// + /// NOTE: The storage AutoYaST settings cannot be loaded because they cannot be modified. The + /// ability of using the storage AutoYaST settings from a JSON config file is temporary and it + /// will be removed in the future. pub async fn load(&self, only: Option>) -> Result { let scopes = match only { Some(scopes) => scopes, @@ -50,6 +57,7 @@ impl<'a> Store<'a> { if scopes.contains(&Scope::Network) { settings.network = Some(self.network.load().await?); } + if scopes.contains(&Scope::Storage) { settings.storage = Some(self.storage.load().await?); } @@ -97,6 +105,12 @@ impl<'a> Store<'a> { if let Some(storage) = &settings.storage { self.storage.store(storage).await?; } + if let Some(storage_autoyast) = &settings.storage_autoyast { + // Storage scope has precedence. + if settings.storage.is_none() { + self.storage_autoyast.store(storage_autoyast.get()).await?; + } + } Ok(()) } } diff --git a/rust/package/agama.changes b/rust/package/agama.changes index d8b8d4e709..70ede6fcec 100644 --- a/rust/package/agama.changes +++ b/rust/package/agama.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Wed Jun 5 13:53:59 UTC 2024 - José Iván López González + +- Process the legacyAutoyastStorage section of the profile + (gh#openSUSE/agama#1284). + ------------------------------------------------------------------- Mon Jun 3 07:49:16 UTC 2024 - Josef Reidinger diff --git a/service/lib/agama/dbus/clients/storage.rb b/service/lib/agama/dbus/clients/storage.rb index bff3080aab..03e3406f6e 100644 --- a/service/lib/agama/dbus/clients/storage.rb +++ b/service/lib/agama/dbus/clients/storage.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2022-2023] SUSE LLC +# Copyright (c) [2022-2024] SUSE LLC # # All Rights Reserved. # @@ -36,12 +36,6 @@ class Storage < Base STORAGE_IFACE = "org.opensuse.Agama.Storage1" private_constant :STORAGE_IFACE - PROPOSAL_CALCULATOR_IFACE = "org.opensuse.Agama.Storage1.Proposal.Calculator" - private_constant :PROPOSAL_CALCULATOR_IFACE - - PROPOSAL_IFACE = "org.opensuse.Agama.Storage1.Proposal" - private_constant :PROPOSAL_IFACE - def service_name @service_name ||= "org.opensuse.Agama.Storage1" end @@ -66,56 +60,12 @@ def finish dbus_object.Finish end - # Devices available for the installation - # - # @return [Array] name of the devices - def available_devices - dbus_object[PROPOSAL_CALCULATOR_IFACE]["AvailableDevices"] - .map(&:first) - end - - # Devices selected for the installation - # - # @return [Array] name of the devices - def candidate_devices - return [] unless dbus_proposal - - dbus_proposal[PROPOSAL_IFACE]["CandidateDevices"] - end - - # Actions to perform in the storage devices - # - # @return [Array] - def actions - return [] unless dbus_proposal - - dbus_proposal[PROPOSAL_IFACE]["Actions"].map do |a| - a["Text"] - end - end - - # Calculates the storage proposal with the given devices - # - # @param candidate_devices [Array] name of the new candidate devices - def calculate(candidate_devices) - calculator_iface = dbus_object[PROPOSAL_CALCULATOR_IFACE] - calculator_iface.Calculate({ "CandidateDevices" => candidate_devices }) - end - private # @return [::DBus::Object] def dbus_object @dbus_object ||= service["/org/opensuse/Agama/Storage1"].tap(&:introspect) end - - # @return [::DBus::Object, nil] - def dbus_proposal - path = dbus_object["org.opensuse.Agama.Storage1.Proposal.Calculator"]["Result"] - return nil if path == "/" - - service.object(path).tap(&:introspect) - end end end end diff --git a/service/lib/agama/dbus/storage/manager.rb b/service/lib/agama/dbus/storage/manager.rb index 283cd54dba..78c9c830c6 100644 --- a/service/lib/agama/dbus/storage/manager.rb +++ b/service/lib/agama/dbus/storage/manager.rb @@ -20,6 +20,7 @@ # find current contact information at www.suse.com. require "dbus" +require "json" require "yast" require "y2storage/storage_manager" require "agama/dbus/base_object" @@ -42,7 +43,7 @@ module Agama module DBus module Storage # D-Bus object to manage storage installation - class Manager < BaseObject + class Manager < BaseObject # rubocop:disable Metrics/ClassLength include WithISCSIAuth include WithServiceStatus include ::DBus::ObjectManager @@ -61,6 +62,7 @@ def initialize(backend, logger) super(PATH, logger: logger) @backend = backend @encryption_methods = read_encryption_methods + @actions = read_actions register_storage_callbacks register_proposal_callbacks @@ -108,6 +110,41 @@ def deprecated_system dbus_reader(:deprecated_system, "b") 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] + def read_actions + backend.actions.map do |action| + { + "Device" => action.target_device.sid, + "Text" => action.sentence, + "Subvol" => action.device_is?(:btrfs_subvolume), + "Delete" => action.delete? + } + end + end + + # A PropertiesChanged signal is emitted (see ::DBus::Object.dbus_reader_attr_accessor). + def update_actions + self.actions = read_actions + end + + dbus_interface STORAGE_DEVICES_INTERFACE do + # PropertiesChanged signal if a proposal is calculated, see + # {#register_proposal_callbacks}. + dbus_reader_attr_accessor :actions, "aa{sv}" + end + + # @todo Rename as "org.opensuse.Agama.Storage1.Proposal". PROPOSAL_CALCULATOR_INTERFACE = "org.opensuse.Agama.Storage1.Proposal.Calculator" private_constant :PROPOSAL_CALCULATOR_INTERFACE @@ -140,13 +177,6 @@ def read_encryption_methods .map { |m| m.id.to_s } end - # Path of the D-Bus object containing the calculated proposal - # - # @return [::DBus::ObjectPath] Proposal object path or root path if no exported proposal yet - def result - dbus_proposal&.path || ::DBus::ObjectPath.new("/") - end - # Default volume used as template # # @return [Hash] @@ -155,25 +185,74 @@ def default_volume(mount_path) VolumeConversion.to_dbus(volume) end - # Calculates a new proposal + module ProposalStrategy + GUIDED = "guided" + AUTOYAST = "autoyast" + end + + # Calculates a guided proposal. # # @param dbus_settings [Hash] # @return [Integer] 0 success; 1 error - def calculate_proposal(dbus_settings) + def calculate_guided_proposal(dbus_settings) settings = ProposalSettingsConversion.from_dbus(dbus_settings, config: config, logger: logger) logger.info( - "Calculating storage proposal from D-Bus.\n " \ + "Calculating guided storage proposal from D-Bus.\n " \ "D-Bus settings: #{dbus_settings}\n" \ "Agama settings: #{settings.inspect}" ) - success = proposal.calculate(settings) + success = proposal.calculate_guided(settings) + success ? 0 : 1 + end + + # Calculates an AutoYaST proposal. + # + # @param dbus_settings [String] + # @return [Integer] 0 success; 1 error + def calculate_autoyast_proposal(dbus_settings) + settings = JSON.parse(dbus_settings) + + logger.info( + "Calculating AutoYaST storage proposal from D-Bus.\n " \ + "D-Bus settings: #{dbus_settings}\n" \ + "AutoYaST settings: #{settings.inspect}" + ) + success = proposal.calculate_autoyast(settings) success ? 0 : 1 end + # Whether a proposal was calculated. + # + # @return [Boolean] + def proposal_calculated? + proposal.calculated? + end + + # Proposal result, including information about success, strategy and settings. + # + # @return [Hash] Empty if there is no proposal yet. + def proposal_result + return {} unless proposal.calculated? + + if proposal.strategy?(ProposalStrategy::GUIDED) + { + "success" => proposal.success?, + "strategy" => ProposalStrategy::GUIDED, + "settings" => ProposalSettingsConversion.to_dbus(proposal.settings) + } + else + { + "success" => proposal.success?, + "strategy" => ProposalStrategy::AUTOYAST, + "settings" => proposal.settings.to_json + } + end + end + dbus_interface PROPOSAL_CALCULATOR_INTERFACE do dbus_reader :available_devices, "ao" @@ -182,16 +261,25 @@ def calculate_proposal(dbus_settings) # PropertiesChanged signal if software is probed, see {#register_software_callbacks}. dbus_reader_attr_accessor :encryption_methods, "as" - dbus_reader :result, "o" - dbus_method :DefaultVolume, "in mount_path:s, out volume:a{sv}" do |mount_path| [default_volume(mount_path)] end + # @todo Rename as CalculateGuided + # + # result: 0 success; 1 error + dbus_method(:Calculate, "in settings:a{sv}, out result:u") do |settings| + busy_while { calculate_guided_proposal(settings) } + end + # result: 0 success; 1 error - dbus_method :Calculate, "in settings:a{sv}, out result:u" do |settings| - busy_while { calculate_proposal(settings) } + dbus_method(:CalculateAutoyast, "in settings:s, out result:u") do |settings| + busy_while { calculate_autoyast_proposal(settings) } end + + dbus_reader :proposal_calculated?, "b", dbus_name: "Calculated" + + dbus_reader :proposal_result, "a{sv}", dbus_name: "Result" end ISCSI_INITIATOR_INTERFACE = "org.opensuse.Agama.Storage1.ISCSI.Initiator" @@ -301,6 +389,7 @@ def register_proposal_callbacks export_proposal proposal_properties_changed refresh_staging_devices + update_actions end end @@ -340,8 +429,16 @@ def deprecate_system backend.deprecated_system = true 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 - @service.unexport(dbus_proposal) if dbus_proposal + if dbus_proposal + @service.unexport(dbus_proposal) + @dbus_proposal = nil + end + + return unless proposal.strategy?(ProposalStrategy::GUIDED) + @dbus_proposal = DBus::Storage::Proposal.new(proposal, logger) @service.export(@dbus_proposal) end diff --git a/service/lib/agama/storage/manager.rb b/service/lib/agama/storage/manager.rb index d590e862b3..af5fc39eb5 100644 --- a/service/lib/agama/storage/manager.rb +++ b/service/lib/agama/storage/manager.rb @@ -23,6 +23,7 @@ require "bootloader/proposal_client" require "y2storage/storage_manager" require "y2storage/clients/inst_prepdisk" +require "agama/storage/actions" require "agama/storage/proposal" require "agama/storage/proposal_settings" require "agama/storage/callbacks" @@ -160,6 +161,16 @@ def software @software ||= DBus::Clients::Software.instance end + # Storage actions. + # + # @return [Array] + def actions + return [] unless Y2Storage::StorageManager.instance.probed? + + staging = Y2Storage::StorageManager.instance.staging + Actions.new(logger, staging.actiongraph).all + end + private PROPOSAL_ID = "storage_proposal" @@ -195,7 +206,7 @@ def probe_devices # Calculates the proposal using the settings from the config file. def calculate_proposal settings = ProposalSettingsReader.new(config).read - proposal.calculate(settings) + proposal.calculate_guided(settings) end # Adds the required packages to the list of resolvables to install diff --git a/service/lib/agama/storage/proposal.rb b/service/lib/agama/storage/proposal.rb index af4d8773b4..c1a370d93a 100644 --- a/service/lib/agama/storage/proposal.rb +++ b/service/lib/agama/storage/proposal.rb @@ -21,8 +21,7 @@ require "agama/issue" require "agama/storage/actions" -require "agama/storage/device_settings" -require "agama/storage/proposal_settings_conversion" +require "agama/storage/proposal_strategies" require "yast" require "y2storage" @@ -32,14 +31,6 @@ module Storage class Proposal include Yast::I18n - # Settings used for calculating the proposal. - # - # @note Some values are recoverd from Y2Storage, see - # {ProposalSettingsConversion::FromY2Storage} - # - # @return [ProposalSettings, nil] nil if no proposal has been calculated yet. - attr_reader :settings - # @param config [Config] Agama config # @param logger [Logger] def initialize(config, logger: nil) @@ -47,17 +38,30 @@ def initialize(config, logger: nil) @config = config @logger = logger || Logger.new($stdout) + @issues = [] @on_calculate_callbacks = [] end + # List of issues. + # + # @return [Array] + attr_reader :issues + + # Whether the proposal was already calculated. + # + # @return [Boolean] + def calculated? + !proposal.nil? + end + # Whether the proposal was successfully calculated. # # @return [Boolean] def success? - calculated? && !proposal.failed? + calculated? && !proposal.failed? && issues.none?(&:error?) end - # Stores callbacks to be call after calculating a proposal. + # Stores callbacks to be called after calculating a proposal. def on_calculate(&block) @on_calculate_callbacks << block end @@ -69,21 +73,34 @@ def available_devices disk_analyzer&.candidate_disks || [] end - # Calculates a new proposal. + # Settings used to calculate the current proposal. # - # @param settings [Agamal::Storage::ProposalSettings] settings to calculate the proposal. - # @return [Boolean] whether the proposal was correctly calculated. - def calculate(settings) - return false unless storage_manager.probed? - - select_target_device(settings) if missing_target_device?(settings) + # The type depends on the kind of proposal, see {#calculate_guided} and {#calculate_autoyast}. + # + # @return [Agama::Storage::ProposalSettings, Array] + def settings + return unless calculated? - calculate_proposal(settings) + strategy_object.settings + end - @settings = ProposalSettingsConversion.from_y2storage(proposal.settings, settings) - @on_calculate_callbacks.each(&:call) + # Calculates a new guided proposal. + # + # @param settings [Agama::Storage::ProposalSettings] settings to calculate the proposal. + # @return [Boolean] whether the proposal was correctly calculated. + def calculate_guided(settings) + @strategy_object = ProposalStrategies::Guided.new(config, logger, settings) + calculate + end - success? + # Calculates a new legacy AutoYaST proposal. + # + # @param partitioning [Array] Hash-based representation of the section + # of the AutoYaST profile + # @return [Boolean] whether the proposal was correctly calculated. + def calculate_autoyast(partitioning) + @strategy_object = ProposalStrategies::Autoyast.new(config, logger, partitioning) + calculate end # Storage actions. @@ -95,17 +112,14 @@ def actions Actions.new(logger, proposal.devices.actiongraph).all end - # List of issues. + # Whether the current proposal was calculated the given strategy (:autoyast or :guided). # - # @return [Array] - def issues - return [] if !calculated? || success? + # @param id [#downcase] + # @return [Boolean] + def strategy?(id) + return false unless calculated? - [ - target_device_issue, - missing_devices_issue, - proposal_issue - ].compact + id.downcase.to_sym == strategy_object.id end private @@ -116,125 +130,78 @@ def issues # @return [Logger] attr_reader :logger - # @return [Y2Storage::MinGuidedProposal, nil] - def proposal - storage_manager.proposal - end + attr_reader :strategy_object - # Whether the proposal was already calculated. + # Calculates a new proposal. # - # @return [Boolean] - def calculated? - !proposal.nil? - end + # @return [Boolean] whether the proposal was correctly calculated. + def calculate + return false unless storage_manager.probed? - # 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 + @issues = [] - # 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? + begin + strategy_object.calculate + @issues << failed_issue if proposal.failed? + rescue Y2Storage::Error => e + handle_exception(e) end + + @issues.concat(strategy_object.issues) + @on_calculate_callbacks.each(&:call) + success? end - # Instantiates and executes a Y2Storage proposal with the given settings - # - # @param settings [Y2Storage::ProposalSettings] - # @return [Y2Storage::GuidedProposal] - def calculate_proposal(settings) - proposal = Y2Storage::MinGuidedProposal.new( - settings: ProposalSettingsConversion.to_y2storage(settings, config: config), - devicegraph: probed_devicegraph, - disk_analyzer: disk_analyzer - ) - proposal.propose - storage_manager.proposal = proposal + # @return [Y2Storage::Proposal::Base, nil] + def proposal + storage_manager.proposal end # @return [Y2Storage::DiskAnalyzer, nil] nil if the system is not probed yet. def disk_analyzer - return nil unless storage_manager.probed? + return unless storage_manager.probed? storage_manager.probed_disk_analyzer end - # Devicegraph representing the system - # - # @return [Y2Storage::Devicegraph, nil] nil if the system is not probed yet. - def probed_devicegraph - return nil unless storage_manager.probed? - - storage_manager.probed - end - + # @return [Y2Storage::StorageManager] def storage_manager Y2Storage::StorageManager.instance 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) + # Handle Y2Storage exceptions + def handle_exception(error) + case error + when Y2Storage::NoDiskSpaceError + @issues << failed_issue + when Y2Storage::Error + @issues << exception_issue(error) + else + raise error + end end - # Returns an issue if any of the devices required for the proposal is not found + # Issue representing the proposal is not valid. # - # @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? - + # @return [Issue] + def failed_issue 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(", ") - ), + _("Cannot accommodate the required file systems for installation"), source: Issue::Source::CONFIG, severity: Issue::Severity::ERROR ) end - # Returns an issue if the proposal is not valid. + # Issue to communicate a generic Y2Storage error. # - # @return [Issue, nil] - def proposal_issue - return if success? - - Issue.new(_("Cannot accommodate the required file systems for installation"), + # @return [Issue] + def exception_issue(error) + Issue.new( + _("A problem ocurred while calculating the storage setup"), + details: error.message, source: Issue::Source::CONFIG, - severity: Issue::Severity::ERROR) + severity: Issue::Severity::ERROR + ) end end end diff --git a/service/lib/agama/storage/proposal_strategies.rb b/service/lib/agama/storage/proposal_strategies.rb new file mode 100644 index 0000000000..c8efb53c08 --- /dev/null +++ b/service/lib/agama/storage/proposal_strategies.rb @@ -0,0 +1,31 @@ +# 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 Storage + # Namespace for the different strategies potentially used by Storage::Proposal. + module ProposalStrategies + end + end +end + +require "agama/storage/proposal_strategies/guided" +require "agama/storage/proposal_strategies/autoyast" diff --git a/service/lib/agama/storage/proposal_strategies/autoyast.rb b/service/lib/agama/storage/proposal_strategies/autoyast.rb new file mode 100644 index 0000000000..832e1b1eb2 --- /dev/null +++ b/service/lib/agama/storage/proposal_strategies/autoyast.rb @@ -0,0 +1,95 @@ +# 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/proposal_strategies/base" +require "agama/storage/proposal_settings" +require "agama/storage/proposal_settings_reader" +require "agama/storage/proposal_settings_conversion" + +module Agama + module Storage + module ProposalStrategies + # Strategy used by the Agama proposal for backwards compatibility with AutoYaST. + class Autoyast < Base + include Yast::I18n + + # @param config [Config] Agama config + # @param logger [Logger] + # @param partitioning [Array] + def initialize(config, logger, partitioning) + textdomain "agama" + + super(config, logger) + @partitioning = partitioning + end + + # Settings used for calculating the proposal. + # + # @return [Array] + attr_reader :partitioning + alias_method :settings, :partitioning + + # @see Base#calculate + def calculate + @ay_issues = ::Installation::AutoinstIssues::List.new + proposal = Y2Storage::AutoinstProposal.new( + partitioning: partitioning, + proposal_settings: proposal_settings, + devicegraph: probed_devicegraph, + disk_analyzer: disk_analyzer, + issues_list: ay_issues + ) + proposal.propose + ensure + storage_manager.proposal = proposal + end + + # @see Base#issues + def issues + ay_issues.map { |i| agama_issue(i) } + end + + private + + # Issues generated by the AutoYaST proposal + # @return [::Installation::AutoinstIssues::List] + attr_reader :ay_issues + + # Default proposal settings, potentially used to calculate omitted information + # + # @return [Y2Storage::ProposalSettings] + def proposal_settings + agama_default = ProposalSettingsReader.new(config).read + ProposalSettingsConversion.to_y2storage(agama_default, config: config) + end + + # Agama issue equivalent to the given AutoYaST issue + def agama_issue(ay_issue) + Issue.new( + ay_issue.message, + source: Issue::Source::CONFIG, + severity: ay_issue.warn? ? Issue::Severity::WARN : Issue::Severity::ERROR + ) + end + end + end + end +end diff --git a/service/lib/agama/storage/proposal_strategies/base.rb b/service/lib/agama/storage/proposal_strategies/base.rb new file mode 100644 index 0000000000..51f15ba528 --- /dev/null +++ b/service/lib/agama/storage/proposal_strategies/base.rb @@ -0,0 +1,103 @@ +# 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/issue" +require "yast" +require "y2storage" + +module Agama + module Storage + module ProposalStrategies + # Base class for the strategies used by the Agama proposal. + class Base + # @param config [Config] Agama config + # @param logger [Logger] + def initialize(config, logger) + @config = config + @logger = logger + end + + # Settings used for calculating the proposal. + def settings + raise NotImplementedError + end + + # Calculates a new proposal, storing the result at the storage manager. + # + # @raise [Y2Storage::NoDiskSpaceError] if it was not possible to calculate the proposal + # @raise [Y2Storage::Error] if something went wrong while calculating the proposal + def calculate + raise NotImplementedError + end + + # Identifier for the strategy. + # + # @return [Symbol] + def id + self.class.name.split("::").last.downcase.to_sym + end + + # List of issues. + # + # @return [Array] + def issues + [] + end + + private + + # @return [Config] + attr_reader :config + + # @return [Logger] + attr_reader :logger + + # @return [Y2Storage::DiskAnalyzer, nil] nil if the system is not probed yet. + def disk_analyzer + return nil unless storage_manager.probed? + + storage_manager.probed_disk_analyzer + end + + # Available devices for installation. + # + # @return [Array] + def available_devices + disk_analyzer&.candidate_disks || [] + end + + # Devicegraph representing the system + # + # @return [Y2Storage::Devicegraph, nil] nil if the system is not probed yet. + def probed_devicegraph + return nil unless storage_manager.probed? + + storage_manager.probed + end + + # @return [Y2Storage::StorageManager] + def storage_manager + Y2Storage::StorageManager.instance + end + end + end + end +end diff --git a/service/lib/agama/storage/proposal_strategies/guided.rb b/service/lib/agama/storage/proposal_strategies/guided.rb new file mode 100644 index 0000000000..105b86d46d --- /dev/null +++ b/service/lib/agama/storage/proposal_strategies/guided.rb @@ -0,0 +1,152 @@ +# 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/proposal_strategies/base" +require "agama/storage/device_settings" +require "agama/storage/proposal_settings_conversion" + +module Agama + module Storage + module ProposalStrategies + # Main strategy for the Agama proposal. + class Guided < Base + include Yast::I18n + + # @param config [Config] Agama config + # @param logger [Logger] + # @param input_settings [ProposalSettings] + def initialize(config, logger, input_settings) + textdomain "agama" + + super(config, logger) + @input_settings = input_settings + end + + # Settings used for calculating the proposal. + # + # @note Some values are recoverd from Y2Storage, see + # {ProposalSettingsConversion::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 = ProposalSettingsConversion.from_y2storage(proposal.settings, input_settings) + end + + # @see Base#issues + def issues + 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 + + # 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: ProposalSettingsConversion.to_y2storage(settings, config: config), + devicegraph: probed_devicegraph, + disk_analyzer: disk_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/package/rubygem-agama-yast.changes b/service/package/rubygem-agama-yast.changes index 06fb06a159..68bafe9416 100644 --- a/service/package/rubygem-agama-yast.changes +++ b/service/package/rubygem-agama-yast.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Wed Jun 5 13:56:54 UTC 2024 - Ancor Gonzalez Sosa + +- Allow to execute the legacy AutoYaST storage proposal + (gh#openSUSE/agama#1284). + ------------------------------------------------------------------- Tue Jun 4 14:16:02 UTC 2024 - José Iván López González diff --git a/service/test/agama/dbus/clients/storage_test.rb b/service/test/agama/dbus/clients/storage_test.rb index 8bfaaf881a..10fa7aad33 100644 --- a/service/test/agama/dbus/clients/storage_test.rb +++ b/service/test/agama/dbus/clients/storage_test.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2022-2023] SUSE LLC +# Copyright (c) [2022-2024] SUSE LLC # # All Rights Reserved. # @@ -21,6 +21,7 @@ require_relative "../../../test_helper" require_relative "with_issues_examples" +require_relative "with_progress_examples" require "agama/dbus/clients/storage" require "dbus" @@ -28,22 +29,9 @@ before do allow(Agama::DBus::Bus).to receive(:current).and_return(bus) 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(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) - allow(dbus_object).to receive(:[]).with("org.opensuse.Agama.Storage1.Proposal.Calculator") - .and_return(proposal_calculator_iface) - - allow(service).to receive(:object).with("/org/opensuse/Agama/Storage1/Proposal") - .and_return(dbus_proposal) - allow(dbus_proposal).to receive(:introspect) - allow(dbus_proposal).to receive(:[]).with("org.opensuse.Agama.Storage1.Proposal") - .and_return(proposal_iface) - - allow(proposal_calculator_iface).to receive(:[]).with("Result").and_return(proposal_path) + allow(dbus_object).to receive(:[]).with("org.opensuse.Agama.Storage1").and_return(storage_iface) end let(:bus) { instance_double(Agama::DBus::Bus) } @@ -51,12 +39,6 @@ let(:dbus_object) { instance_double(::DBus::ProxyObject) } let(:storage_iface) { instance_double(::DBus::ProxyObjectInterface) } - let(:proposal_calculator_iface) { instance_double(::DBus::ProxyObjectInterface) } - - let(:dbus_proposal) { instance_double(::DBus::ProxyObject) } - let(:proposal_iface) { instance_double(::DBus::ProxyObjectInterface) } - - let(:proposal_path) { "/" } subject { described_class.new } @@ -101,98 +83,6 @@ end end - describe "#available_devices" do - before do - allow(proposal_calculator_iface).to receive(:[]).with("AvailableDevices").and_return( - [ - ["/dev/sda", "/dev/sda (50 GiB)"], - ["/dev/sdb", "/dev/sda (20 GiB)"] - ] - ) - end - - it "returns the name of all available devices for the installation" do - expect(subject.available_devices).to contain_exactly("/dev/sda", "/dev/sdb") - end - end - - describe "#candidate_devices" do - context "if a proposal object is not exported yet" do - let(:proposal_path) { "/" } - - it "returns an empty list" do - expect(subject.candidate_devices).to eq([]) - end - end - - context "if a proposal object is already exported" do - let(:proposal_path) { "/org/opensuse/Agama/Storage1/Proposal" } - - before do - allow(proposal_iface).to receive(:[]).with("CandidateDevices").and_return(["/dev/sda"]) - end - - it "returns the name of the candidate devices for the installation" do - expect(subject.candidate_devices).to contain_exactly("/dev/sda") - end - end - end - - describe "#calculate" do - # Using partial double because methods are dynamically added to the proxy object - let(:proposal_calculator_iface) { double(::DBus::ProxyObjectInterface) } - - it "calculates the proposal with the given devices" do - expect(proposal_calculator_iface) - .to receive(:Calculate).with({ "CandidateDevices" => ["/dev/sdb"] }) - - subject.calculate(["/dev/sdb"]) - end - end - - describe "#actions" do - context "if a proposal object is not exported yet" do - let(:proposal_path) { "/" } - - it "returns an empty list" do - expect(subject.actions).to eq([]) - end - end - - context "if a proposal object is already exported" do - let(:proposal_path) { "/org/opensuse/Agama/Storage1/Proposal" } - - before do - allow(proposal_iface).to receive(:[]).with("Actions").and_return( - [ - { - "Text" => "Create GPT on /dev/vdc", - "Subvolume" => false - }, - { - "Text" => "Create partition /dev/vdc1 (8.00 MiB) as BIOS Boot Partition", - "Subvolume" => false - }, - { - "Text" => "Create partition /dev/vdc2 (27.99 GiB) for / with btrfs", - "Subvolume" => false - }, - { - "Text" => "Create partition /dev/vdc3 (2.00 GiB) for swap", - "Subvolume" => false - } - ] - ) - end - - it "returns the actions to perform" do - expect(subject.actions).to include(/Create GPT/) - expect(subject.actions).to include(/Create partition \/dev\/vdc1/) - expect(subject.actions).to include(/Create partition \/dev\/vdc2/) - expect(subject.actions).to include(/Create partition \/dev\/vdc3/) - end - end - end - include_examples "issues" + include_examples "progress" end diff --git a/service/test/agama/dbus/storage/manager_test.rb b/service/test/agama/dbus/storage/manager_test.rb index 1ad2f847e8..76f1c07536 100644 --- a/service/test/agama/dbus/storage/manager_test.rb +++ b/service/test/agama/dbus/storage/manager_test.rb @@ -41,6 +41,7 @@ let(:backend) do instance_double(Agama::Storage::Manager, + actions: [], proposal: proposal, iscsi: iscsi, software: software, @@ -98,6 +99,59 @@ 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([]) + end + end + + context "if there are actions" do + let(:actions) { [action1, action2] } + + let(:action1) do + instance_double(Y2Storage::CompoundAction, + sentence: "test1", target_device: device1, device_is?: false, delete?: false) + end + + let(:action2) do + instance_double(Y2Storage::CompoundAction, + sentence: "test2", target_device: device2, device_is?: true, delete?: true) + end + + let(:device1) { instance_double(Y2Storage::Device, sid: 1) } + + let(:device2) { instance_double(Y2Storage::Device, sid: 2) } + + it "returns a list with a hash for each action" do + expect(subject.actions.size).to eq(2) + expect(subject.actions).to all(be_a(Hash)) + + action1, action2 = subject.actions + + expect(action1).to eq({ + "Device" => 1, + "Text" => "test1", + "Subvol" => false, + "Delete" => false + }) + + expect(action2).to eq({ + "Device" => 2, + "Text" => "test2", + "Subvol" => true, + "Delete" => true + }) + end + end + end + describe "#available_devices" do before do allow(proposal).to receive(:available_devices).and_return(devices) @@ -238,31 +292,7 @@ end end - describe "#result" do - before do - allow(subject).to receive(:dbus_proposal).and_return(dbus_proposal) - end - - context "when there is no exported proposal object yet" do - let(:dbus_proposal) { nil } - - it "returns root path" do - expect(subject.result.to_s).to eq("/") - end - end - - context "when there is an exported proposal object" do - let(:dbus_proposal) do - instance_double(Agama::DBus::Storage::Proposal, path: ::DBus::ObjectPath.new("/test")) - end - - it "returns the proposal object path" do - expect(subject.result.to_s).to eq("/test") - end - end - end - - describe "#calculate_proposal" do + describe "#calculate_guided_proposal" do let(:dbus_settings) do { "Target" => "disk", @@ -281,7 +311,7 @@ end it "calculates a proposal with settings having values from D-Bus" do - expect(proposal).to receive(:calculate) do |settings| + 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" @@ -294,14 +324,14 @@ ) end - subject.calculate_proposal(dbus_settings) + subject.calculate_guided_proposal(dbus_settings) end context "when the D-Bus settings does not include some values" do let(:dbus_settings) { {} } it "calculates a proposal with default values for the missing settings" do - expect(proposal).to receive(:calculate) do |settings| + 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 @@ -311,7 +341,7 @@ expect(settings.volumes).to eq([]) end - subject.calculate_proposal(dbus_settings) + subject.calculate_guided_proposal(dbus_settings) end end @@ -320,11 +350,11 @@ # This is likely a temporary behavior it "calculates a proposal ignoring the unknown attributes" do - expect(proposal).to receive(:calculate) do |settings| + expect(proposal).to receive(:calculate_guided) do |settings| expect(settings).to be_a(Agama::Storage::ProposalSettings) end - subject.calculate_proposal(dbus_settings) + subject.calculate_guided_proposal(dbus_settings) end end @@ -358,7 +388,7 @@ end it "calculates a proposal with settings having a volume with values from D-Bus" do - expect(proposal).to receive(:calculate) do |settings| + expect(proposal).to receive(:calculate_guided) do |settings| volume = settings.volumes.first expect(volume.mount_path).to eq("/") @@ -368,7 +398,7 @@ expect(volume.btrfs.snapshots).to eq(true) end - subject.calculate_proposal(dbus_settings) + subject.calculate_guided_proposal(dbus_settings) end context "and the D-Bus volume does not include some values" do @@ -387,7 +417,7 @@ end it "calculates a proposal with a volume completed with its default settings" do - expect(proposal).to receive(:calculate) do |settings| + expect(proposal).to receive(:calculate_guided) do |settings| volume = settings.volumes.first expect(volume.mount_path).to eq("/") @@ -398,7 +428,95 @@ expect(volume.btrfs.snapshots).to eq(false) end - subject.calculate_proposal(dbus_settings) + subject.calculate_guided_proposal(dbus_settings) + end + end + end + end + + describe "#calculate_autoyast_proposal" do + let(:dbus_settings) { '[{ "device": "/dev/vda" }]' } + + it "calculates an AutoYaST proposal with the settings from D-Bus" do + expect(proposal).to receive(:calculate_autoyast) do |settings| + expect(settings).to eq([{ "device" => "/dev/vda" }]) + end + + subject.calculate_autoyast_proposal(dbus_settings) + end + end + + describe "#proposal_calculated?" do + before do + allow(proposal).to receive(:calculated?).and_return(calculated) + end + + context "if the proposal is not calculated yet" do + let(:calculated) { false } + + it "returns false" do + expect(subject.proposal_calculated?).to eq(false) + end + end + + context "if the proposal is calculated" do + let(:calculated) { true } + + it "returns true" do + expect(subject.proposal_calculated?).to eq(true) + end + end + end + + describe "#proposal_result" do + before do + allow(proposal).to receive(:calculated?).and_return(calculated) + end + + context "if the proposal is not calculated yet" do + let(:calculated) { false } + + it "returns an empty hash" do + expect(subject.proposal_result).to eq({}) + end + end + + context "if the proposal is calculated" do + let(:calculated) { true } + let(:guided) { Agama::DBus::Storage::Manager::ProposalStrategy::GUIDED } + let(:autoyast) { Agama::DBus::Storage::Manager::ProposalStrategy::AUTOYAST } + + context "and it is a guided proposal" do + before do + allow(proposal).to receive(:strategy?).with(guided).and_return(true) + allow(proposal).to receive(:success?).and_return(true) + allow(proposal).to receive(:settings).and_return(Agama::Storage::ProposalSettings.new) + end + + it "returns a Hash with success, strategy and settings" do + result = subject.proposal_result + + expect(result.keys).to contain_exactly("success", "strategy", "settings") + expect(result["success"]).to eq(true) + expect(result["strategy"]).to eq(guided) + expect(result["settings"]).to be_a(Hash) + end + end + + context "and it is an autoyast proposal" do + before do + allow(proposal).to receive(:strategy?).with(guided).and_return(false) + allow(proposal).to receive(:success?).and_return(true) + allow(proposal).to receive(:settings).and_return({}) + end + + it "returns a Hash with success, strategy and settings" do + result = subject.proposal_result + + expect(result.keys).to contain_exactly("success", "strategy", "settings") + expect(result["success"]).to eq(true) + expect(result["strategy"]).to eq(autoyast) + expect(result["settings"]).to be_a(String) end end end diff --git a/service/test/agama/storage/autoyast_proposal_test.rb b/service/test/agama/storage/autoyast_proposal_test.rb new file mode 100644 index 0000000000..e68a3e10f0 --- /dev/null +++ b/service/test/agama/storage/autoyast_proposal_test.rb @@ -0,0 +1,546 @@ +# 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 "storage_helpers" +require "agama/config" +require "agama/storage/proposal" +require "agama/issue" +require "y2storage" + +describe Agama::Storage::Proposal do + include Agama::RSpec::StorageHelpers + using Y2Storage::Refinements::SizeCasts + + subject(:proposal) { described_class.new(config, logger: logger) } + + let(:logger) { Logger.new($stdout, level: :warn) } + + before do + mock_storage(devicegraph: scenario) + allow(Y2Storage::StorageManager.instance).to receive(:arch).and_return(arch) + end + + let(:scenario) { "windows-linux-pc.yml" } + + let(:arch) do + instance_double(Y2Storage::Arch, efiboot?: true, ram_size: 4.GiB.to_i) + end + + let(:config_path) do + File.join(FIXTURES_PATH, "storage.yaml") + end + let(:config) { Agama::Config.from_file(config_path) } + + ROOT_PART = { + "filesystem" => :ext4, "mount" => "/", "size" => "25%", "label" => "new_root" + }.freeze + + let(:root) { ROOT_PART.merge("create" => true) } + let(:home) do + { "filesystem" => :xfs, "mount" => "/home", "size" => "50%", "create" => true } + end + + describe "#success?" do + it "returns false if no calculate has been called yet" do + expect(subject.success?).to eq(false) + end + + context "if calculate_autoyast was already called" do + let(:partitioning) do + [{ "device" => "/dev/#{disk}", "use" => "free", "partitions" => [root] }] + end + + before do + subject.calculate_autoyast(partitioning) + end + + context "and the proposal was successful" do + let(:disk) { "sdb" } + + it "returns true" do + expect(subject.success?).to eq(true) + end + end + + context "and the proposal failed" do + let(:disk) { "sda" } + + it "returns false" do + expect(subject.success?).to eq(false) + end + end + end + end + + describe "#calculate_autoyast" do + def staging + Y2Storage::StorageManager.instance.proposal.devices + end + + def root_filesystem(disk) + disk.partitions.map(&:filesystem).compact.find(&:root?) + end + + describe "when partitions are specified" do + context "if the requested layout is valid" do + let(:partitioning) do + [{ "device" => "/dev/sda", "use" => "all", "partitions" => [root, home] }] + end + + it "returns true and stores a successful proposal" do + expect(subject.calculate_autoyast(partitioning)).to eq true + expect(Y2Storage::StorageManager.instance.proposal.failed?).to eq false + end + + it "proposes a layout including specified partitions" do + subject.calculate_autoyast(partitioning) + + sda_partitions = staging.find_by_name("/dev/sda").partitions.sort_by(&:number) + expect(sda_partitions.size).to eq(3) + efi, root, home = sda_partitions + + expect(efi).to have_attributes( + filesystem_type: Y2Storage::Filesystems::Type::VFAT, + filesystem_mountpoint: "/boot/efi", + size: 512.MiB + ) + + expect(root).to have_attributes( + filesystem_type: Y2Storage::Filesystems::Type::EXT4, + filesystem_mountpoint: "/", + size: 125.GiB + ) + + expect(home).to have_attributes( + filesystem_type: Y2Storage::Filesystems::Type::XFS, + filesystem_mountpoint: "/home", + size: 250.GiB + ) + end + + it "registers no issues" do + subject.calculate_autoyast(partitioning) + expect(subject.issues).to be_empty + end + + it "runs all the callbacks" do + callback1 = proc {} + callback2 = proc {} + + subject.on_calculate(&callback1) + subject.on_calculate(&callback2) + + expect(callback1).to receive(:call) + expect(callback2).to receive(:call) + + subject.calculate_autoyast(partitioning) + end + end + + context "if no root is specified" do + let(:partitioning) do + [{ "device" => "/dev/sda", "use" => "all", "partitions" => [home] }] + end + + it "returns false and stores the resulting proposal" do + expect(subject.calculate_autoyast(partitioning)).to eq false + # From the AutoinstProposal POV, a proposal without root is not an error + # if root was not requested + expect(Y2Storage::StorageManager.instance.proposal.failed?).to eq false + end + + it "proposes a layout including only the specified partitions" do + subject.calculate_autoyast(partitioning) + + sda_partitions = staging.find_by_name("/dev/sda").partitions + # Only /boot/efi and /home, no root filesystem + expect(sda_partitions.size).to eq(2) + end + + it "registers a fatal issue due to the lack of root" do + subject.calculate_autoyast(partitioning) + expect(subject.issues).to include( + an_object_having_attributes( + description: /No root/, severity: Agama::Issue::Severity::ERROR + ) + ) + end + + it "runs all the callbacks" do + callback1 = proc {} + callback2 = proc {} + + subject.on_calculate(&callback1) + subject.on_calculate(&callback2) + + expect(callback1).to receive(:call) + expect(callback2).to receive(:call) + + subject.calculate_autoyast(partitioning) + end + end + end + + context "when the profile does not specify what to do with existing partitions" do + let(:partitioning) do + [{ "device" => "/dev/sda", "partitions" => [root] }] + end + + it "returns false and stores a failed proposal" do + expect(subject.calculate_autoyast(partitioning)).to eq false + expect(Y2Storage::StorageManager.instance.proposal.failed?).to eq true + end + + it "register a fatal issue about the missing 'use' element" do + subject.calculate_autoyast(partitioning) + expect(subject.issues).to include( + an_object_having_attributes( + description: /Missing element 'use'/, severity: Agama::Issue::Severity::ERROR + ) + ) + end + end + + describe "when existing partitions should be kept" do + let(:partitioning) do + [{ "device" => "/dev/#{disk}", "use" => "free", "partitions" => [root] }] + end + + context "if the requested partitions fit into the available space" do + let(:disk) { "sdb" } + + it "proposes a layout including previous and new partitions" do + subject.calculate_autoyast(partitioning) + sdb_partitions = staging.find_by_name("/dev/sdb").partitions + expect(sdb_partitions.size).to eq 3 + end + end + + context "if there is no available space" do + let(:disk) { "sda" } + + it "returns false and stores a failed proposal" do + expect(subject.calculate_autoyast(partitioning)).to eq false + expect(Y2Storage::StorageManager.instance.proposal.failed?).to eq true + end + + it "register issues" do + subject.calculate_autoyast(partitioning) + expect(subject.issues).to include( + an_object_having_attributes( + description: /Cannot accommodate/, severity: Agama::Issue::Severity::ERROR + ) + ) + end + end + end + + context "when only space from Linux partitions should be used" do + let(:partitioning) do + [{ "device" => "/dev/sda", "use" => "linux", "partitions" => [root] }] + end + + it "keeps all partitions except Linux ones" do + subject.calculate_autoyast(partitioning) + partitions = staging.find_by_name("/dev/sda").partitions + expect(partitions.map(&:filesystem_label)).to contain_exactly("windows", "", "new_root") + end + end + + describe "reusing partitions" do + let(:partitioning) do + [{ "device" => "/dev/sdb", "use" => "free", "partitions" => [root] }] + end + + let(:root) do + { "mount" => "/", "partition_nr" => 1, "create" => false } + end + + it "returns true and registers no issues" do + expect(subject.calculate_autoyast(partitioning)).to eq true + expect(subject.issues).to be_empty + end + + it "reuses the indicated partition" do + subject.calculate_autoyast(partitioning) + root, efi = staging.find_by_name("/dev/sdb").partitions.sort_by(&:number) + expect(root).to have_attributes( + filesystem_type: Y2Storage::Filesystems::Type::XFS, + filesystem_mountpoint: "/", + size: 113.GiB + ) + expect(efi).to have_attributes( + filesystem_type: Y2Storage::Filesystems::Type::VFAT, + filesystem_mountpoint: "/boot/efi", + id: Y2Storage::PartitionId::ESP, + size: 512.MiB + ) + end + + context "if the partitions needed for booting do not fit" do + let(:partitioning) do + [{ "device" => "/dev/sda", "use" => "free", "partitions" => [root] }] + end + + let(:root) do + { "mount" => "/", "partition_nr" => 3, "create" => false } + end + + it "returns true and stores a successful proposal" do + expect(subject.calculate_autoyast(partitioning)).to eq true + expect(Y2Storage::StorageManager.instance.proposal.failed?).to eq false + end + + it "does not create the boot partitions" do + subject.calculate_autoyast(partitioning) + partitions = staging.find_by_name("/dev/sda").partitions + expect(partitions.size).to eq 3 + expect(partitions.map(&:id)).to_not include Y2Storage::PartitionId::ESP + end + + it "register a non-fatal issue" do + subject.calculate_autoyast(partitioning) + expect(subject.issues).to include( + an_object_having_attributes( + description: /partitions recommended for booting/, + severity: Agama::Issue::Severity::WARN + ) + ) + end + end + end + + describe "skipping disks" do + let(:skip_list) do + [{ "skip_key" => "name", "skip_value" => skip_device }] + end + + let(:partitioning) do + [{ "use" => "all", "partitions" => [root, home], "skip_list" => skip_list }] + end + + context "when no disk is included in the skip_list" do + let(:skip_device) { "sdc" } + + it "does not skip any disk" do + subject.calculate_autoyast(partitioning) + sda = staging.find_by_name("/dev/sda") + expect(root_filesystem(sda)).to_not be_nil + end + end + + context "when a disk is included in the skip_list" do + let(:skip_device) { "sda" } + + it "skips the given disk" do + subject.calculate_autoyast(partitioning) + sda = staging.find_by_name("/dev/sda") + sdb = staging.find_by_name("/dev/sdb") + expect(root_filesystem(sda)).to be_nil + expect(root_filesystem(sdb)).to_not be_nil + end + end + + context "when all disks are skipped" do + let(:skip_list) do + [{ "skip_key" => "name", "skip_value" => "sda" }, + { "skip_key" => "name", "skip_value" => "sdb" }] + end + + it "returns false and stores a failed proposal" do + expect(subject.calculate_autoyast(partitioning)).to eq false + expect(Y2Storage::StorageManager.instance.proposal.failed?).to eq true + end + + it "register issues" do + subject.calculate_autoyast(partitioning) + expect(subject.issues).to include( + an_object_having_attributes( + description: /Cannot accommodate/, severity: Agama::Issue::Severity::ERROR + ) + ) + end + end + end + + describe "LVM on RAID" do + let(:partitioning) do + [ + { "device" => "/dev/sda", "use" => "all", "partitions" => [raid_spec] }, + { "device" => "/dev/sdb", "use" => "all", "partitions" => [raid_spec] }, + { "device" => "/dev/md", "partitions" => [md_spec] }, + { "device" => "/dev/vg0", "partitions" => [root_spec, home_spec], "type" => :CT_LVM } + ] + end + + let(:md_spec) do + { + "partition_nr" => 1, "raid_options" => raid_options, "lvm_group" => "vg0" + } + end + + let(:raid_options) do + { "raid_type" => "raid0" } + end + + let(:root_spec) do + { "mount" => "/", "filesystem" => :ext4, "lv_name" => "root", "size" => "5G" } + end + + let(:home_spec) do + { "mount" => "/home", "filesystem" => :xfs, "lv_name" => "home", "size" => "5G" } + end + + let(:raid_spec) do + { "raid_name" => "/dev/md1", "size" => "20GB", "partition_id" => 253 } + end + + it "returns true and registers no issues" do + expect(subject.calculate_autoyast(partitioning)).to eq true + expect(subject.issues).to be_empty + end + + it "creates the expected layout" do + subject.calculate_autoyast(partitioning) + expect(staging.md_raids).to contain_exactly( + an_object_having_attributes( + "number" => 1, + "md_level" => Y2Storage::MdLevel::RAID0 + ) + ) + raid = staging.md_raids.first + expect(raid.lvm_pv.lvm_vg.vg_name).to eq "vg0" + expect(staging.lvm_vgs).to contain_exactly( + an_object_having_attributes("vg_name" => "vg0") + ) + vg = staging.lvm_vgs.first.lvm_pvs.first + expect(vg.blk_device).to be_a(Y2Storage::Md) + expect(staging.lvm_lvs).to contain_exactly( + an_object_having_attributes("lv_name" => "root", "filesystem_mountpoint" => "/"), + an_object_having_attributes("lv_name" => "home", "filesystem_mountpoint" => "/home") + ) + end + end + + describe "using 'auto' for the size of some partitions" do + let(:partitioning) do + [{ "device" => "/dev/sda", "use" => "all", "partitions" => [root, swap] }] + end + let(:swap) do + { "filesystem" => :swap, "mount" => "swap", "size" => "auto" } + end + + it "returns true and stores a successful proposal" do + expect(subject.calculate_autoyast(partitioning)).to eq true + expect(Y2Storage::StorageManager.instance.proposal.failed?).to eq false + end + + # To prevent this fallback, we would need either to: + # - Fix AutoinstProposal to honor the passed ProposalSettings everywhere + # - Mock ProposalSettings.new_for_current_product to return settings obtained from Agama + it "fallbacks to legacy settings hardcoded at YaST" do + subject.calculate_autoyast(partitioning) + partitions = staging.find_by_name("/dev/sda").partitions.sort_by(&:number) + expect(partitions.size).to eq(3) + expect(partitions[0].id.is?(:esp)).to eq(true) + expect(partitions[1].filesystem.root?).to eq(true) + expect(partitions[2].filesystem.mount_path).to eq("swap") + expect(partitions[2].size).to eq 1.GiB + end + end + + describe "automatic partitioning" do + let(:partitioning) do + [ + { + "device" => "/dev/sda", + "use" => use, + "enable_snapshots" => snapshots + } + ] + end + + let(:use) { "all" } + let(:snapshots) { true } + + it "falls back to the initial guided proposal with the given disk" do + subject.calculate_autoyast(partitioning) + + partitions = staging.find_by_name("/dev/sda").partitions.sort_by(&:number) + expect(partitions.size).to eq(3) + expect(partitions[0].id.is?(:esp)).to eq(true) + expect(partitions[1].filesystem.root?).to eq(true) + expect(partitions[2].filesystem.mount_path).to eq("swap") + end + + context "when a subset of partitions should be used" do + let(:use) { "1" } + + # Since we use :bigger_resize, there is no compatibility with "use" + it "keeps partitions that should not be removed" do + subject.calculate_autoyast(partitioning) + + partitions = staging.find_by_name("/dev/sda").partitions + partitions.reject! { |p| p.type.is?(:extended) } + expect(partitions.size).to eq(5) + end + end + + context "when snapshots are enabled in the AutoYaST profile" do + let(:snapshots) { true } + + it "configures snapshots for root" do + subject.calculate_autoyast(partitioning) + + sda = staging.find_by_name("/dev/sda") + root = root_filesystem(sda) + expect(root.snapshots?).to eq(true) + end + end + + context "when snapshots are disabled in the AutoYaST profile" do + let(:snapshots) { false } + + it "does not configure snapshots for root" do + subject.calculate_autoyast(partitioning) + + sda = staging.find_by_name("/dev/sda") + root = root_filesystem(sda) + expect(root.snapshots?).to eq(false) + end + end + + it "runs all the callbacks" do + callback1 = proc {} + callback2 = proc {} + + subject.on_calculate(&callback1) + subject.on_calculate(&callback2) + + expect(callback1).to receive(:call) + expect(callback2).to receive(:call) + + subject.calculate_autoyast(partitioning) + end + end + end +end diff --git a/service/test/agama/storage/manager_test.rb b/service/test/agama/storage/manager_test.rb index 88c0757e6e..3a8299c994 100644 --- a/service/test/agama/storage/manager_test.rb +++ b/service/test/agama/storage/manager_test.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2022-2023] SUSE LLC +# Copyright (c) [2022-2024] SUSE LLC # # All Rights Reserved. # @@ -24,8 +24,10 @@ require_relative "../with_issues_examples" require_relative "storage_helpers" require "agama/storage/manager" +require "agama/storage/proposal" require "agama/storage/proposal_settings" require "agama/storage/iscsi/manager" +require "agama/storage/volume" require "agama/config" require "agama/issue" require "agama/dbus/clients/questions" @@ -148,7 +150,7 @@ allow(proposal).to receive(:issues).and_return(proposal_issues) allow(proposal).to receive(:available_devices).and_return(devices) allow(proposal).to receive(:settings).and_return(settings) - allow(proposal).to receive(:calculate) + allow(proposal).to receive(:calculate_guided) allow(config).to receive(:pick_product) allow(iscsi).to receive(:activate) @@ -190,7 +192,7 @@ end expect(iscsi).to receive(:probe) expect(y2storage_manager).to receive(:probe) - expect(proposal).to receive(:calculate).with(config_settings) + expect(proposal).to receive(:calculate_guided).with(config_settings) storage.probe end @@ -359,6 +361,41 @@ end end + describe "#actions" do + it "return an empty list if the system has not been probed yet" do + expect(subject.actions).to eq([]) + end + + context "if the system was probed" do + before do + mock_storage(devicegraph: "partitioned_md.yml") + end + + it "returns an empty list if a proposal has not been calculated yet" do + expect(subject.actions).to eq([]) + end + + context "if a proposal was successfully calculated" do + before do + subject.proposal.calculate_guided(settings) + end + + let(:settings) do + Agama::Storage::ProposalSettings.new.tap do |settings| + settings.device.name = "/dev/sdb" + settings.volumes = [Agama::Storage::Volume.new("/")] + end + end + + it "returns the list of actions" do + expect(subject.actions).to include( + an_object_having_attributes(sentence: /Create partition \/dev\/sdb1/) + ) + end + end + end + end + include_examples "progress" include_examples "issues" diff --git a/service/test/agama/storage/proposal_test.rb b/service/test/agama/storage/proposal_test.rb index 87eb6f166b..2dbc51ae5a 100644 --- a/service/test/agama/storage/proposal_test.rb +++ b/service/test/agama/storage/proposal_test.rb @@ -57,13 +57,13 @@ end describe "#success?" do - it "returns false if calculate has not been called yet" do + it "returns false if no calculate has been called yet" do expect(subject.success?).to eq(false) end - context "if calculate was already called" do + context "if calculate_guided was already called" do before do - subject.calculate(settings) + subject.calculate_guided(settings) end context "and the proposal was successful" do @@ -84,11 +84,11 @@ end end - describe "#calculate" do + describe "#calculate_guided" do it "calculates a new proposal with the given settings" do expect(Y2Storage::StorageManager.instance.proposal).to be_nil - subject.calculate(achievable_settings) + subject.calculate_guided(achievable_settings) expect(Y2Storage::StorageManager.instance.proposal).to_not be_nil y2storage_settings = Y2Storage::StorageManager.instance.proposal.settings @@ -108,12 +108,12 @@ expect(callback1).to receive(:call) expect(callback2).to receive(:call) - subject.calculate(achievable_settings) + subject.calculate_guided(achievable_settings) end it "returns whether the proposal was successful" do - expect(subject.calculate(achievable_settings)).to eq(true) - expect(subject.calculate(impossible_settings)).to eq(false) + expect(subject.calculate_guided(achievable_settings)).to eq(true) + expect(subject.calculate_guided(impossible_settings)).to eq(false) end context "if the given device settings sets a disk as target" do @@ -127,7 +127,7 @@ end it "sets the first available device as target device for volumes" do - subject.calculate(achievable_settings) + subject.calculate_guided(achievable_settings) y2storage_settings = Y2Storage::StorageManager.instance.proposal.settings expect(y2storage_settings.volumes).to contain_exactly( @@ -148,7 +148,7 @@ end it "sets the first available device as candidate device" do - subject.calculate(achievable_settings) + subject.calculate_guided(achievable_settings) y2storage_settings = Y2Storage::StorageManager.instance.proposal.settings expect(y2storage_settings.candidate_devices).to contain_exactly("/dev/sda") @@ -162,7 +162,7 @@ end it "does not calculate a proposal" do - subject.calculate(achievable_settings) + subject.calculate_guided(achievable_settings) expect(Y2Storage::StorageManager.instance.proposal).to be_nil end @@ -176,11 +176,11 @@ expect(callback1).to_not receive(:call) expect(callback2).to_not receive(:call) - subject.calculate(achievable_settings) + subject.calculate_guided(achievable_settings) end it "returns false" do - expect(subject.calculate(achievable_settings)).to eq(false) + expect(subject.calculate_guided(achievable_settings)).to eq(false) end end end @@ -192,7 +192,7 @@ context "if the proposal was already calculated" do before do - subject.calculate(achievable_settings) + subject.calculate_guided(achievable_settings) end it "returns the settings used for calculating the proposal" do @@ -215,7 +215,7 @@ context "if the proposal failed" do before do - subject.calculate(impossible_settings) + subject.calculate_guided(impossible_settings) end it "returns an empty list" do @@ -225,7 +225,7 @@ context "if the proposal was successful" do before do - subject.calculate(achievable_settings) + subject.calculate_guided(achievable_settings) end it "returns the actions from the actiongraph" do @@ -242,7 +242,7 @@ end it "returns an empty list if the current proposal is successful" do - subject.calculate(achievable_settings) + subject.calculate_guided(achievable_settings) expect(subject.issues).to eq([]) end @@ -251,7 +251,7 @@ let(:settings) { impossible_settings } it "includes an error because the volumes cannot be accommodated" do - subject.calculate(settings) + subject.calculate_guided(settings) expect(subject.issues).to include( an_object_having_attributes(description: /Cannot accommodate/) @@ -261,13 +261,14 @@ context "and the settings does not indicate a target device" do before do # Avoid to automatically set the first device - allow(subject).to receive(:available_devices).and_return([]) + 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(settings) + subject.calculate_guided(settings) expect(subject.issues).to include( an_object_having_attributes(description: /No device selected/) @@ -287,7 +288,7 @@ let(:settings) { impossible_settings.tap { |s| s.device.name = "/dev/vdz" } } it "includes an error because the device is not found" do - subject.calculate(settings) + subject.calculate_guided(settings) expect(subject.issues).to include( an_object_having_attributes(description: /is not found/) diff --git a/service/test/agama/storage/proposal_volumes_test.rb b/service/test/agama/storage/proposal_volumes_test.rb index d6f3867f6a..fa055227b6 100644 --- a/service/test/agama/storage/proposal_volumes_test.rb +++ b/service/test/agama/storage/proposal_volumes_test.rb @@ -149,13 +149,13 @@ def expect_proposal_with_specs(*specs) { mount_point: "swap", proposed: false }, { mount_point: "/two", proposed: false, fallback_for_min_size: "/" } ) - proposal.calculate(settings) + proposal.calculate_guided(settings) end end describe "#settings" do it "returns settings with a set of volumes with adjusted sizes" do - proposal.calculate(settings) + proposal.calculate_guided(settings) expect(proposal.settings.volumes).to contain_exactly( an_object_having_attributes( @@ -190,13 +190,13 @@ def expect_proposal_with_specs(*specs) { mount_point: "swap", proposed: false }, { mount_point: "/two", proposed: true, fallback_for_min_size: "/" } ) - proposal.calculate(settings) + proposal.calculate_guided(settings) end end describe "#settings" do it "returns settings with a set of volumes with adjusted sizes" do - proposal.calculate(settings) + proposal.calculate_guided(settings) expect(proposal.settings.volumes).to contain_exactly( an_object_having_attributes( @@ -232,13 +232,13 @@ def expect_proposal_with_specs(*specs) { mount_point: "swap", proposed: false }, { mount_point: "/two", proposed: false, fallback_for_min_size: "/" } ) - proposal.calculate(settings) + 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(settings) + proposal.calculate_guided(settings) expect(proposal.settings.volumes).to contain_exactly( an_object_having_attributes( @@ -273,13 +273,13 @@ def expect_proposal_with_specs(*specs) min_size: Y2Storage::DiskSize.GiB(1) } ) - proposal.calculate(settings) + proposal.calculate_guided(settings) end end describe "#settings" do it "returns settings with a set of volumes with adjusted sizes" do - proposal.calculate(settings) + proposal.calculate_guided(settings) expect(proposal.settings.volumes).to contain_exactly( an_object_having_attributes(mount_path: "/", auto_size: true), @@ -322,13 +322,13 @@ def expect_proposal_with_specs(*specs) }, { mount_point: "/two", proposed: false, fallback_for_min_size: "/" } ) - proposal.calculate(settings) + 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(settings) + proposal.calculate_guided(settings) expect(proposal.settings.volumes).to contain_exactly( an_object_having_attributes( diff --git a/service/test/fixtures/storage.yaml b/service/test/fixtures/storage.yaml new file mode 100644 index 0000000000..16f5a3a5c1 --- /dev/null +++ b/service/test/fixtures/storage.yaml @@ -0,0 +1,40 @@ +storage: + space_policy: delete + volumes: + - "/" + - "swap" + volume_templates: + - mount_path: "/" + filesystem: btrfs + btrfs: + snapshots: true + read_only: false + default_subvolume: "@" + subvolumes: + - path: home + - path: opt + - path: root + size: + auto: true + outline: + required: true + filesystems: + - btrfs + - ext4 + - xfs + auto_size: + base_min: 5 GiB + snapshots_increment: 250% + snapshots_configurable: true + - mount_path: "swap" + filesystem: swap + size: + auto: true + outline: + auto_size: + base_min: 1 GiB + base_max: 2 GiB + adjust_by_ram: true + required: false + filesystems: + - swap diff --git a/service/test/fixtures/windows-linux-pc.yml b/service/test/fixtures/windows-linux-pc.yml new file mode 100644 index 0000000000..f5f815a602 --- /dev/null +++ b/service/test/fixtures/windows-linux-pc.yml @@ -0,0 +1,40 @@ +--- +- disk: + name: /dev/sda + size: 500 GiB + partition_table: ms-dos + partitions: + + - partition: + size: 250 GiB + name: /dev/sda1 + id: 0x7 + file_system: ntfs + label: windows + + - partition: + size: 2 GiB + name: /dev/sda2 + id: swap + file_system: swap + label: swap + + - partition: + size: unlimited + name: /dev/sda3 + file_system: ext4 + label: root + uuid: sda3-uuid + +- disk: + name: /dev/sdb + size: 500 GiB + partition_table: gpt + partitions: + + - partition: + size: 113 GiB + name: /dev/sdb1 + file_system: xfs + label: data + uuid: sda1-uuid diff --git a/web/src/components/overview/StorageSection.jsx b/web/src/components/overview/StorageSection.jsx index 242933457d..0691c59ec0 100644 --- a/web/src/components/overview/StorageSection.jsx +++ b/web/src/components/overview/StorageSection.jsx @@ -271,7 +271,7 @@ export default function StorageSection({ showErrors = false }) { const errors = showErrors ? state.errors : []; - const busy = state.busy || !state.proposal; + const busy = state.busy || !state.proposal?.result; const SectionContent = () => { if (busy) {