diff --git a/doc/dbus/bus/org.opensuse.Agama.Storage1.bus.xml b/doc/dbus/bus/org.opensuse.Agama.Storage1.bus.xml index f5c91d7edb..cc0e530113 100644 --- a/doc/dbus/bus/org.opensuse.Agama.Storage1.bus.xml +++ b/doc/dbus/bus/org.opensuse.Agama.Storage1.bus.xml @@ -52,6 +52,10 @@ + + + + diff --git a/doc/dbus/org.opensuse.Agama.Storage1.doc.xml b/doc/dbus/org.opensuse.Agama.Storage1.doc.xml index a848f9f8c0..a4ac2393f4 100644 --- a/doc/dbus/org.opensuse.Agama.Storage1.doc.xml +++ b/doc/dbus/org.opensuse.Agama.Storage1.doc.xml @@ -55,6 +55,31 @@ --> + + + + + + + diff --git a/doc/storage_proposal_from_profile.md b/doc/storage_proposal_from_profile.md new file mode 100644 index 0000000000..600abc9d7c --- /dev/null +++ b/doc/storage_proposal_from_profile.md @@ -0,0 +1,267 @@ +# Calculating a proposal from a profile + +The Agama proposal can be calculated either from a very detailed JSON profile or from a "sparse +profile". The information not provided by the profile is automatically inferred (solved) by Agama. +Several layers are involved in the process of obtaining the final storage config used by the Agama +proposal, as shown in the following diagram: + +``` +JSON profile ------------> JSON profile (solved) ------------> Storage::Config ------------> Storage::Config (solved) + | | | + (JSON solver) (config conversion) (config solver) +``` + +## JSON solver + +The JSON profile provides the *generator* concept. A *generator* allows indicating what volumes to +create without explicitly defining them. The JSON solver (`Agama::Storage::JSONConfigSolver` class) +takes care of replacing the volume generator by the corresponding JSON volumes according to the +product. + +For example, a JSON profile like this: + +~~~json +{ + "drives": [ + { + "partitions": [ { "generate": "default" } ] + } + ] +} +~~~ + +would be solved to something like: + +~~~json +{ + "drives": [ + { + "partitions": [ + { "filesystem": { "path": "/" } }, + { "filesystem": { "path": "swap" } } + ] + } + ] +} +~~~ + +The volumes are solved with their very minimum information (i.e., a mount path). The resulting +solved JSON is used for getting the storage config object. + +## Config conversion + +The class `Agama::Storage::ConfigConversions::FromJSON` takes a solved JSON profile and generates a +`Agama::Storage::Config` object. The resulting config only contains the information provided by the +profile. For example, if the profile does not specify a file system type for a partition, then the +config would not have any information about the file system to use for such a partition. + +If something is not provided by the profile (e.g., "boot", "size", "filesystem"), then the config +marks that values as default ones. For example: + +```json +{ + "drives": [ + { + "partitions": [ + { "filesystem": { "path": "/" } } + ] + } + ] +} +``` + +generates a config with these default values: + +```ruby +config.boot.device.default? #=> true + +partition = config.drives.first.partitions.first +partition.size.default? #=> true +partition.filesystem.type.default? #=> true +``` + +The configs set as default and any other missing value have to be solved to a value provided by the +product definition. + +## Config solver + +The config solver (`Agama::Storage::ConfigSolver` class) assigns a value to all the unknown +properties of a config object. As result, the config object is totally complete and ready to be used +by the agama proposal. + +### How sizes are solved + +A volume size in the profile: + +* Can be totally omitted. +* Can omit the max size. +* Can use "current" as value for min and/or max. + +Let's see each case. + +#### Omitting size completely + +```json +"partitions": [ + { "filesystem": { "path": "/" } } +] +``` + +In this case, the config conversion would generate something like: + +```ruby +partition.size.default? #=> true +partition.size.min #=> nil +partition.size.max #=> nil +``` + +If the size is default, then the config solver always assigns a value for `#min` and `#max` +according to the product definition and ignoring the current values assigned to `#min` and `#max`. +The solver takes into account the mount path, the fallback devices and swap config in order to set +the proper sizes. + +If the size is default and the volume already exists, then the solver sets the current size of the +volume to both `#min` and `#max` sizes. + +#### Omitting the max size + +```json +"partitions": [ + { + "size": { "min": "10 GiB" }, + "filesystem": { "path": "/" } + } +] +``` + +The config conversion generates: + +```ruby +partition.size.default? #=> false +partition.size.min #=> Y2Storage::DiskSize.GiB(10) +partition.size.max #=> Y2Storage::DiskSize.Unlimited +``` + +Note that `#max` is set to unlimited when it does not appear in the profile. In this case, nothing +has to be solved because both `#min` and `#max` have a value. + +#### Using "current" + +Both *min* and *max* sizes admit "current" as a valid size value in the JSON profile. The "current" +value stands for the current size of the volume. Using "current" is useful for growing or shrinking +a device. + +The config conversion knows nothing about the current size of a volume, so it simply replaces +"current" values by `nil`. + +For example, in this case: + +```json +"partitions": [ + { + "search": "/dev/vda1", + "size": { "min": "current" }, + "filesystem": { "path": "/" } + } +] +``` + +the config conversion generates a size with `nil` for `#min`: + +```ruby +partition.size.default? #=> false +partition.size.min #=> nil +partition.size.max #=> Y2Storage::DiskSize.Unlimited +``` + +The config solver replaces the `nil` sizes by the device size. In the example before, let's say that +/dev/vda1 has 10 GiB, so the resulting config would be: + +```ruby +partition.size.default? #=> false +partition.size.min #=> Y2Storage::DiskSize.GiB(10) +partition.size.max #=> Y2Storage::DiskSize.Unlimited +``` + +##### Use case: growing a device + +```json +"partitions": [ + { + "search": "/dev/vda1", + "size": { "min": "current" }, + "filesystem": { "path": "/" } + } +] +``` + +```ruby +partition.size.default? #=> false +partition.size.min #=> Y2Storage::DiskSize.GiB(10) +partition.size.max #=> Y2Storage::DiskSize.Unlimited +``` + +##### Use case: shrinking a device + +```json +"partitions": [ + { + "search": "/dev/vda1", + "size": { "min": 0, "max": "current" }, + "filesystem": { "path": "/" } + } +] +``` + +```ruby +partition.size.default? #=> false +partition.size.min #=> 0 +partition.size.max #=> Y2Storage::DiskSize.GiB(10) +``` + +##### Use case: keeping a device size + +Note that this is equivalent to omitting the size. + +```json +"partitions": [ + { + "search": "/dev/vda1", + "size": { "min": "current", "max": "current" }, + "filesystem": { "path": "/" } + } +] +``` + +```ruby +partition.size.default? #=> false +partition.size.min #=> Y2Storage::DiskSize.GiB(10) +partition.size.max #=> Y2Storage::DiskSize.GiB(10) +``` + +##### Use case: fallback for not found devices + +A profile can specify an "advanced search" to indicate that a volume has to be created if it is not +found in the system. + +```json +"partitions": [ + { + "search": { + "condition": { "name": "/dev/vda1" }, + "ifNotFound": "create" + }, + "size": { "min": "current" }, + "filesystem": { "path": "/" } + } +] +``` + +If the device does not exist, then "current" cannot be replaced by any device size. In this case, +the config solver uses the default size defined by the product as fallback for "current". + +```ruby +partition.size.default? #=> false +partition.size.min #=> Y2Storage::DiskSize.GiB(15) +partition.size.max #=> Y2Storage::DiskSize.Unlimited +``` diff --git a/rust/agama-lib/src/storage/client.rs b/rust/agama-lib/src/storage/client.rs index 14af4ae594..cfbf228609 100644 --- a/rust/agama-lib/src/storage/client.rs +++ b/rust/agama-lib/src/storage/client.rs @@ -154,6 +154,14 @@ impl<'a> StorageClient<'a> { Ok(settings) } + /// Set the storage config according to the JSON schema + pub async fn set_config_model(&self, model: Box) -> Result { + Ok(self + .storage_proxy + .set_config_model(serde_json::to_string(&model).unwrap().as_str()) + .await?) + } + /// Get the storage config model according to the JSON schema pub async fn get_config_model(&self) -> Result, ServiceError> { let serialized_config_model = self.storage_proxy.get_config_model().await?; diff --git a/rust/agama-lib/src/storage/proxies/storage1.rs b/rust/agama-lib/src/storage/proxies/storage1.rs index 052e91ebf3..67e31c247d 100644 --- a/rust/agama-lib/src/storage/proxies/storage1.rs +++ b/rust/agama-lib/src/storage/proxies/storage1.rs @@ -62,6 +62,9 @@ pub trait Storage1 { /// Get the current storage config according to the JSON schema fn get_config(&self) -> zbus::Result; + /// Set the storage config model according to the JSON schema + fn set_config_model(&self, model: &str) -> zbus::Result; + /// Get the storage config model according to the JSON schema fn get_config_model(&self) -> zbus::Result; diff --git a/rust/agama-server/src/storage/web.rs b/rust/agama-server/src/storage/web.rs index 442f4002e5..26c0b60bef 100644 --- a/rust/agama-server/src/storage/web.rs +++ b/rust/agama-server/src/storage/web.rs @@ -114,7 +114,7 @@ pub async fn storage_service(dbus: zbus::Connection) -> Result>) -> Result>, + Json(settings): Json, +) -> Result, Error> { + let _status: u32 = state + .client + .set_config(settings) + .await + .map_err(Error::Service)?; + Ok(Json(())) +} + /// Returns the storage config model. /// /// * `state` : service state. @@ -181,27 +207,28 @@ async fn get_config_model( Ok(Json(config_model)) } -/// Sets the storage configuration. +/// Sets the storage config model. /// /// * `state`: service state. -/// * `config`: storage configuration. +/// * `config_model`: storage config model. #[utoipa::path( put, - path = "/config", + request_body = String, + path = "/config_model", context_path = "/api/storage", - operation_id = "set_storage_config", + operation_id = "set_storage_config_model", responses( - (status = 200, description = "Set the storage configuration"), + (status = 200, description = "Set the storage config model"), (status = 400, description = "The D-Bus service could not perform the action") ) )] -async fn set_config( +async fn set_config_model( State(state): State>, - Json(settings): Json, + Json(model): Json>, ) -> Result, Error> { let _status: u32 = state .client - .set_config(settings) + .set_config_model(model) .await .map_err(Error::Service)?; Ok(Json(())) diff --git a/service/lib/agama/dbus/storage/manager.rb b/service/lib/agama/dbus/storage/manager.rb index c6742718eb..ccc8616691 100644 --- a/service/lib/agama/dbus/storage/manager.rb +++ b/service/lib/agama/dbus/storage/manager.rb @@ -119,6 +119,18 @@ def recover_config JSON.pretty_generate(json) 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}") + + model_json = JSON.parse(serialized_model, symbolize_names: true) + proposal.calculate_from_model(model_json) + proposal.success? ? 0 : 1 + end + # Gets and serializes the storage config model. # # @return [String] @@ -148,6 +160,9 @@ def deprecated_system busy_while { apply_config(serialized_config) } end dbus_method(:GetConfig, "out serialized_config:s") { recover_config } + dbus_method(:SetConfigModel, "in serialized_model:s, out result:u") do |serialized_model| + busy_while { apply_config_model(serialized_model) } + end dbus_method(:GetConfigModel, "out serialized_model:s") { recover_model } dbus_method(:Install) { install } dbus_method(:Finish) { finish } diff --git a/service/lib/agama/storage/config.rb b/service/lib/agama/storage/config.rb index 928c787b30..dbc249223d 100644 --- a/service/lib/agama/storage/config.rb +++ b/service/lib/agama/storage/config.rb @@ -25,7 +25,10 @@ module Agama module Storage - # Settings used to calculate an storage proposal. + # Config used to calculate an storage proposal. + # + # See doc/storage_proposal_from_profile.md for a complete description of how the config is + # generated from a profile. class Config include Copyable diff --git a/service/lib/agama/storage/config_conversions.rb b/service/lib/agama/storage/config_conversions.rb index 1bcc863703..65233dde94 100644 --- a/service/lib/agama/storage/config_conversions.rb +++ b/service/lib/agama/storage/config_conversions.rb @@ -20,6 +20,7 @@ # find current contact information at www.suse.com. require "agama/storage/config_conversions/from_json" +require "agama/storage/config_conversions/from_model" require "agama/storage/config_conversions/to_json" require "agama/storage/config_conversions/to_model" diff --git a/service/lib/agama/storage/config_conversions/from_json.rb b/service/lib/agama/storage/config_conversions/from_json.rb index 86cddab38a..61c1f66127 100644 --- a/service/lib/agama/storage/config_conversions/from_json.rb +++ b/service/lib/agama/storage/config_conversions/from_json.rb @@ -27,6 +27,9 @@ module Agama module Storage module ConfigConversions # Config conversion from JSON hash according to schema. + # + # See doc/storage_proposal_from_profile.md for a complete description of how the config is + # generated from a profile. class FromJSON # @param config_json [Hash] # @param default_paths [Array] Default paths of the product. diff --git a/service/lib/agama/storage/config_conversions/from_json_conversions/base.rb b/service/lib/agama/storage/config_conversions/from_json_conversions/base.rb index e3e449f0f6..63c2076bc5 100644 --- a/service/lib/agama/storage/config_conversions/from_json_conversions/base.rb +++ b/service/lib/agama/storage/config_conversions/from_json_conversions/base.rb @@ -25,15 +25,17 @@ module ConfigConversions module FromJSONConversions # Base class for conversions from JSON hash according to schema. class Base + # @param config_json [Hash] def initialize(config_json) @config_json = config_json end # Performs the conversion from Hash according to the JSON schema. # - # @param config [Object] A {Config} or any of its configs from {Storage::Configs}. # @return [Object] A {Config} or any its configs from {Storage::Configs}. - def convert(config) + def convert + config = default_config + conversions.each do |property, value| next if value.nil? @@ -45,8 +47,16 @@ def convert(config) private + # @return [Hash] attr_reader :config_json + # Default config object (defined by derived classes). + # + # @return [Object] + def default_config + raise "Undefined default config" + end + # Values to apply to the config. # # @return [Hash] e.g., { name: "/dev/vda" }. diff --git a/service/lib/agama/storage/config_conversions/from_json_conversions/boot.rb b/service/lib/agama/storage/config_conversions/from_json_conversions/boot.rb index e2d2096abf..fa120ab880 100644 --- a/service/lib/agama/storage/config_conversions/from_json_conversions/boot.rb +++ b/service/lib/agama/storage/config_conversions/from_json_conversions/boot.rb @@ -28,16 +28,16 @@ module ConfigConversions module FromJSONConversions # Boot conversion from JSON hash according to schema. class Boot < Base - # @see Base#convert - # @return [Configs::Boot] - def convert - super(Configs::Boot.new) - end - private alias_method :boot_json, :config_json + # @see Base + # @return [Configs::Boot] + def default_config + Configs::Boot.new + end + # @see Base#conversions # @return [Hash] def conversions diff --git a/service/lib/agama/storage/config_conversions/from_json_conversions/btrfs.rb b/service/lib/agama/storage/config_conversions/from_json_conversions/btrfs.rb index f658287b74..df02e04f8b 100644 --- a/service/lib/agama/storage/config_conversions/from_json_conversions/btrfs.rb +++ b/service/lib/agama/storage/config_conversions/from_json_conversions/btrfs.rb @@ -28,16 +28,16 @@ module ConfigConversions module FromJSONConversions # Btrfs conversion from JSON hash according to schema. class Btrfs < Base - # @see Base#convert - # @return [Configs::Btrfs] - def convert - super(Configs::Btrfs.new) - end - private alias_method :btrfs_json, :config_json + # @see Base + # @return [Configs::Btrfs] + def default_config + Configs::Btrfs.new + end + # @see Base#conversions # @return [Hash] def conversions diff --git a/service/lib/agama/storage/config_conversions/from_json_conversions/config.rb b/service/lib/agama/storage/config_conversions/from_json_conversions/config.rb index 4e86fef679..46b0ea71fd 100644 --- a/service/lib/agama/storage/config_conversions/from_json_conversions/config.rb +++ b/service/lib/agama/storage/config_conversions/from_json_conversions/config.rb @@ -31,14 +31,14 @@ module ConfigConversions module FromJSONConversions # Config conversion from JSON hash according to schema. class Config < Base - # @see Base#convert + private + + # @see Base # @return [Config] - def convert - super(Storage::Config.new) + def default_config + Storage::Config.new end - private - # @see Base#conversions # @return [Hash] def conversions diff --git a/service/lib/agama/storage/config_conversions/from_json_conversions/drive.rb b/service/lib/agama/storage/config_conversions/from_json_conversions/drive.rb index 8fd661dd6c..dacb84bc2b 100644 --- a/service/lib/agama/storage/config_conversions/from_json_conversions/drive.rb +++ b/service/lib/agama/storage/config_conversions/from_json_conversions/drive.rb @@ -33,22 +33,22 @@ module ConfigConversions module FromJSONConversions # Drive conversion from JSON hash according to schema. class Drive < Base + private + include WithSearch include WithEncryption include WithFilesystem include WithPtableType include WithPartitions - # @see Base#convert + alias_method :drive_json, :config_json + + # @see Base # @return [Configs::Drive] - def convert - super(Configs::Drive.new) + def default_config + Configs::Drive.new end - private - - alias_method :drive_json, :config_json - # @see Base#conversions # @return [Hash] def conversions diff --git a/service/lib/agama/storage/config_conversions/from_json_conversions/encryption.rb b/service/lib/agama/storage/config_conversions/from_json_conversions/encryption.rb index 9f75003ee7..e059b32478 100644 --- a/service/lib/agama/storage/config_conversions/from_json_conversions/encryption.rb +++ b/service/lib/agama/storage/config_conversions/from_json_conversions/encryption.rb @@ -30,16 +30,16 @@ module ConfigConversions module FromJSONConversions # Encryption conversion from JSON hash according to schema. class Encryption < Base - # @see Base#convert - # @return [Configs::Encryption] - def convert - super(Configs::Encryption.new) - end - private alias_method :encryption_json, :config_json + # @see Base + # @return [Configs::Encryption] + def default_config + Configs::Encryption.new + end + # @see Base#conversions # @return [Hash] def conversions diff --git a/service/lib/agama/storage/config_conversions/from_json_conversions/filesystem.rb b/service/lib/agama/storage/config_conversions/from_json_conversions/filesystem.rb index b5aac14056..537416374a 100644 --- a/service/lib/agama/storage/config_conversions/from_json_conversions/filesystem.rb +++ b/service/lib/agama/storage/config_conversions/from_json_conversions/filesystem.rb @@ -30,16 +30,16 @@ module ConfigConversions module FromJSONConversions # Filesystem conversion from JSON hash according to schema. class Filesystem < Base - # @see Base#convert - # @return [Configs::Filesystem] - def convert - super(Configs::Filesystem.new) - end - private alias_method :filesystem_json, :config_json + # @see Base + # @return [Configs::Filesystem] + def default_config + Configs::Filesystem.new + end + # @see Base#conversions # @return [Hash] def conversions diff --git a/service/lib/agama/storage/config_conversions/from_json_conversions/filesystem_type.rb b/service/lib/agama/storage/config_conversions/from_json_conversions/filesystem_type.rb index 995604a9bc..4872c48678 100644 --- a/service/lib/agama/storage/config_conversions/from_json_conversions/filesystem_type.rb +++ b/service/lib/agama/storage/config_conversions/from_json_conversions/filesystem_type.rb @@ -31,16 +31,16 @@ module ConfigConversions module FromJSONConversions # Filesystem type conversion from JSON hash according to schema. class FilesystemType < Base - # @see Base#convert - # @return [Configs::FilesystemType] - def convert - super(Configs::FilesystemType.new) - end - private alias_method :filesystem_type_json, :config_json + # @see Base + # @return [Configs::FilesystemType] + def default_config + Configs::FilesystemType.new + end + # @see Base#conversions # @return [Hash] def conversions diff --git a/service/lib/agama/storage/config_conversions/from_json_conversions/logical_volume.rb b/service/lib/agama/storage/config_conversions/from_json_conversions/logical_volume.rb index cbd98c3e34..ea0b2be263 100644 --- a/service/lib/agama/storage/config_conversions/from_json_conversions/logical_volume.rb +++ b/service/lib/agama/storage/config_conversions/from_json_conversions/logical_volume.rb @@ -32,18 +32,18 @@ module ConfigConversions module FromJSONConversions # Logical volume conversion from JSON hash according to schema. class LogicalVolume < Base + private + include WithEncryption include WithFilesystem include WithSize - # @see Base#convert + # @see Base # @return [Configs::LogicalVolume] - def convert - super(Configs::LogicalVolume.new) + def default_config + Configs::LogicalVolume.new end - private - alias_method :logical_volume_json, :config_json # @see Base#conversions diff --git a/service/lib/agama/storage/config_conversions/from_json_conversions/partition.rb b/service/lib/agama/storage/config_conversions/from_json_conversions/partition.rb index 00823a0c45..5da159eb46 100644 --- a/service/lib/agama/storage/config_conversions/from_json_conversions/partition.rb +++ b/service/lib/agama/storage/config_conversions/from_json_conversions/partition.rb @@ -33,19 +33,19 @@ module ConfigConversions module FromJSONConversions # Partition conversion from JSON hash according to schema. class Partition < Base + private + include WithSearch include WithEncryption include WithFilesystem include WithSize - # @see Base#convert + # @see Base # @return [Configs::Partition] - def convert - super(Configs::Partition.new) + def default_config + Configs::Partition.new end - private - alias_method :partition_json, :config_json # @see Base#conversions diff --git a/service/lib/agama/storage/config_conversions/from_json_conversions/search.rb b/service/lib/agama/storage/config_conversions/from_json_conversions/search.rb index e742de66a2..c221fd8f77 100644 --- a/service/lib/agama/storage/config_conversions/from_json_conversions/search.rb +++ b/service/lib/agama/storage/config_conversions/from_json_conversions/search.rb @@ -28,14 +28,14 @@ module ConfigConversions module FromJSONConversions # Search conversion from JSON hash according to schema. class Search < Base - # @see Base#convert + private + + # @see Base # @return [Configs::Search] - def convert - super(Configs::Search.new) + def default_config + Configs::Search.new end - private - # Reserved search value meaning 'match all devices or ignore the section'. # # { search: "*" } is a shortcut for { search: { ifNotFound: "skip" } }. diff --git a/service/lib/agama/storage/config_conversions/from_json_conversions/size.rb b/service/lib/agama/storage/config_conversions/from_json_conversions/size.rb index c1cf93881b..9291a93546 100644 --- a/service/lib/agama/storage/config_conversions/from_json_conversions/size.rb +++ b/service/lib/agama/storage/config_conversions/from_json_conversions/size.rb @@ -29,16 +29,16 @@ module ConfigConversions module FromJSONConversions # Size conversion from JSON hash according to schema. class Size < Base - # @see Base#convert - # @return [Configs::Size] - def convert - super(Configs::Size.new) - end - private alias_method :size_json, :config_json + # @see Base + # @return [Configs::Size] + def default_config + Configs::Size.new + end + # @see Base#conversions # @return [Hash] def conversions diff --git a/service/lib/agama/storage/config_conversions/from_json_conversions/volume_group.rb b/service/lib/agama/storage/config_conversions/from_json_conversions/volume_group.rb index f1516456b7..8770d41a06 100644 --- a/service/lib/agama/storage/config_conversions/from_json_conversions/volume_group.rb +++ b/service/lib/agama/storage/config_conversions/from_json_conversions/volume_group.rb @@ -30,16 +30,16 @@ module ConfigConversions module FromJSONConversions # Volume group conversion from JSON hash according to schema. class VolumeGroup < Base - # @see Base#convert - # @return [Configs::VolumeGroup] - def convert - super(Configs::VolumeGroup.new) - end - private alias_method :volume_group_json, :config_json + # @see Base + # @return [Configs::VolumeGroup] + def default_config + Configs::VolumeGroup.new + end + # @see Base#conversions # @return [Hash] def conversions diff --git a/service/lib/agama/storage/config_conversions/from_model.rb b/service/lib/agama/storage/config_conversions/from_model.rb new file mode 100644 index 0000000000..e48bc8bfa7 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/from_model.rb @@ -0,0 +1,50 @@ +# 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/config" +require "agama/storage/config_conversions/from_model_conversions/config" + +module Agama + module Storage + module ConfigConversions + # Config conversion from model according to the JSON schema. + class FromModel + # @param model_json [Hash] + def initialize(model_json) + @model_json = model_json + end + + # Performs the conversion from model according to the JSON schema. + # + # @return [Storage::Config] + def convert + # TODO: Raise error if model_json does not match the JSON schema. + FromModelConversions::Config.new(model_json).convert + end + + private + + # @return [Hash] + attr_reader :model_json + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions.rb b/service/lib/agama/storage/config_conversions/from_model_conversions.rb new file mode 100644 index 0000000000..267a567fd5 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/from_model_conversions.rb @@ -0,0 +1,38 @@ +# 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/config_conversions/from_model_conversions/config" +require "agama/storage/config_conversions/from_model_conversions/drive" +require "agama/storage/config_conversions/from_model_conversions/filesystem" +require "agama/storage/config_conversions/from_model_conversions/filesystem_type" +require "agama/storage/config_conversions/from_model_conversions/partition" +require "agama/storage/config_conversions/from_model_conversions/search" +require "agama/storage/config_conversions/from_model_conversions/size" + +module Agama + module Storage + module ConfigConversions + # Conversions from model according to the JSON schema. + module FromModelConversions + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/base.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/base.rb new file mode 100644 index 0000000000..9b7fe83747 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/from_model_conversions/base.rb @@ -0,0 +1,70 @@ +# 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 + module ConfigConversions + module FromModelConversions + # Base class for conversions from model according to the JSON schema. + class Base + # @param model_json [Hash] + def initialize(model_json) + @model_json = model_json + end + + # Performs the conversion from model according to the JSON schema. + # + # @return [Object] A {Config} or any its configs from {Storage::Configs}. + def convert + config = default_config + + conversions.each do |property, value| + next if value.nil? + + config.public_send("#{property}=", value) + end + + config + end + + private + + # @return [Hash] + attr_reader :model_json + + # Default config object (defined by derived classes). + # + # @return [Object] + def default_config + raise "Undefined default config" + end + + # Values to apply to the config. + # + # @return [Hash] e.g., { name: "/dev/vda" }. + def conversions + {} + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/config.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/config.rb new file mode 100644 index 0000000000..29a0af95cd --- /dev/null +++ b/service/lib/agama/storage/config_conversions/from_model_conversions/config.rb @@ -0,0 +1,65 @@ +# 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/config_conversions/from_model_conversions/base" +require "agama/storage/config_conversions/from_model_conversions/drive" +require "agama/storage/config" + +module Agama + module Storage + module ConfigConversions + module FromModelConversions + # Config conversion from model according to the JSON schema. + class Config < Base + private + + # @see Base + # @return [Storage::Config] + def default_config + Storage::Config.new + end + + # @see Base#conversions + # @return [Hash] + def conversions + { + drives: convert_drives + } + end + + # @return [Array, nil] + def convert_drives + drive_models = model_json[:drives] + return unless drive_models + + drive_models.map { |d| convert_drive(d) } + end + + # @param drive_model [Hash] + # @return [Configs::Drive] + def convert_drive(drive_model) + FromModelConversions::Drive.new(drive_model).convert + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/drive.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/drive.rb new file mode 100644 index 0000000000..97bedf6cc0 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/from_model_conversions/drive.rb @@ -0,0 +1,65 @@ +# 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/config_conversions/from_model_conversions/base" +require "agama/storage/config_conversions/from_model_conversions/with_filesystem" +require "agama/storage/config_conversions/from_model_conversions/with_partitions" +require "agama/storage/config_conversions/from_model_conversions/with_ptable_type" +require "agama/storage/config_conversions/from_model_conversions/with_search" +require "agama/storage/configs/drive" + +module Agama + module Storage + module ConfigConversions + module FromModelConversions + # Drive conversion from model according to the JSON schema. + class Drive < Base + private + + include WithFilesystem + include WithPtableType + include WithPartitions + include WithSearch + + alias_method :drive_model, :model_json + + # @see Base + # @return [Configs::Drive] + def default_config + Configs::Drive.new + end + + # @see Base#conversions + # @return [Hash] + def conversions + { + search: convert_search, + alias: drive_model[:alias], + filesystem: convert_filesystem, + ptable_type: convert_ptable_type, + partitions: convert_partitions + } + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/filesystem.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/filesystem.rb new file mode 100644 index 0000000000..d30091a985 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/from_model_conversions/filesystem.rb @@ -0,0 +1,60 @@ +# 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/config_conversions/from_model_conversions/base" +require "agama/storage/config_conversions/from_model_conversions/filesystem_type" +require "agama/storage/configs/filesystem" + +module Agama + module Storage + module ConfigConversions + module FromModelConversions + # Filesystem conversion from model according to the JSON schema. + class Filesystem < Base + private + + # @see Base + # @return [Configs::Filesystem] + def default_config + Configs::Filesystem.new + end + + # @see Base#conversions + # @return [Hash] + def conversions + { + path: model_json[:mountPath], + type: convert_type + } + end + + # @return [Configs::FilesystemType, nil] + def convert_type + filesystem_model = model_json[:filesystem] + return if filesystem_model.nil? + + FromModelConversions::FilesystemType.new(filesystem_model).convert + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/filesystem_type.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/filesystem_type.rb new file mode 100644 index 0000000000..04a7565985 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/from_model_conversions/filesystem_type.rb @@ -0,0 +1,71 @@ +# 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/config_conversions/from_model_conversions/base" +require "agama/storage/configs/btrfs" +require "agama/storage/configs/filesystem_type" +require "y2storage/filesystems/type" + +module Agama + module Storage + module ConfigConversions + module FromModelConversions + # Filesystem type conversion from model according to the JSON schema. + class FilesystemType < Base + private + + alias_method :filesystem_model, :model_json + + # @see Base + # @return [Configs::FilesystemType] + def default_config + Configs::FilesystemType.new + end + + # @see Base#conversions + # @return [Hash] + def conversions + { + default: filesystem_model[:default], + fs_type: convert_type, + btrfs: convert_btrfs + } + end + + # @return [Y2Storage::Filesystems::Type, nil] + def convert_type + value = filesystem_model[:type] + return unless value + + Y2Storage::Filesystems::Type.find(value.to_sym) + end + + # @return [Configs::Btrfs, nil] + def convert_btrfs + return unless filesystem_model[:type] == "btrfs" + + Configs::Btrfs.new.tap { |c| c.snapshots = filesystem_model[:snapshots] } + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/partition.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/partition.rb new file mode 100644 index 0000000000..bbf1325d40 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/from_model_conversions/partition.rb @@ -0,0 +1,74 @@ +# 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/config_conversions/from_model_conversions/base" +require "agama/storage/config_conversions/from_model_conversions/with_filesystem" +require "agama/storage/config_conversions/from_model_conversions/with_search" +require "agama/storage/config_conversions/from_model_conversions/with_size" +require "agama/storage/configs/partition" +require "y2storage/partition_id" + +module Agama + module Storage + module ConfigConversions + module FromModelConversions + # Partition conversion from model according to the JSON schema. + class Partition < Base + private + + include WithSearch + include WithFilesystem + include WithSize + + # @see Base + # @return [Configs::Partition] + def default_config + Configs::Partition.new + end + + alias_method :partition_model, :model_json + + # @see Base#conversions + # @return [Hash] + def conversions + { + search: convert_search, + alias: partition_model[:alias], + filesystem: convert_filesystem, + size: convert_size, + id: convert_id, + delete: partition_model[:delete], + delete_if_needed: partition_model[:deleteIfNeeded] + } + end + + # @return [Y2Storage::PartitionId, nil] + def convert_id + value = partition_model[:id] + return unless value + + Y2Storage::PartitionId.find(value) + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/size.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/size.rb new file mode 100644 index 0000000000..9805acb0bf --- /dev/null +++ b/service/lib/agama/storage/config_conversions/from_model_conversions/size.rb @@ -0,0 +1,77 @@ +# 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/config_conversions/from_model_conversions/base" +require "agama/storage/configs/size" +require "y2storage/disk_size" + +module Agama + module Storage + module ConfigConversions + module FromModelConversions + # Size conversion from model according to the JSON schema. + class Size < Base + private + + alias_method :size_model, :model_json + + # @see Base + # @return [Configs::Size] + def default_config + Configs::Size.new + end + + # @see Base#conversions + # @return [Hash] + def conversions + { + default: size_model[:default], + min: convert_min_size, + max: convert_max_size + } + end + + # @return [Y2Storage::DiskSize, nil] + def convert_min_size + value = size_model[:min] + return unless value + + disk_size(value) + end + + # @return [Y2Storage::DiskSize] + def convert_max_size + value = size_model[:max] + return Y2Storage::DiskSize.unlimited unless value + + disk_size(value) + end + + # @param value [Integer] + # @return [Y2Storage::DiskSize] + def disk_size(value) + Y2Storage::DiskSize.new(value) + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/with_filesystem.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/with_filesystem.rb new file mode 100644 index 0000000000..46853c16e1 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/from_model_conversions/with_filesystem.rb @@ -0,0 +1,40 @@ +# 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/config_conversions/from_model_conversions/filesystem" + +module Agama + module Storage + module ConfigConversions + module FromModelConversions + # Mixin for filesystem conversion. + module WithFilesystem + # @return [Configs::Filesystem, nil] + def convert_filesystem + return unless model_json[:mountPath] || model_json[:filesystem] + + FromModelConversions::Filesystem.new(model_json).convert + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/with_partitions.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/with_partitions.rb new file mode 100644 index 0000000000..da8c4bd5f7 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/from_model_conversions/with_partitions.rb @@ -0,0 +1,114 @@ +# 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/config_conversions/from_model_conversions/partition" +require "agama/storage/configs/partition" + +module Agama + module Storage + module ConfigConversions + module FromModelConversions + # Mixin for partitions conversion. + module WithPartitions + # @return [Array] + def convert_partitions + space_policy = model_json[:spacePolicy] + + case space_policy + when "keep" + used_partition_configs + when "delete" + [used_partition_configs, delete_all_partition_config].flatten + when "resize" + [used_partition_configs, resize_all_partition_config].flatten + else + partition_configs + end + end + + # @param partition_model [Hash] + # @return [Configs::Partition] + def convert_partition(partition_model) + FromModelConversions::Partition.new(partition_model).convert + end + + # @return [Array] + def partition_configs + partitions.map { |p| convert_partition(p) } + end + + # @return [Array] + def used_partition_configs + used_partitions.map { |p| convert_partition(p) } + end + + # @return [Array] + def partitions + model_json[:partitions] || [] + end + + # @return [Array] + def used_partitions + partitions.select { |p| used_partition?(p) } + end + + # @param partition_model [Hash] + # @return [Boolean] + def used_partition?(partition_model) + new_partition?(partition_model) || reused_partition?(partition_model) + end + + # @param partition_model [Hash] + # @return [Boolean] + def new_partition?(partition_model) + partition_model[:name].nil? && + !partition_model[:delete] && + !partition_model[:deleteIfNeeded] + end + + # @param partition_model [Hash] + # @return [Boolean] + def reused_partition?(partition_model) + # TODO: improve check by ensuring the alias is referenced by other device. + any_usage = partition_model[:mountPath] || + partition_model[:filesystem] || + partition_model[:alias] + + any_usage && + partition_model[:name] && + !partition_model[:delete] && + !partition_model[:deleteIfNeeded] + end + + # @return [Configs::Partition] + def delete_all_partition_config + Configs::Partition.new_for_delete_all + end + + # @return [Configs::Partition] + def resize_all_partition_config + Configs::Partition.new_for_shrink_any_if_needed + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/with_ptable_type.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/with_ptable_type.rb new file mode 100644 index 0000000000..7789a75280 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/from_model_conversions/with_ptable_type.rb @@ -0,0 +1,41 @@ +# 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 "y2storage/partition_tables/type" + +module Agama + module Storage + module ConfigConversions + module FromModelConversions + # Mixin for partition table type conversion. + module WithPtableType + # @return [Y2Storage::PartitionTables::Type, nil] + def convert_ptable_type + value = model_json[:ptableType] + return unless value + + Y2Storage::PartitionTables::Type.find(value) + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/with_search.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/with_search.rb new file mode 100644 index 0000000000..a8d29f09be --- /dev/null +++ b/service/lib/agama/storage/config_conversions/from_model_conversions/with_search.rb @@ -0,0 +1,41 @@ +# 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/search" + +module Agama + module Storage + module ConfigConversions + module FromModelConversions + # Mixin for search conversion. + module WithSearch + # @return [Configs::Search, nil] + def convert_search + name = model_json[:name] + return unless name + + Configs::Search.new.tap { |c| c.name = name } + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/with_size.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/with_size.rb new file mode 100644 index 0000000000..23e7a3686c --- /dev/null +++ b/service/lib/agama/storage/config_conversions/from_model_conversions/with_size.rb @@ -0,0 +1,44 @@ +# 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/config_conversions/from_model_conversions/size" +require "agama/storage/configs/size" + +module Agama + module Storage + module ConfigConversions + module FromModelConversions + # Mixin for size conversion. + module WithSize + # @return [Configs::Size, nil] + def convert_size + return Configs::Size.new_for_shrink_if_needed if model_json[:resizeIfNeeded] + + size_model = model_json[:size] + return if size_model.nil? + + FromModelConversions::Size.new(size_model).convert + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/to_json_conversions/base.rb b/service/lib/agama/storage/config_conversions/to_json_conversions/base.rb index e96efd3891..32f68e4855 100644 --- a/service/lib/agama/storage/config_conversions/to_json_conversions/base.rb +++ b/service/lib/agama/storage/config_conversions/to_json_conversions/base.rb @@ -25,22 +25,6 @@ module ConfigConversions module ToJSONConversions # Base class for conversions to JSON hash according to schema. class Base - # Defines the expected config type to perform the conversion. - # - # @raise If a subclass does not defines a type. - # @return [Class] - def self.config_type - raise "Undefined config type" - end - - # @param config [Object] The config type is provided by the {.config_type} method. - def initialize(config) - type = self.class.config_type - raise "Invalid config (#{type} expected): #{config}" unless config.is_a?(type) - - @config = config - end - # Performs the conversion to Hash according to the JSON schema. # # @return [Hash, nil] @@ -58,7 +42,7 @@ def convert private - # @return [Object] The config type is provided by the {.config_type} method. + # @return [Object] See {#initialize}. attr_reader :config # Values to generate the JSON. diff --git a/service/lib/agama/storage/config_conversions/to_json_conversions/boot.rb b/service/lib/agama/storage/config_conversions/to_json_conversions/boot.rb index 88a96e3716..698f3f09fe 100644 --- a/service/lib/agama/storage/config_conversions/to_json_conversions/boot.rb +++ b/service/lib/agama/storage/config_conversions/to_json_conversions/boot.rb @@ -20,7 +20,6 @@ # find current contact information at www.suse.com. require "agama/storage/config_conversions/to_json_conversions/base" -require "agama/storage/configs/logical_volume" module Agama module Storage @@ -28,9 +27,10 @@ module ConfigConversions module ToJSONConversions # Boot conversion to JSON hash according to schema. class Boot < Base - # @see Base - def self.config_type - Configs::Boot + # @param config [Configs::Boot] + def initialize(config) + super() + @config = config end private diff --git a/service/lib/agama/storage/config_conversions/to_json_conversions/config.rb b/service/lib/agama/storage/config_conversions/to_json_conversions/config.rb index 2eb2017e7c..21114a2d2c 100644 --- a/service/lib/agama/storage/config_conversions/to_json_conversions/config.rb +++ b/service/lib/agama/storage/config_conversions/to_json_conversions/config.rb @@ -19,7 +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/config" require "agama/storage/config_conversions/to_json_conversions/base" require "agama/storage/config_conversions/to_json_conversions/boot" require "agama/storage/config_conversions/to_json_conversions/drive" @@ -31,9 +30,10 @@ module ConfigConversions module ToJSONConversions # Config conversion to JSON hash according to schema. class Config < Base - # @see Base - def self.config_type - Storage::Config + # @param config [Storage::Config] + def initialize(config) + super() + @config = config end private diff --git a/service/lib/agama/storage/config_conversions/to_json_conversions/drive.rb b/service/lib/agama/storage/config_conversions/to_json_conversions/drive.rb index 5a6f9bf1b1..3ba6c139e4 100644 --- a/service/lib/agama/storage/config_conversions/to_json_conversions/drive.rb +++ b/service/lib/agama/storage/config_conversions/to_json_conversions/drive.rb @@ -25,7 +25,6 @@ require "agama/storage/config_conversions/to_json_conversions/with_partitions" require "agama/storage/config_conversions/to_json_conversions/with_ptable_type" require "agama/storage/config_conversions/to_json_conversions/with_search" -require "agama/storage/configs/drive" module Agama module Storage @@ -39,9 +38,10 @@ class Drive < Base include WithPtableType include WithPartitions - # @see Base - def self.config_type - Configs::Drive + # @param config [Configs::Drive] + def initialize(config) + super() + @config = config end private diff --git a/service/lib/agama/storage/config_conversions/to_json_conversions/encryption.rb b/service/lib/agama/storage/config_conversions/to_json_conversions/encryption.rb index d12692a8f8..bf4d26a310 100644 --- a/service/lib/agama/storage/config_conversions/to_json_conversions/encryption.rb +++ b/service/lib/agama/storage/config_conversions/to_json_conversions/encryption.rb @@ -21,7 +21,6 @@ require "agama/storage/config_conversions/to_json_conversions/base" require "agama/storage/config_conversions/to_json_conversions/encryption_properties" -require "agama/storage/configs/encryption" module Agama module Storage @@ -29,9 +28,10 @@ module ConfigConversions module ToJSONConversions # Encryption conversion to JSON hash according to schema. class Encryption < Base - # @see Base - def self.config_type - Configs::Encryption + # @param config [Configs::Encryption] + def initialize(config) + super() + @config = config end # @see Base#convert diff --git a/service/lib/agama/storage/config_conversions/to_json_conversions/encryption_properties.rb b/service/lib/agama/storage/config_conversions/to_json_conversions/encryption_properties.rb index 44ef0cfd21..66f1c8c2c1 100644 --- a/service/lib/agama/storage/config_conversions/to_json_conversions/encryption_properties.rb +++ b/service/lib/agama/storage/config_conversions/to_json_conversions/encryption_properties.rb @@ -20,7 +20,6 @@ # find current contact information at www.suse.com. require "agama/storage/config_conversions/to_json_conversions/base" -require "agama/storage/configs/encryption" module Agama module Storage @@ -28,9 +27,10 @@ module ConfigConversions module ToJSONConversions # Encryption properties conversion to JSON hash according to schema. class EncryptionProperties < Base - # @see Base - def self.config_type - Configs::Encryption + # @param config [Configs::Encryption] + def initialize(config) + super() + @config = config end private diff --git a/service/lib/agama/storage/config_conversions/to_json_conversions/filesystem.rb b/service/lib/agama/storage/config_conversions/to_json_conversions/filesystem.rb index 45f67d7d18..9cfaaf0628 100644 --- a/service/lib/agama/storage/config_conversions/to_json_conversions/filesystem.rb +++ b/service/lib/agama/storage/config_conversions/to_json_conversions/filesystem.rb @@ -20,7 +20,6 @@ # find current contact information at www.suse.com. require "agama/storage/config_conversions/to_json_conversions/base" -require "agama/storage/configs/filesystem" module Agama module Storage @@ -28,9 +27,10 @@ module ConfigConversions module ToJSONConversions # Filesystem conversion to JSON hash according to schema. class Filesystem < Base - # @see Base - def self.config_type - Configs::Filesystem + # @param config [Configs::Filesystem] + def initialize(config) + super() + @config = config end private diff --git a/service/lib/agama/storage/config_conversions/to_json_conversions/logical_volume.rb b/service/lib/agama/storage/config_conversions/to_json_conversions/logical_volume.rb index 531bcd2054..bb3c5ce60c 100644 --- a/service/lib/agama/storage/config_conversions/to_json_conversions/logical_volume.rb +++ b/service/lib/agama/storage/config_conversions/to_json_conversions/logical_volume.rb @@ -23,7 +23,6 @@ require "agama/storage/config_conversions/to_json_conversions/with_encryption" require "agama/storage/config_conversions/to_json_conversions/with_filesystem" require "agama/storage/config_conversions/to_json_conversions/with_size" -require "agama/storage/configs/logical_volume" module Agama module Storage @@ -35,9 +34,10 @@ class LogicalVolume < Base include WithFilesystem include WithSize - # @see Base - def self.config_type - Configs::LogicalVolume + # @param config [Configs::LogicalVolume] + def initialize(config) + super() + @config = config end private diff --git a/service/lib/agama/storage/config_conversions/to_json_conversions/partition.rb b/service/lib/agama/storage/config_conversions/to_json_conversions/partition.rb index 9155ebe2aa..99bda1c7ce 100644 --- a/service/lib/agama/storage/config_conversions/to_json_conversions/partition.rb +++ b/service/lib/agama/storage/config_conversions/to_json_conversions/partition.rb @@ -24,7 +24,6 @@ require "agama/storage/config_conversions/to_json_conversions/with_filesystem" require "agama/storage/config_conversions/to_json_conversions/with_search" require "agama/storage/config_conversions/to_json_conversions/with_size" -require "agama/storage/configs/partition" module Agama module Storage @@ -37,9 +36,10 @@ class Partition < Base include WithFilesystem include WithSize - # @see Base - def self.config_type - Configs::Partition + # @param config [Configs::Partition] + def initialize(config) + super() + @config = config end private diff --git a/service/lib/agama/storage/config_conversions/to_json_conversions/search.rb b/service/lib/agama/storage/config_conversions/to_json_conversions/search.rb index 957392e25b..a6eda81759 100644 --- a/service/lib/agama/storage/config_conversions/to_json_conversions/search.rb +++ b/service/lib/agama/storage/config_conversions/to_json_conversions/search.rb @@ -20,7 +20,6 @@ # find current contact information at www.suse.com. require "agama/storage/config_conversions/to_json_conversions/base" -require "agama/storage/configs/search" module Agama module Storage @@ -28,9 +27,10 @@ module ConfigConversions module ToJSONConversions # Search conversion to JSON hash according to schema. class Search < Base - # @see Base - def self.config_type - Configs::Search + # @param config [Configs::Search] + def initialize(config) + super() + @config = config end private diff --git a/service/lib/agama/storage/config_conversions/to_json_conversions/size.rb b/service/lib/agama/storage/config_conversions/to_json_conversions/size.rb index f57c2ba55c..f43d1c44b6 100644 --- a/service/lib/agama/storage/config_conversions/to_json_conversions/size.rb +++ b/service/lib/agama/storage/config_conversions/to_json_conversions/size.rb @@ -20,7 +20,6 @@ # find current contact information at www.suse.com. require "agama/storage/config_conversions/to_json_conversions/base" -require "agama/storage/configs/size" module Agama module Storage @@ -28,9 +27,10 @@ module ConfigConversions module ToJSONConversions # Size conversion to JSON hash according to schema. class Size < Base - # @see Base - def self.config_type - Configs::Size + # @param config [Configs::Size] + def initialize(config) + super() + @config = config end private diff --git a/service/lib/agama/storage/config_conversions/to_json_conversions/volume_group.rb b/service/lib/agama/storage/config_conversions/to_json_conversions/volume_group.rb index ffe0be78a4..217d88ded0 100644 --- a/service/lib/agama/storage/config_conversions/to_json_conversions/volume_group.rb +++ b/service/lib/agama/storage/config_conversions/to_json_conversions/volume_group.rb @@ -22,7 +22,6 @@ require "agama/storage/config_conversions/to_json_conversions/base" require "agama/storage/config_conversions/to_json_conversions/encryption" require "agama/storage/config_conversions/to_json_conversions/logical_volume" -require "agama/storage/configs/volume_group" module Agama module Storage @@ -30,9 +29,10 @@ module ConfigConversions module ToJSONConversions # Volume group conversion to JSON hash according to schema. class VolumeGroup < Base - # @see Base - def self.config_type - Configs::VolumeGroup + # @param config [Configs::VolumeGroup] + def initialize(config) + super() + @config = config end private diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/base.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/base.rb index e447dd1e11..9178e770b4 100644 --- a/service/lib/agama/storage/config_conversions/to_model_conversions/base.rb +++ b/service/lib/agama/storage/config_conversions/to_model_conversions/base.rb @@ -25,22 +25,6 @@ module ConfigConversions module ToModelConversions # Base class for conversions to model according to the JSON schema. class Base - # Defines the expected config type to perform the conversion. - # - # @raise If a subclass does not defines a type. - # @return [Class] - def self.config_type - raise "Undefined config type" - end - - # @param config [Object] The config type is provided by the {.config_type} method. - def initialize(config) - type = self.class.config_type - raise "Invalid config (#{type} expected): #{config}" unless config.is_a?(type) - - @config = config - end - # Performs the conversion to model according to the JSON schema. # # @return [Hash, nil] @@ -58,7 +42,7 @@ def convert private - # @return [Object] The config type is provided by the {.config_type} method. + # @return [Object] See {#initialize}. attr_reader :config # Values to generate the model. diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/config.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/config.rb index 94b65aeeab..40e3da02e7 100644 --- a/service/lib/agama/storage/config_conversions/to_model_conversions/config.rb +++ b/service/lib/agama/storage/config_conversions/to_model_conversions/config.rb @@ -19,7 +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/config" require "agama/storage/config_conversions/to_model_conversions/base" require "agama/storage/config_conversions/to_model_conversions/drive" @@ -29,9 +28,10 @@ module ConfigConversions module ToModelConversions # Config conversion to model according to the JSON schema. class Config < Base - # @see Base - def self.config_type - Storage::Config + # @param config [Storage::Config] + def initialize(config) + super() + @config = config end private diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/drive.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/drive.rb index 8bd54b4e62..0542d7f4fe 100644 --- a/service/lib/agama/storage/config_conversions/to_model_conversions/drive.rb +++ b/service/lib/agama/storage/config_conversions/to_model_conversions/drive.rb @@ -23,7 +23,6 @@ require "agama/storage/config_conversions/to_model_conversions/with_filesystem" require "agama/storage/config_conversions/to_model_conversions/with_partitions" require "agama/storage/config_conversions/to_model_conversions/with_space_policy" -require "agama/storage/configs/drive" module Agama module Storage @@ -35,9 +34,10 @@ class Drive < Base include WithPartitions include WithSpacePolicy - # @see Base - def self.config_type - Configs::Drive + # @param config [Configs::Drive] + def initialize(config) + super() + @config = config end private diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/filesystem.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/filesystem.rb index c8b6b04f24..eb3e02b448 100644 --- a/service/lib/agama/storage/config_conversions/to_model_conversions/filesystem.rb +++ b/service/lib/agama/storage/config_conversions/to_model_conversions/filesystem.rb @@ -20,7 +20,6 @@ # find current contact information at www.suse.com. require "agama/storage/config_conversions/to_model_conversions/base" -require "agama/storage/configs/filesystem" module Agama module Storage @@ -28,9 +27,10 @@ module ConfigConversions module ToModelConversions # Drive conversion to model according to the JSON schema. class Filesystem < Base - # @see Base - def self.config_type - Configs::Filesystem + # @param config [Configs::Filesystem] + def initialize(config) + super() + @config = config end private diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/partition.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/partition.rb index f8ac908ba0..dd1944db26 100644 --- a/service/lib/agama/storage/config_conversions/to_model_conversions/partition.rb +++ b/service/lib/agama/storage/config_conversions/to_model_conversions/partition.rb @@ -22,7 +22,6 @@ require "agama/storage/config_conversions/to_model_conversions/base" require "agama/storage/config_conversions/to_model_conversions/with_filesystem" require "agama/storage/config_conversions/to_model_conversions/with_size" -require "agama/storage/configs/partition" module Agama module Storage @@ -33,9 +32,10 @@ class Partition < Base include WithFilesystem include WithSize - # @see Base - def self.config_type - Configs::Partition + # @param config [Configs::Partition] + def initialize(config) + super() + @config = config end private diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/size.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/size.rb index 23e8217f73..13ab91151a 100644 --- a/service/lib/agama/storage/config_conversions/to_model_conversions/size.rb +++ b/service/lib/agama/storage/config_conversions/to_model_conversions/size.rb @@ -20,7 +20,6 @@ # find current contact information at www.suse.com. require "agama/storage/config_conversions/to_model_conversions/base" -require "agama/storage/configs/size" module Agama module Storage @@ -28,9 +27,10 @@ module ConfigConversions module ToModelConversions # Size conversion to model according to the JSON schema. class Size < Base - # @see Base - def self.config_type - Configs::Size + # @param config [Configs::Size] + def initialize(config) + super() + @config = config end private diff --git a/service/lib/agama/storage/config_json_solver.rb b/service/lib/agama/storage/config_json_solver.rb index 3f05dda4af..06e8ba4663 100644 --- a/service/lib/agama/storage/config_json_solver.rb +++ b/service/lib/agama/storage/config_json_solver.rb @@ -57,6 +57,9 @@ module Storage # } # ] # } + # + # See doc/storage_proposal_from_profile.md for a complete description of how the config is + # generated from a profile. class ConfigJSONSolver # @param default_paths [Array] Default paths of the product. # @param mandatory_paths [Array] Mandatory paths of the product. diff --git a/service/lib/agama/storage/config_solver.rb b/service/lib/agama/storage/config_solver.rb index 3ec4084c5c..1fbff4e1fe 100644 --- a/service/lib/agama/storage/config_solver.rb +++ b/service/lib/agama/storage/config_solver.rb @@ -31,6 +31,9 @@ module Storage # Solving a config means to assign proper values according to the product and the system. For # example, the sizes of a partition config taking into account its fallbacks, assigning a # specific device when a config has a search, etc. + # + # See doc/storage_proposal_from_profile.md for a complete description of how the config is + # generated from a profile. class ConfigSolver # @param devicegraph [Y2Storage::Devicegraph] initial layout of the system # @param product_config [Agama::Config] configuration of the product to install diff --git a/service/lib/agama/storage/configs/partition.rb b/service/lib/agama/storage/configs/partition.rb index 7d8a853e47..d615ade999 100644 --- a/service/lib/agama/storage/configs/partition.rb +++ b/service/lib/agama/storage/configs/partition.rb @@ -28,6 +28,26 @@ module Storage module Configs # Section of the configuration representing a partition class Partition + # Partition config meaning "delete all partitions". + # + # @return [Configs::Partition] + def self.new_for_delete_all + new.tap do |config| + config.search = Configs::Search.new_for_search_all + config.delete = true + end + end + + # Partition config meaning "shrink any partitions if needed". + # + # @return [Configs::Partition] + def self.new_for_shrink_any_if_needed + new.tap do |config| + config.search = Configs::Search.new_for_search_all + config.size = Configs::Size.new_for_shrink_if_needed + end + end + include WithAlias include WithSearch diff --git a/service/lib/agama/storage/configs/search.rb b/service/lib/agama/storage/configs/search.rb index abc826b9da..b0af036151 100644 --- a/service/lib/agama/storage/configs/search.rb +++ b/service/lib/agama/storage/configs/search.rb @@ -25,6 +25,13 @@ module Configs # Configuration used to match drives, partitions and other device definition with devices # from the initial devicegraph class Search + # Search config meaning "search all". + # + # @return [Configs::Search] + def self.new_for_search_all + new.tap { |c| c.if_not_found = :skip } + end + # Found device, if any # @return [Y2Storage::Device, nil] attr_reader :device diff --git a/service/lib/agama/storage/configs/size.rb b/service/lib/agama/storage/configs/size.rb index a84a774154..424ae12917 100644 --- a/service/lib/agama/storage/configs/size.rb +++ b/service/lib/agama/storage/configs/size.rb @@ -19,11 +19,25 @@ # To contact SUSE LLC about this file by physical or electronic mail, you may # find current contact information at www.suse.com. +require "y2storage/disk_size" + module Agama module Storage module Configs # Size configuration. class Size + # Size config meaning "shrink if needed". + # + # @return [Configs::Size] + def self.new_for_shrink_if_needed + new.tap do |config| + config.default = false + config.min = Y2Storage::DiskSize.zero + end + end + + # Whether the size is the default size for the volume. + # # @return [Boolean] attr_accessor :default alias_method :default?, :default diff --git a/service/lib/agama/storage/proposal.rb b/service/lib/agama/storage/proposal.rb index 74531aaf1e..fff8bd40ad 100644 --- a/service/lib/agama/storage/proposal.rb +++ b/service/lib/agama/storage/proposal.rb @@ -21,9 +21,7 @@ require "agama/issue" require "agama/storage/actions_generator" -require "agama/storage/config_conversions/from_json" -require "agama/storage/config_conversions/to_json" -require "agama/storage/config_conversions/to_model" +require "agama/storage/config_conversions" require "agama/storage/proposal_settings" require "agama/storage/proposal_strategies" require "json" @@ -129,6 +127,15 @@ def calculate_from_json(source_json) success? end + # Calculates a new proposal from a config model. + # + # @param model_json [Hash] Source config model according to the JSON schema. + # @return [Boolean] Whether the proposal successes. + def calculate_from_model(model_json) + config = ConfigConversions::FromModel.new(model_json).convert + calculate_agama(config) + end + # Calculates a new proposal using the guided strategy. # # @param settings [Agama::Storage::ProposalSettings] diff --git a/service/test/agama/dbus/storage/manager_test.rb b/service/test/agama/dbus/storage/manager_test.rb index 7381474efb..8a423168e3 100644 --- a/service/test/agama/dbus/storage/manager_test.rb +++ b/service/test/agama/dbus/storage/manager_test.rb @@ -557,6 +557,37 @@ end end + describe "#apply_config_model" do + let(:serialized_model) { model_json.to_json } + + let(:model_json) do + { + drives: [ + name: "/dev/vda", + partitions: [ + { mountPath: "/" } + ] + ] + } + end + + it "calculates an agama proposal with the given config" do + expect(proposal).to receive(:calculate_agama) do |config| + expect(config).to be_a(Agama::Storage::Config) + expect(config.drives.size).to eq(1) + + drive = config.drives.first + expect(drive.search.name).to eq("/dev/vda") + expect(drive.partitions.size).to eq(1) + + partition = drive.partitions.first + expect(partition.filesystem.path).to eq("/") + end + + subject.apply_config_model(serialized_model) + end + end + describe "#recover_config" do def serialize(value) JSON.pretty_generate(value) diff --git a/service/test/agama/storage/config_conversions/from_model_test.rb b/service/test/agama/storage/config_conversions/from_model_test.rb new file mode 100644 index 0000000000..31ec4226e5 --- /dev/null +++ b/service/test/agama/storage/config_conversions/from_model_test.rb @@ -0,0 +1,924 @@ +# 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/config" +require "agama/storage/config_conversions" +require "y2storage/encryption_method" +require "y2storage/filesystems/mount_by_type" +require "y2storage/filesystems/type" +require "y2storage/pbkd_function" +require "y2storage/refinements" + +using Y2Storage::Refinements::SizeCasts + +shared_examples "without alias" do |config_proc| + it "does not set #alias" do + config = config_proc.call(subject.convert) + expect(config.alias).to be_nil + end +end + +shared_examples "without filesystem" do |config_proc| + it "does not set #filesystem" do + config = config_proc.call(subject.convert) + expect(config.filesystem).to be_nil + end +end + +shared_examples "without ptableType" do |config_proc| + it "does not set #ptable_type" do + config = config_proc.call(subject.convert) + expect(config.ptable_type).to be_nil + end +end + +shared_examples "without partitions" do |config_proc| + it "sets #partitions to the expected value" do + config = config_proc.call(subject.convert) + expect(config.partitions).to eq([]) + end +end + +shared_examples "without size" do |config_proc| + it "sets #size to default size" do + config = config_proc.call(subject.convert) + expect(config.size.default?).to eq(true) + expect(config.size.min).to be_nil + expect(config.size.max).to be_nil + end +end + +shared_examples "without delete" do |config_proc| + it "sets #delete to false" do + config = config_proc.call(subject.convert) + expect(config.delete?).to eq(false) + end +end + +shared_examples "without deleteIfNeeded" do |config_proc| + it "sets #delete_if_needed to false" do + config = config_proc.call(subject.convert) + expect(config.delete_if_needed?).to eq(false) + end +end + +shared_examples "with name" do |config_proc| + let(:name) { "/dev/vda" } + + it "sets #search to the expected value" do + config = config_proc.call(subject.convert) + expect(config.search).to be_a(Agama::Storage::Configs::Search) + expect(config.search.name).to eq("/dev/vda") + expect(config.search.max).to be_nil + expect(config.search.if_not_found).to eq(:error) + end +end + +shared_examples "with alias" do |config_proc| + let(:device_alias) { "test" } + + it "sets #alias to the expected value" do + config = config_proc.call(subject.convert) + expect(config.alias).to eq("test") + end +end + +shared_examples "with mountPath" do |config_proc| + let(:mountPath) { "/test" } + + it "sets #filesystem to the expected value" do + config = config_proc.call(subject.convert) + filesystem = config.filesystem + expect(filesystem).to be_a(Agama::Storage::Configs::Filesystem) + expect(filesystem.reuse?).to eq(false) + expect(filesystem.type).to be_nil + expect(filesystem.label).to be_nil + expect(filesystem.path).to eq("/test") + expect(filesystem.mount_by).to be_nil + expect(filesystem.mkfs_options).to be_empty + expect(filesystem.mount_options).to be_empty + end +end + +shared_examples "with filesystem" do |config_proc| + context "if the filesystem is default" do + let(:filesystem) do + { + default: true, + type: type, + snapshots: true + } + end + + context "and the type is 'btrfs'" do + let(:type) { "btrfs" } + + it "sets #filesystem to the expected value" do + config = config_proc.call(subject.convert) + filesystem = config.filesystem + expect(filesystem).to be_a(Agama::Storage::Configs::Filesystem) + expect(filesystem.reuse?).to eq(false) + expect(filesystem.type.default?).to eq(true) + expect(filesystem.type.fs_type).to eq(Y2Storage::Filesystems::Type::BTRFS) + expect(filesystem.type.btrfs).to be_a(Agama::Storage::Configs::Btrfs) + expect(filesystem.type.btrfs.snapshots?).to eq(true) + expect(filesystem.label).to be_nil + expect(filesystem.path).to be_nil + expect(filesystem.mount_by).to be_nil + expect(filesystem.mkfs_options).to be_empty + expect(filesystem.mount_options).to be_empty + end + end + + context "and the type is not 'btrfs'" do + let(:type) { "xfs" } + + it "sets #filesystem to the expected value" do + config = config_proc.call(subject.convert) + filesystem = config.filesystem + expect(filesystem).to be_a(Agama::Storage::Configs::Filesystem) + expect(filesystem.reuse?).to eq(false) + expect(filesystem.type.default?).to eq(true) + expect(filesystem.type.fs_type).to eq(Y2Storage::Filesystems::Type::XFS) + expect(filesystem.type.btrfs).to be_nil + expect(filesystem.label).to be_nil + expect(filesystem.path).to be_nil + expect(filesystem.mount_by).to be_nil + expect(filesystem.mkfs_options).to be_empty + expect(filesystem.mount_options).to be_empty + end + end + end + + context "if the filesystem is not default" do + let(:filesystem) do + { + default: false, + type: type, + snapshots: true + } + end + + context "and the type is 'btrfs'" do + let(:type) { "btrfs" } + + it "sets #filesystem to the expected value" do + config = config_proc.call(subject.convert) + filesystem = config.filesystem + expect(filesystem).to be_a(Agama::Storage::Configs::Filesystem) + expect(filesystem.reuse?).to eq(false) + expect(filesystem.type.default?).to eq(false) + expect(filesystem.type.fs_type).to eq(Y2Storage::Filesystems::Type::BTRFS) + expect(filesystem.type.btrfs).to be_a(Agama::Storage::Configs::Btrfs) + expect(filesystem.type.btrfs.snapshots?).to eq(true) + expect(filesystem.label).to be_nil + expect(filesystem.path).to be_nil + expect(filesystem.mount_by).to be_nil + expect(filesystem.mkfs_options).to be_empty + expect(filesystem.mount_options).to be_empty + end + end + + context "and the type is not 'btrfs'" do + let(:type) { "xfs" } + + it "sets #filesystem to the expected value" do + config = config_proc.call(subject.convert) + filesystem = config.filesystem + expect(filesystem).to be_a(Agama::Storage::Configs::Filesystem) + expect(filesystem.reuse?).to eq(false) + expect(filesystem.type.default?).to eq(false) + expect(filesystem.type.fs_type).to eq(Y2Storage::Filesystems::Type::XFS) + expect(filesystem.type.btrfs).to be_nil + expect(filesystem.label).to be_nil + expect(filesystem.path).to be_nil + expect(filesystem.mount_by).to be_nil + expect(filesystem.mkfs_options).to be_empty + expect(filesystem.mount_options).to be_empty + end + end + end + + context "if the filesystem does not specify 'type'" do + let(:filesystem) { { default: false } } + + it "sets #filesystem to the expected value" do + config = config_proc.call(subject.convert) + filesystem = config.filesystem + expect(filesystem).to be_a(Agama::Storage::Configs::Filesystem) + expect(filesystem.reuse?).to eq(false) + expect(filesystem.type.default?).to eq(false) + expect(filesystem.type.fs_type).to be_nil + expect(filesystem.type.btrfs).to be_nil + expect(filesystem.label).to be_nil + expect(filesystem.path).to be_nil + expect(filesystem.mount_by).to be_nil + expect(filesystem.mkfs_options).to eq([]) + expect(filesystem.mount_options).to eq([]) + end + end +end + +shared_examples "with mountPath and filesystem" do |config_proc| + let(:mountPath) { "/test" } + + let(:filesystem) do + { + default: false, + type: "btrfs", + snapshots: true + } + end + + it "sets #filesystem to the expected value" do + config = config_proc.call(subject.convert) + filesystem = config.filesystem + expect(filesystem).to be_a(Agama::Storage::Configs::Filesystem) + expect(filesystem.reuse?).to eq(false) + expect(filesystem.type.default?).to eq(false) + expect(filesystem.type.fs_type).to eq(Y2Storage::Filesystems::Type::BTRFS) + expect(filesystem.type.btrfs).to be_a(Agama::Storage::Configs::Btrfs) + expect(filesystem.type.btrfs.snapshots?).to eq(true) + expect(filesystem.label).to be_nil + expect(filesystem.path).to eq("/test") + expect(filesystem.mount_by).to be_nil + expect(filesystem.mkfs_options).to be_empty + expect(filesystem.mount_options).to be_empty + end +end + +shared_examples "with ptableType" do |config_proc| + let(:ptableType) { "gpt" } + + it "sets #ptable_type to the expected value" do + config = config_proc.call(subject.convert) + expect(config.ptable_type).to eq(Y2Storage::PartitionTables::Type::GPT) + end +end + +shared_examples "with size" do |config_proc| + context "if the size is default" do + let(:size) do + { + default: true, + min: 1.GiB.to_i, + max: 10.GiB.to_i + } + end + + it "sets #size to the expected value" do + config = config_proc.call(subject.convert) + size = config.size + expect(size).to be_a(Agama::Storage::Configs::Size) + expect(size.default?).to eq(true) + expect(size.min).to eq(1.GiB) + expect(size.max).to eq(10.GiB) + end + end + + context "if the size is not default" do + let(:size) do + { + default: false, + min: 1.GiB.to_i, + max: 10.GiB.to_i + } + end + + it "sets #size to the expected value" do + config = config_proc.call(subject.convert) + size = config.size + expect(size).to be_a(Agama::Storage::Configs::Size) + expect(size.default?).to eq(false) + expect(size.min).to eq(1.GiB) + expect(size.max).to eq(10.GiB) + end + end + + context "if the size does not spicify 'max'" do + let(:size) do + { + default: false, + min: 1.GiB.to_i + } + end + + it "sets #size to the expected value" do + config = config_proc.call(subject.convert) + size = config.size + expect(size).to be_a(Agama::Storage::Configs::Size) + expect(size.default?).to eq(false) + expect(size.min).to eq(1.GiB) + expect(size.max).to eq(Y2Storage::DiskSize.unlimited) + end + end +end + +shared_examples "with resizeIfNeeded" do |config_proc| + context "if 'resizeIfNeeded' is true" do + let(:resizeIfNeeded) { true } + + it "sets #size to the expected value" do + config = config_proc.call(subject.convert) + size = config.size + expect(size).to be_a(Agama::Storage::Configs::Size) + expect(size.default?).to eq(false) + expect(size.min).to eq(Y2Storage::DiskSize.zero) + expect(size.max).to be_nil + end + end + + context "if 'resizeIfNeeded' is false" do + let(:resizeIfNeeded) { false } + + it "sets #size to the expected value" do + config = config_proc.call(subject.convert) + size = config.size + expect(size).to be_a(Agama::Storage::Configs::Size) + expect(size.default?).to eq(true) + expect(size.min).to be_nil + expect(size.max).to be_nil + end + end +end + +shared_examples "with size and resizeIfNeeded" do |config_proc| + let(:size) do + { + default: true, + min: 1.GiB.to_i, + max: 10.GiB.to_i + } + end + + context "if 'resizeIfNeeded' is true" do + let(:resizeIfNeeded) { true } + + it "sets #size to the expected value" do + config = config_proc.call(subject.convert) + size = config.size + expect(size).to be_a(Agama::Storage::Configs::Size) + expect(size.default?).to eq(false) + expect(size.min).to eq(Y2Storage::DiskSize.zero) + expect(size.max).to be_nil + end + end + + context "if 'resizeIfNeeded' is false" do + let(:resizeIfNeeded) { false } + + it "sets #size to the expected value" do + config = config_proc.call(subject.convert) + size = config.size + expect(size).to be_a(Agama::Storage::Configs::Size) + expect(size.default?).to eq(true) + expect(size.min).to eq(1.GiB) + expect(size.max).to eq(10.GiB) + end + end +end + +shared_examples "with size and resize" do |config_proc| + let(:size) do + { + default: true, + min: 1.GiB.to_i, + max: 10.GiB.to_i + } + end + + context "if 'resize' is true" do + let(:resize) { true } + + it "sets #size to the expected value" do + config = config_proc.call(subject.convert) + size = config.size + expect(size).to be_a(Agama::Storage::Configs::Size) + expect(size.default?).to eq(true) + expect(size.min).to eq(1.GiB) + expect(size.max).to eq(10.GiB) + end + end + + context "if 'size' is false" do + let(:resize) { false } + + it "sets #size to the expected value" do + config = config_proc.call(subject.convert) + size = config.size + expect(size).to be_a(Agama::Storage::Configs::Size) + expect(size.default?).to eq(true) + expect(size.min).to eq(1.GiB) + expect(size.max).to eq(10.GiB) + end + end +end + +shared_examples "with delete" do |config_proc| + it "sets #delete to true" do + config = config_proc.call(subject.convert) + expect(config.delete?).to eq(true) + end +end + +shared_examples "with deleteIfNeeded" do |config_proc| + it "sets #delete_if_needed to true" do + config = config_proc.call(subject.convert) + expect(config.delete_if_needed?).to eq(true) + end +end + +shared_examples "with partitions" do |config_proc| + let(:partitions) do + [ + partition, + { mountPath: "/test" } + ] + end + + let(:partition) { { mountPath: "/" } } + + context "with an empty list" do + let(:partitions) { [] } + + it "sets #partitions to empty" do + config = config_proc.call(subject.convert) + expect(config.partitions).to eq([]) + end + end + + context "with a list of partitions" do + it "sets #partitions to the expected value" do + config = config_proc.call(subject.convert) + partitions = config.partitions + expect(partitions.size).to eq(2) + + partition1, partition2 = partitions + expect(partition1).to be_a(Agama::Storage::Configs::Partition) + expect(partition1.filesystem.path).to eq("/") + expect(partition2).to be_a(Agama::Storage::Configs::Partition) + expect(partition2.filesystem.path).to eq("/test") + end + end + + partition_proc = proc { |c| config_proc.call(c).partitions.first } + + context "if a partition does not specify 'name'" do + let(:partition) { {} } + + it "does not set #search" do + partition = partition_proc.call(subject.convert) + expect(partition.search).to be_nil + end + end + + context "if a partition does not spicify 'alias'" do + let(:partition) { {} } + include_examples "without alias", partition_proc + end + + context "if a partition does not spicify 'id'" do + let(:partition) { {} } + + it "does not set #id" do + partition = partition_proc.call(subject.convert) + expect(partition.id).to be_nil + end + end + + context "if a partition does not spicify 'size'" do + let(:partition) { {} } + include_examples "without size", partition_proc + end + + context "if a partition does not spicify neither 'mountPath' nor 'filesystem'" do + let(:partition) { {} } + include_examples "without filesystem", partition_proc + end + + context "if a partition does not spicify 'delete'" do + let(:partition) { {} } + include_examples "without delete", partition_proc + end + + context "if a partition does not spicify 'deleteIfNeeded'" do + let(:partition) { {} } + include_examples "without deleteIfNeeded", partition_proc + end + + context "if a partition specifies 'name'" do + let(:partition) { { name: name } } + include_examples "with name", partition_proc + end + + context "if a partition specifies 'alias'" do + let(:partition) { { alias: device_alias } } + include_examples "with alias", partition_proc + end + + context "if a partition spicifies 'id'" do + let(:partition) { { id: "esp" } } + + it "sets #id to the expected value" do + partition = partition_proc.call(subject.convert) + expect(partition.id).to eq(Y2Storage::PartitionId::ESP) + end + end + + context "if a partition specifies 'mountPath'" do + let(:partition) { { mountPath: mountPath } } + include_examples "with mountPath", partition_proc + end + + context "if a partition specifies 'filesystem'" do + let(:partition) { { filesystem: filesystem } } + include_examples "with filesystem", partition_proc + end + + context "if a partition specifies both 'mountPath' and 'filesystem'" do + let(:partition) { { mountPath: mountPath, filesystem: filesystem } } + include_examples "with mountPath and filesystem", partition_proc + end + + context "if a partition spicifies 'size'" do + let(:partition) { { size: size } } + include_examples "with size", partition_proc + end + + context "if a partition spicifies 'resizeIfNeeded'" do + let(:partition) { { resizeIfNeeded: resizeIfNeeded } } + include_examples "with resizeIfNeeded", partition_proc + end + + context "if a partition spicifies both 'size' and 'resizeIfNeeded'" do + let(:partition) { { size: size, resizeIfNeeded: resizeIfNeeded } } + include_examples "with size and resizeIfNeeded", partition_proc + end + + context "if a partition spicifies both 'size' and 'resize'" do + let(:partition) { { size: size, resize: resize } } + include_examples "with size and resize", partition_proc + end + + context "if a partition specifies 'delete'" do + let(:partition) { { delete: true } } + include_examples "with delete", partition_proc + end + + context "if a partition specifies 'deleteIfNeeded'" do + let(:partition) { { deleteIfNeeded: true } } + include_examples "with deleteIfNeeded", partition_proc + end +end + +shared_examples "with spacePolicy" do |config_proc| + context "if space policy is 'keep'" do + let(:spacePolicy) { "keep" } + + it "sets #partitions to the expected value" do + config = config_proc.call(subject.convert) + partitions = config.partitions + expect(partitions).to be_empty + end + end + + context "if space policy is 'delete'" do + let(:spacePolicy) { "delete" } + + it "sets #partitions to the expected value" do + config = config_proc.call(subject.convert) + partitions = config.partitions + expect(partitions.size).to eq(1) + + partition = partitions.first + expect(partition.search.name).to be_nil + expect(partition.search.if_not_found).to eq(:skip) + expect(partition.search.max).to be_nil + expect(partition.delete?).to eq(true) + end + end + + context "if space policy is 'resize'" do + let(:spacePolicy) { "resize" } + + it "sets #partitions to the expected value" do + config = config_proc.call(subject.convert) + partitions = config.partitions + expect(partitions.size).to eq(1) + + partition = partitions.first + expect(partition.search.name).to be_nil + expect(partition.search.if_not_found).to eq(:skip) + expect(partition.search.max).to be_nil + expect(partition.delete?).to eq(false) + expect(partition.size.default?).to eq(false) + expect(partition.size.min).to eq(Y2Storage::DiskSize.zero) + expect(partition.size.max).to be_nil + end + end + + context "if space policy is 'custom'" do + let(:spacePolicy) { "custom" } + + it "sets #partitions to the expected value" do + config = config_proc.call(subject.convert) + partitions = config.partitions + expect(partitions).to be_empty + end + end +end + +shared_examples "with spacePolicy and partitions" do |config_proc| + let(:partitions) do + [ + { + name: "/dev/vda1", + mountPath: "/data" + }, + { + name: "/dev/vda2", + mountPath: "swap", + filesystem: { type: "swap" } + }, + { + name: "/dev/vda3", + mountPath: "/home", + size: { default: false, min: 1.GiB.to_i, max: 10.GiB.to_i } + }, + { + name: "/dev/vda4", + resizeIfNeeded: true + }, + { + name: "/dev/vda5", + deleteIfNeeded: true + }, + { + name: "/dev/vda6", + size: { default: false, min: 5.GiB } + }, + { + name: "/dev/vda7", + delete: true + }, + { + mountPath: "/", + filesystem: { type: "btrfs" } + } + ] + end + + context "if space policy is 'keep'" do + let(:spacePolicy) { "keep" } + + it "sets #partitions to the expected value" do + config = config_proc.call(subject.convert) + partitions = config.partitions + expect(partitions.size).to eq(4) + expect(partitions[0].search.name).to eq("/dev/vda1") + expect(partitions[1].search.name).to eq("/dev/vda2") + expect(partitions[2].search.name).to eq("/dev/vda3") + expect(partitions[2].size.default?).to eq(false) + expect(partitions[2].size.min).to eq(1.GiB) + expect(partitions[2].size.max).to eq(10.GiB) + expect(partitions[3].filesystem.path).to eq("/") + end + end + + context "if space policy is 'delete'" do + let(:spacePolicy) { "delete" } + + it "sets #partitions to the expected value" do + config = config_proc.call(subject.convert) + partitions = config.partitions + expect(partitions.size).to eq(5) + expect(partitions[0].search.name).to eq("/dev/vda1") + expect(partitions[1].search.name).to eq("/dev/vda2") + expect(partitions[2].search.name).to eq("/dev/vda3") + expect(partitions[2].size.default?).to eq(false) + expect(partitions[2].size.min).to eq(1.GiB) + expect(partitions[2].size.max).to eq(10.GiB) + expect(partitions[3].filesystem.path).to eq("/") + expect(partitions[4].search.name).to be_nil + expect(partitions[4].search.max).to be_nil + expect(partitions[4].delete).to eq(true) + end + end + + context "if space policy is 'resize'" do + let(:spacePolicy) { "resize" } + + it "sets #partitions to the expected value" do + config = config_proc.call(subject.convert) + partitions = config.partitions + expect(partitions.size).to eq(5) + expect(partitions[0].search.name).to eq("/dev/vda1") + expect(partitions[1].search.name).to eq("/dev/vda2") + expect(partitions[2].search.name).to eq("/dev/vda3") + expect(partitions[2].size.default?).to eq(false) + expect(partitions[2].size.min).to eq(1.GiB) + expect(partitions[2].size.max).to eq(10.GiB) + expect(partitions[3].filesystem.path).to eq("/") + expect(partitions[4].search.name).to be_nil + expect(partitions[4].search.max).to be_nil + expect(partitions[4].size.default?).to eq(false) + expect(partitions[4].size.min).to eq(Y2Storage::DiskSize.zero) + expect(partitions[4].size.max).to be_nil + end + end + + context "if space policy is 'custom'" do + let(:spacePolicy) { "custom" } + + it "sets #partitions to the expected value" do + config = config_proc.call(subject.convert) + partitions = config.partitions + expect(partitions.size).to eq(8) + expect(partitions[0].search.name).to eq("/dev/vda1") + expect(partitions[1].search.name).to eq("/dev/vda2") + expect(partitions[2].search.name).to eq("/dev/vda3") + expect(partitions[2].size.default?).to eq(false) + expect(partitions[2].size.min).to eq(1.GiB) + expect(partitions[2].size.max).to eq(10.GiB) + expect(partitions[3].search.name).to eq("/dev/vda4") + expect(partitions[3].size.default?).to eq(false) + expect(partitions[3].size.min).to eq(Y2Storage::DiskSize.zero) + expect(partitions[3].size.max).to be_nil + expect(partitions[4].search.name).to eq("/dev/vda5") + expect(partitions[4].delete_if_needed?).to eq(true) + expect(partitions[5].search.name).to eq("/dev/vda6") + expect(partitions[5].size.default?).to eq(false) + expect(partitions[5].size.min).to eq(5.GiB) + expect(partitions[5].size.max).to eq(Y2Storage::DiskSize.unlimited) + expect(partitions[6].search.name).to eq("/dev/vda7") + expect(partitions[6].delete?).to eq(true) + expect(partitions[7].filesystem.path).to eq("/") + end + end +end + +describe Agama::Storage::ConfigConversions::FromModel do + subject do + described_class.new(model_json) + end + + before do + # Speed up tests by avoding real check of TPM presence. + allow(Y2Storage::EncryptionMethod::TPM_FDE).to receive(:possible?).and_return(true) + end + + describe "#convert" do + let(:model_json) { {} } + + it "returns a storage config" do + config = subject.convert + expect(config).to be_a(Agama::Storage::Config) + end + + context "with an empty JSON" do + let(:model_json) { {} } + + it "sets #drives to the expected value" do + config = subject.convert + expect(config.drives).to be_empty + end + end + + context "with a JSON specifying 'drives'" do + let(:model_json) do + { drives: drives } + end + + let(:drives) do + [ + drive, + { name: "/dev/vdb" } + ] + end + + let(:drive) do + { name: "/dev/vda" } + end + + context "with an empty list" do + let(:drives) { [] } + + it "sets #drives to the expected value" do + config = subject.convert + expect(config.drives).to eq([]) + end + end + + context "with a list of drives" do + it "sets #drives to the expected value" do + config = subject.convert + expect(config.drives.size).to eq(2) + expect(config.drives).to all(be_a(Agama::Storage::Configs::Drive)) + + drive1, drive2 = config.drives + expect(drive1.search.name).to eq("/dev/vda") + expect(drive1.partitions).to eq([]) + expect(drive2.search.name).to eq("/dev/vdb") + expect(drive2.partitions).to eq([]) + end + end + + drive_proc = proc { |c| c.drives.first } + + context "if a drive does not specify 'name'" do + let(:drive) { {} } + + it "sets #search to the expected value" do + drive = drive_proc.call(subject.convert) + expect(drive.search).to be_a(Agama::Storage::Configs::Search) + expect(drive.search.name).to be_nil + expect(drive.search.if_not_found).to eq(:error) + end + end + + context "if a drive does not spicify 'alias'" do + let(:drive) { {} } + include_examples "without alias", drive_proc + end + + context "if a drive does not spicify neither 'mountPath' nor 'filesystem'" do + let(:drive) { {} } + include_examples "without filesystem", drive_proc + end + + context "if a drive does not spicify 'ptableType'" do + let(:drive) { {} } + include_examples "without ptableType", drive_proc + end + + context "if a drive does not spicify neither 'spacePolicy' nor 'partitions'" do + let(:drive) { {} } + include_examples "without partitions", drive_proc + end + + context "if a drive specifies 'name'" do + let(:drive) { { name: name } } + include_examples "with name", drive_proc + end + + context "if a drive specifies 'alias'" do + let(:drive) { { alias: device_alias } } + include_examples "with alias", drive_proc + end + + context "if a drive specifies 'mountPath'" do + let(:drive) { { mountPath: mountPath } } + include_examples "with mountPath", drive_proc + end + + context "if a drive specifies 'filesystem'" do + let(:drive) { { filesystem: filesystem } } + include_examples "with filesystem", drive_proc + end + + context "if a drive specifies both 'mountPath' and 'filesystem'" do + let(:drive) { { mountPath: mountPath, filesystem: filesystem } } + include_examples "with mountPath and filesystem", drive_proc + end + + context "if a drive specifies 'ptableType'" do + let(:drive) { { ptableType: ptableType } } + include_examples "with ptableType", drive_proc + end + + context "if a drive specifies 'partitions'" do + let(:drive) { { partitions: partitions } } + include_examples "with partitions", drive_proc + end + + context "if a drive specifies 'spacePolicy'" do + let(:drive) { { spacePolicy: spacePolicy } } + include_examples "with spacePolicy", drive_proc + end + + context "if a drive specifies both 'spacePolicy' and 'partitions'" do + let(:drive) { { spacePolicy: spacePolicy, partitions: partitions } } + include_examples "with spacePolicy and partitions", drive_proc + end + end + end +end diff --git a/service/test/agama/storage/config_solver_test.rb b/service/test/agama/storage/config_solver_test.rb index d6504126c5..8b3c1253ec 100644 --- a/service/test/agama/storage/config_solver_test.rb +++ b/service/test/agama/storage/config_solver_test.rb @@ -550,6 +550,52 @@ end end end + + context "if a config does not specify max size" do + let(:config_json) do + { + drives: [ + { + partitions: [ + { + search: search, + filesystem: { path: "/" }, + size: { + min: "10 GiB" + } + } + ] + } + ] + } + end + + context "and there is no device assigned" do + let(:search) { nil } + + it "sets max size to unlimited" do + subject.solve(config) + partition = partition_proc.call(config) + expect(partition.size.default?).to eq(false) + expect(partition.size.min).to eq(10.GiB) + expect(partition.size.max).to eq(Y2Storage::DiskSize.unlimited) + end + end + + context "and there is a device assigned" do + let(:scenario) { "disks.yaml" } + + let(:search) { "/dev/vda2" } + + it "sets max size to unlimited" do + subject.solve(config) + partition = partition_proc.call(config) + expect(partition.size.default?).to eq(false) + expect(partition.size.min).to eq(10.GiB) + expect(partition.size.max).to eq(Y2Storage::DiskSize.unlimited) + end + end + end end context "if a drive omits the search" do diff --git a/service/test/agama/storage/proposal_test.rb b/service/test/agama/storage/proposal_test.rb index 97c13326af..a5dda91ee3 100644 --- a/service/test/agama/storage/proposal_test.rb +++ b/service/test/agama/storage/proposal_test.rb @@ -692,6 +692,34 @@ def drive(partitions) end end + describe "#calculate_from_model" do + let(:model_json) do + { + drives: [ + { + name: "/dev/vda", + filesystem: { + type: "xfs" + } + } + ] + } + end + + it "calculates a proposal with the agama strategy and with the expected config" do + expect(subject).to receive(:calculate_agama) do |config| + expect(config).to be_a(Agama::Storage::Config) + expect(config.drives.size).to eq(1) + + drive = config.drives.first + expect(drive.search.name).to eq("/dev/vda") + expect(drive.filesystem.type.fs_type).to eq(Y2Storage::Filesystems::Type::XFS) + end + + subject.calculate_from_model(model_json) + end + end + describe "#actions" do it "returns an empty list if calculate has not been called yet" do expect(subject.actions).to eq([]) diff --git a/web/src/api/storage.ts b/web/src/api/storage.ts index 9913e89d39..2c8ebedd13 100644 --- a/web/src/api/storage.ts +++ b/web/src/api/storage.ts @@ -38,7 +38,9 @@ const fetchConfig = (): Promise => const fetchConfigModel = (): Promise => get("/api/storage/config_model"); -const setConfig = (config: config.Config) => put("/api/storage/config", config); +const setConfig = (config: config.Config) => put("/api/storage/config", { storage: config }); + +const setConfigModel = (model: configModel.Config) => put("/api/storage/config_model", model); /** * Returns the list of jobs @@ -69,6 +71,7 @@ export { fetchConfig, fetchConfigModel, setConfig, + setConfigModel, fetchStorageJobs, findStorageJob, refresh, diff --git a/web/src/api/storage/types/config-model.ts b/web/src/api/storage/types/config-model.ts index b27f2421a3..7951ae948b 100644 --- a/web/src/api/storage/types/config-model.ts +++ b/web/src/api/storage/types/config-model.ts @@ -39,6 +39,8 @@ export interface Drive { spacePolicy?: SpacePolicy; ptableType?: PtableType; partitions?: Partition[]; + boot?: string; + volumeGroups: string[]; } export interface Filesystem { default: boolean; @@ -53,7 +55,9 @@ export interface Partition { filesystem?: Filesystem; size?: Size; delete?: boolean; + // TODO: ignore deleteIfNeeded?: boolean; + // TODO: ignore resize?: boolean; resizeIfNeeded?: boolean; } diff --git a/web/src/components/overview/StorageSection.test.tsx b/web/src/components/overview/StorageSection.test.tsx index bc85cd1fdd..811f83d0e2 100644 --- a/web/src/components/overview/StorageSection.test.tsx +++ b/web/src/components/overview/StorageSection.test.tsx @@ -24,24 +24,25 @@ import React from "react"; import { screen } from "@testing-library/react"; import { plainRender } from "~/test-utils"; import { StorageSection } from "~/components/overview"; -import * as ConfigModel from "~/storage/model/config"; +import * as ConfigModel from "~/api/storage/types/config-model"; const mockDevices = [ - { name: "/dev/sda", size: 536870912000 }, - { name: "/dev/sdb", size: 697932185600 }, + { name: "/dev/sda", size: 536870912000, volumeGroups: [] }, + { name: "/dev/sdb", size: 697932185600, volumeGroups: [] }, ]; -const mockConfig = { devices: [] as ConfigModel.Device[] }; +const mockConfig = { drives: [] as ConfigModel.Drive[] }; jest.mock("~/queries/storage", () => ({ ...jest.requireActual("~/queries/storage"), + useConfigModel: () => mockConfig, useDevices: () => mockDevices, - useConfigDevices: () => mockConfig.devices, + useConfigDevices: () => mockConfig.drives, })); describe("when the configuration does not include any device", () => { beforeEach(() => { - mockConfig.devices = []; + mockConfig.drives = []; }); it("indicates that a device is not selected", async () => { @@ -53,7 +54,7 @@ describe("when the configuration does not include any device", () => { describe("when the configuration contains one drive", () => { beforeEach(() => { - mockConfig.devices = [{ name: "/dev/sda", spacePolicy: "delete" }]; + mockConfig.drives = [{ name: "/dev/sda", spacePolicy: "delete", volumeGroups: [] }]; }); it("renders the proposal summary", async () => { @@ -66,7 +67,7 @@ describe("when the configuration contains one drive", () => { describe("and the space policy is set to 'resize'", () => { beforeEach(() => { - mockConfig.devices[0].spacePolicy = "resize"; + mockConfig.drives[0].spacePolicy = "resize"; }); it("indicates that partitions may be shrunk", async () => { @@ -78,7 +79,7 @@ describe("when the configuration contains one drive", () => { describe("and the space policy is set to 'keep'", () => { beforeEach(() => { - mockConfig.devices[0].spacePolicy = "keep"; + mockConfig.drives[0].spacePolicy = "keep"; }); it("indicates that partitions will be kept", async () => { @@ -90,7 +91,7 @@ describe("when the configuration contains one drive", () => { describe("and the drive matches no disk", () => { beforeEach(() => { - mockConfig.devices[0].name = null; + mockConfig.drives[0].name = null; }); it("indicates that a device is not selected", async () => { @@ -103,9 +104,9 @@ describe("when the configuration contains one drive", () => { describe("when the configuration contains several drives", () => { beforeEach(() => { - mockConfig.devices = [ - { name: "/dev/sda", spacePolicy: "delete" }, - { name: "/dev/sdb", spacePolicy: "delete" }, + mockConfig.drives = [ + { name: "/dev/sda", spacePolicy: "delete", volumeGroups: [] }, + { name: "/dev/sdb", spacePolicy: "delete", volumeGroups: [] }, ]; }); diff --git a/web/src/components/overview/StorageSection.tsx b/web/src/components/overview/StorageSection.tsx index b0c0b6082a..7c02aae9ca 100644 --- a/web/src/components/overview/StorageSection.tsx +++ b/web/src/components/overview/StorageSection.tsx @@ -89,25 +89,33 @@ export default function StorageSection() { return _("Install using several devices with a custom strategy to find the needed space."); }; - if (drives.length === 0) return {_("No device selected yet")}; + const existDevice = (name) => devices.some((d) => d.name === name); + const noDrive = drives.length === 0 || drives.some((d) => !existDevice(d.name)); - if (drives.length > 1) { + if (noDrive) return ( - {msgMultipleDisks(drives)} + {_("No device selected yet")} ); - } else { - const [msg1, msg2] = msgSingleDisk(drives[0]).split("%s"); + if (drives.length > 1) { return ( - - {msg1} - {label(drives[0])} - {msg2} - + {msgMultipleDisks(drives)} ); } + + const [msg1, msg2] = msgSingleDisk(drives[0]).split("%s"); + + return ( + + + {msg1} + {label(drives[0])} + {msg2} + + + ); } diff --git a/web/src/components/storage/DeviceSelectorTable.tsx b/web/src/components/storage/DeviceSelectorTable.tsx index bed25f5c15..3dfe18517c 100644 --- a/web/src/components/storage/DeviceSelectorTable.tsx +++ b/web/src/components/storage/DeviceSelectorTable.tsx @@ -37,6 +37,7 @@ import { sprintf } from "sprintf-js"; import { deviceBaseName } from "~/components/storage/utils"; import { PartitionSlot, StorageDevice } from "~/types/storage"; import { ExpandableSelectorColumn, ExpandableSelectorProps } from "../core/ExpandableSelector"; +import { typeDescription, contentDescription } from "./utils/device"; /** * @component diff --git a/web/src/components/storage/DriveEditor.tsx b/web/src/components/storage/DriveEditor.tsx index b206327957..cbcbcae82d 100644 --- a/web/src/components/storage/DriveEditor.tsx +++ b/web/src/components/storage/DriveEditor.tsx @@ -21,13 +21,15 @@ */ import React, { useRef, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { useNavigate, generatePath } from "react-router-dom"; import { _, formatList } from "~/i18n"; import { sprintf } from "sprintf-js"; import { baseName, deviceLabel, formattedPath, SPACE_POLICIES } from "~/components/storage/utils"; import { useAvailableDevices } from "~/queries/storage"; -import { config as type } from "~/api/storage/types"; +import { configModel } from "~/api/storage/types"; import { StorageDevice } from "~/types/storage"; +import { STORAGE as PATHS } from "~/routes/paths"; +import { useChangeDrive, useSetSpacePolicy } from "~/queries/storage"; import * as driveUtils from "~/components/storage/utils/drive"; import { typeDescription, contentDescription } from "~/components/storage/utils/device"; import { Icon } from "../layout"; @@ -50,7 +52,7 @@ import { MenuToggle, } from "@patternfly/react-core"; -type DriveEditorProps = { drive: type.DriveElement; driveDevice: StorageDevice }; +type DriveEditorProps = { drive: configModel.Drive; driveDevice: StorageDevice }; // FIXME: Presentation is quite poor const SpacePolicySelectorIntro = ({ device }) => { @@ -82,11 +84,21 @@ const SpacePolicySelectorIntro = ({ device }) => { ); }; -const SpacePolicySelector = ({ drive, driveDevice }) => { +const SpacePolicySelector = ({ drive, driveDevice }: DriveEditorProps) => { const menuRef = useRef(); const toggleMenuRef = useRef(); const [isOpen, setIsOpen] = useState(false); + const navigate = useNavigate(); + const setSpacePolicy = useSetSpacePolicy(); const onToggle = () => setIsOpen(!isOpen); + const onSpacePolicyChange = (spacePolicy: configModel.SpacePolicy) => { + if (spacePolicy === "custom") { + return navigate(generatePath(PATHS.spacePolicy, { id: baseName(drive.name) })); + } else { + setSpacePolicy(drive.name, spacePolicy); + setIsOpen(false); + } + }; const currentPolicy = driveUtils.spacePolicyEntry(drive); @@ -96,7 +108,12 @@ const SpacePolicySelector = ({ drive, driveDevice }) => { const Name = () => (isSelected ? {policy.label} : policy.label); return ( - + onSpacePolicyChange(policy.id)} + > ); @@ -139,7 +156,7 @@ const SpacePolicySelector = ({ drive, driveDevice }) => { }; const SearchSelectorIntro = ({ drive }) => { - const mainText = (drive: type.DriveElement): string => { + const mainText = (drive: configModel.Drive): string => { if (driveUtils.hasReuse(drive)) { // The current device will be the only option to choose from return _("This uses existing partitions at the device"); @@ -190,12 +207,12 @@ const SearchSelectorIntro = ({ drive }) => { return sprintf( // TRANSLATORS: %s is a list of formatted mount points like '"/", "/var" and "swap"' (or a // single mount point in the singular case). - _("Select a device to create %s", "Select a device to create %s", mountPaths.length), + _("Select a device to create %s"), formatList(mountPaths), ); }; - const extraText = (drive: type.DriveElement): string => { + const extraText = (drive: configModel.Drive): string => { // Nothing to add in these cases if (driveUtils.hasReuse(drive)) return; if (!driveUtils.hasFilesystem(drive)) return; @@ -268,7 +285,8 @@ const SearchSelectorIntro = ({ drive }) => { ); }; -const SearchSelectorMultipleOptions = ({ selected, withNewVg }) => { +const SearchSelectorMultipleOptions = ({ selected, withNewVg = false, onChange }) => { + const navigate = useNavigate(); const devices = useAvailableDevices(); // FIXME: Presentation is quite poor @@ -295,7 +313,7 @@ const SearchSelectorMultipleOptions = ({ selected, withNewVg }) => { return ( navigate("/storage/target-device")} + onClick={() => navigate(PATHS.targetDevice)} itemId="lvm" description={_("The configured partitions will be created as logical volumes")} > @@ -319,6 +337,7 @@ const SearchSelectorMultipleOptions = ({ selected, withNewVg }) => { itemId={device.sid} isSelected={isSelected} description={} + onClick={() => onChange(device.name)} > @@ -342,7 +361,7 @@ const SearchSelectorSingleOption = ({ selected }) => { ); }; -const SearchSelectorOptions = ({ drive, selected }) => { +const SearchSelectorOptions = ({ drive, selected, onChange }) => { if (driveUtils.hasReuse(drive)) return ; if (!driveUtils.hasFilesystem(drive)) { @@ -350,17 +369,17 @@ const SearchSelectorOptions = ({ drive, selected }) => { return ; } - return ; + return ; } - return ; + return ; }; -const SearchSelector = ({ drive, selected }) => { +const SearchSelector = ({ drive, selected, onChange }) => { return ( <> - + ); }; @@ -384,6 +403,11 @@ const DriveSelector = ({ drive, selected }) => { const menuRef = useRef(); const toggleMenuRef = useRef(); const [isOpen, setIsOpen] = useState(false); + const changeDrive = useChangeDrive(); + const onDriveChange = (newDriveName: string) => { + changeDrive(drive.name, newDriveName); + setIsOpen(false); + }; const onToggle = () => setIsOpen(!isOpen); return ( @@ -409,19 +433,20 @@ const DriveSelector = ({ drive, selected }) => { - + } + // @ts-expect-error popperProps={{ appendTo: document.body }} /> ); }; const DriveHeader = ({ drive, driveDevice }: DriveEditorProps) => { - const text = (drive: type.DriveElement): string => { + const text = (drive: configModel.Drive): string => { if (driveUtils.hasRoot(drive)) { if (driveUtils.hasPv(drive)) { if (drive.boot) { @@ -487,7 +512,6 @@ const DriveHeader = ({ drive, driveDevice }: DriveEditorProps) => { }; const PartitionsNoContentSelector = () => { - const navigate = useNavigate(); const menuRef = useRef(); const toggleMenuRef = useRef(); const [isOpen, setIsOpen] = useState(false); @@ -521,7 +545,6 @@ const PartitionsNoContentSelector = () => { key="add-partition" itemId="add-partition" description={_("Add another partition or mount an existing one")} - onClick={() => navigate("/storage/space-policy")} > {_("Add or use partition")} @@ -536,7 +559,6 @@ const PartitionsNoContentSelector = () => { }; const PartitionsWithContentSelector = ({ drive }) => { - const navigate = useNavigate(); const menuRef = useRef(); const toggleMenuRef = useRef(); const [isOpen, setIsOpen] = useState(false); @@ -600,7 +622,6 @@ const PartitionsWithContentSelector = ({ drive }) => { key="add-partition" itemId="add-partition" description={_("Add another partition or mount an existing one")} - onClick={() => navigate("/storage/space-policy")} > {_("Add or use partition")} @@ -619,7 +640,7 @@ const PartitionsSelector = ({ drive }) => { return ; } - return ; + return ; }; export default function DriveEditor({ drive, driveDevice }: DriveEditorProps) { diff --git a/web/src/components/storage/ProposalResultSection.tsx b/web/src/components/storage/ProposalResultSection.tsx index c929724848..4b6c0470da 100644 --- a/web/src/components/storage/ProposalResultSection.tsx +++ b/web/src/components/storage/ProposalResultSection.tsx @@ -29,6 +29,7 @@ import { ProposalActionsDialog } from "~/components/storage"; import { _, n_, formatList } from "~/i18n"; import { Action, StorageDevice } from "~/types/storage"; import { ValidationError } from "~/types/issues"; +import { sprintf } from "sprintf-js"; /** * @todo Create a component for rendering a customized skeleton diff --git a/web/src/components/storage/ProposalTransactionalInfo.test.tsx b/web/src/components/storage/ProposalTransactionalInfo.test.tsx index ebcd2c93b2..7bcea38451 100644 --- a/web/src/components/storage/ProposalTransactionalInfo.test.tsx +++ b/web/src/components/storage/ProposalTransactionalInfo.test.tsx @@ -24,8 +24,9 @@ import React from "react"; import { screen } from "@testing-library/react"; import { plainRender } from "~/test-utils"; import { ProposalTransactionalInfo } from "~/components/storage"; -import { ProposalSettings, ProposalTarget, Volume, VolumeTarget } from "~/types/storage"; +import { Volume, VolumeTarget } from "~/types/storage"; +let mockVolumes: Volume[] = []; jest.mock("~/queries/software", () => ({ ...jest.requireActual("~/queries/software"), useProduct: () => ({ @@ -34,20 +35,10 @@ jest.mock("~/queries/software", () => ({ useProductChanges: () => jest.fn(), })); -const settings: ProposalSettings = { - target: ProposalTarget.DISK, - targetDevice: "/dev/sda", - targetPVDevices: [], - configureBoot: false, - bootDevice: "", - defaultBootDevice: "", - encryptionPassword: "", - encryptionMethod: "", - spacePolicy: "delete", - spaceActions: [], - volumes: [], - installationDevices: [], -}; +jest.mock("~/queries/storage", () => ({ + ...jest.requireActual("~/queries/storage"), + useVolumeTemplates: () => mockVolumes, +})); const rootVolume: Volume = { mountPath: "/", @@ -70,30 +61,24 @@ const rootVolume: Volume = { }, }; -const props = { settings }; - -beforeEach(() => { - settings.volumes = []; -}); - describe("if the system is not transactional", () => { beforeEach(() => { - settings.volumes = [rootVolume]; + mockVolumes = [rootVolume]; }); it("renders nothing", () => { - const { container } = plainRender(); + const { container } = plainRender(); expect(container).toBeEmptyDOMElement(); }); }); describe("if the system is transactional", () => { beforeEach(() => { - settings.volumes = [{ ...rootVolume, transactional: true }]; + mockVolumes = [{ ...rootVolume, transactional: true }]; }); it("renders an explanation about the transactional system", () => { - plainRender(); + plainRender(); screen.getByText("Transactional root file system"); }); diff --git a/web/src/components/storage/ProposalTransactionalInfo.tsx b/web/src/components/storage/ProposalTransactionalInfo.tsx index dbc5dcb447..2a5d3e9321 100644 --- a/web/src/components/storage/ProposalTransactionalInfo.tsx +++ b/web/src/components/storage/ProposalTransactionalInfo.tsx @@ -33,11 +33,10 @@ import { isTransactionalSystem } from "~/components/storage/utils"; * @component * * @param props - * @param props.settings - Settings used for calculating a proposal. */ export default function ProposalTransactionalInfo() { const { selectedProduct } = useProduct({ suspense: true }); - const volumes = useVolumeTemplates({ suspense: true }); + const volumes = useVolumeTemplates(); if (!isTransactionalSystem(volumes)) return; diff --git a/web/src/components/storage/SpaceActionsTable.test.tsx b/web/src/components/storage/SpaceActionsTable.test.tsx index 68c937f5dc..1d6a60ace2 100644 --- a/web/src/components/storage/SpaceActionsTable.test.tsx +++ b/web/src/components/storage/SpaceActionsTable.test.tsx @@ -24,7 +24,7 @@ import React from "react"; import { screen, within } from "@testing-library/react"; -import { gib } from "~/components/storage/utils"; +import { deviceChildren, gib } from "~/components/storage/utils"; import { plainRender } from "~/test-utils"; import SpaceActionsTable, { SpaceActionsTableProps } from "~/components/storage/SpaceActionsTable"; import { StorageDevice } from "~/types/storage"; @@ -80,27 +80,6 @@ sda.partitionTable = { unusedSlots: [{ start: 3, size: gib(2) }], }; -/** @type {StorageDevice} */ -const sdb: StorageDevice = { - sid: 62, - name: "/dev/sdb", - isDrive: true, - type: "disk", - description: "Ext3 disk", - size: gib(5), - filesystem: { sid: 100, type: "ext3" }, -}; - -/** @type {StorageDevice} */ -const sdc: StorageDevice = { - sid: 63, - name: "/dev/sdc", - isDrive: true, - type: "disk", - description: "", - size: gib(20), -}; - /** * Function to ask for the action of a device. * @@ -110,7 +89,7 @@ const sdc: StorageDevice = { const deviceAction = (device) => { if (device === sda1) return "keep"; - return "force_delete"; + return "delete"; }; let props: SpaceActionsTableProps; @@ -118,8 +97,7 @@ let props: SpaceActionsTableProps; describe("SpaceActionsTable", () => { beforeEach(() => { props = { - devices: [sda, sdb, sdc], - expandedDevices: [sda], + devices: deviceChildren(sda), deviceAction, onActionChange: jest.fn(), }; @@ -135,8 +113,6 @@ describe("SpaceActionsTable", () => { name: "sda2 EXT4 partition 6 GiB Do not modify Allow shrink Delete", }); screen.getByRole("row", { name: "Unused space 2 GiB" }); - screen.getByRole("row", { name: "/dev/sdb Ext3 disk 5 GiB The content may be deleted" }); - screen.getByRole("row", { name: "/dev/sdc 20 GiB No content found" }); }); it("selects the action for each device", () => { @@ -173,8 +149,8 @@ describe("SpaceActionsTable", () => { await user.click(sda1DeleteButton); expect(props.onActionChange).toHaveBeenCalledWith({ - device: "/dev/sda1", - action: "force_delete", + deviceName: "/dev/sda1", + value: "delete", }); }); diff --git a/web/src/components/storage/SpaceActionsTable.tsx b/web/src/components/storage/SpaceActionsTable.tsx index cb5936bbad..7fb21a274c 100644 --- a/web/src/components/storage/SpaceActionsTable.tsx +++ b/web/src/components/storage/SpaceActionsTable.tsx @@ -36,17 +36,17 @@ import { import { sprintf } from "sprintf-js"; import { _ } from "~/i18n"; -import { deviceChildren, deviceSize } from "~/components/storage/utils"; +import { deviceSize } from "~/components/storage/utils"; import { DeviceName, DeviceDetails, DeviceSize, toStorageDevice, } from "~/components/storage/device-utils"; -import { TreeTable } from "~/components/core"; import { Icon } from "~/components/layout"; -import { PartitionSlot, SpaceAction, StorageDevice } from "~/types/storage"; +import { PartitionSlot, SpacePolicyAction, StorageDevice } from "~/types/storage"; import { TreeTableColumn } from "~/components/core/TreeTable"; +import { Table, Td, Th, Tr, Thead, Tbody } from "@patternfly/react-table"; /** * Info about the device. @@ -112,9 +112,9 @@ const DeviceActionSelector = ({ }: { device: StorageDevice; action: string; - onChange?: (action: SpaceAction) => void; + onChange?: (action: SpacePolicyAction) => void; }) => { - const changeAction = (action) => onChange({ device: device.name, action }); + const changeAction = (value) => onChange({ deviceName: device.name, value }); const isResizeDisabled = device.shrinking?.supported === undefined; const hasInfo = device.shrinking !== undefined; @@ -133,14 +133,14 @@ const DeviceActionSelector = ({ text="Allow shrink" buttonId="resize" isDisabled={isResizeDisabled} - isSelected={action === "resize"} - onChange={() => changeAction("resize")} + isSelected={action === "resizeIfNeeded"} + onChange={() => changeAction("resizeIfNeeded")} /> changeAction("force_delete")} + isSelected={action === "delete"} + onChange={() => changeAction("delete")} /> @@ -169,7 +169,7 @@ const DeviceAction = ({ }: { item: PartitionSlot | StorageDevice; action: string; - onChange?: (action: SpaceAction) => void; + onChange?: (action: SpacePolicyAction) => void; }) => { const device = toStorageDevice(item); if (!device) return null; @@ -187,10 +187,9 @@ const DeviceAction = ({ }; export type SpaceActionsTableProps = { - devices: StorageDevice[]; - expandedDevices?: StorageDevice[]; + devices: (PartitionSlot | StorageDevice)[]; deviceAction: (item: PartitionSlot | StorageDevice) => string; - onActionChange: (action: SpaceAction) => void; + onActionChange: (action: SpacePolicyAction) => void; }; /** @@ -198,8 +197,7 @@ export type SpaceActionsTableProps = { * @component */ export default function SpaceActionsTable({ - devices, - expandedDevices = [], + devices = [], deviceAction, onActionChange, }: SpaceActionsTableProps) { @@ -216,16 +214,27 @@ export default function SpaceActionsTable({ ]; return ( - { - if (!item.sid) return "dimmed-row"; - }} - className="devices-table" - /> + + + + {columns.map((c, i) => ( + + ))} + + + + {devices.map((d, idx) => ( + + {columns.map((c, i) => ( + + ))} + + ))} + +
+ {c.name} +
+ {c.value(d)} +
); } diff --git a/web/src/components/storage/SpacePolicySelection.tsx b/web/src/components/storage/SpacePolicySelection.tsx index a03cb24620..a9ef947835 100644 --- a/web/src/components/storage/SpacePolicySelection.tsx +++ b/web/src/components/storage/SpacePolicySelection.tsx @@ -20,120 +20,67 @@ * find current contact information at www.suse.com. */ -import React, { useEffect, useState } from "react"; -import { Card, CardBody, Form, Grid, GridItem, Radio, Stack } from "@patternfly/react-core"; -import { useNavigate } from "react-router-dom"; +import React, { useState } from "react"; +import { Card, CardBody, Form, Grid, GridItem } from "@patternfly/react-core"; +import { useNavigate, useParams } from "react-router-dom"; import { Page } from "~/components/core"; import { SpaceActionsTable } from "~/components/storage"; -import { SPACE_POLICIES, SpacePolicy } from "~/components/storage/utils"; -import { noop } from "~/utils"; +import { baseName, deviceChildren } from "~/components/storage/utils"; import { _ } from "~/i18n"; +import { PartitionSlot, SpacePolicyAction, StorageDevice } from "~/types/storage"; +import { configModel } from "~/api/storage/types"; +import { useConfigModel, useDevices, useSetCustomSpacePolicy } from "~/queries/storage"; +import { toStorageDevice } from "./device-utils"; import textStyles from "@patternfly/react-styles/css/utilities/Text/text"; -import { SpaceAction } from "~/types/storage"; -import { useProposalMutation, useProposalResult } from "~/queries/storage"; +import { sprintf } from "sprintf-js"; -/** - * Widget to allow user picking desired policy to make space. - * @component - * - * @param props - * @param props.currentPolicy - * @param [props.onChange] - */ -const SpacePolicyPicker = ({ - currentPolicy, - onChange = noop, -}: { - currentPolicy: SpacePolicy; - onChange?: (policy: SpacePolicy) => void; -}) => { - return ( - - - {/* eslint-disable agama-i18n/string-literals */} - {SPACE_POLICIES.map((policy) => { - const isChecked = currentPolicy?.id === policy.id; - let labelStyle = textStyles.fontSizeLg; - if (isChecked) labelStyle += ` ${textStyles.fontWeightBold}`; - - return ( - {_(policy.label)}} - body={{_(policy.description)}} - onChange={() => onChange(policy)} - defaultChecked={isChecked} - /> - ); - })} - {/* eslint-enable agama-i18n/string-literals */} - - - ); +const partitionAction = (partition: configModel.Partition) => { + if (partition.delete) return "delete"; + if (partition.resizeIfNeeded) return "resizeIfNeeded"; + + return undefined; }; /** * Renders a page that allows the user to select the space policy and actions. */ export default function SpacePolicySelection() { - const { settings } = useProposalResult(); - const { mutateAsync: updateProposal } = useProposalMutation(); - const [state, setState] = useState({ load: false }); - const [policy, setPolicy] = useState(); - const [actions, setActions] = useState([]); - const [expandedDevices, setExpandedDevices] = useState([]); - const [customUsed, setCustomUsed] = useState(false); - const [devices, setDevices] = useState([]); + const { id } = useParams(); + const model = useConfigModel({ suspense: true }); + const devices = useDevices("system", { suspense: true }); + const device = devices.find((d) => baseName(d.name) === id); + const setCustomSpacePolicy = useSetCustomSpacePolicy(); + const children = deviceChildren(device); + const drive = model.drives.find((d) => baseName(d.name) === id); + + const partitionDeviceAction = (device: StorageDevice) => { + const partition = drive.partitions?.find((p) => p.name === device.name); + + return partition ? partitionAction(partition) : undefined; + }; + + const [actions, setActions] = useState( + children + .filter((d) => toStorageDevice(d) && partitionDeviceAction(toStorageDevice(d))) + .map( + (d: StorageDevice): SpacePolicyAction => ({ + deviceName: toStorageDevice(d).name, + value: partitionDeviceAction(toStorageDevice(d)), + }), + ), + ); + const navigate = useNavigate(); - useEffect(() => { - if (state.load) return; - - // FIXME: move to a state/reducer - const policy = SPACE_POLICIES.find((p) => p.id === settings.spacePolicy); - setPolicy(policy); - setActions(settings.spaceActions); - setCustomUsed(policy.id === "custom"); - setDevices(settings.installationDevices); - setState({ load: true }); - }, [settings, state.load]); - - useEffect(() => { - if (policy?.id === "custom") setExpandedDevices(devices); - }, [devices, policy, setExpandedDevices]); - - // The selectors for the space action have to be initialized always to the same value - // (e.g., "keep") when the custom policy is selected for first time. The following two useEffect - // ensures that. - - // Stores whether the custom policy has been used. - useEffect(() => { - if (policy?.id === "custom" && !customUsed) setCustomUsed(true); - }, [policy, customUsed, setCustomUsed]); - - // Resets actions (i.e., sets everything to "keep") if the custom policy has not been used yet. - useEffect(() => { - if (policy && policy.id !== "custom" && !customUsed) setActions([]); - }, [policy, customUsed, setActions]); - - if (!state.load) return; - - // Generates the action value according to the policy. - const deviceAction = (device) => { - if (policy?.id === "custom") { - return actions.find((a) => a.device === device.name)?.action || "keep"; - } - - const policyAction = { delete: "force_delete", resize: "resize", keep: "keep" }; - return policyAction[policy?.id]; + const deviceAction = (device: StorageDevice | PartitionSlot) => { + if (toStorageDevice(device) === undefined) return "keep"; + + return actions.find((a) => a.deviceName === toStorageDevice(device).name)?.value || "keep"; }; - const changeActions = (spaceAction: SpaceAction) => { - const spaceActions = actions.filter((a) => a.device !== spaceAction.device); - if (spaceAction.action !== "keep") spaceActions.push(spaceAction); + const changeActions = (spaceAction: SpacePolicyAction) => { + const spaceActions = actions.filter((a) => a.deviceName !== spaceAction.deviceName); + spaceActions.push(spaceAction); setActions(spaceActions); }; @@ -141,35 +88,32 @@ export default function SpacePolicySelection() { const onSubmit = (e) => { e.preventDefault(); - updateProposal({ - ...settings, - spacePolicy: policy.id, - spaceActions: actions, - }); + setCustomSpacePolicy(drive.name, actions); + navigate(".."); }; - const xl2Columns = policy.id === "custom" ? 6 : 12; + const xl2Columns = 6; + const description = _( + "Select what to do with each partition in order to find space for allocating the new system.", + ); return ( -

{_("Space policy")}

+

{sprintf(_("Find space in %s"), device.name)}

+

{description}

- - - - {policy.id === "custom" && devices.length > 0 && ( + {children.length > 0 && ( diff --git a/web/src/components/storage/utils.ts b/web/src/components/storage/utils.ts index 333087b183..91a8633e42 100644 --- a/web/src/components/storage/utils.ts +++ b/web/src/components/storage/utils.ts @@ -34,6 +34,7 @@ import xbytes from "xbytes"; import { _, N_ } from "~/i18n"; import { PartitionSlot, StorageDevice, Volume } from "~/types/storage"; import { configModel } from "~/api/storage/types"; +import { sprintf } from "sprintf-js"; /** * @note undefined for either property means unknown @@ -47,7 +48,7 @@ export type SpacePolicy = { id: string; label: string; description: string; - summaryLabel: string; + summaryLabel?: string; }; export type SizeMethod = "auto" | "fixed" | "range"; diff --git a/web/src/components/storage/utils/drive.tsx b/web/src/components/storage/utils/drive.tsx index 84e31f19e7..2c1277cf55 100644 --- a/web/src/components/storage/utils/drive.tsx +++ b/web/src/components/storage/utils/drive.tsx @@ -23,20 +23,21 @@ // @ts-check import { _, n_, formatList } from "~/i18n"; -import { DriveElement } from "~/api/storage/types"; +import { configModel } from "~/api/storage/types"; import { SpacePolicy, SPACE_POLICIES, baseName, formattedPath } from "~/components/storage/utils"; import * as partitionUtils from "~/components/storage/utils/partition"; +import { sprintf } from "sprintf-js"; /** * String to identify the drive. */ -const label = (drive: DriveElement): string => { +const label = (drive: configModel.Drive): string => { if (drive.alias) return drive.alias; return baseName(drive.name); }; -const spacePolicyEntry = (drive: DriveElement): SpacePolicy => { +const spacePolicyEntry = (drive: configModel.Drive): SpacePolicy => { return SPACE_POLICIES.find((p) => p.id === drive.spacePolicy); }; @@ -74,7 +75,7 @@ const resizeTextFor = (partitions) => { * FIXME: the case with two sentences looks a bit weird. But trying to summarize everything in one * sentence was too hard. */ -const contentActionsDescription = (drive: DriveElement): string => { +const contentActionsDescription = (drive: configModel.Drive): string => { const policyLabel = spacePolicyEntry(drive).summaryLabel; // eslint-disable-next-line agama-i18n/string-literals @@ -102,7 +103,7 @@ const contentActionsDescription = (drive: DriveElement): string => { * FIXME: right now, this considers only the case in which the drive is going to host some formatted * partitions. */ -const contentDescription = (drive: DriveElement): string => { +const contentDescription = (drive: configModel.Drive): string => { const newPartitions = drive.partitions.filter((p) => !p.name); const reusedPartitions = drive.partitions.filter((p) => p.name && p.mountPath); @@ -146,23 +147,23 @@ const contentDescription = (drive: DriveElement): string => { return sprintf(_("Partitions will be used and created for %s"), formatList(mountPaths)); }; -const hasFilesystem = (drive: DriveElement): boolean => { +const hasFilesystem = (drive: configModel.Drive): boolean => { return drive.partitions && drive.partitions.some((p) => p.mountPath); }; -const hasRoot = (drive: DriveElement): boolean => { +const hasRoot = (drive: configModel.Drive): boolean => { return drive.partitions && drive.partitions.some((p) => p.mountPath && p.mountPath === "/"); }; -const hasReuse = (drive: DriveElement): boolean => { +const hasReuse = (drive: configModel.Drive): boolean => { return drive.partitions && drive.partitions.some((p) => p.mountPath && p.name); }; -const hasPv = (drive: DriveElement): boolean => { +const hasPv = (drive: configModel.Drive): boolean => { return drive.volumeGroups && drive.volumeGroups.length > 0; }; -const explicitBoot = (drive: DriveElement): boolean => { +const explicitBoot = (drive: configModel.Drive): boolean => { return drive.boot && drive.boot === "explicit"; }; diff --git a/web/src/components/storage/utils/partition.tsx b/web/src/components/storage/utils/partition.tsx index 278354daa4..4dd2d6b35d 100644 --- a/web/src/components/storage/utils/partition.tsx +++ b/web/src/components/storage/utils/partition.tsx @@ -24,13 +24,13 @@ import { _ } from "~/i18n"; import { sprintf } from "sprintf-js"; -import { PartitionElement } from "~/api/storage/types"; import { formattedPath, sizeDescription } from "~/components/storage/utils"; +import { configModel } from "~/api/storage/types"; /** * String to identify the drive. */ -const pathWithSize = (partition: PartitionElement): string => { +const pathWithSize = (partition: configModel.Partition): string => { return sprintf( // TRANSLATORS: %1$s is an already formatted mount path (eg. "/"), // %2$s is a size description (eg. at least 10 GiB) diff --git a/web/src/queries/storage.ts b/web/src/queries/storage.ts index 05385f6cf1..e74606adf0 100644 --- a/web/src/queries/storage.ts +++ b/web/src/queries/storage.ts @@ -28,7 +28,7 @@ import { useSuspenseQuery, } from "@tanstack/react-query"; import React from "react"; -import { fetchConfig, fetchConfigModel, setConfig } from "~/api/storage"; +import { fetchConfig, setConfig } from "~/api/storage"; import { fetchDevices, fetchDevicesDirty } from "~/api/storage/devices"; import { calculate, @@ -40,7 +40,6 @@ import { import { useInstallerClient } from "~/context/installer"; import { config, - configModel, ProductParams, Volume as APIVolume, ProposalSettingsPatch, @@ -61,12 +60,6 @@ const configQuery = { staleTime: Infinity, }; -const configModelQuery = { - queryKey: ["storage", "configModel"], - queryFn: fetchConfigModel, - staleTime: Infinity, -}; - const devicesQuery = (scope: "result" | "system") => ({ queryKey: ["storage", "devices", scope], queryFn: () => fetchDevices(scope), @@ -127,16 +120,6 @@ const useConfig = (options?: QueryHookOptions): config.Config => { return data; }; -/** - * Hook that returns the config model. - */ -const useConfigModel = (options?: QueryHookOptions): configModel.Config => { - const query = configModelQuery; - const func = options?.suspense ? useSuspenseQuery : useQuery; - const { data } = func(query); - return data; -}; - /** * Hook for setting a new config. */ @@ -334,7 +317,6 @@ const useDeprecatedChanges = () => { export { useConfig, useConfigMutation, - useConfigModel, useDevices, useAvailableDevices, useProductParams, @@ -345,3 +327,5 @@ export { useDeprecated, useDeprecatedChanges, }; + +export * from "~/queries/storage/config-model"; diff --git a/web/src/queries/storage/config-model.ts b/web/src/queries/storage/config-model.ts new file mode 100644 index 0000000000..a78764a08f --- /dev/null +++ b/web/src/queries/storage/config-model.ts @@ -0,0 +1,163 @@ +/* + * 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 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. + */ + +import { useMutation, useQuery, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; +import { fetchConfigModel, setConfigModel } from "~/api/storage"; +import { configModel } from "~/api/storage/types"; +import { QueryHookOptions } from "~/types/queries"; +import { SpacePolicyAction } from "~/types/storage"; + +function findDrive(model: configModel.Config, driveName: string): configModel.Drive | undefined { + const drives = model.drives || []; + return drives.find((d) => d.name === driveName); +} + +// TODO: add a second drive if needed (e.g., reusing a partition). +function changeDrive(model: configModel.Config, driveName: string, newDriveName: string) { + const drive = findDrive(model, driveName); + if (drive === undefined) return; + + drive.name = newDriveName; + // TODO: assign space policy according to the use-case. + if (drive.spacePolicy === "custom") drive.spacePolicy = "keep"; +} + +function setSpacePolicy( + model: configModel.Config, + driveName: string, + spacePolicy: "keep" | "delete" | "resize", +) { + const drive = findDrive(model, driveName); + if (drive === undefined) return; + + drive.spacePolicy = spacePolicy; +} + +function setCustomSpacePolicy( + model: configModel.Config, + driveName: string, + actions: SpacePolicyAction[], +) { + const drive = findDrive(model, driveName); + if (drive === undefined) return; + + drive.spacePolicy = "custom"; + drive.partitions ||= []; + + // Reset resize/delete actions of all current partition configs. + drive.partitions + .filter((p) => p.name !== undefined) + .forEach((partition) => { + partition.delete = false; + partition.deleteIfNeeded = false; + partition.resizeIfNeeded = false; + partition.size = undefined; + }); + + // Apply the given actions. + actions.forEach(({ deviceName, value }) => { + const isDelete = value === "delete"; + const isResizeIfNeeded = value === "resizeIfNeeded"; + const partition = drive.partitions.find((p) => p.name === deviceName); + + if (partition) { + partition.delete = isDelete; + partition.resizeIfNeeded = isResizeIfNeeded; + } else { + drive.partitions.push({ + name: deviceName, + delete: isDelete, + resizeIfNeeded: isResizeIfNeeded, + }); + } + }); +} + +const configModelQuery = { + queryKey: ["storage", "configModel"], + queryFn: fetchConfigModel, + staleTime: Infinity, +}; + +/** + * Hook that returns the config model. + */ +export function useConfigModel(options?: QueryHookOptions): configModel.Config { + const query = configModelQuery; + const func = options?.suspense ? useSuspenseQuery : useQuery; + const { data } = func(query); + return data; +} + +/** + * Hook for setting a new config model. + */ +export function useConfigModelMutation() { + const queryClient = useQueryClient(); + const query = { + mutationFn: (model: configModel.Config) => setConfigModel(model), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["storage"] }), + }; + + return useMutation(query); +} + +type ModelActionF = (model: configModel.Config) => void; + +function useApplyModelAction() { + const originalModel = useConfigModel({ suspense: true }); + const { mutate } = useConfigModelMutation(); + + const model = JSON.parse(JSON.stringify(originalModel)); + + return (action: ModelActionF) => { + action(model); + mutate(model); + }; +} + +export function useChangeDrive() { + const applyModelAction = useApplyModelAction(); + + return (driveName: string, newDriveName: string) => { + const action: ModelActionF = (model) => changeDrive(model, driveName, newDriveName); + applyModelAction(action); + }; +} + +export function useSetSpacePolicy() { + const applyModelAction = useApplyModelAction(); + + return (deviceName: string, spacePolicy: "keep" | "delete" | "resize") => { + const action: ModelActionF = (model) => setSpacePolicy(model, deviceName, spacePolicy); + applyModelAction(action); + }; +} + +export function useSetCustomSpacePolicy() { + const applyModelAction = useApplyModelAction(); + + return (deviceName: string, actions: SpacePolicyAction[]) => { + const action: ModelActionF = (model) => setCustomSpacePolicy(model, deviceName, actions); + applyModelAction(action); + }; +} diff --git a/web/src/routes/paths.ts b/web/src/routes/paths.ts index 63d1047e05..de6c97b825 100644 --- a/web/src/routes/paths.ts +++ b/web/src/routes/paths.ts @@ -66,7 +66,7 @@ const STORAGE = { root: "/storage", targetDevice: "/storage/target-device", bootingPartition: "/storage/booting-partition", - spacePolicy: "/storage/space-policy", + spacePolicy: "/storage/space-policy/:id", iscsi: "/storage/iscsi", dasd: "/storage/dasd", zfcp: { diff --git a/web/src/types/storage.ts b/web/src/types/storage.ts index 44880a81c9..01321780fc 100644 --- a/web/src/types/storage.ts +++ b/web/src/types/storage.ts @@ -97,6 +97,11 @@ type Action = { resize: boolean; }; +type SpacePolicyAction = { + deviceName: string; + value: "delete" | "resizeIfNeeded"; +}; + type ProposalSettings = { target: ProposalTarget; targetDevice?: string; @@ -206,6 +211,7 @@ export type { ProposalSettings, ShrinkingInfo, SpaceAction, + SpacePolicyAction, StorageDevice, Volume, VolumeOutline,